mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57:40 +03:00
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:
parent
9cb7902a9d
commit
3b3a513929
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -312,6 +312,7 @@ export type CameraGroupConfig = {
|
||||
cameras: string[];
|
||||
icon: IconName;
|
||||
order: number;
|
||||
users?: string[];
|
||||
};
|
||||
|
||||
export type StreamType = "no-streaming" | "smart" | "continuous";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user