frigate/web/src/pages/Settings.tsx
Josh Hawkins ed1e3a7c9a
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
2025-09-12 05:19:29 -06:00

443 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { isIOS, isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
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 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";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
import { isInIframe } from "@/utils/isIFrame";
import { isPWA } from "@/utils/isPWA";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
import TriggerView from "@/views/settings/TriggerView";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
const allSettingsViews = [
"ui",
"enrichments",
"cameras",
"masksAndZones",
"motionTuner",
"triggers",
"debug",
"users",
"roles",
"notifications",
"frigateplus",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
export default function Settings() {
const { t } = useTranslation(["views/settings"]);
const [page, setPage] = useState<SettingsType>("ui");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config");
const [searchParams] = useSearchParams();
// auth and roles
const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = [
"ui",
"debug",
"notifications",
];
const visibleSettingsViews = !isAdmin
? allowedViewsForViewer
: allSettingsViews;
// TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",
true,
);
const cameraEnabledStates = useMemo(() => {
const states: Record<string, boolean> = {};
if (allCameraStates) {
Object.entries(allCameraStates).forEach(([camName, state]) => {
states[camName] = state.config?.enabled ?? false;
});
}
// fallback to config if ws data isnt available yet
cameras.forEach((cam) => {
if (!(cam.name in states)) {
states[cam.name] = cam.enabled;
}
});
return states;
}, [allCameraStates, cameras]);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
const handleDialog = useCallback(
(save: boolean) => {
if (unsavedChanges && save) {
// TODO
}
setConfirmationDialogOpen(false);
setUnsavedChanges(false);
},
[unsavedChanges],
);
useEffect(() => {
if (cameras.length > 0) {
if (!selectedCamera) {
// Set to first enabled camera initially if no selection
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
setSelectedCamera(firstEnabledCamera.name);
} else if (!cameraEnabledStates[selectedCamera] && page !== "cameras") {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
if (firstEnabledCamera.name !== selectedCamera) {
setSelectedCamera(firstEnabledCamera.name);
}
}
}
}, [cameras, selectedCamera, cameraEnabledStates, page]);
useEffect(() => {
if (tabsRef.current) {
const element = tabsRef.current.querySelector(
`[data-nav-item="${pageToggle}"]`,
);
if (element instanceof HTMLElement) {
scrollIntoView(element, {
behavior:
isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth",
inline: "start",
});
}
}
}, [tabsRef, pageToggle]);
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings
if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) {
setPage("ui");
} else {
setPage(page as SettingsType);
}
}
// don't clear url params if we're creating a new object mask
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
});
useSearchEffect("camera", (camera: string) => {
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
}
// don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
});
useEffect(() => {
document.title = t("documentTitle.default");
}, [t]);
return (
<div className="flex size-full flex-col p-2">
<div className="relative flex h-11 w-full items-center justify-between">
<ScrollArea className="w-full whitespace-nowrap">
<div ref={tabsRef} className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SettingsType) => {
if (value) {
// Restrict viewer navigation
if (!isAdmin && !allowedViewsForViewer.includes(value)) {
setPageToggle("ui");
} else {
setPageToggle(value);
}
}
}}
>
{visibleSettingsViews.map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "ui" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={t("selectItem", {
ns: "common",
item: t("menu." + item),
})}
>
<div className="smart-capitalize">{t("menu." + item)}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
{(page == "debug" ||
page == "cameras" ||
page == "masksAndZones" ||
page == "motionTuner" ||
page == "triggers") && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
{page == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
)}
</div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "ui" && <UiSettingsView />}
{page == "enrichments" && (
<EnrichmentsSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}
{page == "cameras" && (
<CameraSettingsView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "masksAndZones" && (
<MasksAndZonesView
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "motionTuner" && (
<MotionTunerView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page === "triggers" && (
<TriggerView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "users" && <UsersView />}
{page == "roles" && <RolesView />}
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "frigateplus" && (
<FrigatePlusSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
</div>
{confirmationDialogOpen && (
<AlertDialog
open={confirmationDialogOpen}
onOpenChange={() => setConfirmationDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dialog.unsavedChanges.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dialog.unsavedChanges.desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => handleDialog(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDialog(true)}>
{t("button.save", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
}
type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
cameraEnabledStates: Record<string, boolean>;
currentPage: SettingsType;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
if (!allCameras.length) {
return null;
}
const trigger = (
<Button
className="flex items-center gap-2 bg-selected smart-capitalize hover:bg-selected"
aria-label="Select a camera"
size="sm"
>
<FaVideo className="text-background dark:text-primary" />
<div className="hidden text-background dark:text-primary md:block">
{selectedCamera == undefined ? (
t("cameraSetting.noCamera")
) : (
<CameraNameLabel camera={selectedCamera} />
)}
</div>
</Button>
);
const content = (
<>
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
{t("cameraSetting.camera")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
<div className="flex flex-col gap-2.5">
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "cameras";
return (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name}
isCameraName={true}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
</div>
</div>
</>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}