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:
Josh Hawkins
2025-03-08 10:01:08 -06:00
committed by GitHub
parent 6f9d9cd5a8
commit 74ca009b0b
50 changed files with 1951 additions and 732 deletions
+21
View File
@@ -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>
);
}
+6 -5
View File
@@ -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],
+7 -10
View File
@@ -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],
+42 -59
View File
@@ -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]);
+23 -3
View File
@@ -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"}`}