Merge pull request #78 from ibs0d/claude/frigate-role-based-groups-2kRHN

feat: add role-based visibility for camera groups
This commit is contained in:
ibs0d 2026-03-20 14:49:11 +11:00 committed by GitHub
commit e947e1ede7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 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) 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 from pydantic import Field, field_validator
@ -23,6 +23,11 @@ class CameraGroupConfig(FrigateBaseModel):
title="Sort order", title="Sort order",
description="Numeric order used to sort camera groups in the UI; larger numbers appear later.", 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") @field_validator("cameras", mode="before")
@classmethod @classmethod
@ -31,3 +36,11 @@ class CameraGroupConfig(FrigateBaseModel):
return [v] return [v]
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), { .refine((value) => Object.keys(LuIcons).includes(value), {
message: "Invalid icon", message: "Invalid icon",
}), }),
roles: z.array(z.string()).optional(),
}); });
const onSubmit = useCallback( const onSubmit = useCallback(
@ -756,10 +757,13 @@ export function CameraGroupEdit({
const cameraQueries = values.cameras const cameraQueries = values.cameras
.map((cam) => `&camera_groups.${values.name}.cameras=${cam}`) .map((cam) => `&camera_groups.${values.name}.cameras=${cam}`)
.join(""); .join("");
const roleQueries = (values.roles ?? [])
.map((role) => `&camera_groups.${values.name}.roles=${role}`)
.join("");
axios axios
.put( .put(
`config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`, `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}${roleQueries}`,
{ {
requires_restart: 0, requires_restart: 0,
}, },
@ -828,6 +832,7 @@ export function CameraGroupEdit({
name: (editingGroup && editingGroup[0]) ?? "", name: (editingGroup && editingGroup[0]) ?? "",
icon: editingGroup && (editingGroup[1].icon as IconName), icon: editingGroup && (editingGroup[1].icon as IconName),
cameras: editingGroup && editingGroup[1].cameras, 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" /> <Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0"> <div className="flex flex-row gap-2 py-5 md:pb-0">

View File

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