{
- setPlaybackStart(currentTime);
- setMainCamera(cam);
- }}
+ onSelectCamera={onSelectCamera}
/>
{isDesktop && (
- {isDesktop && allCameras.length > 1 && (
+ {isDesktop && effectiveCameras.length > 1 && (
- {allCameras.map((cam) => {
+ {effectiveCameras.map((cam) => {
if (cam == mainCamera || cam == "birdseye") {
return;
}
diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx
index 6ca2a0f6d..2bb726bad 100644
--- a/web/src/views/search/SearchView.tsx
+++ b/web/src/views/search/SearchView.tsx
@@ -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
(() => {
if (!config) {
@@ -103,7 +105,9 @@ export default function SearchView({
}
const labels = new Set();
- 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();
- 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,
],
);
diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx
index aa71e763c..161da0f81 100644
--- a/web/src/views/settings/AuthenticationView.tsx
+++ b/web/src/views/settings/AuthenticationView.tsx
@@ -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("config");
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
const { data: users, mutate: mutateUsers } = useSWR("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();
- const [selectedUserRole, setSelectedUserRole] = useState<
- "admin" | "viewer"
- >();
+ const [selectedUserRole, setSelectedUserRole] = useState();
+
+ const [selectedRole, setSelectedRole] = useState();
+ const [currentRoleCameras, setCurrentRoleCameras] = useState([]);
+ const [selectedRoleForDelete, setSelectedRoleForDelete] = useState();
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 (
@@ -192,84 +402,84 @@ export default function AuthenticationView() {
);
}
- return (
-
-
-
-
-
-
- {t("users.management.title")}
-
-
- {t("users.management.desc")}
-
-
-
+ // Users section
+ const UsersSection = (
+ <>
+
+
+
+ {t("users.management.title")}
+
+
+ {t("users.management.desc")}
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {t("users.table.username")}
+
+ {t("users.table.role")}
+
+ {t("users.table.actions")}
+
+
+
+
+ {users.length === 0 ? (
-
- {t("users.table.username")}
-
- {t("users.table.role")}
-
- {t("users.table.actions")}
-
+
+ {t("users.table.noUsers")}
+
-
-
- {users.length === 0 ? (
-
-
- {t("users.table.noUsers")}
+ ) : (
+ users.map((user) => (
+
+
+
+ {user.username === "admin" ? (
+
+ ) : (
+
+ )}
+ {user.username}
+
-
- ) : (
- users.map((user) => (
-
-
-
- {user.username === "admin" ? (
-
- ) : (
-
- )}
- {user.username}
-
-
-
-
- {t("role." + (user.role || "viewer"), {
- ns: "common",
- })}
-
-
-
-
-
- {user.username !== "admin" && (
+
+
+ {t("role." + (user.role || "viewer"), {
+ ns: "common",
+ })}
+
+
+
+
+
+ {user.username !== "admin" &&
+ user.username !== "viewer" && (
)}
+
+
+
+
+
+ {t("users.updatePassword")}
+
+
+
+ {user.username !== "admin" && (
- {t("users.updatePassword")}
+ {t("users.table.deleteUser")}
-
- {user.username !== "admin" && (
-
-
-
-
-
- {t("users.table.deleteUser")}
-
-
- )}
-
-
-
-
- ))
- )}
-
-
-
+ )}
+
+
+
+
+ ))
+ )}
+
+
-
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 = (
+ <>
+
+
+
+ {t("roles.management.title")}
+
+
+ {t("roles.management.desc")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("roles.table.role")}
+
+ {t("roles.table.cameras")}
+
+ {t("roles.table.actions")}
+
+
+
+
+ {roles.length === 0 ? (
+
+
+ {t("roles.table.noRoles")}
+
+
+ ) : (
+ roles.map((roleData) => (
+
+
+ {roleData.name}
+
+
+ {roleData.cameras.length === 0 ? (
+
+ {t("menu.live.allCameras", { ns: "common" })}
+
+ ) : roleData.cameras.length > 5 ? (
+
+ {roleData.cameras.length} cameras
+
+ ) : (
+
+ {roleData.cameras.map((camera) => (
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {roleData.name !== "admin" &&
+ roleData.name !== "viewer" && (
+ <>
+
+
+
+
+
+ {t("roles.table.editCameras")}
+
+
+
+
+
+
+
+
+ {t("roles.table.deleteRole")}
+
+
+ >
+ )}
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ setShowCreateRole(false)}
+ />
+ {selectedRole && (
+ {
+ setShowEditRole(false);
+ setSelectedRole(undefined);
+ setCurrentRoleCameras([]);
+ }}
+ />
+ )}
+ {
+ setShowDeleteRole(false);
+ setSelectedRoleForDelete("");
+ }}
+ onDelete={async () => {
+ if (selectedRoleForDelete) {
+ try {
+ await onDeleteRole(selectedRoleForDelete);
+ } catch (error) {
+ // Error handling is already done in onDeleteRole
+ }
+ }
+ }}
+ />
+ >
+ );
+
+ return (
+
+
+
+ {section === "users" && UsersSection}
+ {section === "roles" && RolesSection}
+ {!section && (
+ <>
+ {UsersSection}
+
+ {RolesSection}
+ >
+ )}
+
);
}
diff --git a/web/src/views/settings/RolesView.tsx b/web/src/views/settings/RolesView.tsx
new file mode 100644
index 000000000..5afbf2eb0
--- /dev/null
+++ b/web/src/views/settings/RolesView.tsx
@@ -0,0 +1,5 @@
+import AuthenticationView from "./AuthenticationView";
+
+export default function RolesView() {
+ return ;
+}
diff --git a/web/src/views/settings/UsersView.tsx b/web/src/views/settings/UsersView.tsx
new file mode 100644
index 000000000..a30d7356f
--- /dev/null
+++ b/web/src/views/settings/UsersView.tsx
@@ -0,0 +1,5 @@
+import AuthenticationView from "./AuthenticationView";
+
+export default function UsersView() {
+ return ;
+}