mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
UI viewer role (#16978)
* db migration * db model * assign admin role on password reset * add role to jwt and api responses * don't restrict api access for admins yet * use json response * frontend auth context * update auth form for profile endpoint * add access denied page * add protected routes * auth hook * dialogs * user settings view * restrict viewer access to settings * restrict camera functions for viewer role * add password dialog to account menu * spacing tweak * migrator default to admin * escape quotes in migrator * ui tweaks * tweaks * colors * colors * fix merge conflict * fix icons * add api layer enforcement * ui tweaks * fix error message * debug * clean up * remove print * guard apis for admin only * fix tests * fix review tests * use correct error responses from api in toasts * add role to account menu
This commit is contained in:
parent
6f9d9cd5a8
commit
74ca009b0b
@ -1,14 +1,16 @@
|
|||||||
## Send a subrequest to verify if the user is authenticated and has permission to access the resource.
|
## Send a subrequest to verify if the user is authenticated and has permission to access the resource.
|
||||||
auth_request /auth;
|
auth_request /auth;
|
||||||
|
|
||||||
## Save the upstream metadata response headers from Authelia to variables.
|
## Save the upstream metadata response headers from the auth request to variables
|
||||||
auth_request_set $user $upstream_http_remote_user;
|
auth_request_set $user $upstream_http_remote_user;
|
||||||
|
auth_request_set $role $upstream_http_remote_role;
|
||||||
auth_request_set $groups $upstream_http_remote_groups;
|
auth_request_set $groups $upstream_http_remote_groups;
|
||||||
auth_request_set $name $upstream_http_remote_name;
|
auth_request_set $name $upstream_http_remote_name;
|
||||||
auth_request_set $email $upstream_http_remote_email;
|
auth_request_set $email $upstream_http_remote_email;
|
||||||
|
|
||||||
## Inject the metadata response headers from the variables into the request made to the backend.
|
## Inject the metadata response headers from the variables into the request made to the backend.
|
||||||
proxy_set_header Remote-User $user;
|
proxy_set_header Remote-User $user;
|
||||||
|
proxy_set_header Remote-Role $role;
|
||||||
proxy_set_header Remote-Groups $groups;
|
proxy_set_header Remote-Groups $groups;
|
||||||
proxy_set_header Remote-Email $email;
|
proxy_set_header Remote-Email $email;
|
||||||
proxy_set_header Remote-Name $name;
|
proxy_set_header Remote-Name $name;
|
||||||
|
@ -22,6 +22,7 @@ from markupsafe import escape
|
|||||||
from peewee import operator
|
from peewee import operator
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||||
from frigate.api.defs.request.app_body import AppConfigSetBody
|
from frigate.api.defs.request.app_body import AppConfigSetBody
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
@ -201,7 +202,7 @@ def config_raw():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/save")
|
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||||
new_config = body.decode()
|
new_config = body.decode()
|
||||||
if not new_config:
|
if not new_config:
|
||||||
@ -326,7 +327,7 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/config/set")
|
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def config_set(request: Request, body: AppConfigSetBody):
|
def config_set(request: Request, body: AppConfigSetBody):
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
|
|
||||||
@ -542,7 +543,7 @@ async def logs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart")
|
@router.post("/restart", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def restart():
|
def restart():
|
||||||
try:
|
try:
|
||||||
restart_frigate()
|
restart_frigate()
|
||||||
|
@ -11,8 +11,9 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from joserfc import jwt
|
from joserfc import jwt
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
@ -22,6 +23,7 @@ from frigate.api.defs.request.app_body import (
|
|||||||
AppPostLoginBody,
|
AppPostLoginBody,
|
||||||
AppPostUsersBody,
|
AppPostUsersBody,
|
||||||
AppPutPasswordBody,
|
AppPutPasswordBody,
|
||||||
|
AppPutRoleBody,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config import AuthConfig, ProxyConfig
|
from frigate.config import AuthConfig, ProxyConfig
|
||||||
@ -169,8 +171,10 @@ def verify_password(password, password_hash):
|
|||||||
return secrets.compare_digest(password_hash, compare_hash)
|
return secrets.compare_digest(password_hash, compare_hash)
|
||||||
|
|
||||||
|
|
||||||
def create_encoded_jwt(user, expiration, secret):
|
def create_encoded_jwt(user, role, expiration, secret):
|
||||||
return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret)
|
return jwt.encode(
|
||||||
|
{"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
||||||
@ -184,7 +188,48 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Endpoint for use with nginx auth_request
|
async def get_current_user(request: Request):
|
||||||
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
|
encoded_token = request.cookies.get(JWT_COOKIE_NAME)
|
||||||
|
if not encoded_token:
|
||||||
|
return JSONResponse(content={"message": "No JWT token found"}, status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = jwt.decode(encoded_token, request.app.jwt_token)
|
||||||
|
if "sub" not in token.claims or "role" not in token.claims:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"message": "Invalid JWT token"}, status_code=401
|
||||||
|
)
|
||||||
|
return {"username": token.claims["sub"], "role": token.claims["role"]}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing JWT: {e}")
|
||||||
|
return JSONResponse(content={"message": "Invalid JWT token"}, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(required_roles: List[str]):
|
||||||
|
async def role_checker(request: Request):
|
||||||
|
# Get role from header (could be comma-separated)
|
||||||
|
role_header = request.headers.get("remote-role")
|
||||||
|
roles = [r.strip() for r in role_header.split(",")] if role_header else []
|
||||||
|
|
||||||
|
# Check if we have any roles
|
||||||
|
if not roles:
|
||||||
|
raise HTTPException(status_code=403, detail="Role not provided")
|
||||||
|
|
||||||
|
# Check if any role matches required_roles
|
||||||
|
if not any(role in required_roles for role in roles):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the first matching role
|
||||||
|
return next((role for role in roles if role in required_roles), roles[0])
|
||||||
|
|
||||||
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
@router.get("/auth")
|
@router.get("/auth")
|
||||||
def auth(request: Request):
|
def auth(request: Request):
|
||||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
@ -195,6 +240,8 @@ def auth(request: Request):
|
|||||||
# dont require auth if the request is on the internal port
|
# dont require auth if the request is on the internal port
|
||||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
||||||
if int(request.headers.get("x-server-port", default=0)) == 5000:
|
if int(request.headers.get("x-server-port", default=0)) == 5000:
|
||||||
|
success_response.headers["remote-user"] = "anonymous"
|
||||||
|
success_response.headers["remote-role"] = "admin"
|
||||||
return success_response
|
return success_response
|
||||||
|
|
||||||
fail_response = Response("", status_code=401)
|
fail_response = Response("", status_code=401)
|
||||||
@ -211,14 +258,18 @@ def auth(request: Request):
|
|||||||
if not auth_config.enabled:
|
if not auth_config.enabled:
|
||||||
# pass the user header value from the upstream proxy if a mapping is specified
|
# pass the user header value from the upstream proxy if a mapping is specified
|
||||||
# or use anonymous if none are specified
|
# or use anonymous if none are specified
|
||||||
if proxy_config.header_map.user is not None:
|
user_header = proxy_config.header_map.user
|
||||||
upstream_user_header_value = request.headers.get(
|
role_header = proxy_config.header_map.get("role", "Remote-Role")
|
||||||
proxy_config.header_map.user,
|
success_response.headers["remote-user"] = (
|
||||||
default="anonymous",
|
request.headers.get(user_header, default="anonymous")
|
||||||
)
|
if user_header
|
||||||
success_response.headers["remote-user"] = upstream_user_header_value
|
else "anonymous"
|
||||||
else:
|
)
|
||||||
success_response.headers["remote-user"] = "anonymous"
|
success_response.headers["remote-role"] = (
|
||||||
|
request.headers.get(role_header, default="viewer")
|
||||||
|
if role_header
|
||||||
|
else "viewer"
|
||||||
|
)
|
||||||
return success_response
|
return success_response
|
||||||
|
|
||||||
# now apply authentication
|
# now apply authentication
|
||||||
@ -251,11 +302,15 @@ def auth(request: Request):
|
|||||||
if "sub" not in token.claims:
|
if "sub" not in token.claims:
|
||||||
logger.debug("user not set in jwt token")
|
logger.debug("user not set in jwt token")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
if "role" not in token.claims:
|
||||||
|
logger.debug("role not set in jwt token")
|
||||||
|
return fail_response
|
||||||
if "exp" not in token.claims:
|
if "exp" not in token.claims:
|
||||||
logger.debug("exp not set in jwt token")
|
logger.debug("exp not set in jwt token")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
user = token.claims.get("sub")
|
user = token.claims.get("sub")
|
||||||
|
role = token.claims.get("role")
|
||||||
current_time = int(time.time())
|
current_time = int(time.time())
|
||||||
|
|
||||||
# if the jwt is expired
|
# if the jwt is expired
|
||||||
@ -283,7 +338,7 @@ def auth(request: Request):
|
|||||||
return fail_response
|
return fail_response
|
||||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||||
new_encoded_jwt = create_encoded_jwt(
|
new_encoded_jwt = create_encoded_jwt(
|
||||||
user, new_expiration, request.app.jwt_token
|
user, role, new_expiration, request.app.jwt_token
|
||||||
)
|
)
|
||||||
set_jwt_cookie(
|
set_jwt_cookie(
|
||||||
success_response,
|
success_response,
|
||||||
@ -294,6 +349,7 @@ def auth(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
success_response.headers["remote-user"] = user
|
success_response.headers["remote-user"] = user
|
||||||
|
success_response.headers["remote-role"] = role
|
||||||
return success_response
|
return success_response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing jwt: {e}")
|
logger.error(f"Error parsing jwt: {e}")
|
||||||
@ -302,8 +358,16 @@ def auth(request: Request):
|
|||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def profile(request: Request):
|
def profile(request: Request):
|
||||||
username = request.headers.get("remote-user")
|
username = request.headers.get("remote-user", "anonymous")
|
||||||
return JSONResponse(content={"username": username})
|
if username != "anonymous":
|
||||||
|
try:
|
||||||
|
user = User.get_by_id(username)
|
||||||
|
role = getattr(user, "role", "viewer")
|
||||||
|
except DoesNotExist:
|
||||||
|
role = "viewer" # Fallback if user deleted
|
||||||
|
else:
|
||||||
|
role = None
|
||||||
|
return JSONResponse(content={"username": username, "role": role})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
@ -333,8 +397,11 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
|
|
||||||
password_hash = db_user.password_hash
|
password_hash = db_user.password_hash
|
||||||
if verify_password(password, password_hash):
|
if verify_password(password, password_hash):
|
||||||
|
role = getattr(db_user, "role", "viewer")
|
||||||
|
if role not in ["admin", "viewer"]:
|
||||||
|
role = "viewer" # Enforce valid roles
|
||||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token)
|
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||||
response = Response("", 200)
|
response = Response("", 200)
|
||||||
set_jwt_cookie(
|
set_jwt_cookie(
|
||||||
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
||||||
@ -343,25 +410,31 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def get_users():
|
def get_users():
|
||||||
exports = User.select(User.username).order_by(User.username).dicts().iterator()
|
exports = (
|
||||||
|
User.select(User.username, User.role).order_by(User.username).dicts().iterator()
|
||||||
|
)
|
||||||
return JSONResponse([e for e in exports])
|
return JSONResponse([e for e in exports])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users")
|
@router.post("/users", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def create_user(request: Request, body: AppPostUsersBody):
|
def create_user(
|
||||||
|
request: Request,
|
||||||
|
body: AppPostUsersBody,
|
||||||
|
):
|
||||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
||||||
JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
||||||
|
|
||||||
|
role = body.role if body.role in ["admin", "viewer"] else "viewer"
|
||||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
|
|
||||||
User.insert(
|
User.insert(
|
||||||
{
|
{
|
||||||
User.username: body.username,
|
User.username: body.username,
|
||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
|
User.role: role,
|
||||||
User.notification_tokens: [],
|
User.notification_tokens: [],
|
||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
@ -375,15 +448,61 @@ def delete_user(username: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/users/{username}/password")
|
@router.put("/users/{username}/password")
|
||||||
def update_password(request: Request, username: str, body: AppPutPasswordBody):
|
async def update_password(
|
||||||
|
request: Request,
|
||||||
|
username: str,
|
||||||
|
body: AppPutPasswordBody,
|
||||||
|
):
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
if isinstance(current_user, JSONResponse):
|
||||||
|
# auth failed
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
current_username = current_user.get("username")
|
||||||
|
current_role = current_user.get("role")
|
||||||
|
|
||||||
|
# viewers can only change their own password
|
||||||
|
if current_role == "viewer" and current_username != username:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Viewers can only update their own password"
|
||||||
|
)
|
||||||
|
|
||||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
|
User.set_by_id(username, {User.password_hash: password_hash})
|
||||||
|
|
||||||
User.set_by_id(
|
return JSONResponse(content={"success": True})
|
||||||
username,
|
|
||||||
{
|
|
||||||
User.password_hash: password_hash,
|
@router.put(
|
||||||
},
|
"/users/{username}/role",
|
||||||
)
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
|
async def update_role(
|
||||||
|
request: Request,
|
||||||
|
username: str,
|
||||||
|
body: AppPutRoleBody,
|
||||||
|
):
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
if isinstance(current_user, JSONResponse):
|
||||||
|
# auth failed
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
current_role = current_user.get("role")
|
||||||
|
# viewers can't change anyone's role
|
||||||
|
if current_role == "viewer":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Admin role is required to change user roles"
|
||||||
|
)
|
||||||
|
if username == "admin":
|
||||||
|
return JSONResponse(
|
||||||
|
content={"message": "Cannot modify admin user's role"}, status_code=403
|
||||||
|
)
|
||||||
|
if body.role not in ["admin", "viewer"]:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
User.set_by_id(username, {User.role: body.role})
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
@ -6,12 +6,13 @@ import random
|
|||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, UploadFile
|
from fastapi import APIRouter, Depends, Request, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pathvalidate import sanitize_filename
|
from pathvalidate import sanitize_filename
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import FACE_DIR
|
from frigate.const import FACE_DIR
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
@ -44,7 +45,7 @@ def get_faces():
|
|||||||
return JSONResponse(status_code=200, content=face_dict)
|
return JSONResponse(status_code=200, content=face_dict)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/faces/reprocess")
|
@router.post("/faces/reprocess", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def reclassify_face(request: Request, body: dict = None):
|
def reclassify_face(request: Request, body: dict = None):
|
||||||
if not request.app.frigate_config.face_recognition.enabled:
|
if not request.app.frigate_config.face_recognition.enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -121,7 +122,7 @@ def train_face(request: Request, name: str, body: dict = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/faces/{name}/create")
|
@router.post("/faces/{name}/create", dependencies=[Depends(require_role(["admin"]))])
|
||||||
async def create_face(request: Request, name: str):
|
async def create_face(request: Request, name: str):
|
||||||
if not request.app.frigate_config.face_recognition.enabled:
|
if not request.app.frigate_config.face_recognition.enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -138,7 +139,7 @@ async def create_face(request: Request, name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/faces/{name}/register")
|
@router.post("/faces/{name}/register", dependencies=[Depends(require_role(["admin"]))])
|
||||||
async def register_face(request: Request, name: str, file: UploadFile):
|
async def register_face(request: Request, name: str, file: UploadFile):
|
||||||
if not request.app.frigate_config.face_recognition.enabled:
|
if not request.app.frigate_config.face_recognition.enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -154,7 +155,7 @@ async def register_face(request: Request, name: str, file: UploadFile):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/faces/{name}/delete")
|
@router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def deregister_faces(request: Request, name: str, body: dict = None):
|
def deregister_faces(request: Request, name: str, body: dict = None):
|
||||||
if not request.app.frigate_config.face_recognition.enabled:
|
if not request.app.frigate_config.face_recognition.enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@ -12,8 +14,13 @@ class AppPutPasswordBody(BaseModel):
|
|||||||
class AppPostUsersBody(BaseModel):
|
class AppPostUsersBody(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
role: Optional[str] = "viewer"
|
||||||
|
|
||||||
|
|
||||||
class AppPostLoginBody(BaseModel):
|
class AppPostLoginBody(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AppPutRoleBody(BaseModel):
|
||||||
|
role: str
|
||||||
|
@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from peewee import JOIN, DoesNotExist, fn, operator
|
from peewee import JOIN, DoesNotExist, fn, operator
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.query.events_query_parameters import (
|
from frigate.api.defs.query.events_query_parameters import (
|
||||||
DEFAULT_TIME_RANGE,
|
DEFAULT_TIME_RANGE,
|
||||||
EventsQueryParams,
|
EventsQueryParams,
|
||||||
@ -708,7 +709,11 @@ def event(event_id: str):
|
|||||||
return JSONResponse(content="Event not found", status_code=404)
|
return JSONResponse(content="Event not found", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events/{event_id}/retain", response_model=GenericResponse)
|
@router.post(
|
||||||
|
"/events/{event_id}/retain",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def set_retain(event_id: str):
|
def set_retain(event_id: str):
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
@ -928,7 +933,11 @@ def false_positive(request: Request, event_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/events/{event_id}/retain", response_model=GenericResponse)
|
@router.delete(
|
||||||
|
"/events/{event_id}/retain",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def delete_retain(event_id: str):
|
def delete_retain(event_id: str):
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
@ -947,7 +956,11 @@ def delete_retain(event_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events/{event_id}/sub_label", response_model=GenericResponse)
|
@router.post(
|
||||||
|
"/events/{event_id}/sub_label",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def set_sub_label(
|
def set_sub_label(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
@ -1022,7 +1035,11 @@ def set_sub_label(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events/{event_id}/description", response_model=GenericResponse)
|
@router.post(
|
||||||
|
"/events/{event_id}/description",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def set_description(
|
def set_description(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
@ -1069,7 +1086,11 @@ def set_description(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/events/{event_id}/description/regenerate", response_model=GenericResponse)
|
@router.put(
|
||||||
|
"/events/{event_id}/description/regenerate",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def regenerate_description(
|
def regenerate_description(
|
||||||
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
|
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
|
||||||
):
|
):
|
||||||
@ -1137,14 +1158,22 @@ def delete_single_event(event_id: str, request: Request) -> dict:
|
|||||||
return {"success": True, "message": f"Event {event_id} deleted"}
|
return {"success": True, "message": f"Event {event_id} deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/events/{event_id}", response_model=GenericResponse)
|
@router.delete(
|
||||||
|
"/events/{event_id}",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def delete_event(request: Request, event_id: str):
|
def delete_event(request: Request, event_id: str):
|
||||||
result = delete_single_event(event_id, request)
|
result = delete_single_event(event_id, request)
|
||||||
status_code = 200 if result["success"] else 404
|
status_code = 200 if result["success"] else 404
|
||||||
return JSONResponse(content=result, status_code=status_code)
|
return JSONResponse(content=result, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/events/", response_model=EventMultiDeleteResponse)
|
@router.delete(
|
||||||
|
"/events/",
|
||||||
|
response_model=EventMultiDeleteResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def delete_events(request: Request, body: EventsDeleteBody):
|
def delete_events(request: Request, body: EventsDeleteBody):
|
||||||
if not body.event_ids:
|
if not body.event_ids:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -1170,7 +1199,11 @@ def delete_events(request: Request, body: EventsDeleteBody):
|
|||||||
return JSONResponse(content=response, status_code=200)
|
return JSONResponse(content=response, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events/{camera_name}/{label}/create", response_model=EventCreateResponse)
|
@router.post(
|
||||||
|
"/events/{camera_name}/{label}/create",
|
||||||
|
response_model=EventCreateResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def create_event(
|
def create_event(
|
||||||
request: Request,
|
request: Request,
|
||||||
camera_name: str,
|
camera_name: str,
|
||||||
@ -1226,7 +1259,11 @@ def create_event(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/events/{event_id}/end", response_model=GenericResponse)
|
@router.put(
|
||||||
|
"/events/{event_id}/end",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def end_event(request: Request, event_id: str, body: EventsEndBody):
|
def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||||
try:
|
try:
|
||||||
end_time = body.end_time or datetime.datetime.now().timestamp()
|
end_time = body.end_time or datetime.datetime.now().timestamp()
|
||||||
|
@ -6,11 +6,12 @@ import string
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
|
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
|
||||||
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
@ -130,7 +131,9 @@ def export_recording(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/export/{event_id}/rename")
|
@router.patch(
|
||||||
|
"/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))]
|
||||||
|
)
|
||||||
def export_rename(event_id: str, body: ExportRenameBody):
|
def export_rename(event_id: str, body: ExportRenameBody):
|
||||||
try:
|
try:
|
||||||
export: Export = Export.get(Export.id == event_id)
|
export: Export = Export.get(Export.id == event_id)
|
||||||
@ -158,7 +161,7 @@ def export_rename(event_id: str, body: ExportRenameBody):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/export/{event_id}")
|
@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def export_delete(event_id: str):
|
def export_delete(event_id: str):
|
||||||
try:
|
try:
|
||||||
export: Export = Export.get(Export.id == event_id)
|
export: Export = Export.get(Export.id == event_id)
|
||||||
|
@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from peewee import Case, DoesNotExist, fn, operator
|
from peewee import Case, DoesNotExist, fn, operator
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.query.review_query_parameters import (
|
from frigate.api.defs.query.review_query_parameters import (
|
||||||
ReviewActivityMotionQueryParams,
|
ReviewActivityMotionQueryParams,
|
||||||
ReviewQueryParams,
|
ReviewQueryParams,
|
||||||
@ -343,7 +344,11 @@ def set_multiple_reviewed(body: ReviewModifyMultipleBody):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reviews/delete", response_model=GenericResponse)
|
@router.post(
|
||||||
|
"/reviews/delete",
|
||||||
|
response_model=GenericResponse,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
def delete_reviews(body: ReviewModifyMultipleBody):
|
def delete_reviews(body: ReviewModifyMultipleBody):
|
||||||
list_of_ids = body.ids
|
list_of_ids = body.ids
|
||||||
reviews = (
|
reviews = (
|
||||||
|
@ -620,6 +620,7 @@ class FrigateApp:
|
|||||||
)
|
)
|
||||||
User.replace(
|
User.replace(
|
||||||
username="admin",
|
username="admin",
|
||||||
|
role="admin",
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
notification_tokens=[],
|
notification_tokens=[],
|
||||||
).execute()
|
).execute()
|
||||||
|
@ -12,6 +12,10 @@ class HeaderMappingConfig(FrigateBaseModel):
|
|||||||
user: str = Field(
|
user: str = Field(
|
||||||
default=None, title="Header name from upstream proxy to identify user."
|
default=None, title="Header name from upstream proxy to identify user."
|
||||||
)
|
)
|
||||||
|
role: str = Field(
|
||||||
|
default=None,
|
||||||
|
title="Header name from upstream proxy to identify user role.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProxyConfig(FrigateBaseModel):
|
class ProxyConfig(FrigateBaseModel):
|
||||||
|
@ -117,5 +117,9 @@ class RecordingsToDelete(Model): # type: ignore[misc]
|
|||||||
|
|
||||||
class User(Model): # type: ignore[misc]
|
class User(Model): # type: ignore[misc]
|
||||||
username = CharField(null=False, primary_key=True, max_length=30)
|
username = CharField(null=False, primary_key=True, max_length=30)
|
||||||
|
role = CharField(
|
||||||
|
max_length=20,
|
||||||
|
default="viewer",
|
||||||
|
)
|
||||||
password_hash = CharField(null=False, max_length=120)
|
password_hash = CharField(null=False, max_length=120)
|
||||||
notification_tokens = JSONField()
|
notification_tokens = JSONField()
|
||||||
|
@ -504,7 +504,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_post_reviews_delete_no_body(self):
|
def test_post_reviews_delete_no_body(self):
|
||||||
with TestClient(self.app) as client:
|
with TestClient(self.app) as client:
|
||||||
super().insert_mock_review_segment("123456.random")
|
super().insert_mock_review_segment("123456.random")
|
||||||
response = client.post("/reviews/delete")
|
response = client.post("/reviews/delete", headers={"remote-role": "admin"})
|
||||||
# Missing ids
|
# Missing ids
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
@ -512,7 +512,9 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
with TestClient(self.app) as client:
|
with TestClient(self.app) as client:
|
||||||
super().insert_mock_review_segment("123456.random")
|
super().insert_mock_review_segment("123456.random")
|
||||||
body = {"ids": [""]}
|
body = {"ids": [""]}
|
||||||
response = client.post("/reviews/delete", json=body)
|
response = client.post(
|
||||||
|
"/reviews/delete", json=body, headers={"remote-role": "admin"}
|
||||||
|
)
|
||||||
# Missing ids
|
# Missing ids
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
@ -521,7 +523,9 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
body = {"ids": ["1"]}
|
body = {"ids": ["1"]}
|
||||||
response = client.post("/reviews/delete", json=body)
|
response = client.post(
|
||||||
|
"/reviews/delete", json=body, headers={"remote-role": "admin"}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response_json["success"] == True
|
assert response_json["success"] == True
|
||||||
@ -536,7 +540,9 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
body = {"ids": [id]}
|
body = {"ids": [id]}
|
||||||
response = client.post("/reviews/delete", json=body)
|
response = client.post(
|
||||||
|
"/reviews/delete", json=body, headers={"remote-role": "admin"}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response_json["success"] == True
|
assert response_json["success"] == True
|
||||||
@ -558,7 +564,9 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert len(recordings_ids_in_db_before) == 2
|
assert len(recordings_ids_in_db_before) == 2
|
||||||
|
|
||||||
body = {"ids": ids}
|
body = {"ids": ids}
|
||||||
response = client.post("/reviews/delete", json=body)
|
response = client.post(
|
||||||
|
"/reviews/delete", json=body, headers={"remote-role": "admin"}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response_json["success"] == True
|
assert response_json["success"] == True
|
||||||
|
@ -172,7 +172,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
client.delete(f"/events/{id}")
|
client.delete(f"/events/{id}", headers={"remote-role": "admin"})
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event == "Event not found"
|
assert event == "Event not found"
|
||||||
|
|
||||||
@ -192,12 +192,12 @@ class TestHttp(unittest.TestCase):
|
|||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_insert_mock_event(id)
|
_insert_mock_event(id)
|
||||||
client.post(f"/events/{id}/retain")
|
client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["retain_indefinitely"] is True
|
assert event["retain_indefinitely"] is True
|
||||||
client.delete(f"/events/{id}/retain")
|
client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
@ -262,6 +262,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
new_sub_label_response = client.post(
|
new_sub_label_response = client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
json={"subLabel": sub_label},
|
json={"subLabel": sub_label},
|
||||||
|
headers={"remote-role": "admin"},
|
||||||
)
|
)
|
||||||
assert new_sub_label_response.status_code == 200
|
assert new_sub_label_response.status_code == 200
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
@ -271,6 +272,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
empty_sub_label_response = client.post(
|
empty_sub_label_response = client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
json={"subLabel": ""},
|
json={"subLabel": ""},
|
||||||
|
headers={"remote-role": "admin"},
|
||||||
)
|
)
|
||||||
assert empty_sub_label_response.status_code == 200
|
assert empty_sub_label_response.status_code == 200
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
@ -298,6 +300,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
client.post(
|
client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
json={"subLabel": sub_label},
|
json={"subLabel": sub_label},
|
||||||
|
headers={"remote-role": "admin"},
|
||||||
)
|
)
|
||||||
sub_labels = client.get("/sub_labels").json()
|
sub_labels = client.get("/sub_labels").json()
|
||||||
assert sub_labels
|
assert sub_labels
|
||||||
|
37
migrations/029_add_user_role.py
Normal file
37
migrations/029_add_user_role.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""Peewee migrations -- 029_add_user_role.py.
|
||||||
|
|
||||||
|
Some examples (model - class or model name)::
|
||||||
|
|
||||||
|
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||||
|
|
||||||
|
> migrator.sql(sql) # Run custom SQL
|
||||||
|
> migrator.python(func, *args, **kwargs) # Run python code
|
||||||
|
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||||
|
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||||
|
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||||
|
> migrator.change_fields(model, **fields) # Change fields
|
||||||
|
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||||
|
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||||
|
> migrator.rename_table(model, new_table_name)
|
||||||
|
> migrator.add_index(model, *col_names, unique=False)
|
||||||
|
> migrator.drop_index(model, *col_names)
|
||||||
|
> migrator.add_not_null(model, *field_names)
|
||||||
|
> migrator.drop_not_null(model, *field_names)
|
||||||
|
> migrator.add_default(model, field_name, default)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import peewee as pw
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.sql(
|
||||||
|
'ALTER TABLE "user" ADD COLUMN "role" VARCHAR(20) NOT NULL DEFAULT \'admin\''
|
||||||
|
)
|
||||||
|
migrator.sql('UPDATE "user" SET "role" = \'admin\' WHERE "role" IS NULL')
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.sql('ALTER TABLE "user" DROP COLUMN "role"')
|
@ -10,6 +10,8 @@ import { Suspense, lazy } from "react";
|
|||||||
import { Redirect } from "./components/navigation/Redirect";
|
import { Redirect } from "./components/navigation/Redirect";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
import { isPWA } from "./utils/isPWA";
|
import { isPWA } from "./utils/isPWA";
|
||||||
|
import ProtectedRoute from "@/components/auth/ProtectedRoute";
|
||||||
|
import { AuthProvider } from "@/context/auth-context";
|
||||||
|
|
||||||
const Live = lazy(() => import("@/pages/Live"));
|
const Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
const Events = lazy(() => import("@/pages/Events"));
|
||||||
@ -21,45 +23,58 @@ const Settings = lazy(() => import("@/pages/Settings"));
|
|||||||
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
|
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
|
||||||
const FaceLibrary = lazy(() => import("@/pages/FaceLibrary"));
|
const FaceLibrary = lazy(() => import("@/pages/FaceLibrary"));
|
||||||
const Logs = lazy(() => import("@/pages/Logs"));
|
const Logs = lazy(() => import("@/pages/Logs"));
|
||||||
|
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<BrowserRouter basename={window.baseUrl}>
|
<AuthProvider>
|
||||||
<Wrapper>
|
<BrowserRouter basename={window.baseUrl}>
|
||||||
<div className="size-full overflow-hidden">
|
<Wrapper>
|
||||||
{isDesktop && <Sidebar />}
|
<div className="size-full overflow-hidden">
|
||||||
{isDesktop && <Statusbar />}
|
{isDesktop && <Sidebar />}
|
||||||
{isMobile && <Bottombar />}
|
{isDesktop && <Statusbar />}
|
||||||
<div
|
{isMobile && <Bottombar />}
|
||||||
id="pageRoot"
|
<div
|
||||||
className={cn(
|
id="pageRoot"
|
||||||
"absolute right-0 top-0 overflow-hidden",
|
className={cn(
|
||||||
isMobile
|
"absolute right-0 top-0 overflow-hidden",
|
||||||
? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16`
|
isMobile
|
||||||
: "bottom-8 left-[52px]",
|
? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16`
|
||||||
)}
|
: "bottom-8 left-[52px]",
|
||||||
>
|
)}
|
||||||
<Suspense>
|
>
|
||||||
<Routes>
|
<Suspense>
|
||||||
<Route index element={<Live />} />
|
<Routes>
|
||||||
<Route path="/events" element={<Redirect to="/review" />} />
|
<Route
|
||||||
<Route path="/review" element={<Events />} />
|
element={
|
||||||
<Route path="/explore" element={<Explore />} />
|
<ProtectedRoute requiredRoles={["viewer", "admin"]} />
|
||||||
<Route path="/export" element={<Exports />} />
|
}
|
||||||
<Route path="/system" element={<System />} />
|
>
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route index element={<Live />} />
|
||||||
<Route path="/config" element={<ConfigEditor />} />
|
<Route path="/review" element={<Events />} />
|
||||||
<Route path="/logs" element={<Logs />} />
|
<Route path="/explore" element={<Explore />} />
|
||||||
<Route path="/playground" element={<UIPlayground />} />
|
<Route path="/export" element={<Exports />} />
|
||||||
<Route path="/faces" element={<FaceLibrary />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="*" element={<Redirect to="/" />} />
|
</Route>
|
||||||
</Routes>
|
<Route
|
||||||
</Suspense>
|
element={<ProtectedRoute requiredRoles={["admin"]} />}
|
||||||
|
>
|
||||||
|
<Route path="/system" element={<System />} />
|
||||||
|
<Route path="/config" element={<ConfigEditor />} />
|
||||||
|
<Route path="/logs" element={<Logs />} />
|
||||||
|
<Route path="/faces" element={<FaceLibrary />} />
|
||||||
|
<Route path="/playground" element={<UIPlayground />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/unauthorized" element={<AccessDenied />} />
|
||||||
|
<Route path="*" element={<Redirect to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Wrapper>
|
||||||
</Wrapper>
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
</AuthProvider>
|
||||||
</Providers>
|
</Providers>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -20,24 +20,23 @@ import {
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||||
|
const { login } = React.useContext(AuthContext);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
user: z.string(),
|
user: z.string().min(1, "Username is required"),
|
||||||
password: z.string(),
|
password: z.string().min(1, "Password is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: { user: "", password: "" },
|
||||||
user: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
@ -50,11 +49,14 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
|||||||
password: values.password,
|
password: values.password,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: { "X-CSRF-TOKEN": 1 },
|
||||||
"X-CSRF-TOKEN": 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const profileRes = await axios.get("/profile", { withCredentials: true });
|
||||||
|
login({
|
||||||
|
username: profileRes.data.username,
|
||||||
|
role: profileRes.data.role || "viewer",
|
||||||
|
});
|
||||||
window.location.href = baseUrl;
|
window.location.href = baseUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
|
40
web/src/components/auth/ProtectedRoute.tsx
Normal file
40
web/src/components/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { AuthContext } from "@/context/auth-context";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
|
||||||
|
export default function ProtectedRoute({
|
||||||
|
requiredRoles,
|
||||||
|
}: {
|
||||||
|
requiredRoles: ("admin" | "viewer")[];
|
||||||
|
}) {
|
||||||
|
const { auth } = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (auth.isLoading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated mode
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated mode (8971): require login
|
||||||
|
if (!auth.user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback
|
||||||
|
// though isAuthenticated should catch this
|
||||||
|
if (auth.user.role === null) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiredRoles.includes(auth.user.role)) {
|
||||||
|
return <Navigate to="/unauthorized" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
@ -281,10 +281,13 @@ function NewGroupDialog({
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setEditState("none");
|
setEditState("none");
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -725,10 +728,13 @@ export function CameraGroupEdit({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -44,8 +44,12 @@ export default function SearchActionGroup({
|
|||||||
pullLatestData();
|
pullLatestData();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
toast.error("Failed to delete tracked objects.", {
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to delete tracked objects.: ${errorMessage}`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -18,22 +18,52 @@ import {
|
|||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
import { DialogClose } from "../ui/dialog";
|
import { DialogClose } from "../ui/dialog";
|
||||||
import { LuLogOut } from "react-icons/lu";
|
import { LuLogOut, LuSquarePen } from "react-icons/lu";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import SetPasswordDialog from "../overlay/SetPasswordDialog";
|
||||||
|
|
||||||
type AccountSettingsProps = {
|
type AccountSettingsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountSettings({ className }: AccountSettingsProps) {
|
export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||||
const { data: profile } = useSWR("profile");
|
const { data: profile } = useSWR("profile");
|
||||||
const { data: config } = useSWR("config");
|
const { data: config } = useSWR("config");
|
||||||
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
|
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
|
||||||
|
|
||||||
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
|
|
||||||
const Container = isDesktop ? DropdownMenu : Drawer;
|
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||||
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||||
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||||
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
|
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
|
||||||
|
|
||||||
|
const handlePasswordSave = async (password: string) => {
|
||||||
|
if (!profile?.username || profile.username === "anonymous") return;
|
||||||
|
axios
|
||||||
|
.put(`users/${profile.username}/password`, { password })
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
toast.success("Password updated successfully.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Error setting password: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container modal={!isDesktop}>
|
<Container modal={!isDesktop}>
|
||||||
<Trigger>
|
<Trigger>
|
||||||
@ -65,9 +95,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
|||||||
>
|
>
|
||||||
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
Current User: {profile?.username || "anonymous"}
|
Current User: {profile?.username || "anonymous"}{" "}
|
||||||
|
{profile?.role && `(${profile.role})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||||
|
{profile?.username && profile.username !== "anonymous" && (
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="Set Password"
|
||||||
|
onClick={() => setPasswordDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuSquarePen className="mr-2 size-4" />
|
||||||
|
<span>Set Password</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={
|
className={
|
||||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||||
@ -81,6 +124,12 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
|
<SetPasswordDialog
|
||||||
|
show={passwordDialogOpen}
|
||||||
|
onSave={handlePasswordSave}
|
||||||
|
onCancel={() => setPasswordDialogOpen(false)}
|
||||||
|
username={profile?.username}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,6 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { CgDarkMode } from "react-icons/cg";
|
import { CgDarkMode } from "react-icons/cg";
|
||||||
import {
|
import {
|
||||||
@ -33,10 +32,8 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from "@/context/theme-provider";
|
} from "@/context/theme-provider";
|
||||||
import { IoColorPalette } from "react-icons/io5";
|
import { IoColorPalette } from "react-icons/io5";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRestart } from "@/api/ws";
|
import { useRestart } from "@/api/ws";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -55,21 +52,27 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import RestartDialog from "../overlay/dialog/RestartDialog";
|
import RestartDialog from "../overlay/dialog/RestartDialog";
|
||||||
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import SetPasswordDialog from "../overlay/SetPasswordDialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
type GeneralSettingsProps = {
|
type GeneralSettingsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||||
const { data: profile } = useSWR("profile");
|
const { data: profile } = useSWR("profile");
|
||||||
const { data: config } = useSWR("config");
|
const { data: config } = useSWR("config");
|
||||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||||
|
|
||||||
// settings
|
|
||||||
|
|
||||||
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
const { send: sendRestart } = useRestart();
|
const { send: sendRestart } = useRestart();
|
||||||
|
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const Container = isDesktop ? DropdownMenu : Drawer;
|
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||||
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||||
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||||
@ -79,6 +82,29 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
|
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
|
||||||
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
|
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
|
||||||
|
|
||||||
|
const handlePasswordSave = async (password: string) => {
|
||||||
|
if (!profile?.username || profile.username === "anonymous") return;
|
||||||
|
axios
|
||||||
|
.put(`users/${profile.username}/password`, { password })
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
toast.success("Password updated successfully.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Error setting password: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container modal={!isDesktop}>
|
<Container modal={!isDesktop}>
|
||||||
@ -121,13 +147,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
>
|
>
|
||||||
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<div className="mb-2">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
Current User: {profile?.username || "anonymous"}
|
Current User: {profile?.username || "anonymous"}{" "}
|
||||||
|
{profile?.role && `(${profile.role})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator
|
<DropdownMenuSeparator
|
||||||
className={isDesktop ? "mt-3" : "mt-1"}
|
className={isDesktop ? "mt-3" : "mt-1"}
|
||||||
/>
|
/>
|
||||||
|
{profile?.username && profile.username !== "anonymous" && (
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="Set Password"
|
||||||
|
onClick={() => setPasswordDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuSquarePen className="mr-2 size-4" />
|
||||||
|
<span>Set Password</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
@ -141,39 +182,45 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>System</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||||
|
<Link to="/system#general">
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex w-full items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="System metrics"
|
||||||
|
>
|
||||||
|
<LuActivity className="mr-2 size-4" />
|
||||||
|
<span>System metrics</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
|
<Link to="/logs">
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex w-full items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="System logs"
|
||||||
|
>
|
||||||
|
<LuList className="mr-2 size-4" />
|
||||||
|
<span>System logs</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuLabel>System</DropdownMenuLabel>
|
<DropdownMenuLabel
|
||||||
<DropdownMenuSeparator />
|
className={isDesktop && isAdmin ? "mt-3" : "mt-1"}
|
||||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
>
|
||||||
<Link to="/system#general">
|
|
||||||
<MenuItem
|
|
||||||
className={
|
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex w-full items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
aria-label="System metrics"
|
|
||||||
>
|
|
||||||
<LuActivity className="mr-2 size-4" />
|
|
||||||
<span>System metrics</span>
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
|
||||||
<Link to="/logs">
|
|
||||||
<MenuItem
|
|
||||||
className={
|
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex w-full items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
aria-label="System logs"
|
|
||||||
>
|
|
||||||
<LuList className="mr-2 size-4" />
|
|
||||||
<span>System logs</span>
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
|
||||||
Configuration
|
Configuration
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@ -191,143 +238,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/config">
|
{isAdmin && (
|
||||||
<MenuItem
|
<>
|
||||||
className={
|
<Link to="/config">
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex w-full items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
aria-label="Configuration editor"
|
|
||||||
>
|
|
||||||
<LuSquarePen className="mr-2 size-4" />
|
|
||||||
<span>Configuration editor</span>
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
|
||||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
|
||||||
Appearance
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<SubItem>
|
|
||||||
<SubItemTrigger
|
|
||||||
className={
|
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LuSunMoon className="mr-2 size-4" />
|
|
||||||
<span>Dark Mode</span>
|
|
||||||
</SubItemTrigger>
|
|
||||||
<Portal>
|
|
||||||
<SubItemContent
|
|
||||||
className={
|
|
||||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span tabIndex={0} className="sr-only" />
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "flex items-center p-2 text-sm"
|
: "flex w-full items-center p-2 text-sm"
|
||||||
}
|
}
|
||||||
aria-label="Light mode"
|
aria-label="Configuration editor"
|
||||||
onClick={() => setTheme("light")}
|
|
||||||
>
|
>
|
||||||
{theme === "light" ? (
|
<LuSquarePen className="mr-2 size-4" />
|
||||||
<>
|
<span>Configuration editor</span>
|
||||||
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
||||||
Light
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="ml-6 mr-2">Light</span>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
</Link>
|
||||||
className={
|
</>
|
||||||
isDesktop
|
)}
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
aria-label="Dark mode"
|
|
||||||
onClick={() => setTheme("dark")}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<>
|
|
||||||
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
||||||
Dark
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="ml-6 mr-2">Dark</span>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
className={
|
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
aria-label="Use the system settings for light or dark mode"
|
|
||||||
onClick={() => setTheme("system")}
|
|
||||||
>
|
|
||||||
{theme === "system" ? (
|
|
||||||
<>
|
|
||||||
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
|
|
||||||
System
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="ml-6 mr-2">System</span>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
</SubItemContent>
|
|
||||||
</Portal>
|
|
||||||
</SubItem>
|
|
||||||
<SubItem>
|
|
||||||
<SubItemTrigger
|
|
||||||
className={
|
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LuSunMoon className="mr-2 size-4" />
|
|
||||||
<span>Theme</span>
|
|
||||||
</SubItemTrigger>
|
|
||||||
<Portal>
|
|
||||||
<SubItemContent
|
|
||||||
className={
|
|
||||||
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span tabIndex={0} className="sr-only" />
|
|
||||||
{colorSchemes.map((scheme) => (
|
|
||||||
<MenuItem
|
|
||||||
key={scheme}
|
|
||||||
className={
|
|
||||||
isDesktop
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "flex items-center p-2 text-sm"
|
|
||||||
}
|
|
||||||
aria-label={`Color scheme - ${scheme}`}
|
|
||||||
onClick={() => setColorScheme(scheme)}
|
|
||||||
>
|
|
||||||
{scheme === colorScheme ? (
|
|
||||||
<>
|
|
||||||
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
|
|
||||||
{friendlyColorSchemeName(scheme)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="ml-6 mr-2">
|
|
||||||
{friendlyColorSchemeName(scheme)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</SubItemContent>
|
|
||||||
</Portal>
|
|
||||||
</SubItem>
|
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||||
|
Appearance
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<SubItem>
|
||||||
|
<SubItemTrigger
|
||||||
|
className={
|
||||||
|
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuSunMoon className="mr-2 size-4" />
|
||||||
|
<span>Dark Mode</span>
|
||||||
|
</SubItemTrigger>
|
||||||
|
<Portal>
|
||||||
|
<SubItemContent
|
||||||
|
className={
|
||||||
|
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span tabIndex={0} className="sr-only" />
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="Light mode"
|
||||||
|
onClick={() => setTheme("light")}
|
||||||
|
>
|
||||||
|
{theme === "light" ? (
|
||||||
|
<>
|
||||||
|
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
Light
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="ml-6 mr-2">Light</span>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="Dark mode"
|
||||||
|
onClick={() => setTheme("dark")}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<>
|
||||||
|
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
Dark
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="ml-6 mr-2">Dark</span>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label="Use the system settings for light or dark mode"
|
||||||
|
onClick={() => setTheme("system")}
|
||||||
|
>
|
||||||
|
{theme === "system" ? (
|
||||||
|
<>
|
||||||
|
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
|
||||||
|
System
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="ml-6 mr-2">System</span>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
</SubItemContent>
|
||||||
|
</Portal>
|
||||||
|
</SubItem>
|
||||||
|
<SubItem>
|
||||||
|
<SubItemTrigger
|
||||||
|
className={
|
||||||
|
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuSunMoon className="mr-2 size-4" />
|
||||||
|
<span>Theme</span>
|
||||||
|
</SubItemTrigger>
|
||||||
|
<Portal>
|
||||||
|
<SubItemContent
|
||||||
|
className={
|
||||||
|
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span tabIndex={0} className="sr-only" />
|
||||||
|
{colorSchemes.map((scheme) => (
|
||||||
|
<MenuItem
|
||||||
|
key={scheme}
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "flex items-center p-2 text-sm"
|
||||||
|
}
|
||||||
|
aria-label={`Color scheme - ${scheme}`}
|
||||||
|
onClick={() => setColorScheme(scheme)}
|
||||||
|
>
|
||||||
|
{scheme === colorScheme ? (
|
||||||
|
<>
|
||||||
|
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
|
||||||
|
{friendlyColorSchemeName(scheme)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="ml-6 mr-2">
|
||||||
|
{friendlyColorSchemeName(scheme)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</SubItemContent>
|
||||||
|
</Portal>
|
||||||
|
</SubItem>
|
||||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||||
Help
|
Help
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
@ -357,17 +404,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
<span>GitHub</span>
|
<span>GitHub</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</a>
|
</a>
|
||||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
{isAdmin && (
|
||||||
<MenuItem
|
<>
|
||||||
className={
|
<DropdownMenuSeparator
|
||||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
className={isDesktop ? "mt-3" : "mt-1"}
|
||||||
}
|
/>
|
||||||
aria-label="Restart Frigate"
|
<MenuItem
|
||||||
onClick={() => setRestartDialogOpen(true)}
|
className={
|
||||||
>
|
isDesktop
|
||||||
<LuRotateCw className="mr-2 size-4" />
|
? "cursor-pointer"
|
||||||
<span>Restart Frigate</span>
|
: "flex items-center p-2 text-sm"
|
||||||
</MenuItem>
|
}
|
||||||
|
aria-label="Restart Frigate"
|
||||||
|
onClick={() => setRestartDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuRotateCw className="mr-2 size-4" />
|
||||||
|
<span>Restart Frigate</span>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
</Container>
|
</Container>
|
||||||
@ -376,6 +431,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
onClose={() => setRestartDialogOpen(false)}
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
onRestart={() => sendRestart("restart")}
|
onRestart={() => sendRestart("restart")}
|
||||||
/>
|
/>
|
||||||
|
<SetPasswordDialog
|
||||||
|
show={passwordDialogOpen}
|
||||||
|
onSave={handlePasswordSave}
|
||||||
|
onCancel={() => setPasswordDialogOpen(false)}
|
||||||
|
username={profile?.username}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -74,8 +74,12 @@ export default function SearchResultActions({
|
|||||||
refreshResults();
|
refreshResults();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
toast.error("Failed to delete tracked object.", {
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to delete tracked object: ${errorMessage}`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import { Button } from "../ui/button";
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { Shield, User } from "lucide-react";
|
||||||
|
import { LuCheck, LuX } from "react-icons/lu";
|
||||||
|
|
||||||
type CreateUserOverlayProps = {
|
type CreateUserOverlayProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onCreate: (user: string, password: string) => void;
|
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateUserDialog({
|
export default function CreateUserDialog({
|
||||||
show,
|
show,
|
||||||
onCreate,
|
onCreate,
|
||||||
@ -33,15 +45,22 @@ export default function CreateUserDialog({
|
|||||||
}: CreateUserOverlayProps) {
|
}: CreateUserOverlayProps) {
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
user: z
|
.object({
|
||||||
.string()
|
user: z
|
||||||
.min(1)
|
.string()
|
||||||
.regex(/^[A-Za-z0-9._]+$/, {
|
.min(1, "Username is required")
|
||||||
message: "Username may only include letters, numbers, . or _",
|
.regex(/^[A-Za-z0-9._]+$/, {
|
||||||
}),
|
message: "Username may only include letters, numbers, . or _",
|
||||||
password: z.string(),
|
}),
|
||||||
});
|
password: z.string().min(1, "Password is required"),
|
||||||
|
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||||
|
role: z.enum(["admin", "viewer"]),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@ -49,32 +68,93 @@ export default function CreateUserDialog({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
user: "",
|
user: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
role: "viewer",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await onCreate(values.user, values.password);
|
await onCreate(values.user, values.password, values.role);
|
||||||
form.reset();
|
form.reset();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if passwords match for real-time feedback
|
||||||
|
const password = form.watch("password");
|
||||||
|
const confirmPassword = form.watch("confirmPassword");
|
||||||
|
const passwordsMatch = password === confirmPassword;
|
||||||
|
const showMatchIndicator = password && confirmPassword;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) {
|
||||||
|
form.reset({
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
role: "viewer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [show, form]);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.reset({
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
role: "viewer",
|
||||||
|
});
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={show} onOpenChange={onCancel}>
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create User</DialogTitle>
|
<DialogTitle>Create New User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new user account and specify an role for access to areas of
|
||||||
|
the Frigate UI.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-5 py-4"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name="user"
|
name="user"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>User</FormLabel>
|
<FormLabel className="text-sm font-medium">
|
||||||
|
Username
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
placeholder="Enter username"
|
||||||
|
className="h-10"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription className="text-xs text-muted-foreground">
|
||||||
|
Only letters, numbers, periods and underscores allowed.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-sm font-medium">
|
||||||
|
Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter password"
|
||||||
|
type="password"
|
||||||
|
className="h-10"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -82,30 +162,121 @@ export default function CreateUserDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="password"
|
name="confirmPassword"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel className="text-sm font-medium">
|
||||||
|
Confirm Password
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
placeholder="Confirm password"
|
||||||
type="password"
|
type="password"
|
||||||
|
className="h-10"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{showMatchIndicator && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||||
|
{passwordsMatch ? (
|
||||||
|
<>
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
<span className="text-green-600">
|
||||||
|
Passwords match
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LuX className="size-3.5 text-red-500" />
|
||||||
|
<span className="text-red-600">
|
||||||
|
Passwords don't match
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button
|
<FormField
|
||||||
variant="select"
|
name="role"
|
||||||
aria-label="Create user"
|
render={({ field }) => (
|
||||||
disabled={isLoading}
|
<FormItem>
|
||||||
>
|
<FormLabel className="text-sm font-medium">Role</FormLabel>
|
||||||
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
|
<Select
|
||||||
Create User
|
onValueChange={field.onChange}
|
||||||
</Button>
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-10">
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
value="admin"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
|
<span>Admin</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="viewer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>Viewer</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription className="text-xs text-muted-foreground">
|
||||||
|
Admins have full access to all features in the Frigate UI.
|
||||||
|
Viewers are limited to viewing cameras, review items, and
|
||||||
|
historical footage in the UI.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label="Cancel"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
aria-label="Save"
|
||||||
|
disabled={isLoading || !form.formState.isValid}
|
||||||
|
className="flex flex-1"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -6,34 +6,61 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
|
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||||
|
|
||||||
type SetPasswordProps = {
|
type DeleteUserDialogProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
username?: string;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
export default function DeleteUserDialog({
|
export default function DeleteUserDialog({
|
||||||
show,
|
show,
|
||||||
|
username,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: SetPasswordProps) {
|
}: DeleteUserDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={show} onOpenChange={onCancel}>
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex flex-col items-center gap-2 sm:items-start">
|
||||||
<DialogTitle>Delete User</DialogTitle>
|
<div className="space-y-1 text-center sm:text-left">
|
||||||
|
<DialogTitle>Delete User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the
|
||||||
|
user account and remove all associated data.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div>Are you sure?</div>
|
|
||||||
<DialogFooter>
|
<div className="my-4 rounded-md border border-destructive/20 bg-destructive/5 p-4 text-center text-sm">
|
||||||
<Button
|
<p className="font-medium text-destructive">
|
||||||
className="flex items-center gap-1"
|
Are you sure you want to delete{" "}
|
||||||
aria-label="Confirm delete"
|
<span className="font-bold">{username}</span>?
|
||||||
variant="destructive"
|
</p>
|
||||||
size="sm"
|
</div>
|
||||||
onClick={onDelete}
|
|
||||||
>
|
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
Delete
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
</Button>
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
aria-label="Delete"
|
||||||
|
className="flex flex-1"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -99,16 +99,13 @@ export default function ExportDialog({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to start export: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{ position: "top-center" },
|
"Unknown error";
|
||||||
);
|
toast.error(`Failed to start export: ${errorMessage}`, {
|
||||||
} else {
|
position: "top-center",
|
||||||
toast.error(`Failed to start export: ${error.message}`, {
|
});
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [camera, name, range, setRange, setName, setMode]);
|
}, [camera, name, range, setRange, setName, setMode]);
|
||||||
|
|
||||||
|
@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to start export: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{ position: "top-center" },
|
"Unknown error";
|
||||||
);
|
toast.error(`Failed to start export: ${errorMessage}`, {
|
||||||
} else {
|
position: "top-center",
|
||||||
toast.error(`Failed to start export: ${error.message}`, {
|
});
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [camera, name, range, setRange, setName, setMode]);
|
}, [camera, name, range, setRange, setName, setMode]);
|
||||||
|
|
||||||
|
119
web/src/components/overlay/RoleChangeDialog.tsx
Normal file
119
web/src/components/overlay/RoleChangeDialog.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LuShield, LuUser } from "react-icons/lu";
|
||||||
|
|
||||||
|
type RoleChangeDialogProps = {
|
||||||
|
show: boolean;
|
||||||
|
username: string;
|
||||||
|
currentRole: "admin" | "viewer";
|
||||||
|
onSave: (role: "admin" | "viewer") => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RoleChangeDialog({
|
||||||
|
show,
|
||||||
|
username,
|
||||||
|
currentRole,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: RoleChangeDialogProps) {
|
||||||
|
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
|
||||||
|
currentRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
Change User Role
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update permissions for{" "}
|
||||||
|
<span className="font-medium">{username}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="mb-4 text-sm text-muted-foreground">
|
||||||
|
<p>Select the appropriate role for this user:</p>
|
||||||
|
<ul className="mt-2 space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
• <span className="font-medium">Admin:</span> Full access to all
|
||||||
|
features.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <span className="font-medium">Viewer:</span> Limited to Live
|
||||||
|
dashboards, Review, Explore, and Exports only.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedRole}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSelectedRole(value as "admin" | "viewer")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin" className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LuShield className="size-4 text-primary" />
|
||||||
|
<span>Admin</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="viewer" className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LuUser className="size-4 text-primary" />
|
||||||
|
<span>Viewer</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
aria-label="Save"
|
||||||
|
className="flex flex-1"
|
||||||
|
onClick={() => onSave(selectedRole)}
|
||||||
|
disabled={selectedRole === currentRole}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,50 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { LuCheck, LuX } from "react-icons/lu";
|
||||||
|
|
||||||
type SetPasswordProps = {
|
type SetPasswordProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onSave: (password: string) => void;
|
onSave: (password: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SetPasswordDialog({
|
export default function SetPasswordDialog({
|
||||||
show,
|
show,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
username,
|
||||||
}: SetPasswordProps) {
|
}: SetPasswordProps) {
|
||||||
const [password, setPassword] = useState<string>();
|
const [password, setPassword] = useState<string>("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState<string>("");
|
||||||
|
const [passwordStrength, setPasswordStrength] = useState<number>(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
// Simple password strength calculation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!password) {
|
||||||
|
setPasswordStrength(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
// Length check
|
||||||
|
if (password.length >= 8) strength += 1;
|
||||||
|
// Contains number
|
||||||
|
if (/\d/.test(password)) strength += 1;
|
||||||
|
// Contains special char
|
||||||
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
||||||
|
// Contains uppercase
|
||||||
|
if (/[A-Z]/.test(password)) strength += 1;
|
||||||
|
|
||||||
|
setPasswordStrength(strength);
|
||||||
|
}, [password]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!password) {
|
||||||
|
setError("Password cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthLabel = () => {
|
||||||
|
if (!password) return "";
|
||||||
|
if (passwordStrength <= 1) return "Weak";
|
||||||
|
if (passwordStrength === 2) return "Medium";
|
||||||
|
if (passwordStrength === 3) return "Strong";
|
||||||
|
return "Very Strong";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthColor = () => {
|
||||||
|
if (!password) return "bg-gray-200";
|
||||||
|
if (passwordStrength <= 1) return "bg-red-500";
|
||||||
|
if (passwordStrength === 2) return "bg-yellow-500";
|
||||||
|
if (passwordStrength === 3) return "bg-green-500";
|
||||||
|
return "bg-green-600";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={show} onOpenChange={onCancel}>
|
<Dialog open={show} onOpenChange={onCancel}>
|
||||||
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader className="space-y-2">
|
||||||
<DialogTitle>Set Password</DialogTitle>
|
<DialogTitle>
|
||||||
|
{username ? `Update Password for ${username}` : "Set Password"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a strong password to secure this account.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input
|
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
<div className="space-y-4 py-4">
|
||||||
type="password"
|
<div className="space-y-2">
|
||||||
value={password}
|
<Label htmlFor="password">New Password</Label>
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
<Input
|
||||||
/>
|
id="password"
|
||||||
<DialogFooter>
|
className="h-10"
|
||||||
<Button
|
type="password"
|
||||||
className="flex items-center gap-1"
|
value={password}
|
||||||
aria-label="Save Password"
|
onChange={(event) => {
|
||||||
variant="select"
|
setPassword(event.target.value);
|
||||||
size="sm"
|
setError(null);
|
||||||
onClick={() => {
|
}}
|
||||||
onSave(password!);
|
placeholder="Enter new password"
|
||||||
}}
|
autoFocus
|
||||||
>
|
/>
|
||||||
Save
|
|
||||||
</Button>
|
{/* Password strength indicator */}
|
||||||
|
{password && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||||
|
<div
|
||||||
|
className={`${getStrengthColor()} transition-all duration-300`}
|
||||||
|
style={{ width: `${(passwordStrength / 3) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Password strength:{" "}
|
||||||
|
<span className="font-medium">{getStrengthLabel()}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
className="h-10"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => {
|
||||||
|
setConfirmPassword(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password match indicator */}
|
||||||
|
{password && confirmPassword && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||||
|
{password === confirmPassword ? (
|
||||||
|
<>
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
<span className="text-green-600">Passwords match</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LuX className="size-3.5 text-red-500" />
|
||||||
|
<span className="text-red-600">Passwords don't match</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
aria-label="Save"
|
||||||
|
className="flex flex-1"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!password || password !== confirmPassword}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -87,10 +87,13 @@ export function AnnotationSettingsPane({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -394,8 +394,12 @@ function ObjectDetailsTab({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
toast.error("Failed to update the description", {
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to update the description: ${errorMessage}`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
setDesc(search.data.description);
|
setDesc(search.data.description);
|
||||||
@ -422,11 +426,13 @@ function ObjectDetailsTab({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
toast.error(
|
toast.error(
|
||||||
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`,
|
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`,
|
||||||
{
|
{ position: "top-center" },
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -492,8 +498,12 @@ function ObjectDetailsTab({
|
|||||||
setIsSubLabelDialogOpen(false);
|
setIsSubLabelDialogOpen(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
toast.error("Failed to update sub label.", {
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to update sub label: ${errorMessage}`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -176,10 +176,13 @@ export default function MotionMaskEditPane({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -208,10 +208,13 @@ export default function ObjectMaskEditPane({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -186,10 +186,13 @@ export default function PolygonItem({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -414,10 +414,13 @@ export default function ZoneEditPane({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
74
web/src/context/auth-context.tsx
Normal file
74
web/src/context/auth-context.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { createContext, useEffect, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: { username: string; role: "admin" | "viewer" | null } | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean; // true if auth is required
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
auth: AuthState;
|
||||||
|
login: (user: AuthState["user"]) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
|
auth: { user: null, isLoading: true, isAuthenticated: false },
|
||||||
|
login: () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [auth, setAuth] = useState<AuthState>({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: profile, error } = useSWR("/profile", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: true,
|
||||||
|
fetcher: (url) =>
|
||||||
|
axios.get(url, { withCredentials: true }).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
// auth required but not logged in
|
||||||
|
setAuth({ user: null, isLoading: false, isAuthenticated: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
if (profile.username && profile.username !== "anonymous") {
|
||||||
|
const newUser = {
|
||||||
|
username: profile.username,
|
||||||
|
role: profile.role || "viewer",
|
||||||
|
};
|
||||||
|
setAuth({ user: newUser, isLoading: false, isAuthenticated: true });
|
||||||
|
} else {
|
||||||
|
// Unauthenticated mode (anonymous)
|
||||||
|
setAuth({ user: null, isLoading: false, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [profile, error]);
|
||||||
|
|
||||||
|
const login = (user: AuthState["user"]) => {
|
||||||
|
setAuth({ user, isLoading: false, isAuthenticated: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setAuth({ user: null, isLoading: false, isAuthenticated: true });
|
||||||
|
axios.get("/logout", { withCredentials: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ auth, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
@ -6,6 +6,7 @@ import { IconContext } from "react-icons";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
|
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
|
||||||
import { StreamingSettingsProvider } from "./streaming-settings-provider";
|
import { StreamingSettingsProvider } from "./streaming-settings-provider";
|
||||||
|
import { AuthProvider } from "./auth-context";
|
||||||
|
|
||||||
type TProvidersProps = {
|
type TProvidersProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -14,19 +15,21 @@ type TProvidersProps = {
|
|||||||
function providers({ children }: TProvidersProps) {
|
function providers({ children }: TProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<RecoilRoot>
|
<RecoilRoot>
|
||||||
<ApiProvider>
|
<AuthProvider>
|
||||||
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
<ApiProvider>
|
||||||
<TooltipProvider>
|
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
||||||
<IconContext.Provider value={{ size: "20" }}>
|
<TooltipProvider>
|
||||||
<StatusBarMessagesProvider>
|
<IconContext.Provider value={{ size: "20" }}>
|
||||||
<StreamingSettingsProvider>
|
<StatusBarMessagesProvider>
|
||||||
{children}
|
<StreamingSettingsProvider>
|
||||||
</StreamingSettingsProvider>
|
{children}
|
||||||
</StatusBarMessagesProvider>
|
</StreamingSettingsProvider>
|
||||||
</IconContext.Provider>
|
</StatusBarMessagesProvider>
|
||||||
</TooltipProvider>
|
</IconContext.Provider>
|
||||||
</ThemeProvider>
|
</TooltipProvider>
|
||||||
</ApiProvider>
|
</ThemeProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
</AuthProvider>
|
||||||
</RecoilRoot>
|
</RecoilRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
10
web/src/hooks/use-is-admin.ts
Normal file
10
web/src/hooks/use-is-admin.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
|
export function useIsAdmin() {
|
||||||
|
const { auth } = useContext(AuthContext);
|
||||||
|
const isAdmin =
|
||||||
|
(auth.isAuthenticated && auth.user?.role === "admin") ||
|
||||||
|
auth.user?.role === undefined;
|
||||||
|
return isAdmin;
|
||||||
|
}
|
21
web/src/pages/AccessDenied.tsx
Normal file
21
web/src/pages/AccessDenied.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { FaExclamationTriangle } from "react-icons/fa";
|
||||||
|
|
||||||
|
export default function AccessDenied() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Access Denied - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center text-center">
|
||||||
|
<FaExclamationTriangle className="mb-4 size-8" />
|
||||||
|
<Heading as="h2" className="mb-2">
|
||||||
|
Access Denied
|
||||||
|
</Heading>
|
||||||
|
<p className="text-primary-variant">
|
||||||
|
You don't have permission to view this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -59,11 +59,12 @@ function ConfigEditor() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error("Error saving config", { position: "top-center" });
|
toast.error("Error saving config", { position: "top-center" });
|
||||||
|
|
||||||
if (error.response) {
|
const errorMessage =
|
||||||
setError(error.response.data.message);
|
error.response?.data?.message ||
|
||||||
} else {
|
error.response?.data?.detail ||
|
||||||
setError(error.message);
|
"Unknown error";
|
||||||
}
|
|
||||||
|
setError(errorMessage);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[editorRef],
|
[editorRef],
|
||||||
|
@ -93,16 +93,13 @@ function Exports() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to rename export: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{ position: "top-center" },
|
"Unknown error";
|
||||||
);
|
toast.error(`Failed to rename export: ${errorMessage}`, {
|
||||||
} else {
|
position: "top-center",
|
||||||
toast.error(`Failed to rename export: ${error.message}`, {
|
});
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mutate],
|
[mutate],
|
||||||
|
@ -99,16 +99,13 @@ export default function FaceLibrary() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to upload image: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{ position: "top-center" },
|
"Unknown error";
|
||||||
);
|
toast.error(`Failed to upload image: ${errorMessage}`, {
|
||||||
} else {
|
position: "top-center",
|
||||||
toast.error(`Failed to upload image: ${error.message}`, {
|
});
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[pageToggle, refreshFaces],
|
[pageToggle, refreshFaces],
|
||||||
@ -132,16 +129,13 @@ export default function FaceLibrary() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to set face name: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{ position: "top-center" },
|
"Unknown error";
|
||||||
);
|
toast.error(`Failed to set face name: ${errorMessage}`, {
|
||||||
} else {
|
position: "top-center",
|
||||||
toast.error(`Failed to set face name: ${error.message}`, {
|
});
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[refreshFaces],
|
[refreshFaces],
|
||||||
@ -308,15 +302,13 @@ function FaceAttempt({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(`Failed to train: ${error.response.data.message}`, {
|
error.response?.data?.message ||
|
||||||
position: "top-center",
|
error.response?.data?.detail ||
|
||||||
});
|
"Unknown error";
|
||||||
} else {
|
toast.error(`Failed to train: ${errorMessage}`, {
|
||||||
toast.error(`Failed to train: ${error.message}`, {
|
position: "top-center",
|
||||||
position: "top-center",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[image, onRefresh],
|
[image, onRefresh],
|
||||||
@ -334,18 +326,13 @@ function FaceAttempt({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to update score: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{
|
"Unknown error";
|
||||||
position: "top-center",
|
toast.error(`Failed to update face score: ${errorMessage}`, {
|
||||||
},
|
position: "top-center",
|
||||||
);
|
});
|
||||||
} else {
|
|
||||||
toast.error(`Failed to update score: ${error.message}`, {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [image, onRefresh]);
|
}, [image, onRefresh]);
|
||||||
|
|
||||||
@ -361,15 +348,13 @@ function FaceAttempt({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(`Failed to delete: ${error.response.data.message}`, {
|
error.response?.data?.message ||
|
||||||
position: "top-center",
|
error.response?.data?.detail ||
|
||||||
});
|
"Unknown error";
|
||||||
} else {
|
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||||
toast.error(`Failed to delete: ${error.message}`, {
|
position: "top-center",
|
||||||
position: "top-center",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [image, onRefresh]);
|
}, [image, onRefresh]);
|
||||||
|
|
||||||
@ -478,15 +463,13 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(`Failed to delete: ${error.response.data.message}`, {
|
error.response?.data?.message ||
|
||||||
position: "top-center",
|
error.response?.data?.detail ||
|
||||||
});
|
"Unknown error";
|
||||||
} else {
|
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||||
toast.error(`Failed to delete: ${error.message}`, {
|
position: "top-center",
|
||||||
position: "top-center",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [name, image, onRefresh]);
|
}, [name, image, onRefresh]);
|
||||||
|
|
||||||
|
@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView";
|
|||||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useInitialCameraState } from "@/api/ws";
|
import { useInitialCameraState } from "@/api/ws";
|
||||||
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"UI settings",
|
"UI settings",
|
||||||
@ -62,6 +63,15 @@ export default function Settings() {
|
|||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// auth and roles
|
||||||
|
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"];
|
||||||
|
const visibleSettingsViews = !isAdmin
|
||||||
|
? allowedViewsForViewer
|
||||||
|
: allSettingsViews;
|
||||||
|
|
||||||
// TODO: confirm leave page
|
// TODO: confirm leave page
|
||||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||||
@ -149,7 +159,12 @@ export default function Settings() {
|
|||||||
|
|
||||||
useSearchEffect("page", (page: string) => {
|
useSearchEffect("page", (page: string) => {
|
||||||
if (allSettingsViews.includes(page as SettingsType)) {
|
if (allSettingsViews.includes(page as SettingsType)) {
|
||||||
setPage(page as SettingsType);
|
// Restrict viewer to UI settings
|
||||||
|
if (!isAdmin && !["UI settings", "debug"].includes(page)) {
|
||||||
|
setPage("UI settings");
|
||||||
|
} else {
|
||||||
|
setPage(page as SettingsType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// don't clear url params if we're creating a new object mask
|
// don't clear url params if we're creating a new object mask
|
||||||
return !searchParams.has("object_mask");
|
return !searchParams.has("object_mask");
|
||||||
@ -180,11 +195,16 @@ export default function Settings() {
|
|||||||
value={pageToggle}
|
value={pageToggle}
|
||||||
onValueChange={(value: SettingsType) => {
|
onValueChange={(value: SettingsType) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setPageToggle(value);
|
// Restrict viewer navigation
|
||||||
|
if (!isAdmin && !["UI settings", "debug"].includes(value)) {
|
||||||
|
setPageToggle("UI settings");
|
||||||
|
} else {
|
||||||
|
setPageToggle(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Object.values(allSettingsViews).map((item) => (
|
{visibleSettingsViews.map((item) => (
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
key={item}
|
key={item}
|
||||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export type User = {
|
export type User = {
|
||||||
username: string;
|
username: string;
|
||||||
|
role: string;
|
||||||
};
|
};
|
||||||
|
@ -204,16 +204,13 @@ export default function EventView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.response?.data?.message) {
|
const errorMessage =
|
||||||
toast.error(
|
error.response?.data?.message ||
|
||||||
`Failed to start export: ${error.response.data.message}`,
|
error.response?.data?.detail ||
|
||||||
{ position: "top-center" },
|
"Unknown error";
|
||||||
);
|
toast.error(`Failed to start export: ${errorMessage}`, {
|
||||||
} else {
|
position: "top-center",
|
||||||
toast.error(`Failed to start export: ${error.message}`, {
|
});
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[reviewItems],
|
[reviewItems],
|
||||||
|
@ -116,6 +116,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
type LiveCameraViewProps = {
|
type LiveCameraViewProps = {
|
||||||
config?: FrigateConfig;
|
config?: FrigateConfig;
|
||||||
@ -982,6 +983,10 @@ function FrigateCameraFeatures({
|
|||||||
const { payload: autotrackingState, send: sendAutotracking } =
|
const { payload: autotrackingState, send: sendAutotracking } =
|
||||||
useAutotrackingState(camera.name);
|
useAutotrackingState(camera.name);
|
||||||
|
|
||||||
|
// roles
|
||||||
|
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// manual event
|
// manual event
|
||||||
|
|
||||||
const recordingEventIdRef = useRef<string | null>(null);
|
const recordingEventIdRef = useRef<string | null>(null);
|
||||||
@ -1080,65 +1085,71 @@ function FrigateCameraFeatures({
|
|||||||
if (isDesktop || isTablet) {
|
if (isDesktop || isTablet) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CameraFeatureToggle
|
{isAdmin && (
|
||||||
className="p-2 md:p-0"
|
<>
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
<CameraFeatureToggle
|
||||||
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
|
className="p-2 md:p-0"
|
||||||
isActive={enabledState == "ON"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
|
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
|
||||||
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
|
isActive={enabledState == "ON"}
|
||||||
disabled={false}
|
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
|
||||||
/>
|
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
|
||||||
<CameraFeatureToggle
|
disabled={false}
|
||||||
className="p-2 md:p-0"
|
/>
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
<CameraFeatureToggle
|
||||||
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
|
className="p-2 md:p-0"
|
||||||
isActive={detectState == "ON"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
|
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
|
||||||
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
isActive={detectState == "ON"}
|
||||||
disabled={!cameraEnabled}
|
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
|
||||||
/>
|
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
||||||
<CameraFeatureToggle
|
disabled={!cameraEnabled}
|
||||||
className="p-2 md:p-0"
|
/>
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
<CameraFeatureToggle
|
||||||
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
|
className="p-2 md:p-0"
|
||||||
isActive={recordState == "ON"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
|
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
|
||||||
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
isActive={recordState == "ON"}
|
||||||
disabled={!cameraEnabled}
|
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
|
||||||
/>
|
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
||||||
<CameraFeatureToggle
|
disabled={!cameraEnabled}
|
||||||
className="p-2 md:p-0"
|
/>
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
<CameraFeatureToggle
|
||||||
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
|
className="p-2 md:p-0"
|
||||||
isActive={snapshotState == "ON"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
|
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
|
||||||
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
|
isActive={snapshotState == "ON"}
|
||||||
disabled={!cameraEnabled}
|
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
|
||||||
/>
|
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
|
||||||
{audioDetectEnabled && (
|
disabled={!cameraEnabled}
|
||||||
<CameraFeatureToggle
|
/>
|
||||||
className="p-2 md:p-0"
|
{audioDetectEnabled && (
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
<CameraFeatureToggle
|
||||||
Icon={audioState == "ON" ? LuEar : LuEarOff}
|
className="p-2 md:p-0"
|
||||||
isActive={audioState == "ON"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
|
Icon={audioState == "ON" ? LuEar : LuEarOff}
|
||||||
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
isActive={audioState == "ON"}
|
||||||
disabled={!cameraEnabled}
|
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
|
||||||
/>
|
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
||||||
)}
|
disabled={!cameraEnabled}
|
||||||
{autotrackingEnabled && (
|
/>
|
||||||
<CameraFeatureToggle
|
)}
|
||||||
className="p-2 md:p-0"
|
{autotrackingEnabled && (
|
||||||
variant={fullscreen ? "overlay" : "primary"}
|
<CameraFeatureToggle
|
||||||
Icon={autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff}
|
className="p-2 md:p-0"
|
||||||
isActive={autotrackingState == "ON"}
|
variant={fullscreen ? "overlay" : "primary"}
|
||||||
title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
|
Icon={
|
||||||
onClick={() =>
|
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
|
||||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
}
|
||||||
}
|
isActive={autotrackingState == "ON"}
|
||||||
disabled={!cameraEnabled}
|
title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
|
||||||
/>
|
onClick={() =>
|
||||||
|
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||||
|
}
|
||||||
|
disabled={!cameraEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<CameraFeatureToggle
|
<CameraFeatureToggle
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -1421,55 +1432,60 @@ function FrigateCameraFeatures({
|
|||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent className="rounded-2xl px-2 py-4">
|
<DrawerContent className="rounded-2xl px-2 py-4">
|
||||||
<div className="mt-2 flex flex-col gap-2">
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
<FilterSwitch
|
{isAdmin && (
|
||||||
label="Camera Enabled"
|
<>
|
||||||
isChecked={enabledState == "ON"}
|
<FilterSwitch
|
||||||
onCheckedChange={() =>
|
label="Camera Enabled"
|
||||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
isChecked={enabledState == "ON"}
|
||||||
}
|
onCheckedChange={() =>
|
||||||
/>
|
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||||
<FilterSwitch
|
}
|
||||||
label="Object Detection"
|
/>
|
||||||
isChecked={detectState == "ON"}
|
<FilterSwitch
|
||||||
onCheckedChange={() =>
|
label="Object Detection"
|
||||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
isChecked={detectState == "ON"}
|
||||||
}
|
onCheckedChange={() =>
|
||||||
/>
|
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||||
{recordingEnabled && (
|
}
|
||||||
<FilterSwitch
|
/>
|
||||||
label="Recording"
|
{recordingEnabled && (
|
||||||
isChecked={recordState == "ON"}
|
<FilterSwitch
|
||||||
onCheckedChange={() =>
|
label="Recording"
|
||||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
isChecked={recordState == "ON"}
|
||||||
}
|
onCheckedChange={() =>
|
||||||
/>
|
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||||
)}
|
}
|
||||||
<FilterSwitch
|
/>
|
||||||
label="Snapshots"
|
)}
|
||||||
isChecked={snapshotState == "ON"}
|
<FilterSwitch
|
||||||
onCheckedChange={() =>
|
label="Snapshots"
|
||||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
isChecked={snapshotState == "ON"}
|
||||||
}
|
onCheckedChange={() =>
|
||||||
/>
|
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||||
{audioDetectEnabled && (
|
}
|
||||||
<FilterSwitch
|
/>
|
||||||
label="Audio Detection"
|
{audioDetectEnabled && (
|
||||||
isChecked={audioState == "ON"}
|
<FilterSwitch
|
||||||
onCheckedChange={() =>
|
label="Audio Detection"
|
||||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
isChecked={audioState == "ON"}
|
||||||
}
|
onCheckedChange={() =>
|
||||||
/>
|
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||||
)}
|
}
|
||||||
{autotrackingEnabled && (
|
/>
|
||||||
<FilterSwitch
|
)}
|
||||||
label="Autotracking"
|
{autotrackingEnabled && (
|
||||||
isChecked={autotrackingState == "ON"}
|
<FilterSwitch
|
||||||
onCheckedChange={() =>
|
label="Autotracking"
|
||||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
isChecked={autotrackingState == "ON"}
|
||||||
}
|
onCheckedChange={() =>
|
||||||
/>
|
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-5">
|
<div className="mt-3 flex flex-col gap-5">
|
||||||
{!isRestreamed && (
|
{!isRestreamed && (
|
||||||
<div className="flex flex-col gap-2 p-2">
|
<div className="flex flex-col gap-2 p-2">
|
||||||
|
@ -11,10 +11,25 @@ import axios from "axios";
|
|||||||
import CreateUserDialog from "@/components/overlay/CreateUserDialog";
|
import CreateUserDialog from "@/components/overlay/CreateUserDialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { HiTrash } from "react-icons/hi";
|
import { HiTrash } from "react-icons/hi";
|
||||||
import { FaUserEdit } from "react-icons/fa";
|
import { FaUserEdit } from "react-icons/fa";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
|
||||||
|
|
||||||
export default function AuthenticationView() {
|
export default function AuthenticationView() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -23,8 +38,12 @@ export default function AuthenticationView() {
|
|||||||
const [showSetPassword, setShowSetPassword] = useState(false);
|
const [showSetPassword, setShowSetPassword] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [showRoleChange, setShowRoleChange] = useState(false);
|
||||||
|
|
||||||
const [selectedUser, setSelectedUser] = useState<string>();
|
const [selectedUser, setSelectedUser] = useState<string>();
|
||||||
|
const [selectedUserRole, setSelectedUserRole] = useState<
|
||||||
|
"admin" | "viewer"
|
||||||
|
>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "Authentication Settings - Frigate";
|
document.title = "Authentication Settings - Frigate";
|
||||||
@ -32,142 +51,303 @@ export default function AuthenticationView() {
|
|||||||
|
|
||||||
const onSavePassword = useCallback((user: string, password: string) => {
|
const onSavePassword = useCallback((user: string, password: string) => {
|
||||||
axios
|
axios
|
||||||
.put(`users/${user}/password`, {
|
.put(`users/${user}/password`, { password })
|
||||||
password: password,
|
|
||||||
})
|
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status == 200) {
|
if (response.status === 200) {
|
||||||
setShowSetPassword(false);
|
setShowSetPassword(false);
|
||||||
|
toast.success("Password updated successfully", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((_error) => {
|
.catch((error) => {
|
||||||
toast.error("Error setting password", {
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save password: ${errorMessage}`, {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onCreate = async (user: string, password: string) => {
|
const onCreate = (
|
||||||
try {
|
user: string,
|
||||||
await axios.post("users", {
|
password: string,
|
||||||
username: user,
|
role: "admin" | "viewer",
|
||||||
password: password,
|
) => {
|
||||||
|
axios
|
||||||
|
.post("users", { username: user, password, role })
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200 || response.status === 201) {
|
||||||
|
setShowCreate(false);
|
||||||
|
mutateUsers((users) => {
|
||||||
|
users?.push({ username: user, role: role });
|
||||||
|
return users;
|
||||||
|
}, false);
|
||||||
|
toast.success(`User ${user} created successfully`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to create user: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setShowCreate(false);
|
|
||||||
mutateUsers((users) => {
|
|
||||||
users?.push({ username: user });
|
|
||||||
return users;
|
|
||||||
}, false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error creating user. Check server logs.", {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async (user: string) => {
|
const onDelete = (user: string) => {
|
||||||
try {
|
axios
|
||||||
await axios.delete(`users/${user}`);
|
.delete(`users/${user}`)
|
||||||
setShowDelete(false);
|
.then((response) => {
|
||||||
mutateUsers((users) => {
|
if (response.status === 200) {
|
||||||
return users?.filter((u) => {
|
setShowDelete(false);
|
||||||
return u.username !== user;
|
mutateUsers(
|
||||||
|
(users) => users?.filter((u) => u.username !== user),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
toast.success(`User ${user} deleted successfully`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to delete user: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeRole = (user: string, newRole: "admin" | "viewer") => {
|
||||||
|
if (user === "admin") return; // Prevent role change for 'admin'
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put(`users/${user}/role`, { role: newRole })
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
setShowRoleChange(false);
|
||||||
|
mutateUsers(
|
||||||
|
(users) =>
|
||||||
|
users?.map((u) =>
|
||||||
|
u.username === user ? { ...u, role: newRole } : u,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
toast.success(`Role updated for ${user}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to update role: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
});
|
});
|
||||||
}, false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error deleting user. Check server logs.", {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!config || !users) {
|
if (!config || !users) {
|
||||||
return <ActivityIndicator />;
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
<div className="flex flex-row items-center justify-between gap-2">
|
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||||
<Heading as="h3" className="my-2">
|
<div className="flex flex-col items-start">
|
||||||
Users
|
<Heading as="h3" className="my-2">
|
||||||
</Heading>
|
User Management
|
||||||
|
</Heading>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage this Frigate instance's user accounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-2 self-start sm:self-auto"
|
||||||
aria-label="Add a new user"
|
aria-label="Add a new user"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => setShowCreate(true)}
|
||||||
setShowCreate(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LuPlus className="text-secondary-foreground" />
|
<LuPlus className="size-4" />
|
||||||
Add User
|
Add User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{users.map((u) => (
|
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||||
<Card key={u.username} className="mb-1 p-2">
|
<div className="h-full overflow-auto">
|
||||||
<div className="flex items-center gap-3">
|
<Table>
|
||||||
<div className="ml-3 flex flex-none shrink overflow-hidden text-ellipsis align-middle text-lg">
|
<TableHeader className="sticky top-0 bg-muted/50">
|
||||||
{u.username}
|
<TableRow>
|
||||||
</div>
|
<TableHead className="w-[250px]">Username</TableHead>
|
||||||
<div className="flex flex-1 justify-end space-x-2">
|
<TableHead>Role</TableHead>
|
||||||
<Button
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
className="flex items-center gap-1"
|
</TableRow>
|
||||||
aria-label="Update the user's password"
|
</TableHeader>
|
||||||
variant="secondary"
|
<TableBody>
|
||||||
onClick={() => {
|
{users.length === 0 ? (
|
||||||
setShowSetPassword(true);
|
<TableRow>
|
||||||
setSelectedUser(u.username);
|
<TableCell colSpan={3} className="h-24 text-center">
|
||||||
}}
|
No users found.
|
||||||
>
|
</TableCell>
|
||||||
<FaUserEdit />
|
</TableRow>
|
||||||
<div className="hidden md:block">Update Password</div>
|
) : (
|
||||||
</Button>
|
users.map((user) => (
|
||||||
<Button
|
<TableRow key={user.username} className="group">
|
||||||
className="flex items-center gap-1"
|
<TableCell className="font-medium">
|
||||||
aria-label="Delete the user"
|
<div className="flex items-center gap-2">
|
||||||
variant="destructive"
|
{user.username === "admin" ? (
|
||||||
onClick={() => {
|
<LuShield className="size-4 text-primary" />
|
||||||
setShowDelete(true);
|
) : (
|
||||||
setSelectedUser(u.username);
|
<LuUserCog className="size-4 text-primary-variant" />
|
||||||
}}
|
)}
|
||||||
>
|
{user.username}
|
||||||
<HiTrash />
|
</div>
|
||||||
<div className="hidden md:block">Delete</div>
|
</TableCell>
|
||||||
</Button>
|
<TableCell>
|
||||||
</div>
|
<Badge
|
||||||
</div>
|
variant={
|
||||||
</Card>
|
user.role === "admin" ? "default" : "outline"
|
||||||
))}
|
}
|
||||||
|
className={
|
||||||
|
user.role === "admin"
|
||||||
|
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.role || "viewer"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{user.username !== "admin" && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(user.username);
|
||||||
|
setSelectedUserRole(
|
||||||
|
(user.role as "admin" | "viewer") ||
|
||||||
|
"viewer",
|
||||||
|
);
|
||||||
|
setShowRoleChange(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuUserCog className="size-3.5" />
|
||||||
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
|
Role
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Change user role</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSetPassword(true);
|
||||||
|
setSelectedUser(user.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaUserEdit className="size-3.5" />
|
||||||
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
|
Password
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Update password</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{user.username !== "admin" && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDelete(true);
|
||||||
|
setSelectedUser(user.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HiTrash className="size-3.5" />
|
||||||
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete user</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SetPasswordDialog
|
<SetPasswordDialog
|
||||||
show={showSetPassword}
|
show={showSetPassword}
|
||||||
onCancel={() => {
|
onCancel={() => setShowSetPassword(false)}
|
||||||
setShowSetPassword(false);
|
onSave={(password) => onSavePassword(selectedUser!, password)}
|
||||||
}}
|
|
||||||
onSave={(password) => {
|
|
||||||
onSavePassword(selectedUser!, password);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<DeleteUserDialog
|
<DeleteUserDialog
|
||||||
show={showDelete}
|
show={showDelete}
|
||||||
onCancel={() => {
|
username={selectedUser ?? "this user"}
|
||||||
setShowDelete(false);
|
onCancel={() => setShowDelete(false)}
|
||||||
}}
|
onDelete={() => onDelete(selectedUser!)}
|
||||||
onDelete={() => {
|
|
||||||
onDelete(selectedUser!);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<CreateUserDialog
|
<CreateUserDialog
|
||||||
show={showCreate}
|
show={showCreate}
|
||||||
onCreate={onCreate}
|
onCreate={onCreate}
|
||||||
onCancel={() => {
|
onCancel={() => setShowCreate(false)}
|
||||||
setShowCreate(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
{selectedUser && selectedUserRole && (
|
||||||
|
<RoleChangeDialog
|
||||||
|
show={showRoleChange}
|
||||||
|
username={selectedUser}
|
||||||
|
currentRole={selectedUserRole}
|
||||||
|
onSave={(role) => onChangeRole(selectedUser, role)}
|
||||||
|
onCancel={() => setShowRoleChange(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -171,10 +171,13 @@ export default function CameraSettingsView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -267,10 +267,13 @@ export default function NotificationView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -103,10 +103,13 @@ export default function SearchSettingsView({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to save config changes: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to save config changes: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -38,10 +38,13 @@ export default function UiSettingsView() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to clear stored layout: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to clear stored layout: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [config]);
|
}, [config]);
|
||||||
@ -58,10 +61,13 @@ export default function UiSettingsView() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
const errorMessage =
|
||||||
`Failed to clear camera groups streaming settings: ${error.response.data.message}`,
|
error.response?.data?.message ||
|
||||||
{ position: "top-center" },
|
error.response?.data?.detail ||
|
||||||
);
|
"Unknown error";
|
||||||
|
toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user