From 8f990817b41bb099c5bd1de8525cdc8025fddd2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 03:38:12 +0000 Subject: [PATCH] feat: add role-based visibility for camera groups Add optional `roles` field to camera groups config to control which user roles can see each group. Groups without roles are visible only to admins. Admin users always see all groups. Backend filters groups in GET /config based on remote-role header. Frontend adds roles multiselect in group editor (admin only). https://claude.ai/code/session_011sp9kHQfM39JvVxKHFh1Xq --- frigate/api/app.py | 12 +++++ frigate/config/camera_group.py | 15 +++++- .../components/filter/CameraGroupSelector.tsx | 50 ++++++++++++++++++- web/src/types/frigateConfig.ts | 1 + 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 379cc7278..9a66a6eda 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -226,6 +226,18 @@ def config(request: Request): request.app.frigate_config.model.merged_labelmap ) + # filter camera_groups by role + username = request.headers.get("remote-user") + role = request.headers.get("remote-role") + + if username and role and role != "admin": + filtered_groups = {} + for group_name, group_config in config.get("camera_groups", {}).items(): + group_roles = group_config.get("roles") + if group_roles and role in group_roles: + filtered_groups[group_name] = group_config + config["camera_groups"] = filtered_groups + return JSONResponse(content=config) diff --git a/frigate/config/camera_group.py b/frigate/config/camera_group.py index 65319001a..5c747cbd6 100644 --- a/frigate/config/camera_group.py +++ b/frigate/config/camera_group.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from pydantic import Field, field_validator @@ -23,6 +23,11 @@ class CameraGroupConfig(FrigateBaseModel): title="Sort order", description="Numeric order used to sort camera groups in the UI; larger numbers appear later.", ) + roles: Optional[list[str]] = Field( + default=None, + title="Roles", + description="List of roles that can see this camera group. If not set, only admin can see it.", + ) @field_validator("cameras", mode="before") @classmethod @@ -31,3 +36,11 @@ class CameraGroupConfig(FrigateBaseModel): return [v] return v + + @field_validator("roles", mode="before") + @classmethod + def validate_roles(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 6d86c57f8..978846c8d 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -721,6 +721,7 @@ export function CameraGroupEdit({ .refine((value) => Object.keys(LuIcons).includes(value), { message: "Invalid icon", }), + roles: z.array(z.string()).optional(), }); const onSubmit = useCallback( @@ -756,10 +757,13 @@ export function CameraGroupEdit({ const cameraQueries = values.cameras .map((cam) => `&camera_groups.${values.name}.cameras=${cam}`) .join(""); + const roleQueries = (values.roles ?? []) + .map((role) => `&camera_groups.${values.name}.roles=${role}`) + .join(""); axios .put( - `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`, + `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}${roleQueries}`, { requires_restart: 0, }, @@ -828,6 +832,7 @@ export function CameraGroupEdit({ name: (editingGroup && editingGroup[0]) ?? "", icon: editingGroup && (editingGroup[1].icon as IconName), cameras: editingGroup && editingGroup[1].cameras, + roles: (editingGroup && editingGroup[1].roles) ?? [], }, }); @@ -970,6 +975,49 @@ export function CameraGroupEdit({ )} /> + {isAdmin && config?.auth?.roles && ( + <> + + ( + + {t("group.roles.label", { defaultValue: "Roles" })} + + {t("group.roles.desc", { + defaultValue: + "Select which roles can see this camera group. If no roles selected, group is only visible to admins.", + })} + + + {Object.keys(config.auth.roles) + .filter((role) => role !== "admin") + .map((role) => ( + +
+ + {role} + + { + const updatedRoles = checked + ? [...(field.value || []), role] + : (field.value || []).filter((r) => r !== role); + form.setValue("roles", updatedRoles); + }} + /> +
+
+ ))} +
+ )} + /> + + )} +
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 3d7f18ddd..ac1e44f6a 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -345,6 +345,7 @@ export type CameraGroupConfig = { cameras: string[]; icon: IconName; order: number; + roles?: string[]; }; export type StreamType = "no-streaming" | "smart" | "continuous";