Enhance user roles to limit camera access (#20024)

* update config for roles and add validator

* ensure admin and viewer are never overridden

* add class method to user to retrieve all allowed cameras

* enforce config roles in auth api endpoints

* add camera access api dependency functions

* protect review endpoints

* protect preview endpoints

* rename param name for better fastapi injection matching

* remove unneeded

* protect export endpoints

* protect event endpoints

* protect media endpoints

* update auth hook for allowed cameras

* update default app view

* ensure anonymous user always returns all cameras

* limit cameras in explore

* cameras is already a list

* limit cameras in review/history

* limit cameras in live view

* limit cameras in camera groups

* only show face library and classification in sidebar for admin

* remove check in delete reviews

since admin role is required, no need to check camera access. fixes failing test

* pass request with camera access for tests

* more async

* camera access tests

* fix proxy auth tests

* allowed cameras for review tests

* combine event tests and refactor for camera access

* fix post validation for roles

* don't limit roles in create user dialog

* fix triggers endpoints

no need to run require camera access dep since the required role is admin

* fix type

* create and edit role dialogs

* delete role dialog

* fix role change dialog

* update settings view for roles

* i18n changes

* minor spacing tweaks

* docs

* use badges and camera name label component

* clarify docs

* display all cameras badge for admin and viewer

* i18n fix

* use validator to prevent reserved and empty roles from being assigned

* split users and roles into separate tabs in settings

* tweak docs

* clarify docs

* change icon

* don't memoize roles

always recalculate on component render
This commit is contained in:
Josh Hawkins
2025-09-12 06:19:29 -05:00
committed by GitHub
parent ba650af6f2
commit ed1e3a7c9a
41 changed files with 2286 additions and 739 deletions

View File

@@ -556,7 +556,68 @@
"admin": "Admin",
"adminDesc": "Full access to all features.",
"viewer": "Viewer",
"viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only."
"viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only.",
"customDesc": "Custom role with specific camera access."
}
}
}
},
"roles": {
"management": {
"title": "Viewer Role Management",
"desc": "Manage custom viewer roles and their camera access permissions for this Frigate instance."
},
"addRole": "Add Role",
"table": {
"role": "Role",
"cameras": "Cameras",
"actions": "Actions",
"noRoles": "No custom roles found.",
"editCameras": "Edit Cameras",
"deleteRole": "Delete Role"
},
"toast": {
"success": {
"createRole": "Role {{role}} created successfully",
"updateCameras": "Cameras updated for role {{role}}",
"deleteRole": "Role {{role}} deleted successfully",
"userRolesUpdated": "{{count}} user(s) assigned to this role have been updated to 'viewer', which has access to all cameras."
},
"error": {
"createRoleFailed": "Failed to create role: {{errorMessage}}",
"updateCamerasFailed": "Failed to update cameras: {{errorMessage}}",
"deleteRoleFailed": "Failed to delete role: {{errorMessage}}",
"userUpdateFailed": "Failed to update user roles: {{errorMessage}}"
}
},
"dialog": {
"createRole": {
"title": "Create New Role",
"desc": "Add a new role and specify camera access permissions."
},
"editCameras": {
"title": "Edit Role Cameras",
"desc": "Update camera access for the role <strong>{{role}}</strong>."
},
"deleteRole": {
"title": "Delete Role",
"desc": "This action cannot be undone. This will permanently delete the role and assign any users with this role to the 'viewer' role, which will give viewer access to all cameras.",
"warn": "Are you sure you want to delete <strong>{{role}}</strong>?",
"deleting": "Deleting..."
},
"form": {
"role": {
"title": "Role Name",
"placeholder": "Enter role name",
"desc": "Only letters, numbers, periods and underscores allowed.",
"roleIsRequired": "Role name is required",
"roleOnlyInclude": "Role name may only include letters, numbers, . or _",
"roleExists": "A role with this name already exists."
},
"cameras": {
"title": "Cameras",
"desc": "Select cameras this role has access to. At least one camera is required.",
"required": "At least one camera must be selected."
}
}
}

View File

@@ -47,6 +47,9 @@ function App() {
}
function DefaultAppView() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
return (
<div className="size-full overflow-hidden">
{isDesktop && <Sidebar />}
@@ -64,7 +67,15 @@ function DefaultAppView() {
<Suspense>
<Routes>
<Route
element={<ProtectedRoute requiredRoles={["viewer", "admin"]} />}
element={
<ProtectedRoute
requiredRoles={
config?.auth.roles
? Object.keys(config.auth.roles)
: ["admin", "viewer"]
}
/>
}
>
<Route index element={<Live />} />
<Route path="/review" element={<Events />} />

View File

@@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
export default function ProtectedRoute({
requiredRoles,
}: {
requiredRoles: ("admin" | "viewer")[];
requiredRoles: string[];
}) {
const { auth } = useContext(AuthContext);

View File

@@ -77,6 +77,8 @@ import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
type CameraGroupSelectorProps = {
className?: string;
@@ -650,6 +652,9 @@ export function CameraGroupEdit({
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
);
const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole();
const [openCamera, setOpenCamera] = useState<string | null>();
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -837,12 +842,17 @@ export function CameraGroupEdit({
<FormDescription>{t("group.cameras.desc")}</FormDescription>
<FormMessage />
{[
...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}).sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
...(birdseyeConfig?.enabled &&
(!isCustomRole || "birdseye" in allowedCameras)
? ["birdseye"]
: []),
...Object.keys(config?.cameras ?? {})
.filter((camera) => allowedCameras.includes(camera))
.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
].map((camera) => (
<FormControl key={camera}>
<div className="flex items-center justify-between gap-1">

View File

@@ -25,6 +25,7 @@ import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
const REVIEW_FILTERS = [
"cameras",
@@ -72,6 +73,7 @@ export default function ReviewFilterGroup({
setMotionOnly,
}: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (filterList?.labels) {
@@ -83,7 +85,9 @@ export default function ReviewFilterGroup({
}
const labels = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -106,7 +110,7 @@ export default function ReviewFilterGroup({
});
return [...labels].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
@@ -118,7 +122,9 @@ export default function ReviewFilterGroup({
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -134,11 +140,11 @@ export default function ReviewFilterGroup({
});
return [...zones].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras ?? {}).sort(
cameras: allowedCameras.sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
@@ -146,7 +152,7 @@ export default function ReviewFilterGroup({
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
}),
[config, allLabels, allZones],
[config, allLabels, allZones, allowedCameras],
);
const groups = useMemo(() => {

View File

@@ -24,9 +24,9 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type SearchFilterGroupProps = {
className: string;
@@ -46,6 +46,7 @@ export default function SearchFilterGroup({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (filterList?.labels) {
@@ -57,7 +58,9 @@ export default function SearchFilterGroup({
}
const labels = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -87,7 +90,7 @@ export default function SearchFilterGroup({
});
return [...labels].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
@@ -99,7 +102,9 @@ export default function SearchFilterGroup({
}
const zones = new Set<string>();
const cameras = filter?.cameras || Object.keys(config.cameras);
const cameras = (filter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -118,16 +123,16 @@ export default function SearchFilterGroup({
});
return [...zones].sort();
}, [config, filterList, filter]);
}, [config, filterList, filter, allowedCameras]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
cameras: allowedCameras,
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
search_type: ["thumbnail", "description"] as SearchSource[],
}),
[config, allLabels, allZones],
[allLabels, allZones, allowedCameras],
);
const availableSortTypes = useMemo(() => {

View File

@@ -0,0 +1,228 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type CreateRoleOverlayProps = {
show: boolean;
config: FrigateConfig;
onCreate: (role: string, cameras: string[]) => void;
onCancel: () => void;
};
export default function CreateRoleDialog({
show,
config,
onCreate,
onCancel,
}: CreateRoleOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const existingRoles = Object.keys(config.auth?.roles || {});
const formSchema = z.object({
role: z
.string()
.min(1, t("roles.dialog.form.role.roleIsRequired"))
.regex(/^[A-Za-z0-9._]+$/, {
message: t("roles.dialog.form.role.roleOnlyInclude"),
})
.refine((role) => !existingRoles.includes(role), {
message: t("roles.dialog.form.role.roleExists"),
}),
cameras: z
.array(z.string())
.min(1, t("roles.dialog.form.cameras.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
role: "",
cameras: [],
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
await onCreate(values.role, values.cameras);
form.reset();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!show) {
form.reset({
role: "",
cameras: [],
});
}
}, [show, form]);
const handleCancel = () => {
form.reset({
role: "",
cameras: [],
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("roles.dialog.createRole.title")}</DialogTitle>
<DialogDescription>
{t("roles.dialog.createRole.desc")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
>
<FormField
name="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("roles.dialog.form.role.title")}
</FormLabel>
<FormControl>
<Input
placeholder={t("roles.dialog.form.role.placeholder")}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.role.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
key={camera}
control={form.control}
name="cameras"
render={({ field }) => {
return (
<FormItem
key={camera}
className="flex flex-row items-center justify-between space-x-3 space-y-0"
>
<div className="space-y-0.5">
<FormLabel className="font-normal">
<CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")}
camera={camera}
/>
</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value?.includes(camera)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value as string[]),
camera,
])
: field.onChange(
(field.value as string[])?.filter(
(value: string) => value !== camera,
) || [],
);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -190,7 +190,7 @@ export default function CreateTriggerDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
className="space-y-5 pt-4"
>
<FormField
control={form.control}

View File

@@ -36,7 +36,7 @@ import { useTranslation } from "react-i18next";
type CreateUserOverlayProps = {
show: boolean;
onCreate: (user: string, password: string, role: "admin" | "viewer") => void;
onCreate: (user: string, password: string, role: string) => void;
onCancel: () => void;
};
@@ -123,7 +123,7 @@ export default function CreateUserDialog({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 py-4"
className="space-y-5 pt-4"
>
<FormField
name="user"

View File

@@ -0,0 +1,109 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useState } from "react";
type DeleteRoleDialogProps = {
show: boolean;
role: string;
onCancel: () => void;
onDelete: () => void;
};
export default function DeleteRoleDialog({
show,
role,
onCancel,
onDelete,
}: DeleteRoleDialogProps) {
const { t } = useTranslation("views/settings");
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleDelete = async () => {
setIsLoading(true);
try {
await onDelete();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("roles.dialog.deleteRole.title")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="roles.dialog.deleteRole.desc"
ns="views/settings"
values={{ role }}
components={{
strong: <span className="font-medium" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="py-3">
<div className="text-sm text-muted-foreground">
<p>
<Trans
ns={"views/settings"}
values={{ role }}
components={{ strong: <span className="font-medium" /> }}
>
roles.dialog.deleteRole.warn
</Trans>
</p>
</div>
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
disabled={isLoading}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex flex-1"
aria-label={t("button.delete", { ns: "common" })}
variant="destructive"
disabled={isLoading}
onClick={handleDelete}
type="button"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("roles.dialog.deleteRole.deleting")}</span>
</div>
) : (
t("button.delete", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,195 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Trans, useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type EditRoleCamerasOverlayProps = {
show: boolean;
config: FrigateConfig;
role: string;
currentCameras: string[];
onSave: (cameras: string[]) => void;
onCancel: () => void;
};
export default function EditRoleCamerasDialog({
show,
config,
role,
currentCameras,
onSave,
onCancel,
}: EditRoleCamerasOverlayProps) {
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {});
const formSchema = z.object({
cameras: z
.array(z.string())
.min(1, t("roles.dialog.form.cameras.required")),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
cameras: currentCameras,
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
await onSave(values.cameras);
form.reset();
} catch (error) {
// Error handled in parent
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
form.reset({
cameras: currentCameras,
});
onCancel();
};
return (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{t("roles.dialog.editCameras.title", { role })}
</DialogTitle>
<DialogDescription>
<Trans
ns={"views/settings"}
values={{ role }}
components={{ strong: <span className="font-medium" /> }}
>
roles.dialog.editCameras.desc
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-5 pt-4"
>
<div className="space-y-2">
<FormLabel>{t("roles.dialog.form.cameras.title")}</FormLabel>
<FormDescription className="text-xs text-muted-foreground">
{t("roles.dialog.form.cameras.desc")}
</FormDescription>
<div className="scrollbar-container max-h-[40dvh] space-y-2 overflow-y-auto">
{cameras.map((camera) => (
<FormField
key={camera}
control={form.control}
name="cameras"
render={({ field }) => {
return (
<FormItem
key={camera}
className="flex flex-row items-center justify-between space-x-3 space-y-0"
>
<div className="space-y-0.5">
<FormLabel className="font-normal">
<CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")}
camera={camera}
/>
</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value?.includes(camera)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value as string[]),
camera,
])
: field.onChange(
(field.value as string[])?.filter(
(value: string) => value !== camera,
) || [],
);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</div>
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
disabled={isLoading}
onClick={handleCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading || !form.formState.isValid}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { Trans, useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -7,22 +7,23 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
} from "@/components/ui/select";
import { useState } from "react";
import { LuShield, LuUser } from "react-icons/lu";
type RoleChangeDialogProps = {
show: boolean;
username: string;
currentRole: "admin" | "viewer";
onSave: (role: "admin" | "viewer") => void;
currentRole: string;
availableRoles: string[];
onSave: (role: string) => void;
onCancel: () => void;
};
@@ -30,13 +31,12 @@ export default function RoleChangeDialog({
show,
username,
currentRole,
availableRoles,
onSave,
onCancel,
}: RoleChangeDialogProps) {
const { t } = useTranslation(["views/settings"]);
const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">(
currentRole,
);
const [selectedRole, setSelectedRole] = useState<string>(currentRole);
return (
<Dialog open={show} onOpenChange={onCancel}>
@@ -73,31 +73,46 @@ export default function RoleChangeDialog({
</span>
: {t("users.dialog.changeRole.roleInfo.viewerDesc")}
</li>
{availableRoles
.filter((role) => role !== "admin" && role !== "viewer")
.map((role) => (
<li key={role}>
<span className="font-medium">{role}</span>:{" "}
{t("users.dialog.changeRole.roleInfo.customDesc")}
</li>
))}
</ul>
</div>
<Select
value={selectedRole}
onValueChange={(value) =>
setSelectedRole(value as "admin" | "viewer")
}
>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("users.dialog.changeRole.select")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuShield className="size-4 text-primary" />
<span>{t("role.admin", { ns: "common" })}</span>
</div>
</SelectItem>
<SelectItem value="viewer" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<LuUser className="size-4 text-primary" />
<span>{t("role.viewer", { ns: "common" })}</span>
</div>
</SelectItem>
{availableRoles.map((role) => (
<SelectItem
key={role}
value={role}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
{role === "admin" ? (
<LuShield className="size-4 text-primary" />
) : role === "viewer" ? (
<LuUser className="size-4 text-primary" />
) : (
<LuUser className="size-4 text-muted-foreground" />
)}
<span>
{role === "admin"
? t("role.admin", { ns: "common" })
: role === "viewer"
? t("role.viewer", { ns: "common" })
: role}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -108,6 +123,7 @@ export default function RoleChangeDialog({
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
variant="outline"
onClick={onCancel}
type="button"
>
@@ -118,6 +134,7 @@ export default function RoleChangeDialog({
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={() => onSave(selectedRole)}
type="button"
disabled={selectedRole === currentRole}
>
{t("button.save", { ns: "common" })}

View File

@@ -114,7 +114,7 @@ export default function SetPasswordDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="password">
{t("users.dialog.form.newPassword.title")}

View File

@@ -3,7 +3,8 @@ import { createContext, useEffect, useState } from "react";
import useSWR from "swr";
interface AuthState {
user: { username: string; role: "admin" | "viewer" | null } | null;
user: { username: string; role: string | null } | null;
allowedCameras: string[];
isLoading: boolean;
isAuthenticated: boolean; // true if auth is required
}
@@ -15,7 +16,12 @@ interface AuthContextType {
}
export const AuthContext = createContext<AuthContextType>({
auth: { user: null, isLoading: true, isAuthenticated: false },
auth: {
user: null,
allowedCameras: [],
isLoading: true,
isAuthenticated: false,
},
login: () => {},
logout: () => {},
});
@@ -23,6 +29,7 @@ export const AuthContext = createContext<AuthContextType>({
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [auth, setAuth] = useState<AuthState>({
user: null,
allowedCameras: [],
isLoading: true,
isAuthenticated: false,
});
@@ -38,7 +45,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
// auth required but not logged in
setAuth({ user: null, isLoading: false, isAuthenticated: true });
setAuth({
user: null,
allowedCameras: [],
isLoading: false,
isAuthenticated: true,
});
}
return;
}
@@ -49,20 +61,44 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
username: profile.username,
role: profile.role || "viewer",
};
setAuth({ user: newUser, isLoading: false, isAuthenticated: true });
const allowedCameras = Array.isArray(profile.allowed_cameras)
? profile.allowed_cameras
: [];
setAuth({
user: newUser,
allowedCameras,
isLoading: false,
isAuthenticated: true,
});
} else {
// Unauthenticated mode (anonymous)
setAuth({ user: null, isLoading: false, isAuthenticated: false });
setAuth({
user: null,
allowedCameras: [],
isLoading: false,
isAuthenticated: false,
});
}
}
}, [profile, error]);
const login = (user: AuthState["user"]) => {
setAuth({ user, isLoading: false, isAuthenticated: true });
setAuth((current) => ({
...current,
user,
isLoading: false,
isAuthenticated: true,
}));
};
const logout = () => {
setAuth({ user: null, isLoading: false, isAuthenticated: true });
setAuth({
user: null,
allowedCameras: [],
isLoading: false,
isAuthenticated: true,
});
axios.get("/logout", { withCredentials: true });
};

View File

@@ -0,0 +1,22 @@
import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
export function useAllowedCameras() {
const { auth } = useContext(AuthContext);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
if (
auth.user?.role === "viewer" ||
auth.user?.role === "admin" ||
!auth.isAuthenticated // anonymous port 5000
) {
// return all cameras
return config?.cameras ? Object.keys(config.cameras) : [];
}
return auth.allowedCameras || [];
}

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
export function useIsCustomRole() {
const { auth } = useContext(AuthContext);
return !(
auth.user?.role === "admin" ||
auth.user?.role == "viewer" ||
!auth.isAuthenticated
);
}

View File

@@ -9,6 +9,7 @@ import { LuConstruction } from "react-icons/lu";
import { MdCategory, MdVideoLibrary } from "react-icons/md";
import { TbFaceId } from "react-icons/tb";
import useSWR from "swr";
import { useIsAdmin } from "./use-is-admin";
export const ID_LIVE = 1;
export const ID_REVIEW = 2;
@@ -24,6 +25,7 @@ export default function useNavigation(
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const isAdmin = useIsAdmin();
return useMemo(
() =>
@@ -70,7 +72,7 @@ export default function useNavigation(
icon: TbFaceId,
title: "menu.faceLibrary",
url: "/faces",
enabled: isDesktop && config?.face_recognition.enabled,
enabled: isDesktop && config?.face_recognition.enabled && isAdmin,
},
{
id: ID_CLASSIFICATION,
@@ -78,9 +80,9 @@ export default function useNavigation(
icon: MdCategory,
title: "menu.classification",
url: "/classification",
enabled: isDesktop,
enabled: isDesktop && isAdmin,
},
] as NavData[],
[config?.face_recognition?.enabled, variant],
[config?.face_recognition?.enabled, variant, isAdmin],
);
}

View File

@@ -13,10 +13,13 @@ import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
function Live() {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isCustomRole = useIsCustomRole();
// selection
@@ -81,19 +84,22 @@ function Live() {
// settings
const allowedCameras = useAllowedCameras();
const includesBirdseye = useMemo(() => {
if (
config &&
Object.keys(config.camera_groups).length &&
cameraGroup &&
config.camera_groups[cameraGroup] &&
cameraGroup != "default"
cameraGroup != "default" &&
(!isCustomRole || "birdseye" in allowedCameras)
) {
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
} else {
return false;
}
}, [config, cameraGroup]);
}, [config, cameraGroup, allowedCameras, isCustomRole]);
const cameras = useMemo(() => {
if (!config) {
@@ -111,13 +117,15 @@ function Live() {
.filter(
(conf) => conf.enabled_in_config && group.cameras.includes(conf.name),
)
.filter((cam) => allowedCameras.includes(cam.name))
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.filter((cam) => allowedCameras.includes(cam.name))
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config, cameraGroup]);
}, [config, cameraGroup, allowedCameras]);
const selectedCamera = useMemo(
() => cameras.find((cam) => cam.name == selectedCameraName),

View File

@@ -33,7 +33,8 @@ import CameraSettingsView from "@/views/settings/CameraSettingsView";
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
@@ -57,6 +58,7 @@ const allSettingsViews = [
"triggers",
"debug",
"users",
"roles",
"notifications",
"frigateplus",
] as const;
@@ -288,7 +290,8 @@ export default function Settings() {
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "users" && <AuthenticationView />}
{page == "users" && <UsersView />}
{page == "roles" && <RolesView />}
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}

View File

@@ -342,6 +342,12 @@ export interface FrigateConfig {
enabled: boolean;
};
auth: {
roles: {
[roleName: string]: string[];
};
};
birdseye: BirdseyeConfig;
cameras: {

View File

@@ -65,6 +65,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type RecordingViewProps = {
startCamera: string;
@@ -97,11 +98,17 @@ export function RecordingView({
const timezone = useTimezone(config);
const allowedCameras = useAllowedCameras();
const effectiveCameras = useMemo(
() => allCameras.filter((camera) => allowedCameras.includes(camera)),
[allCameras, allowedCameras],
);
const { data: recordingsSummary } = useSWR<RecordingsSummary>([
"recordings/summary",
{
timezone: timezone,
cameras: allCameras.join(",") ?? null,
cameras: effectiveCameras.join(",") ?? null,
},
]);
@@ -276,14 +283,16 @@ export function RecordingView({
const onSelectCamera = useCallback(
(newCam: string) => {
setMainCamera(newCam);
setFullResolution({
width: 0,
height: 0,
});
setPlaybackStart(currentTime);
if (allowedCameras.includes(newCam)) {
setMainCamera(newCam);
setFullResolution({
width: 0,
height: 0,
});
setPlaybackStart(currentTime);
}
},
[currentTime],
[currentTime, allowedCameras],
);
// fullscreen
@@ -488,12 +497,9 @@ export function RecordingView({
</div>
<div className="flex items-center justify-end gap-2">
<MobileCameraDrawer
allCameras={allCameras}
allCameras={effectiveCameras}
selected={mainCamera}
onSelectCamera={(cam) => {
setPlaybackStart(currentTime);
setMainCamera(cam);
}}
onSelectCamera={onSelectCamera}
/>
{isDesktop && (
<ExportDialog
@@ -674,7 +680,7 @@ export function RecordingView({
containerRef={mainLayoutRef}
/>
</div>
{isDesktop && allCameras.length > 1 && (
{isDesktop && effectiveCameras.length > 1 && (
<div
ref={previewRowRef}
className={cn(
@@ -686,7 +692,7 @@ export function RecordingView({
)}
>
<div className="w-2" />
{allCameras.map((cam) => {
{effectiveCameras.map((cam) => {
if (cam == mainCamera || cam == "birdseye") {
return;
}

View File

@@ -33,6 +33,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
type SearchViewProps = {
search: string;
@@ -96,6 +97,7 @@ export default function SearchView({
);
// suggestions values
const allowedCameras = useAllowedCameras();
const allLabels = useMemo<string[]>(() => {
if (!config) {
@@ -103,7 +105,9 @@ export default function SearchView({
}
const labels = new Set<string>();
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
const cameras = (searchFilter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -128,7 +132,7 @@ export default function SearchView({
});
return [...labels].sort();
}, [config, searchFilter]);
}, [config, searchFilter, allowedCameras]);
const { data: allSubLabels } = useSWR("sub_labels");
const { data: allRecognizedLicensePlates } = useSWR(
@@ -141,7 +145,9 @@ export default function SearchView({
}
const zones = new Set<string>();
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
const cameras = (searchFilter?.cameras || allowedCameras).filter((camera) =>
allowedCameras.includes(camera),
);
cameras.forEach((camera) => {
if (camera == "birdseye") {
@@ -160,11 +166,11 @@ export default function SearchView({
});
return [...zones].sort();
}, [config, searchFilter]);
}, [config, searchFilter, allowedCameras]);
const suggestionsValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
cameras: allowedCameras,
labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}),
sub_labels: allSubLabels,
@@ -192,6 +198,7 @@ export default function SearchView({
allSubLabels,
allRecognizedLicensePlates,
searchFilter,
allowedCameras,
],
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import { Toaster } from "@/components/ui/sonner";
@@ -14,7 +14,7 @@ import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
import { HiTrash } from "react-icons/hi";
import { FaUserEdit } from "react-icons/fa";
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import { LuPencil, LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import {
Table,
TableBody,
@@ -31,22 +31,39 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
import CreateRoleDialog from "@/components/overlay/CreateRoleDialog";
import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
import { useTranslation } from "react-i18next";
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
import { Separator } from "@/components/ui/separator";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
export default function AuthenticationView() {
type AuthenticationViewProps = {
section?: "users" | "roles";
};
export default function AuthenticationView({
section,
}: AuthenticationViewProps) {
const { t } = useTranslation("views/settings");
const { data: config } = useSWR<FrigateConfig>("config");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
const [showSetPassword, setShowSetPassword] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRoleChange, setShowRoleChange] = useState(false);
const [showCreateRole, setShowCreateRole] = useState(false);
const [showEditRole, setShowEditRole] = useState(false);
const [showDeleteRole, setShowDeleteRole] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>();
const [selectedUserRole, setSelectedUserRole] = useState<
"admin" | "viewer"
>();
const [selectedUserRole, setSelectedUserRole] = useState<string>();
const [selectedRole, setSelectedRole] = useState<string>();
const [currentRoleCameras, setCurrentRoleCameras] = useState<string[]>([]);
const [selectedRoleForDelete, setSelectedRoleForDelete] = useState<string>();
useEffect(() => {
document.title = t("documentTitle.authentication");
@@ -82,11 +99,7 @@ export default function AuthenticationView() {
[t],
);
const onCreate = (
user: string,
password: string,
role: "admin" | "viewer",
) => {
const onCreate = (user: string, password: string, role: string) => {
axios
.post("users", { username: user, password, role })
.then((response) => {
@@ -148,8 +161,8 @@ export default function AuthenticationView() {
});
};
const onChangeRole = (user: string, newRole: "admin" | "viewer") => {
if (user === "admin") return; // Prevent role change for 'admin'
const onChangeRole = (user: string, newRole: string) => {
if (user === "admin") return;
axios
.put(`users/${user}/role`, { role: newRole })
@@ -184,6 +197,203 @@ export default function AuthenticationView() {
});
};
type ConfigSetBody = {
requires_restart: number;
config_data: {
auth: {
roles: {
[key: string]: string[] | string;
};
};
};
update_topic?: string;
};
const onCreateRole = useCallback(
async (role: string, cameras: string[]) => {
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
auth: {
roles: {
[role]: cameras,
},
},
},
update_topic: "config/auth",
};
return axios
.put("config/set", configBody)
.then((response) => {
if (response.status === 200) {
setShowCreateRole(false);
updateConfig();
toast.success(t("roles.toast.success.createRole", { role }), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.createRoleFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
throw error;
});
},
[t, updateConfig],
);
const onEditRoleCameras = useCallback(
async (cameras: string[]) => {
if (!selectedRole) return;
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
auth: {
roles: {
[selectedRole]: cameras,
},
},
},
update_topic: "config/auth",
};
return axios
.put("config/set", configBody)
.then((response) => {
if (response.status === 200) {
setShowEditRole(false);
setSelectedRole(undefined);
setCurrentRoleCameras([]);
updateConfig();
toast.success(
t("roles.toast.success.updateCameras", { role: selectedRole }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.updateCamerasFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
throw error;
});
},
[t, selectedRole, updateConfig],
);
const onDeleteRole = useCallback(
async (role: string) => {
// Update users assigned to this role to 'viewer'
const usersToUpdate = users?.filter((user) => user.role === role) || [];
if (usersToUpdate.length > 0) {
Promise.all(
usersToUpdate.map((user) =>
axios.put(`users/${user.username}/role`, { role: "viewer" }),
),
)
.then(() => {
mutateUsers(
(users) =>
users?.map((u) =>
u.role === role ? { ...u, role: "viewer" } : u,
),
false,
);
toast.success(
t("roles.toast.success.userRolesUpdated", {
count: usersToUpdate.length,
}),
{ position: "top-center" },
);
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.userUpdateFailed", { errorMessage }),
{ position: "top-center" },
);
});
}
// Now delete the role from config
const configBody: ConfigSetBody = {
requires_restart: 0,
config_data: {
auth: {
roles: {
[role]: "",
},
},
},
update_topic: "config/auth",
};
return axios
.put("config/set", configBody)
.then((response) => {
if (response.status === 200) {
setShowDeleteRole(false);
setSelectedRoleForDelete("");
updateConfig();
toast.success(t("roles.toast.success.deleteRole", { role }), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("roles.toast.error.deleteRoleFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
throw error;
});
},
[t, updateConfig, users, mutateUsers],
);
const roles = config?.auth?.roles
? Object.entries(config.auth.roles)
.filter(([name]) => name !== "admin")
.map(([name, data]) => ({
name,
cameras: Array.isArray(data) ? data : [],
}))
: [];
const availableRoles = useMemo(() => {
return config ? [...Object.keys(config.auth?.roles || {})] : [];
}, [config]);
if (!config || !users) {
return (
<div className="flex h-full w-full items-center justify-center">
@@ -192,84 +402,84 @@ export default function AuthenticationView() {
);
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
{t("users.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("users.management.desc")}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("users.addUser")}
variant="default"
onClick={() => setShowCreate(true)}
>
<LuPlus className="size-4" />
{t("users.addUser")}
</Button>
// Users section
const UsersSection = (
<>
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
{t("users.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("users.management.desc")}
</p>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
<div className="h-full overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("users.addUser")}
variant="default"
onClick={() => setShowCreate(true)}
>
<LuPlus className="size-4" />
{t("users.addUser")}
</Button>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
<div className="h-full overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-[250px]">
{t("users.table.username")}
</TableHead>
<TableHead>{t("users.table.role")}</TableHead>
<TableHead className="text-right">
{t("users.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableHead className="w-[250px]">
{t("users.table.username")}
</TableHead>
<TableHead>{t("users.table.role")}</TableHead>
<TableHead className="text-right">
{t("users.table.actions")}
</TableHead>
<TableCell colSpan={3} className="h-24 text-center">
{t("users.table.noUsers")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("users.table.noUsers")}
) : (
users.map((user) => (
<TableRow key={user.username} className="group">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{user.username === "admin" ? (
<LuShield className="size-4 text-primary" />
) : (
<LuUserCog className="size-4 text-primary-variant" />
)}
{user.username}
</div>
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.username} className="group">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{user.username === "admin" ? (
<LuShield className="size-4 text-primary" />
) : (
<LuUserCog className="size-4 text-primary-variant" />
)}
{user.username}
</div>
</TableCell>
<TableCell>
<Badge
variant={
user.role === "admin" ? "default" : "outline"
}
className={
user.role === "admin"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t("role." + (user.role || "viewer"), {
ns: "common",
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{user.username !== "admin" && (
<TableCell>
<Badge
variant={
user.role === "admin" ? "default" : "outline"
}
className={
user.role === "admin"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t("role." + (user.role || "viewer"), {
ns: "common",
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{user.username !== "admin" &&
user.username !== "viewer" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -279,8 +489,7 @@ export default function AuthenticationView() {
onClick={() => {
setSelectedUser(user.username);
setSelectedUserRole(
(user.role as "admin" | "viewer") ||
"viewer",
user.role || "viewer",
);
setShowRoleChange(true);
}}
@@ -297,64 +506,62 @@ export default function AuthenticationView() {
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setShowSetPassword(true);
setSelectedUser(user.username);
}}
>
<FaUserEdit className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("users.table.password")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.updatePassword")}</p>
</TooltipContent>
</Tooltip>
{user.username !== "admin" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setShowSetPassword(true);
setShowDelete(true);
setSelectedUser(user.username);
}}
>
<FaUserEdit className="size-3.5" />
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("users.table.password")}
{t("button.delete", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.updatePassword")}</p>
<p>{t("users.table.deleteUser")}</p>
</TooltipContent>
</Tooltip>
{user.username !== "admin" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setShowDelete(true);
setSelectedUser(user.username);
}}
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("button.delete", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.table.deleteUser")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<SetPasswordDialog
show={showSetPassword}
onCancel={() => setShowSetPassword(false)}
@@ -376,10 +583,218 @@ export default function AuthenticationView() {
show={showRoleChange}
username={selectedUser}
currentRole={selectedUserRole}
onSave={(role) => onChangeRole(selectedUser, role)}
availableRoles={availableRoles}
onSave={(role) => onChangeRole(selectedUser!, role)}
onCancel={() => setShowRoleChange(false)}
/>
)}
</>
);
// Roles section
const RolesSection = (
<>
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
{t("roles.management.title")}
</Heading>
<p className="text-sm text-muted-foreground">
{t("roles.management.desc")}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label={t("roles.addRole")}
variant="default"
onClick={() => setShowCreateRole(true)}
>
<LuPlus className="size-4" />
{t("roles.addRole")}
</Button>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
<div className="h-full overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-[250px]">
{t("roles.table.role")}
</TableHead>
<TableHead>{t("roles.table.cameras")}</TableHead>
<TableHead className="text-right">
{t("roles.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("roles.table.noRoles")}
</TableCell>
</TableRow>
) : (
roles.map((roleData) => (
<TableRow key={roleData.name} className="group">
<TableCell className="font-medium">
{roleData.name}
</TableCell>
<TableCell>
{roleData.cameras.length === 0 ? (
<Badge
variant="default"
className="bg-primary/20 text-xs text-primary hover:bg-primary/30"
>
{t("menu.live.allCameras", { ns: "common" })}
</Badge>
) : roleData.cameras.length > 5 ? (
<Badge variant="outline" className="text-xs">
{roleData.cameras.length} cameras
</Badge>
) : (
<div className="flex flex-wrap gap-1">
{roleData.cameras.map((camera) => (
<Badge
key={camera}
variant="outline"
className="text-xs"
>
<CameraNameLabel
camera={camera}
className="text-xs smart-capitalize"
/>
</Badge>
))}
</div>
)}
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{roleData.name !== "admin" &&
roleData.name !== "viewer" && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedRole(roleData.name);
setCurrentRoleCameras(
roleData.cameras,
);
setShowEditRole(true);
}}
disabled={roleData.name === "admin"}
>
<LuPencil className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("roles.table.editCameras")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.editCameras")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={() => {
setSelectedRoleForDelete(
roleData.name,
);
setShowDeleteRole(true);
}}
disabled={roleData.name === "admin"}
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("button.delete", {
ns: "common",
})}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("roles.table.deleteRole")}</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<CreateRoleDialog
show={showCreateRole}
config={config}
onCreate={onCreateRole}
onCancel={() => setShowCreateRole(false)}
/>
{selectedRole && (
<EditRoleCamerasDialog
show={showEditRole}
config={config}
role={selectedRole}
currentCameras={currentRoleCameras}
onSave={onEditRoleCameras}
onCancel={() => {
setShowEditRole(false);
setSelectedRole(undefined);
setCurrentRoleCameras([]);
}}
/>
)}
<DeleteRoleDialog
show={showDeleteRole}
role={selectedRoleForDelete || ""}
onCancel={() => {
setShowDeleteRole(false);
setSelectedRoleForDelete("");
}}
onDelete={async () => {
if (selectedRoleForDelete) {
try {
await onDeleteRole(selectedRoleForDelete);
} catch (error) {
// Error handling is already done in onDeleteRole
}
}
}}
/>
</>
);
return (
<div className="flex size-full flex-col">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
{section === "users" && UsersSection}
{section === "roles" && RolesSection}
{!section && (
<>
{UsersSection}
<Separator className="my-6 flex bg-secondary" />
{RolesSection}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function RolesView() {
return <AuthenticationView section="roles" />;
}

View File

@@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function UsersView() {
return <AuthenticationView section="users" />;
}