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 05:19:29 -06:00
committed by GitHub
parent ba650af6f2
commit ed1e3a7c9a
41 changed files with 2286 additions and 739 deletions
+12 -1
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 />} />
+1 -1
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);
@@ -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">
@@ -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(() => {
@@ -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(() => {
@@ -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>
);
}
@@ -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}
@@ -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"
@@ -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>
);
}
@@ -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>
);
}
+43 -26
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" })}
@@ -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")}
+43 -7
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 });
};
+22
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 || [];
}
+11
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
);
}
+5 -3
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],
);
}
+11 -3
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),
+5 -2
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} />
)}
+6
View File
@@ -342,6 +342,12 @@ export interface FrigateConfig {
enabled: boolean;
};
auth: {
roles: {
[roleName: string]: string[];
};
};
birdseye: BirdseyeConfig;
cameras: {
+21 -15
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;
}
+12 -5
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,
],
);
+545 -130
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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function RolesView() {
return <AuthenticationView section="roles" />;
}
+5
View File
@@ -0,0 +1,5 @@
import AuthenticationView from "./AuthenticationView";
export default function UsersView() {
return <AuthenticationView section="users" />;
}