diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index aa71e763c..9f5f7866e 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"; @@ -31,22 +31,32 @@ 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"; export default function AuthenticationView() { 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 +92,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 +154,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 +190,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 = useMemo(() => { + return config?.auth?.roles + ? Object.entries(config.auth.roles).map(([name, data]) => ({ + name, + cameras: Array.isArray(data) ? data : [], + })) + : []; + }, [config]); + + const availableRoles = useMemo(() => { + return config ? [...Object.keys(config.auth?.roles || {})] : []; + }, [config]); + if (!config || !users) { return (
@@ -193,7 +396,7 @@ export default function AuthenticationView() { } return ( -
+
@@ -269,33 +472,33 @@ export default function AuthenticationView() {
- {user.username !== "admin" && ( - - - - - -

{t("users.table.changeRole")}

-
-
- )} + {user.username !== "admin" && + user.username !== "viewer" && ( + + + + + +

{t("users.table.changeRole")}

+
+
+ )} @@ -353,6 +556,136 @@ export default function AuthenticationView() {
+ + + +
+
+ + {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 ? ( + roleData.cameras.join(", ") + ) : ( + + {t("menu.live.allCameras", { ns: "common" })} + + )} + + + +
+ {roleData.name !== "admin" && + roleData.name !== "viewer" && ( + <> + + + + + +

{t("roles.table.editCameras")}

+
+
+ + + + + + +

{t("roles.table.deleteRole")}

+
+
+ + )} +
+
+
+
+ )) + )} +
+
+
+
+
onChangeRole(selectedUser, role)} + availableRoles={availableRoles} + onSave={(role) => onChangeRole(selectedUser!, role)} onCancel={() => setShowRoleChange(false)} /> )} + 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 + } + } + }} + />
); }