mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-26 19:41:29 +08: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:
@@ -124,7 +124,7 @@ proxy:
|
|||||||
role: x-forwarded-groups
|
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.
|
A default role can be provided. Any value in the mapped `role` header will override the default.
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ proxy:
|
|||||||
|
|
||||||
## Role mapping
|
## 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
|
```yaml
|
||||||
proxy:
|
proxy:
|
||||||
@@ -150,14 +150,17 @@ proxy:
|
|||||||
- access-level-security
|
- access-level-security
|
||||||
viewer:
|
viewer:
|
||||||
- camera-viewer
|
- camera-viewer
|
||||||
|
operator: # Custom role mapping
|
||||||
|
- operators
|
||||||
```
|
```
|
||||||
|
|
||||||
In this example:
|
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 `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 `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 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
|
#### Port Considerations
|
||||||
|
|
||||||
@@ -167,6 +170,7 @@ In this example:
|
|||||||
- The `remote-role` header determines the user’s privileges:
|
- The `remote-role` header determines the user’s privileges:
|
||||||
- **admin** → Full access (user management, configuration changes).
|
- **admin** → Full access (user management, configuration changes).
|
||||||
- **viewer** → Read-only access.
|
- **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.
|
- Ensure your **proxy sends both user and role headers** for proper role enforcement.
|
||||||
|
|
||||||
**Unauthenticated Port (5000)**
|
**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.
|
- **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.
|
- **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
|
### Role Enforcement
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
@@ -33,7 +33,6 @@ from frigate.models import User
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=[Tags.auth])
|
router = APIRouter(tags=[Tags.auth])
|
||||||
VALID_ROLES = ["admin", "viewer"]
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
@@ -204,6 +203,7 @@ async def get_current_user(request: Request):
|
|||||||
def require_role(required_roles: List[str]):
|
def require_role(required_roles: List[str]):
|
||||||
async def role_checker(request: Request):
|
async def role_checker(request: Request):
|
||||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
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)
|
# Get role from header (could be comma-separated)
|
||||||
role_header = request.headers.get("remote-role")
|
role_header = request.headers.get("remote-role")
|
||||||
@@ -217,12 +217,12 @@ def require_role(required_roles: List[str]):
|
|||||||
if not roles:
|
if not roles:
|
||||||
raise HTTPException(status_code=403, detail="Role not provided")
|
raise HTTPException(status_code=403, detail="Role not provided")
|
||||||
|
|
||||||
# enforce VALID_ROLES
|
# enforce config roles
|
||||||
valid_roles = [r for r in roles if r in VALID_ROLES]
|
valid_roles = [r for r in roles if r in config_roles]
|
||||||
if not valid_roles:
|
if not valid_roles:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
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):
|
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
|
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.
|
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
|
- If a role_map is configured, treat the header as group claims
|
||||||
(split by proxy_config.separator) and map to roles.
|
(split by proxy_config.separator) and map to roles.
|
||||||
- If no role_map is configured, treat the header as role names directly.
|
- 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:
|
Args:
|
||||||
headers (dict): Incoming request headers (case-insensitive).
|
headers (dict): Incoming request headers (case-insensitive).
|
||||||
proxy_config (ProxyConfig): Proxy configuration.
|
proxy_config (ProxyConfig): Proxy configuration.
|
||||||
|
config_roles (set[str]): Set of valid roles from config.
|
||||||
|
|
||||||
Returns:
|
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
|
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:
|
if not role_header:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No role header configured in proxy_config.header_map. Returning default role '%s'.",
|
"No role header configured in proxy_config.header_map. Returning validated default role '%s'.",
|
||||||
role,
|
validated_default,
|
||||||
)
|
)
|
||||||
return role
|
return validated_default
|
||||||
|
|
||||||
raw_value = headers.get(role_header, "")
|
raw_value = headers.get(role_header, "")
|
||||||
logger.debug("Raw role header value from '%s': %r", role_header, raw_value)
|
logger.debug("Raw role header value from '%s': %r", role_header, raw_value)
|
||||||
|
|
||||||
if not raw_value:
|
if not raw_value:
|
||||||
logger.debug("Role header missing or empty. Returning default role '%s'.", role)
|
logger.debug(
|
||||||
return role
|
"Role header missing or empty. Returning validated default role '%s'.",
|
||||||
|
validated_default,
|
||||||
|
)
|
||||||
|
return validated_default
|
||||||
|
|
||||||
# role_map configured, treat header as group claims
|
# role_map configured, treat header as group claims
|
||||||
if proxy_config.header_map.role_map:
|
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)
|
logger.debug("Matched roles from role_map: %s", matched_roles)
|
||||||
|
|
||||||
if 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)
|
logger.debug("Resolved role (with role_map) to '%s'.", resolved)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
logger.debug(
|
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,
|
raw_value,
|
||||||
proxy_config.default_role,
|
validated_default,
|
||||||
)
|
)
|
||||||
return role
|
return validated_default
|
||||||
|
|
||||||
# no role_map, treat as role names directly
|
# no role_map, treat as role names directly
|
||||||
roles_from_header = [
|
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)
|
logger.debug("Parsed roles directly from header: %s", roles_from_header)
|
||||||
|
|
||||||
resolved = next(
|
resolved = next(
|
||||||
(r for r in VALID_ROLES if r in roles_from_header),
|
(r for r in config_roles if r in roles_from_header),
|
||||||
proxy_config.default_role,
|
validated_default,
|
||||||
)
|
)
|
||||||
if resolved == proxy_config.default_role and roles_from_header:
|
if resolved == validated_default and roles_from_header:
|
||||||
logger.debug(
|
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,
|
raw_value,
|
||||||
proxy_config.default_role,
|
validated_default,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("Resolved role (direct header) to '%s'.", resolved)
|
logger.debug("Resolved role (direct header) to '%s'.", resolved)
|
||||||
@@ -358,7 +371,8 @@ def auth(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# parse header and resolve a valid role
|
# 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
|
success_response.headers["remote-role"] = role
|
||||||
return success_response
|
return success_response
|
||||||
@@ -452,7 +466,13 @@ def profile(request: Request):
|
|||||||
username = request.headers.get("remote-user", "anonymous")
|
username = request.headers.get("remote-user", "anonymous")
|
||||||
role = request.headers.get("remote-role", "viewer")
|
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")
|
@router.get("/logout")
|
||||||
@@ -483,8 +503,12 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
password_hash = db_user.password_hash
|
password_hash = db_user.password_hash
|
||||||
if verify_password(password, password_hash):
|
if verify_password(password, password_hash):
|
||||||
role = getattr(db_user, "role", "viewer")
|
role = getattr(db_user, "role", "viewer")
|
||||||
if role not in VALID_ROLES:
|
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
||||||
role = "viewer" # Enforce valid roles
|
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
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||||
response = Response("", 200)
|
response = Response("", 200)
|
||||||
@@ -509,11 +533,17 @@ def create_user(
|
|||||||
body: AppPostUsersBody,
|
body: AppPostUsersBody,
|
||||||
):
|
):
|
||||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
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):
|
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
||||||
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
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)
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
User.insert(
|
User.insert(
|
||||||
{
|
{
|
||||||
@@ -584,10 +614,52 @@ async def update_role(
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"message": "Cannot modify admin user's role"}, status_code=403
|
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(
|
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})
|
User.set_by_id(username, {User.role: body.role})
|
||||||
return JSONResponse(content={"success": True})
|
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
|
import string
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -19,7 +20,11 @@ from pathvalidate import sanitize_filename
|
|||||||
from peewee import JOIN, DoesNotExist, fn, operator
|
from peewee import JOIN, DoesNotExist, fn, operator
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.auth import require_role
|
from frigate.api.auth import (
|
||||||
|
get_allowed_cameras_for_filter,
|
||||||
|
require_camera_access,
|
||||||
|
require_role,
|
||||||
|
)
|
||||||
from frigate.api.defs.query.events_query_parameters import (
|
from frigate.api.defs.query.events_query_parameters import (
|
||||||
DEFAULT_TIME_RANGE,
|
DEFAULT_TIME_RANGE,
|
||||||
EventsQueryParams,
|
EventsQueryParams,
|
||||||
@@ -61,7 +66,10 @@ router = APIRouter(tags=[Tags.events])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events", response_model=list[EventResponse])
|
@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
|
camera = params.camera
|
||||||
cameras = params.cameras
|
cameras = params.cameras
|
||||||
|
|
||||||
@@ -135,7 +143,13 @@ def events(params: EventsQueryParams = Depends()):
|
|||||||
clauses.append((Event.camera == camera))
|
clauses.append((Event.camera == camera))
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
camera_list = cameras.split(",")
|
requested = set(cameras.split(","))
|
||||||
|
filtered = requested.intersection(allowed_cameras)
|
||||||
|
if not filtered:
|
||||||
|
return JSONResponse(content=[])
|
||||||
|
camera_list = list(filtered)
|
||||||
|
else:
|
||||||
|
camera_list = allowed_cameras
|
||||||
clauses.append((Event.camera << camera_list))
|
clauses.append((Event.camera << camera_list))
|
||||||
|
|
||||||
if labels != "all":
|
if labels != "all":
|
||||||
@@ -321,9 +335,17 @@ def events(params: EventsQueryParams = Depends()):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events/explore", response_model=list[EventResponse])
|
@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
|
# 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 = {}
|
label_counts = {}
|
||||||
|
|
||||||
@@ -334,14 +356,18 @@ def events_explore(limit: int = 10):
|
|||||||
# get most recent events for this label
|
# get most recent events for this label
|
||||||
label_events = (
|
label_events = (
|
||||||
Event.select()
|
Event.select()
|
||||||
.where(Event.label == label)
|
.where((Event.label == label) & (Event.camera << allowed_cameras))
|
||||||
.order_by(Event.start_time.desc())
|
.order_by(Event.start_time.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|
||||||
# count total events for this label
|
# 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
|
yield from label_events
|
||||||
|
|
||||||
@@ -394,7 +420,7 @@ def events_explore(limit: int = 10):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/event_ids", response_model=list[EventResponse])
|
@router.get("/event_ids", response_model=list[EventResponse])
|
||||||
def event_ids(ids: str):
|
async def event_ids(ids: str, request: Request):
|
||||||
ids = ids.split(",")
|
ids = ids.split(",")
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
@@ -403,6 +429,16 @@ def event_ids(ids: str):
|
|||||||
status_code=400,
|
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:
|
try:
|
||||||
events = Event.select().where(Event.id << ids).dicts().iterator()
|
events = Event.select().where(Event.id << ids).dicts().iterator()
|
||||||
return JSONResponse(list(events))
|
return JSONResponse(list(events))
|
||||||
@@ -413,7 +449,11 @@ def event_ids(ids: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events/search")
|
@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
|
query = params.query
|
||||||
search_type = params.search_type
|
search_type = params.search_type
|
||||||
include_thumbnails = params.include_thumbnails
|
include_thumbnails = params.include_thumbnails
|
||||||
@@ -486,7 +526,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
event_filters = []
|
event_filters = []
|
||||||
|
|
||||||
if cameras != "all":
|
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":
|
if labels != "all":
|
||||||
event_filters.append((Event.label << labels.split(",")))
|
event_filters.append((Event.label << labels.split(",")))
|
||||||
@@ -739,7 +785,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events/summary")
|
@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
|
tz_name = params.timezone
|
||||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
|
||||||
has_clip = params.has_clip
|
has_clip = params.has_clip
|
||||||
@@ -771,7 +820,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
|
|||||||
Event.zones,
|
Event.zones,
|
||||||
fn.COUNT(Event.id).alias("count"),
|
fn.COUNT(Event.id).alias("count"),
|
||||||
)
|
)
|
||||||
.where(reduce(operator.and_, clauses))
|
.where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
|
||||||
.group_by(
|
.group_by(
|
||||||
Event.camera,
|
Event.camera,
|
||||||
Event.label,
|
Event.label,
|
||||||
@@ -786,9 +835,11 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events/{event_id}", response_model=EventResponse)
|
@router.get("/events/{event_id}", response_model=EventResponse)
|
||||||
def event(event_id: str):
|
async def event(event_id: str, request: Request):
|
||||||
try:
|
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:
|
except DoesNotExist:
|
||||||
return JSONResponse(content="Event not found", status_code=404)
|
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)
|
@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():
|
if not request.app.frigate_config.plus_api.is_active():
|
||||||
message = "PLUS_API_KEY environment variable is not set"
|
message = "PLUS_API_KEY environment variable is not set"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
@@ -835,6 +886,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
message = f"Event {event_id} not found"
|
message = f"Event {event_id} not found"
|
||||||
logger.error(message)
|
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)
|
@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():
|
if not request.app.frigate_config.plus_api.is_active():
|
||||||
message = "PLUS_API_KEY environment variable is not set"
|
message = "PLUS_API_KEY environment variable is not set"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
@@ -945,6 +997,7 @@ def false_positive(request: Request, event_id: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
message = f"Event {event_id} not found"
|
message = f"Event {event_id} not found"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
@@ -1022,9 +1075,10 @@ def false_positive(request: Request, event_id: str):
|
|||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def delete_retain(event_id: str):
|
async def delete_retain(event_id: str, request: Request):
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
@@ -1045,13 +1099,14 @@ def delete_retain(event_id: str):
|
|||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def set_sub_label(
|
async def set_sub_label(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
body: EventsSubLabelBody,
|
body: EventsSubLabelBody,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
event = None
|
event = None
|
||||||
|
|
||||||
@@ -1099,13 +1154,14 @@ def set_sub_label(
|
|||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def set_plate(
|
async def set_plate(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
body: EventsLPRBody,
|
body: EventsLPRBody,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
event = None
|
event = None
|
||||||
|
|
||||||
@@ -1154,13 +1210,14 @@ def set_plate(
|
|||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def set_description(
|
async def set_description(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
body: EventsDescriptionBody,
|
body: EventsDescriptionBody,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||||
@@ -1205,11 +1262,12 @@ def set_description(
|
|||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def regenerate_description(
|
async def regenerate_description(
|
||||||
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
|
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
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:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return {"success": False, "message": f"Event {event_id} not found"}
|
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,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def delete_event(request: Request, event_id: str):
|
async def delete_event(request: Request, event_id: str):
|
||||||
result = delete_single_event(event_id, request)
|
result = await delete_single_event(event_id, request)
|
||||||
status_code = 200 if result["success"] else 404
|
status_code = 200 if result["success"] else 404
|
||||||
return JSONResponse(content=result, status_code=status_code)
|
return JSONResponse(content=result, status_code=status_code)
|
||||||
|
|
||||||
@@ -1323,7 +1382,7 @@ def delete_event(request: Request, event_id: str):
|
|||||||
response_model=EventMultiDeleteResponse,
|
response_model=EventMultiDeleteResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
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:
|
if not body.event_ids:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "No event IDs provided."}),
|
content=({"success": False, "message": "No event IDs provided."}),
|
||||||
@@ -1334,7 +1393,7 @@ def delete_events(request: Request, body: EventsDeleteBody):
|
|||||||
not_found_events = []
|
not_found_events = []
|
||||||
|
|
||||||
for event_id in body.event_ids:
|
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"]:
|
if result["success"]:
|
||||||
deleted_events.append(event_id)
|
deleted_events.append(event_id)
|
||||||
else:
|
else:
|
||||||
@@ -1410,8 +1469,10 @@ def create_event(
|
|||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
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:
|
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()
|
end_time = body.end_time or datetime.datetime.now().timestamp()
|
||||||
request.app.event_metadata_updater.publish(
|
request.app.event_metadata_updater.publish(
|
||||||
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
|
(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(
|
def create_trigger_embedding(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: TriggerEmbeddingBody,
|
body: TriggerEmbeddingBody,
|
||||||
camera: str,
|
camera_name: str,
|
||||||
name: str,
|
name: str,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -1454,13 +1515,13 @@ def create_trigger_embedding(
|
|||||||
# Check if trigger already exists
|
# Check if trigger already exists
|
||||||
if (
|
if (
|
||||||
Trigger.select()
|
Trigger.select()
|
||||||
.where(Trigger.camera == camera, Trigger.name == name)
|
.where(Trigger.camera == camera_name, Trigger.name == name)
|
||||||
.exists()
|
.exists()
|
||||||
):
|
):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Trigger {camera}:{name} already exists",
|
"message": f"Trigger {camera_name}:{name} already exists",
|
||||||
},
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
@@ -1530,28 +1591,29 @@ def create_trigger_embedding(
|
|||||||
# Save image to the triggers directory
|
# Save image to the triggers directory
|
||||||
try:
|
try:
|
||||||
os.makedirs(
|
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(
|
with open(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
TRIGGER_DIR,
|
TRIGGER_DIR,
|
||||||
sanitize_filename(camera),
|
sanitize_filename(camera_name),
|
||||||
f"{sanitize_filename(body.data)}.webp",
|
f"{sanitize_filename(body.data)}.webp",
|
||||||
),
|
),
|
||||||
"wb",
|
"wb",
|
||||||
) as f:
|
) as f:
|
||||||
f.write(thumbnail)
|
f.write(thumbnail)
|
||||||
logger.debug(
|
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:
|
except Exception as e:
|
||||||
logger.error(e.with_traceback())
|
logger.error(e.with_traceback())
|
||||||
logger.error(
|
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(
|
Trigger.create(
|
||||||
camera=camera,
|
camera=camera_name,
|
||||||
name=name,
|
name=name,
|
||||||
type=body.type,
|
type=body.type,
|
||||||
data=body.data,
|
data=body.data,
|
||||||
@@ -1565,7 +1627,7 @@ def create_trigger_embedding(
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Trigger created successfully for {camera}:{name}",
|
"message": f"Trigger created successfully for {camera_name}:{name}",
|
||||||
},
|
},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
@@ -1582,13 +1644,13 @@ def create_trigger_embedding(
|
|||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/trigger/embedding/{camera}/{name}",
|
"/trigger/embedding/{camera_name}/{name}",
|
||||||
response_model=dict,
|
response_model=dict,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def update_trigger_embedding(
|
def update_trigger_embedding(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera: str,
|
camera_name: str,
|
||||||
name: str,
|
name: str,
|
||||||
body: TriggerEmbeddingBody,
|
body: TriggerEmbeddingBody,
|
||||||
):
|
):
|
||||||
@@ -1609,7 +1671,9 @@ def update_trigger_embedding(
|
|||||||
embedding = context.generate_description_embedding(body.data)
|
embedding = context.generate_description_embedding(body.data)
|
||||||
elif body.type == "thumbnail":
|
elif body.type == "thumbnail":
|
||||||
webp_file = sanitize_filename(body.data) + ".webp"
|
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:
|
try:
|
||||||
event: Event = Event.get(Event.id == body.data)
|
event: Event = Event.get(Event.id == body.data)
|
||||||
@@ -1656,7 +1720,9 @@ def update_trigger_embedding(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check if trigger exists for upsert
|
# 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:
|
if trigger:
|
||||||
# Update existing trigger
|
# Update existing trigger
|
||||||
@@ -1665,17 +1731,17 @@ def update_trigger_embedding(
|
|||||||
os.remove(
|
os.remove(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
TRIGGER_DIR,
|
TRIGGER_DIR,
|
||||||
sanitize_filename(camera),
|
sanitize_filename(camera_name),
|
||||||
f"{trigger.data}.webp",
|
f"{trigger.data}.webp",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.debug(
|
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:
|
except Exception as e:
|
||||||
logger.error(e.with_traceback())
|
logger.error(e.with_traceback())
|
||||||
logger.error(
|
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(
|
Trigger.update(
|
||||||
@@ -1685,11 +1751,11 @@ def update_trigger_embedding(
|
|||||||
threshold=body.threshold,
|
threshold=body.threshold,
|
||||||
triggering_event_id="",
|
triggering_event_id="",
|
||||||
last_triggered=None,
|
last_triggered=None,
|
||||||
).where(Trigger.camera == camera, Trigger.name == name).execute()
|
).where(Trigger.camera == camera_name, Trigger.name == name).execute()
|
||||||
else:
|
else:
|
||||||
# Create new trigger (for rename case)
|
# Create new trigger (for rename case)
|
||||||
Trigger.create(
|
Trigger.create(
|
||||||
camera=camera,
|
camera=camera_name,
|
||||||
name=name,
|
name=name,
|
||||||
type=body.type,
|
type=body.type,
|
||||||
data=body.data,
|
data=body.data,
|
||||||
@@ -1703,7 +1769,7 @@ def update_trigger_embedding(
|
|||||||
if body.type == "thumbnail":
|
if body.type == "thumbnail":
|
||||||
# Save image to the triggers directory
|
# Save image to the triggers directory
|
||||||
try:
|
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)
|
os.makedirs(camera_path, exist_ok=True)
|
||||||
with open(
|
with open(
|
||||||
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
|
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
|
||||||
@@ -1711,18 +1777,18 @@ def update_trigger_embedding(
|
|||||||
) as f:
|
) as f:
|
||||||
f.write(thumbnail)
|
f.write(thumbnail)
|
||||||
logger.debug(
|
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:
|
except Exception as e:
|
||||||
logger.error(e.with_traceback())
|
logger.error(e.with_traceback())
|
||||||
logger.error(
|
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(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Trigger updated successfully for {camera}:{name}",
|
"message": f"Trigger updated successfully for {camera_name}:{name}",
|
||||||
},
|
},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
@@ -1739,36 +1805,38 @@ def update_trigger_embedding(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/trigger/embedding/{camera}/{name}",
|
"/trigger/embedding/{camera_name}/{name}",
|
||||||
response_model=dict,
|
response_model=dict,
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
)
|
)
|
||||||
def delete_trigger_embedding(
|
def delete_trigger_embedding(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera: str,
|
camera_name: str,
|
||||||
name: str,
|
name: str,
|
||||||
):
|
):
|
||||||
try:
|
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:
|
if trigger is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Trigger {camera}:{name} not found",
|
"message": f"Trigger {camera_name}:{name} not found",
|
||||||
},
|
},
|
||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
deleted = (
|
deleted = (
|
||||||
Trigger.delete()
|
Trigger.delete()
|
||||||
.where(Trigger.camera == camera, Trigger.name == name)
|
.where(Trigger.camera == camera_name, Trigger.name == name)
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
if deleted == 0:
|
if deleted == 0:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Error deleting trigger {camera}:{name}",
|
"message": f"Error deleting trigger {camera_name}:{name}",
|
||||||
},
|
},
|
||||||
status_code=401,
|
status_code=401,
|
||||||
)
|
)
|
||||||
@@ -1776,22 +1844,22 @@ def delete_trigger_embedding(
|
|||||||
try:
|
try:
|
||||||
os.remove(
|
os.remove(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
TRIGGER_DIR, sanitize_filename(camera), f"{trigger.data}.webp"
|
TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.debug(
|
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:
|
except Exception as e:
|
||||||
logger.error(e.with_traceback())
|
logger.error(e.with_traceback())
|
||||||
logger.error(
|
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(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Trigger deleted successfully for {camera}:{name}",
|
"message": f"Trigger deleted successfully for {camera_name}:{name}",
|
||||||
},
|
},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
@@ -4,6 +4,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
@@ -11,7 +12,11 @@ from fastapi.responses import JSONResponse
|
|||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.auth import require_role
|
from frigate.api.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_recordings_body import ExportRecordingsBody
|
||||||
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
@@ -30,12 +35,23 @@ router = APIRouter(tags=[Tags.export])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/exports")
|
@router.get("/exports")
|
||||||
def get_exports():
|
def get_exports(
|
||||||
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
|
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])
|
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(
|
def export_recording(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
@@ -134,9 +150,10 @@ def export_recording(
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
|
"/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:
|
try:
|
||||||
export: Export = Export.get(Export.id == event_id)
|
export: Export = Export.get(Export.id == event_id)
|
||||||
|
await require_camera_access(export.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
@@ -162,9 +179,10 @@ def export_rename(event_id: str, body: ExportRenameBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
|
@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:
|
try:
|
||||||
export: Export = Export.get(Export.id == event_id)
|
export: Export = Export.get(Export.id == event_id)
|
||||||
|
await require_camera_access(export.camera, request=request)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
@@ -215,9 +233,11 @@ def export_delete(event_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/exports/{export_id}")
|
@router.get("/exports/{export_id}")
|
||||||
def get_export(export_id: str):
|
async def get_export(export_id: str, request: Request):
|
||||||
try:
|
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:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Export not found"},
|
content={"success": False, "message": "Export not found"},
|
||||||
|
@@ -10,19 +10,19 @@ import time
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path as FilePath
|
from pathlib import Path as FilePath
|
||||||
from typing import Any
|
from typing import Any, List
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytz
|
import pytz
|
||||||
from fastapi import APIRouter, Path, Query, Request, Response
|
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||||
from fastapi.params import Depends
|
|
||||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||||
from pathvalidate import sanitize_filename
|
from pathvalidate import sanitize_filename
|
||||||
from peewee import DoesNotExist, fn, operator
|
from peewee import DoesNotExist, fn, operator
|
||||||
from tzlocal import get_localzone_name
|
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 (
|
from frigate.api.defs.query.media_query_parameters import (
|
||||||
Extension,
|
Extension,
|
||||||
MediaEventsSnapshotQueryParams,
|
MediaEventsSnapshotQueryParams,
|
||||||
@@ -50,12 +50,11 @@ from frigate.util.path import get_event_thumbnail_bytes
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=[Tags.media])
|
router = APIRouter(tags=[Tags.media])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{camera_name}")
|
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
|
||||||
def mjpeg_feed(
|
async def mjpeg_feed(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
params: MediaMjpegFeedQueryParams = Depends(),
|
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):
|
async def camera_ptz_info(request: Request, camera_name: str):
|
||||||
if camera_name in request.app.frigate_config.cameras:
|
if camera_name in request.app.frigate_config.cameras:
|
||||||
# Schedule get_camera_info in the OnvifController's event loop
|
# 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}")
|
@router.get(
|
||||||
def latest_frame(
|
"/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
|
||||||
|
)
|
||||||
|
async def latest_frame(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
extension: Extension,
|
extension: Extension,
|
||||||
@@ -236,8 +237,11 @@ def latest_frame(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{camera_name}/recordings/{frame_time}/snapshot.{format}")
|
@router.get(
|
||||||
def get_snapshot_from_recording(
|
"/{camera_name}/recordings/{frame_time}/snapshot.{format}",
|
||||||
|
dependencies=[Depends(require_camera_access)],
|
||||||
|
)
|
||||||
|
async def get_snapshot_from_recording(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
frame_time: float,
|
frame_time: float,
|
||||||
@@ -323,8 +327,10 @@ def get_snapshot_from_recording(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{camera_name}/plus/{frame_time}")
|
@router.post(
|
||||||
def submit_recording_snapshot_to_plus(
|
"/{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
|
request: Request, camera_name: str, frame_time: str
|
||||||
):
|
):
|
||||||
if camera_name not in request.app.frigate_config.cameras:
|
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")
|
@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"""
|
"""Returns true/false by day indicating if recordings exist"""
|
||||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
|
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
|
||||||
|
|
||||||
cameras = params.cameras
|
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 = (
|
query = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
@@ -445,7 +463,7 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
|
|||||||
)
|
)
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
query = query.where(Recordings.camera << cameras.split(","))
|
query = query.where(Recordings.camera << cameras)
|
||||||
|
|
||||||
recording_days = query.namedtuples()
|
recording_days = query.namedtuples()
|
||||||
days = {day.day: True for day in recording_days}
|
days = {day.day: True for day in recording_days}
|
||||||
@@ -453,8 +471,10 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()
|
|||||||
return JSONResponse(content=days)
|
return JSONResponse(content=days)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{camera_name}/recordings/summary")
|
@router.get(
|
||||||
def recordings_summary(camera_name: str, timezone: str = "utc"):
|
"/{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"""
|
"""Returns hourly summary for recordings of given camera"""
|
||||||
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
|
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
|
||||||
recording_groups = (
|
recording_groups = (
|
||||||
@@ -515,8 +535,8 @@ def recordings_summary(camera_name: str, timezone: str = "utc"):
|
|||||||
return JSONResponse(content=list(days.values()))
|
return JSONResponse(content=list(days.values()))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{camera_name}/recordings")
|
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
||||||
def recordings(
|
async def recordings(
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
|
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
|
||||||
before: float = datetime.now().timestamp(),
|
before: float = datetime.now().timestamp(),
|
||||||
@@ -546,9 +566,22 @@ def recordings(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/unavailable", response_model=list[dict])
|
@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."""
|
"""Get time ranges with no recordings."""
|
||||||
cameras = params.cameras
|
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()
|
before = params.before or datetime.datetime.now().timestamp()
|
||||||
after = (
|
after = (
|
||||||
params.after
|
params.after
|
||||||
@@ -560,6 +593,8 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
|
|||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
camera_list = cameras.split(",")
|
camera_list = cameras.split(",")
|
||||||
clauses.append((Recordings.camera << camera_list))
|
clauses.append((Recordings.camera << camera_list))
|
||||||
|
else:
|
||||||
|
camera_list = allowed_cameras
|
||||||
|
|
||||||
# Get recording start times
|
# Get recording start times
|
||||||
data: list[Recordings] = (
|
data: list[Recordings] = (
|
||||||
@@ -607,9 +642,10 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()):
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
|
"/{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.",
|
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,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
start_ts: float,
|
start_ts: float,
|
||||||
@@ -705,9 +741,10 @@ def recording_clip(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/vod/{camera_name}/start/{start_ts}/end/{end_ts}",
|
"/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.",
|
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 = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.path,
|
Recordings.path,
|
||||||
@@ -782,6 +819,7 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float):
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/vod/{year_month}/{day}/{hour}/{camera_name}",
|
"/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.",
|
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):
|
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(
|
@router.get(
|
||||||
"/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}",
|
"/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.",
|
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):
|
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}",
|
"/vod/event/{event_id}",
|
||||||
description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
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,
|
event_id: str,
|
||||||
padding: int = Query(0, description="Padding to apply to the vod."),
|
padding: int = Query(0, description="Padding to apply to the vod."),
|
||||||
):
|
):
|
||||||
@@ -828,15 +868,7 @@ def vod_event(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not event.has_clip:
|
await require_camera_access(event.camera, request=request)
|
||||||
logger.error(f"Event does not have recordings: {event_id}")
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Recordings not available.",
|
|
||||||
},
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
end_ts = (
|
end_ts = (
|
||||||
datetime.now().timestamp()
|
datetime.now().timestamp()
|
||||||
@@ -861,7 +893,7 @@ def vod_event(
|
|||||||
"/events/{event_id}/snapshot.jpg",
|
"/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.",
|
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,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
params: MediaEventsSnapshotQueryParams = Depends(),
|
params: MediaEventsSnapshotQueryParams = Depends(),
|
||||||
@@ -871,6 +903,7 @@ def event_snapshot(
|
|||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id, Event.end_time != None)
|
event = Event.get(Event.id == event_id, Event.end_time != None)
|
||||||
event_complete = True
|
event_complete = True
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
if not event.has_snapshot:
|
if not event.has_snapshot:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Snapshot not available"},
|
content={"success": False, "message": "Snapshot not available"},
|
||||||
@@ -899,6 +932,7 @@ def event_snapshot(
|
|||||||
height=params.height,
|
height=params.height,
|
||||||
quality=params.quality,
|
quality=params.quality,
|
||||||
)
|
)
|
||||||
|
await require_camera_access(camera_state.name, request=request)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Ongoing event not found"},
|
content={"success": False, "message": "Ongoing event not found"},
|
||||||
@@ -932,7 +966,7 @@ def event_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events/{event_id}/thumbnail.{extension}")
|
@router.get("/events/{event_id}/thumbnail.{extension}")
|
||||||
def event_thumbnail(
|
async def event_thumbnail(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
extension: Extension,
|
extension: Extension,
|
||||||
@@ -945,6 +979,7 @@ def event_thumbnail(
|
|||||||
event_complete = False
|
event_complete = False
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
if event.end_time is not None:
|
if event.end_time is not None:
|
||||||
event_complete = True
|
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(
|
def grid_snapshot(
|
||||||
request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5
|
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)
|
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(
|
def preview_gif(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
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(
|
def preview_mp4(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
@@ -1650,8 +1691,13 @@ def preview_thumbnail(file_name: str):
|
|||||||
####################### dynamic routes ###########################
|
####################### dynamic routes ###########################
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{camera_name}/{label}/best.jpg")
|
@router.get(
|
||||||
@router.get("/{camera_name}/{label}/thumbnail.jpg")
|
"/{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):
|
def label_thumbnail(request: Request, camera_name: str, label: str):
|
||||||
label = unquote(label)
|
label = unquote(label)
|
||||||
event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name)
|
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):
|
def label_clip(request: Request, camera_name: str, label: str):
|
||||||
label = unquote(label)
|
label = unquote(label)
|
||||||
event_query = Event.select(fn.MAX(Event.id)).where(
|
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):
|
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"""
|
"""Returns the snapshot image from the latest event for the given camera and label combo"""
|
||||||
label = unquote(label)
|
label = unquote(label)
|
||||||
|
@@ -5,9 +5,10 @@ import os
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from frigate.api.auth import require_camera_access
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||||
from frigate.models import Previews
|
from frigate.models import Previews
|
||||||
@@ -18,7 +19,10 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.preview])
|
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):
|
def preview_ts(camera_name: str, start_ts: float, end_ts: float):
|
||||||
"""Get all mp4 previews relevant for time period."""
|
"""Get all mp4 previews relevant for time period."""
|
||||||
if camera_name != "all":
|
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)
|
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):
|
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"""
|
"""Get all mp4 previews relevant for time period given the timezone"""
|
||||||
parts = year_month.split("-")
|
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)
|
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):
|
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
|
||||||
"""Get list of cached preview frames"""
|
"""Get list of cached preview frames"""
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
@@ -4,6 +4,7 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
@@ -12,7 +13,12 @@ from fastapi.responses import JSONResponse
|
|||||||
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
||||||
from playhouse.shortcuts import model_to_dict
|
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 (
|
from frigate.api.defs.query.review_query_parameters import (
|
||||||
ReviewActivityMotionQueryParams,
|
ReviewActivityMotionQueryParams,
|
||||||
ReviewQueryParams,
|
ReviewQueryParams,
|
||||||
@@ -41,6 +47,7 @@ router = APIRouter(tags=[Tags.review])
|
|||||||
async def review(
|
async def review(
|
||||||
params: ReviewQueryParams = Depends(),
|
params: ReviewQueryParams = Depends(),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
):
|
):
|
||||||
if isinstance(current_user, JSONResponse):
|
if isinstance(current_user, JSONResponse):
|
||||||
return current_user
|
return current_user
|
||||||
@@ -65,7 +72,13 @@ async def review(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
camera_list = cameras.split(",")
|
requested = set(cameras.split(","))
|
||||||
|
filtered = requested.intersection(allowed_cameras)
|
||||||
|
if not filtered:
|
||||||
|
return JSONResponse(content=[])
|
||||||
|
camera_list = list(filtered)
|
||||||
|
else:
|
||||||
|
camera_list = allowed_cameras
|
||||||
clauses.append((ReviewSegment.camera << camera_list))
|
clauses.append((ReviewSegment.camera << camera_list))
|
||||||
|
|
||||||
if labels != "all":
|
if labels != "all":
|
||||||
@@ -140,7 +153,7 @@ async def review(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
|
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
|
||||||
def review_ids(ids: str):
|
async def review_ids(request: Request, ids: str):
|
||||||
ids = ids.split(",")
|
ids = ids.split(",")
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
@@ -149,6 +162,18 @@ def review_ids(ids: str):
|
|||||||
status_code=400,
|
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:
|
try:
|
||||||
reviews = (
|
reviews = (
|
||||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||||
@@ -165,6 +190,7 @@ def review_ids(ids: str):
|
|||||||
async def review_summary(
|
async def review_summary(
|
||||||
params: ReviewSummaryQueryParams = Depends(),
|
params: ReviewSummaryQueryParams = Depends(),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
):
|
):
|
||||||
if isinstance(current_user, JSONResponse):
|
if isinstance(current_user, JSONResponse):
|
||||||
return current_user
|
return current_user
|
||||||
@@ -181,7 +207,13 @@ async def review_summary(
|
|||||||
clauses = [(ReviewSegment.start_time > day_ago)]
|
clauses = [(ReviewSegment.start_time > day_ago)]
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
camera_list = cameras.split(",")
|
requested = set(cameras.split(","))
|
||||||
|
filtered = requested.intersection(allowed_cameras)
|
||||||
|
if not filtered:
|
||||||
|
return JSONResponse(content={})
|
||||||
|
camera_list = list(filtered)
|
||||||
|
else:
|
||||||
|
camera_list = allowed_cameras
|
||||||
clauses.append((ReviewSegment.camera << camera_list))
|
clauses.append((ReviewSegment.camera << camera_list))
|
||||||
|
|
||||||
if labels != "all":
|
if labels != "all":
|
||||||
@@ -276,7 +308,13 @@ async def review_summary(
|
|||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
if cameras != "all":
|
if cameras != "all":
|
||||||
camera_list = cameras.split(",")
|
requested = set(cameras.split(","))
|
||||||
|
filtered = requested.intersection(allowed_cameras)
|
||||||
|
if not filtered:
|
||||||
|
return JSONResponse(content={})
|
||||||
|
camera_list = list(filtered)
|
||||||
|
else:
|
||||||
|
camera_list = allowed_cameras
|
||||||
clauses.append((ReviewSegment.camera << camera_list))
|
clauses.append((ReviewSegment.camera << camera_list))
|
||||||
|
|
||||||
if labels != "all":
|
if labels != "all":
|
||||||
@@ -380,6 +418,7 @@ async def review_summary(
|
|||||||
|
|
||||||
@router.post("/reviews/viewed", response_model=GenericResponse)
|
@router.post("/reviews/viewed", response_model=GenericResponse)
|
||||||
async def set_multiple_reviewed(
|
async def set_multiple_reviewed(
|
||||||
|
request: Request,
|
||||||
body: ReviewModifyMultipleBody,
|
body: ReviewModifyMultipleBody,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -390,6 +429,8 @@ async def set_multiple_reviewed(
|
|||||||
|
|
||||||
for review_id in body.ids:
|
for review_id in body.ids:
|
||||||
try:
|
try:
|
||||||
|
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||||
|
await require_camera_access(review.camera, request=request)
|
||||||
review_status = UserReviewStatus.get(
|
review_status = UserReviewStatus.get(
|
||||||
UserReviewStatus.user_id == user_id,
|
UserReviewStatus.user_id == user_id,
|
||||||
UserReviewStatus.review_segment == review_id,
|
UserReviewStatus.review_segment == review_id,
|
||||||
@@ -471,7 +512,10 @@ def delete_reviews(body: ReviewModifyMultipleBody):
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
|
"/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."""
|
"""Get motion and audio activity."""
|
||||||
cameras = params.cameras
|
cameras = params.cameras
|
||||||
before = params.before or datetime.datetime.now().timestamp()
|
before = params.before or datetime.datetime.now().timestamp()
|
||||||
@@ -486,8 +530,14 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
|||||||
clauses.append((Recordings.motion > 0))
|
clauses.append((Recordings.motion > 0))
|
||||||
|
|
||||||
if cameras != "all":
|
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))
|
clauses.append((Recordings.camera << camera_list))
|
||||||
|
else:
|
||||||
|
clauses.append((Recordings.camera << allowed_cameras))
|
||||||
|
|
||||||
data: list[Recordings] = (
|
data: list[Recordings] = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
@@ -545,15 +595,13 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
|
@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:
|
try:
|
||||||
return JSONResponse(
|
review = ReviewSegment.get(
|
||||||
model_to_dict(
|
|
||||||
ReviewSegment.get(
|
|
||||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||||
)
|
)
|
||||||
)
|
await require_camera_access(review.camera, request=request)
|
||||||
)
|
return JSONResponse(model_to_dict(review))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Review item not found"},
|
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)
|
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
|
||||||
def get_review(review_id: str):
|
async def get_review(request: Request, review_id: str):
|
||||||
try:
|
try:
|
||||||
return JSONResponse(
|
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||||
content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id))
|
await require_camera_access(review.camera, request=request)
|
||||||
)
|
return JSONResponse(content=model_to_dict(review))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Review item not found"},
|
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
|
from .base import FrigateBaseModel
|
||||||
|
|
||||||
@@ -34,3 +34,41 @@ class AuthConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
|
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
|
||||||
hash_iterations: int = Field(default=600000, title="Password hash iterations")
|
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./"
|
"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
|
return self
|
||||||
|
|
||||||
@field_validator("cameras")
|
@field_validator("cameras")
|
||||||
|
@@ -135,6 +135,18 @@ class User(Model):
|
|||||||
password_hash = CharField(null=False, max_length=120)
|
password_hash = CharField(null=False, max_length=120)
|
||||||
notification_tokens = JSONField()
|
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):
|
class Trigger(Model):
|
||||||
camera = CharField(max_length=20)
|
camera = CharField(max_length=20)
|
||||||
|
@@ -112,7 +112,7 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def create_app(self, stats=None):
|
def create_app(self, stats=None, event_metadata_publisher=None):
|
||||||
return create_fastapi_app(
|
return create_fastapi_app(
|
||||||
FrigateConfig(**self.minimal_config),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
@@ -121,7 +121,7 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
stats,
|
stats,
|
||||||
None,
|
event_metadata_publisher,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,12 +134,13 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
top_score: int = 100,
|
top_score: int = 100,
|
||||||
score: int = 0,
|
score: int = 0,
|
||||||
data: Json = {},
|
data: Json = {},
|
||||||
|
camera: str = "front_door",
|
||||||
) -> Event:
|
) -> Event:
|
||||||
"""Inserts a basic event model with a given id."""
|
"""Inserts a basic event model with a given id."""
|
||||||
return Event.insert(
|
return Event.insert(
|
||||||
id=id,
|
id=id,
|
||||||
label="Mock",
|
label="Mock",
|
||||||
camera="front_door",
|
camera=camera,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
top_score=top_score,
|
top_score=top_score,
|
||||||
@@ -158,15 +159,23 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
def insert_mock_review_segment(
|
def insert_mock_review_segment(
|
||||||
self,
|
self,
|
||||||
id: str,
|
id: str,
|
||||||
start_time: float = datetime.datetime.now().timestamp(),
|
start_time: float | None = None,
|
||||||
end_time: float = datetime.datetime.now().timestamp() + 20,
|
end_time: float | None = None,
|
||||||
severity: SeverityEnum = SeverityEnum.alert,
|
severity: SeverityEnum = SeverityEnum.alert,
|
||||||
data: Json = {},
|
data: dict | None = None,
|
||||||
|
camera: str = "front_door",
|
||||||
) -> ReviewSegment:
|
) -> ReviewSegment:
|
||||||
"""Inserts a review segment model with a given id."""
|
"""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(
|
return ReviewSegment.insert(
|
||||||
id=id,
|
id=id,
|
||||||
camera="front_door",
|
camera=camera,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
severity=severity,
|
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 datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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
|
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
class TestHttpApp(BaseTestHttp):
|
class TestHttpApp(BaseTestHttp):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp([Event, Recordings, ReviewSegment])
|
super().setUp([Event, Recordings, ReviewSegment, Timeline])
|
||||||
self.app = super().create_app()
|
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 #########################################################
|
################################### GET /events Endpoint #########################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
@@ -135,3 +153,143 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
assert events[1]["id"] == id2
|
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 fastapi.testclient import TestClient
|
||||||
from peewee import DoesNotExist
|
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.models import Event, Recordings, ReviewSegment, UserReviewStatus
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
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_current_user] = mock_get_current_user
|
||||||
|
|
||||||
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||||
|
"front_door"
|
||||||
|
]
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.app.dependency_overrides.clear()
|
self.app.dependency_overrides.clear()
|
||||||
super().tearDown()
|
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):
|
def test_role_map_single_group_match(self):
|
||||||
headers = {"x-remote-role": "group_admin"}
|
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")
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
def test_role_map_multiple_groups(self):
|
def test_role_map_multiple_groups(self):
|
||||||
headers = {"x-remote-role": "group_viewer|group_admin"}
|
headers = {"x-remote-role": "group_admin|group_viewer"}
|
||||||
role = resolve_role(headers, self.proxy_config)
|
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||||
# admin should win since VALID_ROLES priority puts it before viewer
|
|
||||||
self.assertEqual(role, "admin")
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
def test_direct_role_header_with_separator(self):
|
def test_direct_role_header_with_separator(self):
|
||||||
config = self.proxy_config
|
config = self.proxy_config
|
||||||
config.header_map.role_map = None # disable role_map
|
config.header_map.role_map = None # disable role_map
|
||||||
headers = {"x-remote-role": "viewer|admin"}
|
headers = {"x-remote-role": "admin|viewer"}
|
||||||
role = resolve_role(headers, config)
|
role = resolve_role(headers, config, self.config_roles)
|
||||||
# admin should be chosen since it appears in VALID_ROLES
|
|
||||||
self.assertEqual(role, "admin")
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
def test_invalid_role_header(self):
|
def test_invalid_role_header(self):
|
||||||
config = self.proxy_config
|
config = self.proxy_config
|
||||||
config.header_map.role_map = None
|
config.header_map.role_map = None
|
||||||
headers = {"x-remote-role": "notarole"}
|
headers = {"x-remote-role": "notarole"}
|
||||||
role = resolve_role(headers, config)
|
role = resolve_role(headers, config, self.config_roles)
|
||||||
self.assertEqual(role, config.default_role)
|
self.assertEqual(role, config.default_role)
|
||||||
|
|
||||||
def test_missing_role_header(self):
|
def test_missing_role_header(self):
|
||||||
headers = {}
|
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)
|
self.assertEqual(role, self.proxy_config.default_role)
|
||||||
|
|
||||||
def test_empty_role_header(self):
|
def test_empty_role_header(self):
|
||||||
headers = {"x-remote-role": ""}
|
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)
|
self.assertEqual(role, self.proxy_config.default_role)
|
||||||
|
|
||||||
def test_whitespace_groups(self):
|
def test_whitespace_groups(self):
|
||||||
headers = {"x-remote-role": " | group_admin | "}
|
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")
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
def test_mixed_valid_and_invalid_groups(self):
|
def test_mixed_valid_and_invalid_groups(self):
|
||||||
headers = {"x-remote-role": "bogus|group_viewer"}
|
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")
|
self.assertEqual(role, "viewer")
|
||||||
|
|
||||||
def test_case_insensitive_role_direct(self):
|
def test_case_insensitive_role_direct(self):
|
||||||
config = self.proxy_config
|
config = self.proxy_config
|
||||||
config.header_map.role_map = None
|
config.header_map.role_map = None
|
||||||
headers = {"x-remote-role": "AdMiN"}
|
headers = {"x-remote-role": "AdMiN"}
|
||||||
role = resolve_role(headers, config)
|
role = resolve_role(headers, config, self.config_roles)
|
||||||
self.assertEqual(role, "admin")
|
self.assertEqual(role, "admin")
|
||||||
|
|
||||||
def test_role_map_no_match_falls_back(self):
|
def test_role_map_no_match_falls_back(self):
|
||||||
headers = {"x-remote-role": "group_unknown"}
|
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)
|
self.assertEqual(role, self.proxy_config.default_role)
|
||||||
|
@@ -556,7 +556,68 @@
|
|||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminDesc": "Full access to all features.",
|
"adminDesc": "Full access to all features.",
|
||||||
"viewer": "Viewer",
|
"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() {
|
function DefaultAppView() {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
{isDesktop && <Sidebar />}
|
{isDesktop && <Sidebar />}
|
||||||
@@ -64,7 +67,15 @@ function DefaultAppView() {
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
element={<ProtectedRoute requiredRoles={["viewer", "admin"]} />}
|
element={
|
||||||
|
<ProtectedRoute
|
||||||
|
requiredRoles={
|
||||||
|
config?.auth.roles
|
||||||
|
? Object.keys(config.auth.roles)
|
||||||
|
: ["admin", "viewer"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Live />} />
|
<Route index element={<Live />} />
|
||||||
<Route path="/review" element={<Events />} />
|
<Route path="/review" element={<Events />} />
|
||||||
|
@@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
|||||||
export default function ProtectedRoute({
|
export default function ProtectedRoute({
|
||||||
requiredRoles,
|
requiredRoles,
|
||||||
}: {
|
}: {
|
||||||
requiredRoles: ("admin" | "viewer")[];
|
requiredRoles: string[];
|
||||||
}) {
|
}) {
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
|
|
||||||
|
@@ -77,6 +77,8 @@ import { DialogTrigger } from "@radix-ui/react-dialog";
|
|||||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
type CameraGroupSelectorProps = {
|
type CameraGroupSelectorProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -650,6 +652,9 @@ export function CameraGroupEdit({
|
|||||||
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
|
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
const [openCamera, setOpenCamera] = useState<string | null>();
|
const [openCamera, setOpenCamera] = useState<string | null>();
|
||||||
|
|
||||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||||
@@ -837,8 +842,13 @@ export function CameraGroupEdit({
|
|||||||
<FormDescription>{t("group.cameras.desc")}</FormDescription>
|
<FormDescription>{t("group.cameras.desc")}</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
{[
|
{[
|
||||||
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
|
...(birdseyeConfig?.enabled &&
|
||||||
...Object.keys(config?.cameras ?? {}).sort(
|
(!isCustomRole || "birdseye" in allowedCameras)
|
||||||
|
? ["birdseye"]
|
||||||
|
: []),
|
||||||
|
...Object.keys(config?.cameras ?? {})
|
||||||
|
.filter((camera) => allowedCameras.includes(camera))
|
||||||
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(config?.cameras[a]?.ui?.order ?? 0) -
|
(config?.cameras[a]?.ui?.order ?? 0) -
|
||||||
(config?.cameras[b]?.ui?.order ?? 0),
|
(config?.cameras[b]?.ui?.order ?? 0),
|
||||||
|
@@ -25,6 +25,7 @@ import { CamerasFilterButton } from "./CamerasFilterButton";
|
|||||||
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
|
||||||
const REVIEW_FILTERS = [
|
const REVIEW_FILTERS = [
|
||||||
"cameras",
|
"cameras",
|
||||||
@@ -72,6 +73,7 @@ export default function ReviewFilterGroup({
|
|||||||
setMotionOnly,
|
setMotionOnly,
|
||||||
}: ReviewFilterGroupProps) {
|
}: ReviewFilterGroupProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
|
||||||
const allLabels = useMemo<string[]>(() => {
|
const allLabels = useMemo<string[]>(() => {
|
||||||
if (filterList?.labels) {
|
if (filterList?.labels) {
|
||||||
@@ -83,7 +85,9 @@ export default function ReviewFilterGroup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labels = new Set<string>();
|
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) => {
|
cameras.forEach((camera) => {
|
||||||
if (camera == "birdseye") {
|
if (camera == "birdseye") {
|
||||||
@@ -106,7 +110,7 @@ export default function ReviewFilterGroup({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...labels].sort();
|
return [...labels].sort();
|
||||||
}, [config, filterList, filter]);
|
}, [config, filterList, filter, allowedCameras]);
|
||||||
|
|
||||||
const allZones = useMemo<string[]>(() => {
|
const allZones = useMemo<string[]>(() => {
|
||||||
if (filterList?.zones) {
|
if (filterList?.zones) {
|
||||||
@@ -118,7 +122,9 @@ export default function ReviewFilterGroup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zones = new Set<string>();
|
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) => {
|
cameras.forEach((camera) => {
|
||||||
if (camera == "birdseye") {
|
if (camera == "birdseye") {
|
||||||
@@ -134,11 +140,11 @@ export default function ReviewFilterGroup({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...zones].sort();
|
return [...zones].sort();
|
||||||
}, [config, filterList, filter]);
|
}, [config, filterList, filter, allowedCameras]);
|
||||||
|
|
||||||
const filterValues = useMemo(
|
const filterValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cameras: Object.keys(config?.cameras ?? {}).sort(
|
cameras: allowedCameras.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(config?.cameras[a]?.ui?.order ?? 0) -
|
(config?.cameras[a]?.ui?.order ?? 0) -
|
||||||
(config?.cameras[b]?.ui?.order ?? 0),
|
(config?.cameras[b]?.ui?.order ?? 0),
|
||||||
@@ -146,7 +152,7 @@ export default function ReviewFilterGroup({
|
|||||||
labels: Object.values(allLabels || {}),
|
labels: Object.values(allLabels || {}),
|
||||||
zones: Object.values(allZones || {}),
|
zones: Object.values(allZones || {}),
|
||||||
}),
|
}),
|
||||||
[config, allLabels, allZones],
|
[config, allLabels, allZones, allowedCameras],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
|
@@ -24,9 +24,9 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
|||||||
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
|
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
|
||||||
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
|
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
|
||||||
type SearchFilterGroupProps = {
|
type SearchFilterGroupProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@@ -46,6 +46,7 @@ export default function SearchFilterGroup({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
|
||||||
const allLabels = useMemo<string[]>(() => {
|
const allLabels = useMemo<string[]>(() => {
|
||||||
if (filterList?.labels) {
|
if (filterList?.labels) {
|
||||||
@@ -57,7 +58,9 @@ export default function SearchFilterGroup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labels = new Set<string>();
|
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) => {
|
cameras.forEach((camera) => {
|
||||||
if (camera == "birdseye") {
|
if (camera == "birdseye") {
|
||||||
@@ -87,7 +90,7 @@ export default function SearchFilterGroup({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...labels].sort();
|
return [...labels].sort();
|
||||||
}, [config, filterList, filter]);
|
}, [config, filterList, filter, allowedCameras]);
|
||||||
|
|
||||||
const allZones = useMemo<string[]>(() => {
|
const allZones = useMemo<string[]>(() => {
|
||||||
if (filterList?.zones) {
|
if (filterList?.zones) {
|
||||||
@@ -99,7 +102,9 @@ export default function SearchFilterGroup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zones = new Set<string>();
|
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) => {
|
cameras.forEach((camera) => {
|
||||||
if (camera == "birdseye") {
|
if (camera == "birdseye") {
|
||||||
@@ -118,16 +123,16 @@ export default function SearchFilterGroup({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...zones].sort();
|
return [...zones].sort();
|
||||||
}, [config, filterList, filter]);
|
}, [config, filterList, filter, allowedCameras]);
|
||||||
|
|
||||||
const filterValues = useMemo(
|
const filterValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cameras: Object.keys(config?.cameras || {}),
|
cameras: allowedCameras,
|
||||||
labels: Object.values(allLabels || {}),
|
labels: Object.values(allLabels || {}),
|
||||||
zones: Object.values(allZones || {}),
|
zones: Object.values(allZones || {}),
|
||||||
search_type: ["thumbnail", "description"] as SearchSource[],
|
search_type: ["thumbnail", "description"] as SearchSource[],
|
||||||
}),
|
}),
|
||||||
[config, allLabels, allZones],
|
[allLabels, allZones, allowedCameras],
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableSortTypes = useMemo(() => {
|
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 {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-5 py-4"
|
className="space-y-5 pt-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
@@ -36,7 +36,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
type CreateUserOverlayProps = {
|
type CreateUserOverlayProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
|
onCreate: (user: string, password: string, role: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export default function CreateUserDialog({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-5 py-4"
|
className="space-y-5 pt-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name="user"
|
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 { Trans, useTranslation } from "react-i18next";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -7,22 +7,23 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LuShield, LuUser } from "react-icons/lu";
|
import { LuShield, LuUser } from "react-icons/lu";
|
||||||
|
|
||||||
type RoleChangeDialogProps = {
|
type RoleChangeDialogProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
currentRole: "admin" | "viewer";
|
currentRole: string;
|
||||||
onSave: (role: "admin" | "viewer") => void;
|
availableRoles: string[];
|
||||||
|
onSave: (role: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,13 +31,12 @@ export default function RoleChangeDialog({
|
|||||||
show,
|
show,
|
||||||
username,
|
username,
|
||||||
currentRole,
|
currentRole,
|
||||||
|
availableRoles,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: RoleChangeDialogProps) {
|
}: RoleChangeDialogProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
|
const [selectedRole, setSelectedRole] = useState<string>(currentRole);
|
||||||
currentRole,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={show} onOpenChange={onCancel}>
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
@@ -73,31 +73,46 @@ export default function RoleChangeDialog({
|
|||||||
</span>
|
</span>
|
||||||
: {t("users.dialog.changeRole.roleInfo.viewerDesc")}
|
: {t("users.dialog.changeRole.roleInfo.viewerDesc")}
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||||
value={selectedRole}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setSelectedRole(value as "admin" | "viewer")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder={t("users.dialog.changeRole.select")} />
|
<SelectValue placeholder={t("users.dialog.changeRole.select")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="admin" className="flex items-center gap-2">
|
{availableRoles.map((role) => (
|
||||||
|
<SelectItem
|
||||||
|
key={role}
|
||||||
|
value={role}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{role === "admin" ? (
|
||||||
<LuShield className="size-4 text-primary" />
|
<LuShield className="size-4 text-primary" />
|
||||||
<span>{t("role.admin", { ns: "common" })}</span>
|
) : role === "viewer" ? (
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="viewer" className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LuUser className="size-4 text-primary" />
|
<LuUser className="size-4 text-primary" />
|
||||||
<span>{t("role.viewer", { ns: "common" })}</span>
|
) : (
|
||||||
|
<LuUser className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{role === "admin"
|
||||||
|
? t("role.admin", { ns: "common" })
|
||||||
|
: role === "viewer"
|
||||||
|
? t("role.viewer", { ns: "common" })
|
||||||
|
: role}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +123,7 @@ export default function RoleChangeDialog({
|
|||||||
<Button
|
<Button
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
|
variant="outline"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -118,6 +134,7 @@ export default function RoleChangeDialog({
|
|||||||
aria-label={t("button.save", { ns: "common" })}
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
onClick={() => onSave(selectedRole)}
|
onClick={() => onSave(selectedRole)}
|
||||||
|
type="button"
|
||||||
disabled={selectedRole === currentRole}
|
disabled={selectedRole === currentRole}
|
||||||
>
|
>
|
||||||
{t("button.save", { ns: "common" })}
|
{t("button.save", { ns: "common" })}
|
||||||
|
@@ -114,7 +114,7 @@ export default function SetPasswordDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
{t("users.dialog.form.newPassword.title")}
|
{t("users.dialog.form.newPassword.title")}
|
||||||
|
@@ -3,7 +3,8 @@ import { createContext, useEffect, useState } from "react";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: { username: string; role: "admin" | "viewer" | null } | null;
|
user: { username: string; role: string | null } | null;
|
||||||
|
allowedCameras: string[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean; // true if auth is required
|
isAuthenticated: boolean; // true if auth is required
|
||||||
}
|
}
|
||||||
@@ -15,7 +16,12 @@ interface AuthContextType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
auth: { user: null, isLoading: true, isAuthenticated: false },
|
auth: {
|
||||||
|
user: null,
|
||||||
|
allowedCameras: [],
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
},
|
||||||
login: () => {},
|
login: () => {},
|
||||||
logout: () => {},
|
logout: () => {},
|
||||||
});
|
});
|
||||||
@@ -23,6 +29,7 @@ export const AuthContext = createContext<AuthContextType>({
|
|||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [auth, setAuth] = useState<AuthState>({
|
const [auth, setAuth] = useState<AuthState>({
|
||||||
user: null,
|
user: null,
|
||||||
|
allowedCameras: [],
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
@@ -38,7 +45,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
// auth required but not logged in
|
// auth required but not logged in
|
||||||
setAuth({ user: null, isLoading: false, isAuthenticated: true });
|
setAuth({
|
||||||
|
user: null,
|
||||||
|
allowedCameras: [],
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,20 +61,44 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
username: profile.username,
|
username: profile.username,
|
||||||
role: profile.role || "viewer",
|
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 {
|
} else {
|
||||||
// Unauthenticated mode (anonymous)
|
// Unauthenticated mode (anonymous)
|
||||||
setAuth({ user: null, isLoading: false, isAuthenticated: false });
|
setAuth({
|
||||||
|
user: null,
|
||||||
|
allowedCameras: [],
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [profile, error]);
|
}, [profile, error]);
|
||||||
|
|
||||||
const login = (user: AuthState["user"]) => {
|
const login = (user: AuthState["user"]) => {
|
||||||
setAuth({ user, isLoading: false, isAuthenticated: true });
|
setAuth((current) => ({
|
||||||
|
...current,
|
||||||
|
user,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
setAuth({ user: null, isLoading: false, isAuthenticated: true });
|
setAuth({
|
||||||
|
user: null,
|
||||||
|
allowedCameras: [],
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
axios.get("/logout", { withCredentials: 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 { MdCategory, MdVideoLibrary } from "react-icons/md";
|
||||||
import { TbFaceId } from "react-icons/tb";
|
import { TbFaceId } from "react-icons/tb";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { useIsAdmin } from "./use-is-admin";
|
||||||
|
|
||||||
export const ID_LIVE = 1;
|
export const ID_LIVE = 1;
|
||||||
export const ID_REVIEW = 2;
|
export const ID_REVIEW = 2;
|
||||||
@@ -24,6 +25,7 @@ export default function useNavigation(
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -70,7 +72,7 @@ export default function useNavigation(
|
|||||||
icon: TbFaceId,
|
icon: TbFaceId,
|
||||||
title: "menu.faceLibrary",
|
title: "menu.faceLibrary",
|
||||||
url: "/faces",
|
url: "/faces",
|
||||||
enabled: isDesktop && config?.face_recognition.enabled,
|
enabled: isDesktop && config?.face_recognition.enabled && isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ID_CLASSIFICATION,
|
id: ID_CLASSIFICATION,
|
||||||
@@ -78,9 +80,9 @@ export default function useNavigation(
|
|||||||
icon: MdCategory,
|
icon: MdCategory,
|
||||||
title: "menu.classification",
|
title: "menu.classification",
|
||||||
url: "/classification",
|
url: "/classification",
|
||||||
enabled: isDesktop,
|
enabled: isDesktop && isAdmin,
|
||||||
},
|
},
|
||||||
] as NavData[],
|
] 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 { useEffect, useMemo, useRef } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const isCustomRole = useIsCustomRole();
|
||||||
|
|
||||||
// selection
|
// selection
|
||||||
|
|
||||||
@@ -81,19 +84,22 @@ function Live() {
|
|||||||
|
|
||||||
// settings
|
// settings
|
||||||
|
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
|
||||||
const includesBirdseye = useMemo(() => {
|
const includesBirdseye = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
config &&
|
config &&
|
||||||
Object.keys(config.camera_groups).length &&
|
Object.keys(config.camera_groups).length &&
|
||||||
cameraGroup &&
|
cameraGroup &&
|
||||||
config.camera_groups[cameraGroup] &&
|
config.camera_groups[cameraGroup] &&
|
||||||
cameraGroup != "default"
|
cameraGroup != "default" &&
|
||||||
|
(!isCustomRole || "birdseye" in allowedCameras)
|
||||||
) {
|
) {
|
||||||
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [config, cameraGroup]);
|
}, [config, cameraGroup, allowedCameras, isCustomRole]);
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -111,13 +117,15 @@ function Live() {
|
|||||||
.filter(
|
.filter(
|
||||||
(conf) => conf.enabled_in_config && group.cameras.includes(conf.name),
|
(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);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.values(config.cameras)
|
return Object.values(config.cameras)
|
||||||
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
|
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
|
||||||
|
.filter((cam) => allowedCameras.includes(cam.name))
|
||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config, cameraGroup]);
|
}, [config, cameraGroup, allowedCameras]);
|
||||||
|
|
||||||
const selectedCamera = useMemo(
|
const selectedCamera = useMemo(
|
||||||
() => cameras.find((cam) => cam.name == selectedCameraName),
|
() => cameras.find((cam) => cam.name == selectedCameraName),
|
||||||
|
@@ -33,7 +33,8 @@ import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
|||||||
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
||||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
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 NotificationView from "@/views/settings/NotificationsSettingsView";
|
||||||
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
|
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
|
||||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||||
@@ -57,6 +58,7 @@ const allSettingsViews = [
|
|||||||
"triggers",
|
"triggers",
|
||||||
"debug",
|
"debug",
|
||||||
"users",
|
"users",
|
||||||
|
"roles",
|
||||||
"notifications",
|
"notifications",
|
||||||
"frigateplus",
|
"frigateplus",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -288,7 +290,8 @@ export default function Settings() {
|
|||||||
setUnsavedChanges={setUnsavedChanges}
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page == "users" && <AuthenticationView />}
|
{page == "users" && <UsersView />}
|
||||||
|
{page == "roles" && <RolesView />}
|
||||||
{page == "notifications" && (
|
{page == "notifications" && (
|
||||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
||||||
)}
|
)}
|
||||||
|
@@ -342,6 +342,12 @@ export interface FrigateConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
roles: {
|
||||||
|
[roleName: string]: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
birdseye: BirdseyeConfig;
|
birdseye: BirdseyeConfig;
|
||||||
|
|
||||||
cameras: {
|
cameras: {
|
||||||
|
@@ -65,6 +65,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
|
||||||
type RecordingViewProps = {
|
type RecordingViewProps = {
|
||||||
startCamera: string;
|
startCamera: string;
|
||||||
@@ -97,11 +98,17 @@ export function RecordingView({
|
|||||||
|
|
||||||
const timezone = useTimezone(config);
|
const timezone = useTimezone(config);
|
||||||
|
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
const effectiveCameras = useMemo(
|
||||||
|
() => allCameras.filter((camera) => allowedCameras.includes(camera)),
|
||||||
|
[allCameras, allowedCameras],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: recordingsSummary } = useSWR<RecordingsSummary>([
|
const { data: recordingsSummary } = useSWR<RecordingsSummary>([
|
||||||
"recordings/summary",
|
"recordings/summary",
|
||||||
{
|
{
|
||||||
timezone: timezone,
|
timezone: timezone,
|
||||||
cameras: allCameras.join(",") ?? null,
|
cameras: effectiveCameras.join(",") ?? null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -276,14 +283,16 @@ export function RecordingView({
|
|||||||
|
|
||||||
const onSelectCamera = useCallback(
|
const onSelectCamera = useCallback(
|
||||||
(newCam: string) => {
|
(newCam: string) => {
|
||||||
|
if (allowedCameras.includes(newCam)) {
|
||||||
setMainCamera(newCam);
|
setMainCamera(newCam);
|
||||||
setFullResolution({
|
setFullResolution({
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
});
|
});
|
||||||
setPlaybackStart(currentTime);
|
setPlaybackStart(currentTime);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[currentTime],
|
[currentTime, allowedCameras],
|
||||||
);
|
);
|
||||||
|
|
||||||
// fullscreen
|
// fullscreen
|
||||||
@@ -488,12 +497,9 @@ export function RecordingView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<MobileCameraDrawer
|
<MobileCameraDrawer
|
||||||
allCameras={allCameras}
|
allCameras={effectiveCameras}
|
||||||
selected={mainCamera}
|
selected={mainCamera}
|
||||||
onSelectCamera={(cam) => {
|
onSelectCamera={onSelectCamera}
|
||||||
setPlaybackStart(currentTime);
|
|
||||||
setMainCamera(cam);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
@@ -674,7 +680,7 @@ export function RecordingView({
|
|||||||
containerRef={mainLayoutRef}
|
containerRef={mainLayoutRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isDesktop && allCameras.length > 1 && (
|
{isDesktop && effectiveCameras.length > 1 && (
|
||||||
<div
|
<div
|
||||||
ref={previewRowRef}
|
ref={previewRowRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -686,7 +692,7 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
{allCameras.map((cam) => {
|
{effectiveCameras.map((cam) => {
|
||||||
if (cam == mainCamera || cam == "birdseye") {
|
if (cam == mainCamera || cam == "birdseye") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@@ -33,6 +33,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|||||||
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
|
||||||
type SearchViewProps = {
|
type SearchViewProps = {
|
||||||
search: string;
|
search: string;
|
||||||
@@ -96,6 +97,7 @@ export default function SearchView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// suggestions values
|
// suggestions values
|
||||||
|
const allowedCameras = useAllowedCameras();
|
||||||
|
|
||||||
const allLabels = useMemo<string[]>(() => {
|
const allLabels = useMemo<string[]>(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -103,7 +105,9 @@ export default function SearchView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labels = new Set<string>();
|
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) => {
|
cameras.forEach((camera) => {
|
||||||
if (camera == "birdseye") {
|
if (camera == "birdseye") {
|
||||||
@@ -128,7 +132,7 @@ export default function SearchView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...labels].sort();
|
return [...labels].sort();
|
||||||
}, [config, searchFilter]);
|
}, [config, searchFilter, allowedCameras]);
|
||||||
|
|
||||||
const { data: allSubLabels } = useSWR("sub_labels");
|
const { data: allSubLabels } = useSWR("sub_labels");
|
||||||
const { data: allRecognizedLicensePlates } = useSWR(
|
const { data: allRecognizedLicensePlates } = useSWR(
|
||||||
@@ -141,7 +145,9 @@ export default function SearchView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zones = new Set<string>();
|
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) => {
|
cameras.forEach((camera) => {
|
||||||
if (camera == "birdseye") {
|
if (camera == "birdseye") {
|
||||||
@@ -160,11 +166,11 @@ export default function SearchView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [...zones].sort();
|
return [...zones].sort();
|
||||||
}, [config, searchFilter]);
|
}, [config, searchFilter, allowedCameras]);
|
||||||
|
|
||||||
const suggestionsValues = useMemo(
|
const suggestionsValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cameras: Object.keys(config?.cameras || {}),
|
cameras: allowedCameras,
|
||||||
labels: Object.values(allLabels || {}),
|
labels: Object.values(allLabels || {}),
|
||||||
zones: Object.values(allZones || {}),
|
zones: Object.values(allZones || {}),
|
||||||
sub_labels: allSubLabels,
|
sub_labels: allSubLabels,
|
||||||
@@ -192,6 +198,7 @@ export default function SearchView({
|
|||||||
allSubLabels,
|
allSubLabels,
|
||||||
allRecognizedLicensePlates,
|
allRecognizedLicensePlates,
|
||||||
searchFilter,
|
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 ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
@@ -14,7 +14,7 @@ import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
|||||||
import { HiTrash } from "react-icons/hi";
|
import { HiTrash } from "react-icons/hi";
|
||||||
import { FaUserEdit } from "react-icons/fa";
|
import { FaUserEdit } from "react-icons/fa";
|
||||||
|
|
||||||
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
import { LuPencil, LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -31,22 +31,39 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
|
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
|
||||||
|
import CreateRoleDialog from "@/components/overlay/CreateRoleDialog";
|
||||||
|
import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { 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 { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
|
||||||
|
|
||||||
const [showSetPassword, setShowSetPassword] = useState(false);
|
const [showSetPassword, setShowSetPassword] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [showRoleChange, setShowRoleChange] = useState(false);
|
const [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 [selectedUser, setSelectedUser] = useState<string>();
|
||||||
const [selectedUserRole, setSelectedUserRole] = useState<
|
const [selectedUserRole, setSelectedUserRole] = useState<string>();
|
||||||
"admin" | "viewer"
|
|
||||||
>();
|
const [selectedRole, setSelectedRole] = useState<string>();
|
||||||
|
const [currentRoleCameras, setCurrentRoleCameras] = useState<string[]>([]);
|
||||||
|
const [selectedRoleForDelete, setSelectedRoleForDelete] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.authentication");
|
document.title = t("documentTitle.authentication");
|
||||||
@@ -82,11 +99,7 @@ export default function AuthenticationView() {
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCreate = (
|
const onCreate = (user: string, password: string, role: string) => {
|
||||||
user: string,
|
|
||||||
password: string,
|
|
||||||
role: "admin" | "viewer",
|
|
||||||
) => {
|
|
||||||
axios
|
axios
|
||||||
.post("users", { username: user, password, role })
|
.post("users", { username: user, password, role })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -148,8 +161,8 @@ export default function AuthenticationView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeRole = (user: string, newRole: "admin" | "viewer") => {
|
const onChangeRole = (user: string, newRole: string) => {
|
||||||
if (user === "admin") return; // Prevent role change for 'admin'
|
if (user === "admin") return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.put(`users/${user}/role`, { role: newRole })
|
.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) {
|
if (!config || !users) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
@@ -192,10 +402,9 @@ export default function AuthenticationView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Users section
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
const UsersSection = (
|
||||||
<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="mb-5 flex flex-row items-center justify-between gap-2">
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex flex-col items-start">
|
||||||
<Heading as="h3" className="my-2">
|
<Heading as="h3" className="my-2">
|
||||||
@@ -269,7 +478,8 @@ export default function AuthenticationView() {
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{user.username !== "admin" && (
|
{user.username !== "admin" &&
|
||||||
|
user.username !== "viewer" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -279,8 +489,7 @@ export default function AuthenticationView() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedUser(user.username);
|
setSelectedUser(user.username);
|
||||||
setSelectedUserRole(
|
setSelectedUserRole(
|
||||||
(user.role as "admin" | "viewer") ||
|
user.role || "viewer",
|
||||||
"viewer",
|
|
||||||
);
|
);
|
||||||
setShowRoleChange(true);
|
setShowRoleChange(true);
|
||||||
}}
|
}}
|
||||||
@@ -353,8 +562,6 @@ export default function AuthenticationView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SetPasswordDialog
|
<SetPasswordDialog
|
||||||
show={showSetPassword}
|
show={showSetPassword}
|
||||||
onCancel={() => setShowSetPassword(false)}
|
onCancel={() => setShowSetPassword(false)}
|
||||||
@@ -376,10 +583,218 @@ export default function AuthenticationView() {
|
|||||||
show={showRoleChange}
|
show={showRoleChange}
|
||||||
username={selectedUser}
|
username={selectedUser}
|
||||||
currentRole={selectedUserRole}
|
currentRole={selectedUserRole}
|
||||||
onSave={(role) => onChangeRole(selectedUser, role)}
|
availableRoles={availableRoles}
|
||||||
|
onSave={(role) => onChangeRole(selectedUser!, role)}
|
||||||
onCancel={() => setShowRoleChange(false)}
|
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>
|
</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" />;
|
||||||
|
}
|
Reference in New Issue
Block a user