mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-14 17:52:10 +02:00
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:
parent
ba650af6f2
commit
ed1e3a7c9a
@ -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 Frigate’s 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 Frigate’s 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 user’s 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 user’s 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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,8 +143,14 @@ def events(params: EventsQueryParams = Depends()):
|
||||
clauses.append((Event.camera == camera))
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Event.camera << camera_list))
|
||||
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":
|
||||
label_list = labels.split(",")
|
||||
@ -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,
|
||||
)
|
||||
|
@ -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"},
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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,8 +72,14 @@ async def review(
|
||||
]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
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":
|
||||
# use matching so segments with multiple labels
|
||||
@ -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,8 +207,14 @@ async def review_summary(
|
||||
clauses = [(ReviewSegment.start_time > day_ago)]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
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":
|
||||
# use matching so segments with multiple labels
|
||||
@ -276,8 +308,14 @@ async def review_summary(
|
||||
clauses = []
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
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":
|
||||
# use matching so segments with multiple labels
|
||||
@ -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(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
)
|
||||
)
|
||||
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"},
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
169
frigate/test/http_api/test_http_camera_access.py
Normal file
169
frigate/test/http_api/test_http_camera_access.py
Normal 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
|
@ -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]
|
||||
|
@ -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()
|
||||
|
@ -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()
|
@ -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)
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 />} />
|
||||
|
@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
}: {
|
||||
requiredRoles: ("admin" | "viewer")[];
|
||||
requiredRoles: string[];
|
||||
}) {
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
|
@ -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,12 +842,17 @@ export function CameraGroupEdit({
|
||||
<FormDescription>{t("group.cameras.desc")}</FormDescription>
|
||||
<FormMessage />
|
||||
{[
|
||||
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
|
||||
...Object.keys(config?.cameras ?? {}).sort(
|
||||
(a, b) =>
|
||||
(config?.cameras[a]?.ui?.order ?? 0) -
|
||||
(config?.cameras[b]?.ui?.order ?? 0),
|
||||
),
|
||||
...(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),
|
||||
),
|
||||
].map((camera) => (
|
||||
<FormControl key={camera}>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
|
@ -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(() => {
|
||||
|
@ -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(() => {
|
||||
|
228
web/src/components/overlay/CreateRoleDialog.tsx
Normal file
228
web/src/components/overlay/CreateRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
@ -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"
|
||||
|
109
web/src/components/overlay/DeleteRoleDialog.tsx
Normal file
109
web/src/components/overlay/DeleteRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
195
web/src/components/overlay/EditRoleCamerasDialog.tsx
Normal file
195
web/src/components/overlay/EditRoleCamerasDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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">
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
<LuUser className="size-4 text-primary" />
|
||||
<span>{t("role.viewer", { ns: "common" })}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{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" />
|
||||
) : role === "viewer" ? (
|
||||
<LuUser className="size-4 text-primary" />
|
||||
) : (
|
||||
<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" })}
|
||||
|
@ -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")}
|
||||
|
@ -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 });
|
||||
};
|
||||
|
||||
|
22
web/src/hooks/use-allowed-cameras.ts
Normal file
22
web/src/hooks/use-allowed-cameras.ts
Normal 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 || [];
|
||||
}
|
11
web/src/hooks/use-is-custom-role.ts
Normal file
11
web/src/hooks/use-is-custom-role.ts
Normal 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
|
||||
);
|
||||
}
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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} />
|
||||
)}
|
||||
|
@ -342,6 +342,12 @@ export interface FrigateConfig {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
auth: {
|
||||
roles: {
|
||||
[roleName: string]: string[];
|
||||
};
|
||||
};
|
||||
|
||||
birdseye: BirdseyeConfig;
|
||||
|
||||
cameras: {
|
||||
|
@ -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) => {
|
||||
setMainCamera(newCam);
|
||||
setFullResolution({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
setPlaybackStart(currentTime);
|
||||
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;
|
||||
}
|
||||
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -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,84 +402,84 @@ 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">
|
||||
<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("users.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("users.management.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label={t("users.addUser")}
|
||||
variant="default"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
{t("users.addUser")}
|
||||
</Button>
|
||||
// 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">
|
||||
{t("users.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("users.management.desc")}
|
||||
</p>
|
||||
</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">
|
||||
<Button
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label={t("users.addUser")}
|
||||
variant="default"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
{t("users.addUser")}
|
||||
</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("users.table.username")}
|
||||
</TableHead>
|
||||
<TableHead>{t("users.table.role")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("users.table.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">
|
||||
{t("users.table.username")}
|
||||
</TableHead>
|
||||
<TableHead>{t("users.table.role")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("users.table.actions")}
|
||||
</TableHead>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
{t("users.table.noUsers")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
{t("users.table.noUsers")}
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.username} className="group">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.username === "admin" ? (
|
||||
<LuShield className="size-4 text-primary" />
|
||||
) : (
|
||||
<LuUserCog className="size-4 text-primary-variant" />
|
||||
)}
|
||||
{user.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.username} className="group">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.username === "admin" ? (
|
||||
<LuShield className="size-4 text-primary" />
|
||||
) : (
|
||||
<LuUserCog className="size-4 text-primary-variant" />
|
||||
)}
|
||||
{user.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.role === "admin" ? "default" : "outline"
|
||||
}
|
||||
className={
|
||||
user.role === "admin"
|
||||
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t("role." + (user.role || "viewer"), {
|
||||
ns: "common",
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.username !== "admin" && (
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.role === "admin" ? "default" : "outline"
|
||||
}
|
||||
className={
|
||||
user.role === "admin"
|
||||
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t("role." + (user.role || "viewer"), {
|
||||
ns: "common",
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{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);
|
||||
}}
|
||||
@ -297,64 +506,62 @@ export default function AuthenticationView() {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setShowSetPassword(true);
|
||||
setSelectedUser(user.username);
|
||||
}}
|
||||
>
|
||||
<FaUserEdit className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("users.table.password")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.updatePassword")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{user.username !== "admin" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="destructive"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setShowSetPassword(true);
|
||||
setShowDelete(true);
|
||||
setSelectedUser(user.username);
|
||||
}}
|
||||
>
|
||||
<FaUserEdit className="size-3.5" />
|
||||
<HiTrash className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("users.table.password")}
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.updatePassword")}</p>
|
||||
<p>{t("users.table.deleteUser")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{user.username !== "admin" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setShowDelete(true);
|
||||
setSelectedUser(user.username);
|
||||
}}
|
||||
>
|
||||
<HiTrash className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.table.deleteUser")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
5
web/src/views/settings/RolesView.tsx
Normal file
5
web/src/views/settings/RolesView.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import AuthenticationView from "./AuthenticationView";
|
||||
|
||||
export default function RolesView() {
|
||||
return <AuthenticationView section="roles" />;
|
||||
}
|
5
web/src/views/settings/UsersView.tsx
Normal file
5
web/src/views/settings/UsersView.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import AuthenticationView from "./AuthenticationView";
|
||||
|
||||
export default function UsersView() {
|
||||
return <AuthenticationView section="users" />;
|
||||
}
|
Loading…
Reference in New Issue
Block a user