mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +03:00
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
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useEffect } from "react";
|
||||
import { FaExclamationTriangle } from "react-icons/fa";
|
||||
|
||||
export default function AccessDenied() {
|
||||
useEffect(() => {
|
||||
document.title = "Access Denied - Frigate";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center text-center">
|
||||
<FaExclamationTriangle className="mb-4 size-8" />
|
||||
<Heading as="h2" className="mb-2">
|
||||
Access Denied
|
||||
</Heading>
|
||||
<p className="text-primary-variant">
|
||||
You don't have permission to view this page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,11 +59,12 @@ function ConfigEditor() {
|
||||
.catch((error) => {
|
||||
toast.error("Error saving config", { position: "top-center" });
|
||||
|
||||
if (error.response) {
|
||||
setError(error.response.data.message);
|
||||
} else {
|
||||
setError(error.message);
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
setError(errorMessage);
|
||||
});
|
||||
},
|
||||
[editorRef],
|
||||
|
||||
@@ -93,16 +93,13 @@ function Exports() {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to rename export: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to rename export: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to rename export: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[mutate],
|
||||
|
||||
@@ -99,16 +99,13 @@ export default function FaceLibrary() {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to upload image: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to upload image: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to upload image: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[pageToggle, refreshFaces],
|
||||
@@ -132,16 +129,13 @@ export default function FaceLibrary() {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to set face name: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to set face name: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to set face name: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[refreshFaces],
|
||||
@@ -308,15 +302,13 @@ function FaceAttempt({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(`Failed to train: ${error.response.data.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.error(`Failed to train: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to train: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[image, onRefresh],
|
||||
@@ -334,18 +326,13 @@ function FaceAttempt({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(
|
||||
`Failed to update score: ${error.response.data.message}`,
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to update score: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to update face score: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [image, onRefresh]);
|
||||
|
||||
@@ -361,15 +348,13 @@ function FaceAttempt({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(`Failed to delete: ${error.response.data.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.error(`Failed to delete: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [image, onRefresh]);
|
||||
|
||||
@@ -478,15 +463,13 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.data?.message) {
|
||||
toast.error(`Failed to delete: ${error.response.data.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.error(`Failed to delete: ${error.message}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [name, image, onRefresh]);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useInitialCameraState } from "@/api/ws";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
const allSettingsViews = [
|
||||
"UI settings",
|
||||
@@ -62,6 +63,15 @@ export default function Settings() {
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// auth and roles
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"];
|
||||
const visibleSettingsViews = !isAdmin
|
||||
? allowedViewsForViewer
|
||||
: allSettingsViews;
|
||||
|
||||
// TODO: confirm leave page
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
@@ -149,7 +159,12 @@ export default function Settings() {
|
||||
|
||||
useSearchEffect("page", (page: string) => {
|
||||
if (allSettingsViews.includes(page as SettingsType)) {
|
||||
setPage(page as SettingsType);
|
||||
// Restrict viewer to UI settings
|
||||
if (!isAdmin && !["UI settings", "debug"].includes(page)) {
|
||||
setPage("UI settings");
|
||||
} else {
|
||||
setPage(page as SettingsType);
|
||||
}
|
||||
}
|
||||
// don't clear url params if we're creating a new object mask
|
||||
return !searchParams.has("object_mask");
|
||||
@@ -180,11 +195,16 @@ export default function Settings() {
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SettingsType) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
// Restrict viewer navigation
|
||||
if (!isAdmin && !["UI settings", "debug"].includes(value)) {
|
||||
setPageToggle("UI settings");
|
||||
} else {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(allSettingsViews).map((item) => (
|
||||
{visibleSettingsViews.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
|
||||
Reference in New Issue
Block a user