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
This commit is contained in:
Claude 2026-03-20 03:38:12 +00:00
parent e8b9f50bc9
commit 8f990817b4
No known key found for this signature in database
4 changed files with 76 additions and 2 deletions

View File

@ -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)

View File

@ -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

View File

@ -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 && (
<>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem>
<FormLabel>{t("group.roles.label", { defaultValue: "Roles" })}</FormLabel>
<FormDescription>
{t("group.roles.desc", {
defaultValue:
"Select which roles can see this camera group. If no roles selected, group is only visible to admins.",
})}
</FormDescription>
<FormMessage />
{Object.keys(config.auth.roles)
.filter((role) => role !== "admin")
.map((role) => (
<FormControl key={role}>
<div className="flex items-center justify-between gap-1">
<span className="mx-2 w-full cursor-pointer text-primary smart-capitalize">
{role}
</span>
<Switch
id={`role-${role}`}
checked={field.value?.includes(role) ?? false}
onCheckedChange={(checked) => {
const updatedRoles = checked
? [...(field.value || []), role]
: (field.value || []).filter((r) => r !== role);
form.setValue("roles", updatedRoles);
}}
/>
</div>
</FormControl>
))}
</FormItem>
)}
/>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0">

View File

@ -345,6 +345,7 @@ export type CameraGroupConfig = {
cameras: string[];
icon: IconName;
order: number;
roles?: string[];
};
export type StreamType = "no-streaming" | "smart" | "continuous";