mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-26 11:31:28 +08:00
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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 />} />
|
||||
|
@@ -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(() => {
|
||||
|
228
web/src/components/overlay/CreateRoleDialog.tsx
Normal file
228
web/src/components/overlay/CreateRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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"
|
||||
|
109
web/src/components/overlay/DeleteRoleDialog.tsx
Normal file
109
web/src/components/overlay/DeleteRoleDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
195
web/src/components/overlay/EditRoleCamerasDialog.tsx
Normal file
195
web/src/components/overlay/EditRoleCamerasDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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")}
|
||||
|
@@ -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
web/src/hooks/use-allowed-cameras.ts
Normal file
22
web/src/hooks/use-allowed-cameras.ts
Normal 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
web/src/hooks/use-is-custom-role.ts
Normal file
11
web/src/hooks/use-is-custom-role.ts
Normal 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
|
||||
);
|
||||
}
|
@@ -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],
|
||||
);
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -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} />
|
||||
)}
|
||||
|
@@ -342,6 +342,12 @@ export interface FrigateConfig {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
auth: {
|
||||
roles: {
|
||||
[roleName: string]: string[];
|
||||
};
|
||||
};
|
||||
|
||||
birdseye: BirdseyeConfig;
|
||||
|
||||
cameras: {
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
@@ -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
web/src/views/settings/RolesView.tsx
Normal file
5
web/src/views/settings/RolesView.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AuthenticationView from "./AuthenticationView";
|
||||
|
||||
export default function RolesView() {
|
||||
return <AuthenticationView section="roles" />;
|
||||
}
|
5
web/src/views/settings/UsersView.tsx
Normal file
5
web/src/views/settings/UsersView.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AuthenticationView from "./AuthenticationView";
|
||||
|
||||
export default function UsersView() {
|
||||
return <AuthenticationView section="users" />;
|
||||
}
|
Reference in New Issue
Block a user