From 3b3a51392969742e8021822d4d2f1959bd9450c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 04:49:47 +0000 Subject: [PATCH] feat: add personal camera groups with per-user visibility Add optional `users` field to camera groups config allowing groups to be restricted to specific users. Groups without users remain visible to all (backward compatible). Admin users always see all groups. Backend filters groups in GET /config based on authenticated user. Frontend adds a users multi-select toggle in the group editor (admin only). https://claude.ai/code/session_01PooiYnugPWqdCYDq4TU7ti --- frigate/api/app.py | 12 ++++ frigate/config/camera_group.py | 7 +- .../components/filter/CameraGroupSelector.tsx | 67 ++++++++++++++++++- web/src/types/frigateConfig.ts | 1 + 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 4c0d3ead2..32843e81f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -171,6 +171,18 @@ def config(request: Request): config["go2rtc"]["streams"][stream_name] = cleaned + # filter camera_groups by current user + 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_users = group_config.get("users") + if not group_users or username in group_users: + filtered_groups[group_name] = group_config + config["camera_groups"] = filtered_groups + config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} config["model"]["colormap"] = config_obj.model.colormap config["model"]["all_attributes"] = config_obj.model.all_attributes diff --git a/frigate/config/camera_group.py b/frigate/config/camera_group.py index 65319001a..b69182806 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.", ) + users: Optional[list[str]] = Field( + default=None, + title="Allowed users", + description="List of usernames who can see this group. If not set or empty, the group is visible to all users.", + ) @field_validator("cameras", mode="before") @classmethod diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 6d86c57f8..862b5e9cf 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -679,6 +679,11 @@ export function CameraGroupEdit({ const allowedCameras = useAllowedCameras(); const isAdmin = useIsAdmin(); + // fetch users list for the users multi-select (admin only) + const { data: usersList } = useSWR<{ username: string; role: string }[]>( + isAdmin ? "users" : null, + ); + const [openCamera, setOpenCamera] = useState(); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); @@ -721,6 +726,7 @@ export function CameraGroupEdit({ .refine((value) => Object.keys(LuIcons).includes(value), { message: "Invalid icon", }), + users: z.array(z.string()).optional(), }); const onSubmit = useCallback( @@ -756,10 +762,18 @@ export function CameraGroupEdit({ const cameraQueries = values.cameras .map((cam) => `&camera_groups.${values.name}.cameras=${cam}`) .join(""); + const usersQueries = + values.users && values.users.length > 0 + ? values.users + .map( + (user) => `&camera_groups.${values.name}.users=${user}`, + ) + .join("") + : ""; axios .put( - `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`, + `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}${usersQueries}`, { requires_restart: 0, }, @@ -828,6 +842,7 @@ export function CameraGroupEdit({ name: (editingGroup && editingGroup[0]) ?? "", icon: editingGroup && (editingGroup[1].icon as IconName), cameras: editingGroup && editingGroup[1].cameras, + users: (editingGroup && editingGroup[1].users) ?? [], }, }); @@ -970,6 +985,56 @@ export function CameraGroupEdit({ )} /> + {isAdmin && usersList && usersList.length > 0 && ( + <> + + ( + + {t("group.users.label", { defaultValue: "Assigned Users" })} + + {t("group.users.desc", { defaultValue: "If no users are selected, the group is visible to all users." })} + + +
+ {usersList.map((user) => ( + +
+ + { + const updatedUsers = checked + ? [...(field.value || []), user.username] + : (field.value || []).filter( + (u) => u !== user.username, + ); + form.setValue("users", updatedUsers); + }} + /> +
+
+ ))} +
+
+ )} + /> + + )} +
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index e3f794602..20ab93a58 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -312,6 +312,7 @@ export type CameraGroupConfig = { cameras: string[]; icon: IconName; order: number; + users?: string[]; }; export type StreamType = "no-streaming" | "smart" | "continuous";