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
This commit is contained in:
Claude 2026-03-19 04:49:47 +00:00
parent 9cb7902a9d
commit 3b3a513929
No known key found for this signature in database
4 changed files with 85 additions and 2 deletions

View File

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

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.",
)
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

View File

@ -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<string | null>();
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 && (
<>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="users"
render={({ field }) => (
<FormItem>
<FormLabel>{t("group.users.label", { defaultValue: "Assigned Users" })}</FormLabel>
<FormDescription>
{t("group.users.desc", { defaultValue: "If no users are selected, the group is visible to all users." })}
</FormDescription>
<FormMessage />
<div className="scrollbar-container max-h-[30dvh] overflow-y-auto">
{usersList.map((user) => (
<FormControl key={user.username}>
<div className="flex items-center justify-between gap-1">
<label
className="mx-2 w-full cursor-pointer text-primary"
htmlFor={`user-${user.username}`}
>
{user.username}
<span className="ml-2 text-xs text-muted-foreground">
({user.role})
</span>
</label>
<Switch
id={`user-${user.username}`}
checked={
field.value?.includes(user.username) ?? false
}
onCheckedChange={(checked) => {
const updatedUsers = checked
? [...(field.value || []), user.username]
: (field.value || []).filter(
(u) => u !== user.username,
);
form.setValue("users", updatedUsers);
}}
/>
</div>
</FormControl>
))}
</div>
</FormItem>
)}
/>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row gap-2 py-5 md:pb-0">

View File

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