frigate/web/src/views/settings/UiSettingsView.tsx
Josh Hawkins 5f092a38a8 UI viewer role (#16978)
* db migration

* db model

* assign admin role on password reset

* add role to jwt and api responses

* don't restrict api access for admins yet

* use json response

* frontend auth context

* update auth form for profile endpoint

* add access denied page

* add protected routes

* auth hook

* dialogs

* user settings view

* restrict viewer access to settings

* restrict camera functions for viewer role

* add password dialog to account menu

* spacing tweak

* migrator default to admin

* escape quotes in migrator

* ui tweaks

* tweaks

* colors

* colors

* fix merge conflict

* fix icons

* add api layer enforcement

* ui tweaks

* fix error message

* debug

* clean up

* remove print

* guard apis for admin only

* fix tests

* fix review tests

* use correct error responses from api in toasts

* add role to account menu
2025-03-18 15:12:41 +01:00

260 lines
9.1 KiB
TypeScript

import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useCallback, useEffect } from "react";
import { Toaster } from "sonner";
import { toast } from "sonner";
import { Separator } from "../../components/ui/separator";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { del as delData } from "idb-keyval";
import { usePersistence } from "@/hooks/use-persistence";
import { isSafari } from "react-device-detect";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "../../components/ui/select";
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`)
.then(() => {
toast.success(`Cleared stored layout for ${value[0]}`, {
position: "top-center",
});
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to clear stored layout: ${errorMessage}`, {
position: "top-center",
});
});
});
}, [config]);
const clearStreamingSettings = useCallback(async () => {
if (!config) {
return [];
}
await delData(`streaming-settings`)
.then(() => {
toast.success(`Cleared streaming settings for all camera groups.`, {
position: "top-center",
});
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
position: "top-center",
});
});
}, [config]);
useEffect(() => {
document.title = "General Settings - Frigate";
}, []);
// settings
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
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">
<Heading as="h3" className="my-2">
General Settings
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Live Dashboard
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Switch
id="auto-live"
checked={autoLive}
onCheckedChange={setAutoLive}
/>
<Label className="cursor-pointer" htmlFor="auto-live">
Automatic Live View
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
Automatically switch to a camera's live view when activity is
detected. Disabling this option causes static camera images on
the your dashboards to only update once per minute.{" "}
<em>
This is a global setting but can be overridden on each
camera <strong>in camera groups only</strong>.
</em>
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Switch
id="images-only"
checked={alertVideos}
onCheckedChange={setAlertVideos}
/>
<Label className="cursor-pointer" htmlFor="images-only">
Play Alert Videos
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
By default, recent alerts on the Live dashboard play as small
looping videos. Disable this option to only show a static
image of recent alerts on this device/browser.
</p>
</div>
</div>
</div>
<div className="my-3 flex w-full flex-col space-y-6">
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">Stored Layouts</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
The layout of cameras in a camera group can be
dragged/resized. The positions are stored in your browser's
local storage.
</p>
</div>
</div>
<Button
aria-label="Clear all saved layouts"
onClick={clearStoredLayouts}
>
Clear All Layouts
</Button>
</div>
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">Camera Group Streaming Settings</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
Streaming settings for each camera group are stored in your
browser's local storage.
</p>
</div>
</div>
<Button
aria-label="Clear all group streaming settings"
onClick={clearStreamingSettings}
>
Clear All Streaming Settings
</Button>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Recordings Viewer
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Default Playback Rate</div>
<div className="my-2 text-sm text-muted-foreground">
<p>Default playback rate for recordings playback.</p>
</div>
</div>
</div>
<Select
value={playbackRate?.toString()}
onValueChange={(value) => setPlaybackRate(parseFloat(value))}
>
<SelectTrigger className="w-20">
{`${playbackRate}x`}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PLAYBACK_RATE_DEFAULT.map((rate) => (
<SelectItem
key={rate}
className="cursor-pointer"
value={rate.toString()}
>
{rate}x
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Calendar
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">First Weekday</div>
<div className="my-2 text-sm text-muted-foreground">
<p>The day that the weeks of the review calendar begin on.</p>
</div>
</div>
</div>
<Select
value={weekStartsOn?.toString()}
onValueChange={(value) => setWeekStartsOn(parseInt(value))}
>
<SelectTrigger className="w-32">
{WEEK_STARTS_ON[weekStartsOn ?? 0]}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{WEEK_STARTS_ON.map((day, index) => (
<SelectItem
key={index}
className="cursor-pointer"
value={index.toString()}
>
{day}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Separator className="my-2 flex bg-secondary" />
</div>
</div>
</div>
</>
);
}