diff --git a/src/components/accessories/admin/users/Users.tsx b/src/components/accessories/admin/users/Users.tsx index 44ac4d8b2..dfac8e331 100644 --- a/src/components/accessories/admin/users/Users.tsx +++ b/src/components/accessories/admin/users/Users.tsx @@ -1,37 +1,49 @@ import { Tab, Tabs } from "@mui/material"; -import React, { useState } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +import { useLocation, useNavigate } from "react-router"; import Button from "../../button/Button"; import UserGroupsTable from "./userGroupsTable"; import UsersTable from "./usersTable"; import { PATHS } from "../../../../consts"; +import { UserDTO, UserGroupDTO } from "../../../../generated"; -import { UserDTO } from "../../../../generated"; +export enum TabOptions { + "users" = "users", + "groups" = "groups", +} export const Users = () => { const navigate = useNavigate(); const { t } = useTranslation(); + const { state }: { state: { tab?: TabOptions } } = useLocation(); + const setTab = (tab: TabOptions) => + navigate(PATHS.admin_users, { state: { tab } }); + + const handleEditGroup = (row: UserGroupDTO) => + navigate(PATHS.admin_usergroups_edit.replace(":id", row.code!), { + state: row, + }); + const handleEditUser = (row: UserDTO) => navigate(PATHS.admin_users_edit.replace(":id", row.userName!), { state: row, }); - const [tab, setTab] = useState<"users" | "groups">("users"); return ( <> setTab(value)} aria-label="switch between users and groups" > - {tab === "users" ? ( + {state?.tab !== TabOptions.groups ? ( { onEdit={handleEditUser} /> ) : ( - + { + navigate(PATHS.admin_usergroups_new); + }} + type="button" + variant="contained" + color="primary" + > + {t("user.addGroup")} + + } + onEdit={handleEditGroup} + /> )} ); diff --git a/src/components/accessories/admin/users/editGroup/EditGroup.tsx b/src/components/accessories/admin/users/editGroup/EditGroup.tsx new file mode 100644 index 000000000..c9ac20d9a --- /dev/null +++ b/src/components/accessories/admin/users/editGroup/EditGroup.tsx @@ -0,0 +1,271 @@ +import { useFormik } from "formik"; +import { useAppDispatch, useAppSelector } from "libraries/hooks/redux"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Navigate, useLocation, useParams } from "react-router"; +import { useNavigate } from "react-router-dom"; + +import checkIcon from "../../../../../assets/check-icon.png"; +import Button from "../../../button/Button"; +import ConfirmationDialog from "../../../confirmationDialog/ConfirmationDialog"; +import InfoBox from "../../../infoBox/InfoBox"; +import TextField from "../../../textField/TextField"; + +import { PATHS } from "../../../../../consts"; +import { PermissionDTO, UserGroupDTO } from "../../../../../generated"; +import { usePermission } from "../../../../../libraries/permissionUtils/usePermission"; + +import { CircularProgress } from "@mui/material"; +import { getAllPermissions } from "../../../../../state/permissions"; +import { + getUserGroup, + updateUserGroup, + updateUserGroupReset, +} from "../../../../../state/usergroups"; +import { GroupPermissionsEditor } from "../editPermissions/GroupPermissionsEditor"; +import { + PermissionActionEnum, + PermissionActionType, + comparePermissions, +} from "../editPermissions/permission.utils"; +import { TabOptions } from "../Users"; +import "./styles.scss"; +import { userGroupSchema } from "./validation"; + +export const EditGroup = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const { state }: { state: UserGroupDTO } = useLocation(); + const { id } = useParams(); + const canUpdatePermissions = usePermission("grouppermission.update"); + + const update = useAppSelector((state) => state.usergroups.update); + const permissions = useAppSelector((state) => state.permissions.getAll); + const group = useAppSelector((state) => state.usergroups.currentGroup); + + // local state to keep track of permissions + const [groupPermissions, setGroupPermissions] = useState([]); + const [dirtyPermissions, setDirtyPermissions] = useState(false); + + // make sure everything is loaded before displaying the editor + const [isPermissionEditorAvailable, setIsPermissionEditorAvailable] = + useState(false); + + // keep track of which permissions have been updated and how + const [updatedPermissionsStack, setUpdatedPermissionsStack] = useState< + Array + >([]); + + const handleUpdatePermissions = ({ + permission, + action, + }: PermissionActionType) => { + const otherPermissions = groupPermissions.filter( + (p) => p.id !== permission.id + ); + + if (action === PermissionActionEnum.REVOKE) { + setGroupPermissions(otherPermissions); + } + if (action === PermissionActionEnum.ASSIGN) { + setGroupPermissions([...otherPermissions, permission]); + } + }; + + const { + handleSubmit, + handleBlur, + getFieldProps, + isValid, + dirty, + resetForm, + errors, + touched, + } = useFormik({ + initialValues: state, + validationSchema: userGroupSchema(t), + onSubmit: (values: UserGroupDTO) => { + values.permissions = groupPermissions; + const dto: UserGroupDTO = { ...values, permissions: groupPermissions }; + + dispatch(updateUserGroup(dto)); + }, + }); + + // load permissions and group on mount + useEffect(() => { + dispatch(getAllPermissions()); + dispatch(getUserGroup(state.code)); + return () => { + dispatch(updateUserGroupReset()); + }; + }, [dispatch, state.code]); + + // update group permissions on group load + useEffect(() => { + if (group.data) { + setGroupPermissions(group.data.permissions ?? []); + } + }, [group.data]); + + // compare permissions to update the update stack + // and display permissions when ready + useEffect(() => { + if (canUpdatePermissions && group.data && permissions.data) { + setIsPermissionEditorAvailable(true); + + const newPermissionStack = comparePermissions( + permissions.data, + group.data?.permissions ?? [], + groupPermissions + ); + + setUpdatedPermissionsStack(newPermissionStack); + } + }, [canUpdatePermissions, group.data, permissions.data, groupPermissions]); + + if (state?.code !== id) { + return ; + } + + const handleFormReset = () => { + resetForm(); + setGroupPermissions(group.data?.permissions ?? []); + }; + + if (permissions.hasFailed) + return ( + + ); + if (group.hasFailed) + return ( + + ); + + return ( + <> + {group.status === "LOADING" || permissions.status === "LOADING" ? ( + + ) : ( +
+
+
+
+ +
+
+ +
+
+ + {isPermissionEditorAvailable && ( + + )} + +
+ {isPermissionEditorAvailable && + updatedPermissionsStack.length > 0 && ( +

+ + Editing permissions:{" "} + {updatedPermissionsStack + .map( + (p) => + `${p.permission.name}: ${ + p.action === PermissionActionEnum.ASSIGN + ? "assign" + : "revoked" + }` + ) + .join(",")} + +
+ {updatedPermissionsStack.length} permission + {updatedPermissionsStack.length > 1 ? "s" : ""} will be + updated. +

+ )} + {update.hasFailed && ( +
+ +
+ )} +
+ +
+
+ +
+
+ +
+
+ + { + navigate(PATHS.admin_users, { + state: { tab: TabOptions.groups }, + }); + }} + handleSecondaryButtonClick={() => ({})} + /> +
+ )} + + ); +}; diff --git a/src/components/accessories/admin/users/editGroup/index.ts b/src/components/accessories/admin/users/editGroup/index.ts new file mode 100644 index 000000000..dfe69bec0 --- /dev/null +++ b/src/components/accessories/admin/users/editGroup/index.ts @@ -0,0 +1 @@ +export { EditGroup } from "./EditGroup"; diff --git a/src/components/accessories/admin/users/editGroup/styles.scss b/src/components/accessories/admin/users/editGroup/styles.scss new file mode 100644 index 000000000..fc783de46 --- /dev/null +++ b/src/components/accessories/admin/users/editGroup/styles.scss @@ -0,0 +1,84 @@ +@import "../../../../../styles/variables"; +@import "../../../../../../node_modules/susy/sass/susy"; + +.newGroupForm { + display: inline-block; + flex-direction: column; + align-items: center; + width: 100%; + + .formInsertMode { + margin: 0px 0px 20px; + } + + .row { + justify-content: space-between; + } + + .newGroupForm__item { + margin: 7px 0px; + padding: 0px 15px; + width: 50%; + @include susy-media($narrow) { + padding: 0px 10px; + } + @include susy-media($tablet_land) { + padding: 0px 10px; + } + @include susy-media($medium-up) { + width: 25%; + } + @include susy-media($tablet_port) { + width: 50%; + } + @include susy-media($smartphone) { + width: 100%; + } + .textField, + .selectField { + width: 100%; + } + + &.fullWidth { + width: 100%; + } + + &.halfWidth { + width: 50%; + @include susy-media($smartphone) { + width: 100%; + } + } + &.thirdWidth { + width: 33%; + @include susy-media($smartphone) { + width: 100%; + } + } + } + + .newGroupForm__buttonSet { + display: flex; + margin-top: 25px; + padding: 0px 15px; + flex-direction: row-reverse; + @include susy-media($smartphone_small) { + display: block; + } + + .submit_button, + .reset_button { + .MuiButton-label { + font-size: smaller; + letter-spacing: 1px; + font-weight: 600; + } + button { + @include susy-media($smartphone_small) { + width: 100%; + margin-top: 10px; + } + } + } + } +} diff --git a/src/components/accessories/admin/users/editGroup/validation.ts b/src/components/accessories/admin/users/editGroup/validation.ts new file mode 100644 index 000000000..f3745a36c --- /dev/null +++ b/src/components/accessories/admin/users/editGroup/validation.ts @@ -0,0 +1,9 @@ +import { object, string } from "yup"; +import { UserGroupDTO } from "../../../../../generated"; +import { TFunction } from "react-i18next"; + +export const userGroupSchema = (t: TFunction<"translation">) => + object().shape({ + code: string().min(2).required(t("user.validateGroupCode")), + desc: string(), + }); diff --git a/src/components/accessories/admin/users/editPermissions/AclPermissionCheckbox.tsx b/src/components/accessories/admin/users/editPermissions/AclPermissionCheckbox.tsx new file mode 100644 index 000000000..6c38dd86f --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/AclPermissionCheckbox.tsx @@ -0,0 +1,59 @@ +import { Checkbox, Popper } from "@mui/material"; +import React from "react"; +import { PermissionDTO } from "../../../../../generated"; +import { PermissionActionEnum } from "./permission.utils"; + +interface IProps { + permission: PermissionDTO; + groupPermissions: Array; + onChange: (permission: PermissionDTO, action: PermissionActionEnum) => void; +} + +export const AclPermissionCheckbox = ({ + permission, + groupPermissions, + onChange, +}: IProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const id = open ? "simple-popper" : undefined; + const checked = + groupPermissions?.some((p) => p.id === permission.id) || false; + return ( + <> + + onChange( + permission, + checked ? PermissionActionEnum.REVOKE : PermissionActionEnum.ASSIGN + ) + } + name={permission.id.toString()} + onMouseEnter={(event: React.MouseEvent) => { + setAnchorEl(anchorEl ? null : event.currentTarget); + }} + onMouseLeave={() => setAnchorEl(null)} + /> + + + {permission.name || "unknown"} + + + + ); +}; diff --git a/src/components/accessories/admin/users/editPermissions/AclTable.module.scss b/src/components/accessories/admin/users/editPermissions/AclTable.module.scss new file mode 100644 index 000000000..58dfeac4f --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/AclTable.module.scss @@ -0,0 +1,22 @@ +.container { + max-height: 400px; + overflow-y: auto; + + table.acl { + min-width: 100%; + th { + text-align: left; + } + td { + border-bottom: 1px solid #ccc; + } + } + td.empty { + span { + padding: 5px 13px; + font-weight: bold; + font-size: 19px; + color: #555; + } + } +} diff --git a/src/components/accessories/admin/users/editPermissions/AclTable.tsx b/src/components/accessories/admin/users/editPermissions/AclTable.tsx new file mode 100644 index 000000000..42eb72d3b --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/AclTable.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from "react"; + +import { useTranslation } from "react-i18next"; +import { PermissionDTO } from "../../../../../generated"; +import { AclPermissionCheckbox } from "./AclPermissionCheckbox"; +import classes from "./AclTable.module.scss"; +import { + Crud, + PermissionActionEnum, + permissionsToCrud, +} from "./permission.utils"; + +interface IProps { + permissions: PermissionDTO[]; + groupPermissions: PermissionDTO[]; + onChange: (permission: PermissionDTO, action: PermissionActionEnum) => void; +} + +export const AclTable = ({ + permissions, + groupPermissions, + onChange, +}: IProps) => { + const crudPermissions = useMemo(() => { + return permissionsToCrud(permissions); + }, [permissions]); + + const { t } = useTranslation(); + + const crudKeys = Array.from(crudPermissions.keys()); + + return ( +
+ + + + + + + + + + + + {Array.from(crudPermissions.values()).map((crudPermission, index) => { + return ( + + + {[Crud.CREATE, Crud.READ, Crud.UPDATE, Crud.DELETE].map( + (access: Crud, index: number) => { + return crudPermission[access] ? ( + + ) : ( + + ); + } + )} + + ); + })} + +
{t("permission.name")}{t("permission.create")}{t("permission.read")}{t("permission.update")}{t("permission.deleted")}
{crudKeys[index]} + + + +
+
+ ); +}; diff --git a/src/components/accessories/admin/users/editPermissions/AreaAccess.tsx b/src/components/accessories/admin/users/editPermissions/AreaAccess.tsx new file mode 100644 index 000000000..ec90d1dbc --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/AreaAccess.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import { PermissionDTO } from "../../../../../generated"; +import { PermissionCheckbox } from "./PermissionCheckbox"; +import { PermissionActionEnum } from "./permission.utils"; + +interface IProps { + permissions: PermissionDTO[]; + groupPermissions: PermissionDTO[]; + onChange: (permission: PermissionDTO, action: PermissionActionEnum) => void; +} +export const AreaAccess = ({ + permissions, + groupPermissions, + onChange, +}: IProps) => { + return ( +
    + {permissions + .filter( + (perm: PermissionDTO) => perm.name && /\.access$/.test(perm.name) + ) + .map((perm, index) => ( +
  • + +
  • + ))} +
+ ); +}; diff --git a/src/components/accessories/admin/users/editPermissions/GroupPermissionsEditor.tsx b/src/components/accessories/admin/users/editPermissions/GroupPermissionsEditor.tsx new file mode 100644 index 000000000..ee15e53f9 --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/GroupPermissionsEditor.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +import { useTranslation } from "react-i18next"; +import { PermissionDTO } from "../../../../../generated"; +import { AclTable } from "./AclTable"; +import { AreaAccess } from "./AreaAccess"; +import { PermissionActionEnum, PermissionActionType } from "./permission.utils"; + +interface IProps { + permissions: PermissionDTO[]; + groupPermissions: PermissionDTO[]; + setDirty: (v: boolean) => void; + update: (pa: PermissionActionType) => void; +} +export const GroupPermissionsEditor = ({ + permissions, + groupPermissions, + setDirty, + update, +}: IProps) => { + const handleChange = ( + newPermission: PermissionDTO, + action: PermissionActionEnum + ) => { + setDirty(true); + update({ permission: newPermission, action }); + }; + + const { t } = useTranslation(); + + return ( + <> +

{t("permission.accessarea")}

+ +

{t("permission.accesscontrollist")}

+ + + ); +}; diff --git a/src/components/accessories/admin/users/editPermissions/PermissionCheckbox.tsx b/src/components/accessories/admin/users/editPermissions/PermissionCheckbox.tsx new file mode 100644 index 000000000..3ac90792c --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/PermissionCheckbox.tsx @@ -0,0 +1,38 @@ +import { Checkbox, FormControlLabel } from "@mui/material"; +import React from "react"; +import { PermissionDTO } from "../../../../../generated"; +import { PermissionActionEnum } from "./permission.utils"; + +interface IProps { + permission: PermissionDTO; + groupPermissions: Array; + onChange: (permission: PermissionDTO, action: PermissionActionEnum) => void; +} + +export const PermissionCheckbox = ({ + permission, + groupPermissions, + onChange, +}: IProps) => { + const checked = + groupPermissions?.some((p) => p.id === permission.id) || false; + return ( + + onChange( + permission, + checked + ? PermissionActionEnum.REVOKE + : PermissionActionEnum.ASSIGN + ) + } + name={permission.id.toString()} + /> + } + label={permission.name || "unknown"} + /> + ); +}; diff --git a/src/components/accessories/admin/users/editPermissions/permission.utils.test.ts b/src/components/accessories/admin/users/editPermissions/permission.utils.test.ts new file mode 100644 index 000000000..2d3468ab1 --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/permission.utils.test.ts @@ -0,0 +1,58 @@ +import { PermissionDTO } from "../../../../../generated"; +import { permissionsToCrud } from "./permission.utils"; + +describe("permissionsToCrud", () => { + it("should ignore non-crud actions and badly formatted permission names", () => { + const nonCrud: PermissionDTO[] = [ + { id: 1, name: "foo.bar", description: "a" }, + { id: 2, name: "hello world", description: "a" }, + ]; + expect(permissionsToCrud(nonCrud).size).toBe(0); + }); + + it("should handle multipleh keys", () => { + const ward: PermissionDTO = { + id: 1, + name: "ward.read", + description: "read ward", + }; + const opd: PermissionDTO = { + id: 2, + name: "opd.create", + description: "create an opd", + }; + const mixedPermissions: PermissionDTO[] = [ward, opd]; + const someGroupedPermissions = permissionsToCrud(mixedPermissions); + + expect(someGroupedPermissions.size).toBe(2); + expect(someGroupedPermissions.get("ward")).toEqual({ read: ward }); + expect(someGroupedPermissions.get("opd")).toEqual({ create: opd }); + }); + + it("should handle multiple actions", () => { + const ward: PermissionDTO[] = [ + { id: 1, name: "ward.read", description: "read ward" }, + { + id: 2, + name: "ward.create", + description: "create ward", + // , + }, + ]; + const wardPermissions = permissionsToCrud(ward); + expect(wardPermissions.size).toBe(1); + expect(wardPermissions.get("ward")).toEqual({ + read: ward[0], + create: ward[1], + }); + }); + + it("should throw on duplicate", () => { + const doublePermissions: PermissionDTO[] = [ + { id: 1, name: "ward.read", description: "read ward" }, + { id: 1, name: "ward.read", description: "read ward" }, + ]; + expect(() => permissionsToCrud(doublePermissions)).toThrow(); + }); +}); + diff --git a/src/components/accessories/admin/users/editPermissions/permission.utils.ts b/src/components/accessories/admin/users/editPermissions/permission.utils.ts new file mode 100644 index 000000000..efd8d53ac --- /dev/null +++ b/src/components/accessories/admin/users/editPermissions/permission.utils.ts @@ -0,0 +1,88 @@ +import { PermissionDTO } from "../../../../../generated"; + +export enum PermissionActionEnum { + ASSIGN = "assign", + REVOKE = "revoke", +} + +export type PermissionActionType = { + action: PermissionActionEnum; + permission: PermissionDTO; +}; + +export enum Crud { + CREATE = "create", + READ = "read", + UPDATE = "update", + DELETE = "delete", +} + +/** + * fomats permission in the CRUD format + * @param permissions + * @returns Map> + * @example + * permissionsToCrud([ + * { id: "1", name: "users.create" }, + * { id: "2", name: "users.read" },- + * ]) => Map(2) { + * "users" => { create: { id: "1", name: "users.create" }, + * { read: { id: "2", name: "users.read" } + * + */ +export const permissionsToCrud = ( + permissions: PermissionDTO[] +): Map> => { + let permissionNames = new Map(); + for (let i = 0; i < permissions.length; i++) { + const matches = + permissions[i].name && + /([a-z]+)\.(create|read|update|delete)$/.exec(permissions[i].name || ""); + // no match: skip + if (!matches) continue; + const [, key, access] = matches; + + if (!!permissionNames.get(key)?.[access]) { + throw new Error(`duplicate permission ${key}.${access}`); + } + + permissionNames.set(key, { + ...permissionNames.get(key), + [access]: permissions[i], + }); + } + return permissionNames; +}; + +export const comparePermissions = ( + allPermissions: PermissionDTO[], + initialPermissions: PermissionDTO[], + stackedPermissions: PermissionDTO[] +): Array => { + let changedPermissions: Array = []; + for (const permission of allPermissions) { + const initialPermission = initialPermissions.find( + ({ id }) => permission.id === id + ); + const stackedPermission = stackedPermissions.find( + ({ id }) => permission.id === id + ); + + if (!!initialPermission && !!stackedPermission) { + continue; + } + + if (initialPermission !== stackedPermission) { + changedPermissions = [ + ...changedPermissions, + { + action: stackedPermission + ? PermissionActionEnum.ASSIGN + : PermissionActionEnum.REVOKE, + permission, + }, + ]; + } + } + return changedPermissions; +}; diff --git a/src/components/accessories/admin/users/editUser/EditUser.tsx b/src/components/accessories/admin/users/editUser/EditUser.tsx index 294aad5fc..10e1c4bf0 100644 --- a/src/components/accessories/admin/users/editUser/EditUser.tsx +++ b/src/components/accessories/admin/users/editUser/EditUser.tsx @@ -9,6 +9,7 @@ import { UserDTO } from "../../../../../generated"; import { getUserGroups } from "../../../../../state/usergroups"; import { getUserById, + getUserByIdReset, updateUser, updateUserReset, } from "../../../../../state/users"; @@ -30,6 +31,7 @@ export const EditUser = () => { dispatch(getUserGroups()); return () => { dispatch(updateUserReset()); + dispatch(getUserByIdReset()); }; }, [dispatch, id]); diff --git a/src/components/accessories/admin/users/index.ts b/src/components/accessories/admin/users/index.ts index 70a5ccbc9..24abc4672 100644 --- a/src/components/accessories/admin/users/index.ts +++ b/src/components/accessories/admin/users/index.ts @@ -1,3 +1,5 @@ +export { EditGroup } from "./editGroup"; export { EditUser } from "./editUser"; +export { NewGroup } from "./newGroup/index"; export { NewUser } from "./newUser"; export { Users } from "./Users"; diff --git a/src/components/accessories/admin/users/newGroup/NewGroup.tsx b/src/components/accessories/admin/users/newGroup/NewGroup.tsx new file mode 100644 index 000000000..f68097baa --- /dev/null +++ b/src/components/accessories/admin/users/newGroup/NewGroup.tsx @@ -0,0 +1,133 @@ +import { useFormik } from "formik"; +import { useAppDispatch, useAppSelector } from "libraries/hooks/redux"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import checkIcon from "../../../../../assets/check-icon.png"; +import Button from "../../../button/Button"; +import ConfirmationDialog from "../../../confirmationDialog/ConfirmationDialog"; +import InfoBox from "../../../infoBox/InfoBox"; +import TextField from "../../../textField/TextField"; + +import { PATHS } from "../../../../../consts"; +import { UserGroupDTO } from "../../../../../generated"; + +import { + createUserGroup, + createUserGroupReset, +} from "../../../../../state/usergroups"; +import { TabOptions } from "../Users"; +import "./styles.scss"; +import { userGroupSchema } from "./validation"; + +const initialValues = { + code: "", + desc: "", +}; + +export const NewGroup = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const create = useAppSelector((state) => state.usergroups.create); + + const { + handleSubmit, + handleBlur, + getFieldProps, + isValid, + dirty, + resetForm, + errors, + touched, + } = useFormik({ + initialValues, + validationSchema: userGroupSchema(t), + onSubmit: (values: UserGroupDTO) => { + dispatch(createUserGroup(values)); + }, + }); + + useEffect(() => { + return () => { + dispatch(createUserGroupReset()); + }; + }, [dispatch]); + + return ( +
+
+
+
+ +
+
+ +
+
+
+

{t("user.groupPermissionsOnlyOnUpdate")}

+ {create.hasFailed && ( +
+ +
+ )} +
+
+
+ +
+
+ +
+
+
+ { + navigate(PATHS.admin_users, { state: { tab: TabOptions.groups } }); + }} + handleSecondaryButtonClick={() => ({})} + /> +
+ ); +}; diff --git a/src/components/accessories/admin/users/newGroup/index.ts b/src/components/accessories/admin/users/newGroup/index.ts new file mode 100644 index 000000000..a1d89b1c8 --- /dev/null +++ b/src/components/accessories/admin/users/newGroup/index.ts @@ -0,0 +1 @@ +export { NewGroup } from "./NewGroup"; diff --git a/src/components/accessories/admin/users/newGroup/styles.scss b/src/components/accessories/admin/users/newGroup/styles.scss new file mode 100644 index 000000000..fc783de46 --- /dev/null +++ b/src/components/accessories/admin/users/newGroup/styles.scss @@ -0,0 +1,84 @@ +@import "../../../../../styles/variables"; +@import "../../../../../../node_modules/susy/sass/susy"; + +.newGroupForm { + display: inline-block; + flex-direction: column; + align-items: center; + width: 100%; + + .formInsertMode { + margin: 0px 0px 20px; + } + + .row { + justify-content: space-between; + } + + .newGroupForm__item { + margin: 7px 0px; + padding: 0px 15px; + width: 50%; + @include susy-media($narrow) { + padding: 0px 10px; + } + @include susy-media($tablet_land) { + padding: 0px 10px; + } + @include susy-media($medium-up) { + width: 25%; + } + @include susy-media($tablet_port) { + width: 50%; + } + @include susy-media($smartphone) { + width: 100%; + } + .textField, + .selectField { + width: 100%; + } + + &.fullWidth { + width: 100%; + } + + &.halfWidth { + width: 50%; + @include susy-media($smartphone) { + width: 100%; + } + } + &.thirdWidth { + width: 33%; + @include susy-media($smartphone) { + width: 100%; + } + } + } + + .newGroupForm__buttonSet { + display: flex; + margin-top: 25px; + padding: 0px 15px; + flex-direction: row-reverse; + @include susy-media($smartphone_small) { + display: block; + } + + .submit_button, + .reset_button { + .MuiButton-label { + font-size: smaller; + letter-spacing: 1px; + font-weight: 600; + } + button { + @include susy-media($smartphone_small) { + width: 100%; + margin-top: 10px; + } + } + } + } +} diff --git a/src/components/accessories/admin/users/newGroup/validation.ts b/src/components/accessories/admin/users/newGroup/validation.ts new file mode 100644 index 000000000..f3745a36c --- /dev/null +++ b/src/components/accessories/admin/users/newGroup/validation.ts @@ -0,0 +1,9 @@ +import { object, string } from "yup"; +import { UserGroupDTO } from "../../../../../generated"; +import { TFunction } from "react-i18next"; + +export const userGroupSchema = (t: TFunction<"translation">) => + object().shape({ + code: string().min(2).required(t("user.validateGroupCode")), + desc: string(), + }); diff --git a/src/components/accessories/admin/users/newUser/NewUser.tsx b/src/components/accessories/admin/users/newUser/NewUser.tsx index f8624a923..1d1564e01 100644 --- a/src/components/accessories/admin/users/newUser/NewUser.tsx +++ b/src/components/accessories/admin/users/newUser/NewUser.tsx @@ -67,10 +67,14 @@ export const NewUser = () => { useEffect(() => { dispatch(getUserGroups()); + }, [dispatch]); + + useEffect(() => { + if (create.hasSucceeded) navigate(PATHS.admin_users); return () => { dispatch(createUserReset()); }; - }, [dispatch]); + }, [create.hasSucceeded, dispatch, navigate]); return (
diff --git a/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.module.scss b/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.module.scss new file mode 100644 index 000000000..253324402 --- /dev/null +++ b/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.module.scss @@ -0,0 +1,4 @@ +.table { + display: grid; + margin-top: 50px; +} diff --git a/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.tsx b/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.tsx index 8a3080863..ddf558149 100644 --- a/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.tsx +++ b/src/components/accessories/admin/users/userGroupsTable/UserGroupsTable.tsx @@ -1,3 +1,109 @@ -import React from "react"; +import { CircularProgress } from "@mui/material"; +import { useAppDispatch, useAppSelector } from "libraries/hooks/redux"; +import React, { ReactNode, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; -export const UserGroupsTable = () => <>UserGroupsTable, coming soon; +import { UserGroupDTO } from "../../../../../generated"; +import { usePermission } from "../../../../../libraries/permissionUtils/usePermission"; +import { ApiResponse } from "../../../../../state/types"; +import { + deleteUserGroup, + deleteUserGroupReset, + getUserGroups, +} from "../../../../../state/usergroups"; +import { IState } from "../../../../../types"; +import InfoBox from "../../../infoBox/InfoBox"; +import Table from "../../../table/Table"; + +import classes from "./UserGroupsTable.module.scss"; + +interface IOwnProps { + headerActions: ReactNode; + onEdit: (row: UserGroupDTO) => void; +} + +export const UserGroupsTable = ({ headerActions, onEdit }: IOwnProps) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const canUpdate = usePermission("users.update"); + const canDelete = usePermission("exams.delete"); + + useEffect(() => { + dispatch(getUserGroups()); + return () => { + dispatch(deleteUserGroupReset()); + }; + }, [dispatch]); + + const handleDelete = (row: UserGroupDTO) => { + dispatch(deleteUserGroup(row.code)); + }; + + const header = ["code", "desc"]; + + const label = { + code: t("user.code"), + desc: t("user.description"), + }; + const order = ["code", "desc"]; + + const { data, status, error } = useSelector< + IState, + ApiResponse + >((state) => state.usergroups.groupList); + + const deleteGroup = useAppSelector((state) => state.usergroups.delete); + + useEffect(() => { + if (deleteGroup.hasSucceeded) dispatch(getUserGroups()); + }, [deleteGroup.hasSucceeded, dispatch]); + + const formatDataToDisplay = (data: UserGroupDTO[]) => { + return data.map((item) => { + return { + code: item.code, + desc: item.desc ?? "", + }; + }); + }; + + return ( +
+ {(() => { + switch (status) { + case "FAIL": + return ; + case "LOADING": + return ( + + ); + + case "SUCCESS": + return ( + + ); + case "SUCCESS_EMPTY": + return ; + default: + return; + } + })()} + + ); +}; diff --git a/src/components/activities/adminActivity/AdminActivity.module.scss b/src/components/activities/adminActivity/AdminActivity.module.scss index 22d4f3937..876abb6a9 100644 --- a/src/components/activities/adminActivity/AdminActivity.module.scss +++ b/src/components/activities/adminActivity/AdminActivity.module.scss @@ -14,7 +14,9 @@ border-radius: 8px; flex-grow: 1; margin: 30px 160px; - // align-self: center; + @include susy-media($medium) { + margin: 20px 20px; + } @include susy-media($medium-down) { flex-direction: column; margin: 20px 20px; @@ -36,7 +38,7 @@ border-radius: 8px 0px 0px 8px; width: max-content; padding: 8px 0px; - min-width: 260px; + min-width: 311px; @include susy-media($medium-down) { border-radius: 8px; width: 100%; diff --git a/src/consts.ts b/src/consts.ts index e895d8c37..636a66ee8 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -25,6 +25,8 @@ export const PATHS = { admin_vaccines_types_edit: "/admin/types/vaccines/:code/edit", admin_users: "/admin/users", admin_users_new: "/admin/users/new", + admin_usergroups_new: "/admin/users/groups/new", + admin_usergroups_edit: "/admin/users/groups/edit/:id", admin_users_edit: "/admin/users/:id/edit", admin_vaccines: "/admin/vaccines", admin_vaccines_new: "/admin/vaccines/new", diff --git a/src/generated/models/AdmissionDTO.ts b/src/generated/models/AdmissionDTO.ts index 4b933c05e..df700ba52 100644 --- a/src/generated/models/AdmissionDTO.ts +++ b/src/generated/models/AdmissionDTO.ts @@ -200,13 +200,13 @@ export interface AdmissionDTO { */ deleted: string; /** - * @type {number} + * @type {string} * @memberof AdmissionDTO */ - yprog?: number; + fhu?: string; /** - * @type {string} + * @type {number} * @memberof AdmissionDTO */ - fhu?: string; + yprog?: number; } diff --git a/src/generated/models/TherapyRow.ts b/src/generated/models/TherapyRow.ts index 7f96100ce..33b37f525 100644 --- a/src/generated/models/TherapyRow.ts +++ b/src/generated/models/TherapyRow.ts @@ -12,112 +12,112 @@ */ import { - Patient, + Patient, } from './'; /** - * @export - * @interface TherapyRow - */ +* @export +* @interface TherapyRow +*/ export interface TherapyRow { - /** - * @type {string} - * @memberof TherapyRow - */ - createdBy?: string; - /** - * @type {string} - * @memberof TherapyRow - */ - createdDate?: string; - /** - * @type {string} - * @memberof TherapyRow - */ - lastModifiedBy?: string; - /** - * @type {string} - * @memberof TherapyRow - */ - lastModifiedDate?: string; - /** - * @type {number} - * @memberof TherapyRow - */ - active?: number; - /** - * @type {number} - * @memberof TherapyRow - */ - therapyID?: number; - /** - * @type {Patient} - * @memberof TherapyRow - */ - patient: Patient; - /** - * @type {string} - * @memberof TherapyRow - */ - startDate: string; - /** - * @type {string} - * @memberof TherapyRow - */ - endDate: string; - /** - * @type {number} - * @memberof TherapyRow - */ - medicalId: number; - /** - * @type {number} - * @memberof TherapyRow - */ - qty: number; - /** - * @type {number} - * @memberof TherapyRow - */ - unitID: number; - /** - * @type {number} - * @memberof TherapyRow - */ - freqInDay: number; - /** - * @type {number} - * @memberof TherapyRow - */ - freqInPeriod: number; - /** - * @type {string} - * @memberof TherapyRow - */ - note?: string; - /** - * @type {number} - * @memberof TherapyRow - */ - notifyInt: number; - /** - * @type {number} - * @memberof TherapyRow - */ - smsInt: number; - /** - * @type {boolean} - * @memberof TherapyRow - */ - sms?: boolean; - /** - * @type {boolean} - * @memberof TherapyRow - */ - notify?: boolean; - /** - * @type {number} - * @memberof TherapyRow - */ - medical?: number; + /** + * @type {string} + * @memberof TherapyRow + */ + createdBy?: string; + /** + * @type {string} + * @memberof TherapyRow + */ + createdDate?: string; + /** + * @type {string} + * @memberof TherapyRow + */ + lastModifiedBy?: string; + /** + * @type {string} + * @memberof TherapyRow + */ + lastModifiedDate?: string; + /** + * @type {number} + * @memberof TherapyRow + */ + active?: number; + /** + * @type {number} + * @memberof TherapyRow + */ + therapyID?: number; + /** + * @type {Patient} + * @memberof TherapyRow + */ + patient: Patient; + /** + * @type {string} + * @memberof TherapyRow + */ + startDate: string; + /** + * @type {string} + * @memberof TherapyRow + */ + endDate: string; + /** + * @type {number} + * @memberof TherapyRow + */ + medicalId: number; + /** + * @type {number} + * @memberof TherapyRow + */ + qty: number; + /** + * @type {number} + * @memberof TherapyRow + */ + unitID: number; + /** + * @type {number} + * @memberof TherapyRow + */ + freqInDay: number; + /** + * @type {number} + * @memberof TherapyRow + */ + freqInPeriod: number; + /** + * @type {string} + * @memberof TherapyRow + */ + note?: string; + /** + * @type {number} + * @memberof TherapyRow + */ + notifyInt: number; + /** + * @type {number} + * @memberof TherapyRow + */ + smsInt: number; + /** + * @type {boolean} + * @memberof TherapyRow + */ + sms?: boolean; + /** + * @type {boolean} + * @memberof TherapyRow + */ + notify?: boolean; + /** + * @type {number} + * @memberof TherapyRow + */ + medical?: number; } diff --git a/src/mockServer/fixtures/permissionDTO.ts b/src/mockServer/fixtures/permissionDTO.ts new file mode 100644 index 000000000..57f53c3c8 --- /dev/null +++ b/src/mockServer/fixtures/permissionDTO.ts @@ -0,0 +1,10 @@ +import { PermissionDTO } from "../../generated"; +import permissionList from "./permissionList"; + +export const permissionDTO: PermissionDTO[] = Array.from(permissionList).map( + (permissionName, i) => ({ + name: permissionName, + id: i, + description: "", + }) +); diff --git a/src/mockServer/fixtures/userGroupsDTO.ts b/src/mockServer/fixtures/userGroupsDTO.ts index 5c39cefd3..f3bd7af6c 100644 --- a/src/mockServer/fixtures/userGroupsDTO.ts +++ b/src/mockServer/fixtures/userGroupsDTO.ts @@ -1,8 +1,45 @@ -import { UserGroupDTO } from "../../generated"; +import { UserGroupDTO, PermissionDTO } from "../../generated"; +import { permissionDTO } from "./permissionDTO"; export const userGroupsDTO: UserGroupDTO[] = [ - { code: "adm", desc: "admin" }, - { code: "con", desc: "contributor" }, - { code: "bot" }, + { code: "adm", desc: "admin", permissions: permissionDTO }, + { + code: "con", + desc: "contributor", + permissions: permissionDTO.reduce( + (acc: PermissionDTO[], curr, i) => (i % 5 === 0 ? [...acc, curr] : acc), + [] + ), + }, + { + code: "guest", + permissions: permissionDTO.reduce( + (acc: PermissionDTO[], curr) => + // only permissions ending with "read" + /read$/.test(curr.name ?? "") ? [...acc, curr] : acc, + [] + ), + }, + { + code: "bot", + permissions: permissionDTO.reduce( + (acc: PermissionDTO[], curr) => + // only permissions ending with "update" or "delete" + /(update|delete)$/.test(curr.name ?? "") ? [...acc, curr] : acc, + [] + ), + }, + { + code: "labo", + permissions: permissionDTO.reduce( + (acc: PermissionDTO[], curr) => + // only examinations + curr.name === "laboratories.access" || + /^examinations/.test(curr.name ?? "") + ? [...acc, curr] + : acc, + [] + ), + }, { code: "doc" }, ]; diff --git a/src/mockServer/routes/permission.js b/src/mockServer/routes/permission.js new file mode 100644 index 000000000..88cfd3486 --- /dev/null +++ b/src/mockServer/routes/permission.js @@ -0,0 +1,12 @@ +import { permissionDTO } from "../fixtures/permissionDTO"; + +export const permissionRoutes = (server) => { + server.namespace("/permissions", () => { + server.get("/").intercept((_req, res) => { + res.status(200).json(permissionDTO); + }); + server.put(":id").intercept((_req, res) => { + res.status(200).json(permissionDTO[0]); + }); + }); +}; diff --git a/src/mockServer/routes/userGroups.js b/src/mockServer/routes/userGroups.js index cee7fddf6..6ab84b4d4 100644 --- a/src/mockServer/routes/userGroups.js +++ b/src/mockServer/routes/userGroups.js @@ -1,9 +1,40 @@ import { userGroupsDTO } from "../fixtures/userGroupsDTO"; export const userGroupRoutes = (server) => { - server.namespace("/users/groups", () => { + server.namespace("/usergroups", () => { server.get("/").intercept((_req, res) => { res.status(200).json(userGroupsDTO); }); + server.get("/:id").intercept((req, res) => { + console.log(req.params.id); + const group = userGroupsDTO.find(({ code }) => code === req.params.id); + if (!group) { + return res.status(404).json({ + status: "BAD_REQUEST", + message: "User group not found.", + debugMessage: "User group not found.", + timestamp: "2024-09-16T08:02:53.878312662", + description: null, + }); + } + res.status(200).json(group); + }); + + server.delete("/:code/permissions/:id").intercept((_req, res) => { + res.status(200).json(true); + }); + server.post("/:code/permissions/:id").intercept((_req, res) => { + res.status(200).json(true); + }); + + server.post("/").intercept((_req, res) => { + res.status(200).json(userGroupsDTO[0]); + }); + server.put("/").intercept((_req, res) => { + res.status(200).json(userGroupsDTO[0]); + }); + server.delete("/:id").intercept((_req, res) => { + res.status(200).json(true); + }); }); }; diff --git a/src/mockServer/server.js b/src/mockServer/server.js index 7e20c60e1..ec433cb26 100644 --- a/src/mockServer/server.js +++ b/src/mockServer/server.js @@ -24,6 +24,7 @@ import { opdRoutes } from "./routes/opd"; import { operationRoutes } from "./routes/operations"; import { operationTypeRoutes } from "./routes/operationTypes"; import { patientRoutes } from "./routes/patients"; +import { permissionRoutes } from "./routes/permission"; import { pregnantTreatmentTypeRoutes } from "./routes/pregnantTreatmentType"; import { pricesRoutes } from "./routes/prices"; import { suppliersRoutes } from "./routes/suppliers"; @@ -77,6 +78,7 @@ export function makeServer() { medicalTypesRoutes(server); pregnantTreatmentTypeRoutes(server); deliveryResultTypeRoutes(server); + permissionRoutes(server); labExamRequestRoutes(server); }); return server; diff --git a/src/resources/i18n/en.json b/src/resources/i18n/en.json index 39fc5a534..ee5d80ab6 100644 --- a/src/resources/i18n/en.json +++ b/src/resources/i18n/en.json @@ -18,15 +18,26 @@ "number": "the value is not a valid number" }, "user": { + "code": "Code", + "desc": "Description", "users": "Users", "username": "Username", "group": "Group", "groups": "Groups", "password": "Password", - "passwordRetype": "Re-type Password", - "lastlogin": "Last login", - "description": "Description", + "addGroup": "New group", "addUser": "New user", + "editGroup": "Edit group", + "description": "Description", + "lastlogin": "Last login", + "groupCreated": "Group created", + "groupDeleted": "Group deleted", + "groupUpdated": "Group updated", + "groupUpdateSuccess": "Group updated successfully", + "groupCreateSuccess": "Group created successfully", + "groupDeleteSuccess": "Group deleted successfully", + "groupPermissionsOnlyOnUpdate": "You can edit a group's permission once you created it.", + "passwordRetype": "Re-type Password", "createdSuccessTitle": "User created", "createdSuccessMessage": "User has been created successfully!", "updatedSuccessTitle": "User updated", @@ -35,6 +46,7 @@ "validatePasswordNeeded": "No password provided.", "validatePasswordTooShort": "Password is too short - should be 5 chars minimum.", "validatePasswordTooWeak": "Please create a stronger password: 1 upper case letter, 1 lower case letter, 1 numeric digit", + "validateGroupCode": "Invalid group code", "validatePasswordMustMatch": "Passwords must match", "validateUserName": "You need to specify a user name", "validateUserNameRegex": "Allowed characters: lowercase letters(abc), numbers(123), dot(.), dash (-) and underscore (_)" @@ -555,7 +567,14 @@ "permission": { "denied": "Permission denied", "unauthorized": "Unauthorized", - "accessdenied": "You don't have permission required to access this content" + "accessdenied": "You don't have permission required to access this content", + "name": "Name", + "create": "Create", + "read": "Read", + "update": "Update", + "deleted": "Deleted", + "accessarea": "Access area", + "accesscontrollist": "Access control list" }, "therapy": { "newtherapy": "New Therapy", diff --git a/src/routes/Admin/AdminRoutes.tsx b/src/routes/Admin/AdminRoutes.tsx index fb6bdfa8d..9f4ffa162 100644 --- a/src/routes/Admin/AdminRoutes.tsx +++ b/src/routes/Admin/AdminRoutes.tsx @@ -22,7 +22,13 @@ import { NewSupplier, Suppliers, } from "../../components/accessories/admin/suppliers"; -import { EditUser, NewUser, Users } from "../../components/accessories/admin/users"; +import { + EditGroup, + EditUser, + NewGroup, + NewUser, + Users, +} from "../../components/accessories/admin/users"; import { EditVaccine, NewVaccine, @@ -210,12 +216,37 @@ export const AdminRoutes = () => { { path: getPath(PATHS.admin_users_new), element: ( - } /> + } + /> + ), + }, + { + path: getPath(PATHS.admin_usergroups_new), + element: ( + } + /> ), - },{ + }, + { + path: getPath(PATHS.admin_usergroups_edit), + element: ( + } + /> + ), + }, + { path: getPath(PATHS.admin_users_edit), element: ( - } /> + } + /> ), }, { diff --git a/src/state/permissions/index.ts b/src/state/permissions/index.ts new file mode 100644 index 000000000..9acb9c91d --- /dev/null +++ b/src/state/permissions/index.ts @@ -0,0 +1,3 @@ +export * from "./slice"; +export * from "./thunk"; +export * from "./types"; diff --git a/src/state/permissions/initial.ts b/src/state/permissions/initial.ts new file mode 100644 index 000000000..1ffced27d --- /dev/null +++ b/src/state/permissions/initial.ts @@ -0,0 +1,10 @@ +import { PermissionDTO } from "../../generated"; +import { IPermissionsState } from "./types"; +import { ApiResponse } from "../types"; + +export const initial: IPermissionsState = { + getAll: new ApiResponse({ + status: "IDLE", + data: new Array(), + }), +}; diff --git a/src/state/permissions/slice.ts b/src/state/permissions/slice.ts new file mode 100644 index 000000000..44dc08746 --- /dev/null +++ b/src/state/permissions/slice.ts @@ -0,0 +1,25 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { isEmpty } from "lodash"; +import { ApiResponse } from "state/types"; +import { initial } from "./initial"; +import * as thunks from "./thunk"; + +export const permissionSlice = createSlice({ + name: "permissions", + initialState: initial, + reducers: {}, + extraReducers: (builder) => + builder + // Get Permissions + .addCase(thunks.getAllPermissions.pending, (state) => { + state.getAll = ApiResponse.loading(); + }) + .addCase(thunks.getAllPermissions.fulfilled, (state, action) => { + state.getAll = isEmpty(action.payload) + ? ApiResponse.empty() + : ApiResponse.value(action.payload); + }) + .addCase(thunks.getAllPermissions.rejected, (state, action) => { + state.getAll = ApiResponse.error(action.payload); + }), +}); diff --git a/src/state/permissions/thunk.ts b/src/state/permissions/thunk.ts new file mode 100644 index 000000000..5f0547a1c --- /dev/null +++ b/src/state/permissions/thunk.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { PermissionDTO, PermissionsApi } from "../../generated"; +import { customConfiguration } from "../../libraries/apiUtils/configuration"; + +const api = new PermissionsApi(customConfiguration()); + +export const getAllPermissions = createAsyncThunk( + "permissions/getPermissions", + async (_, thunkApi) => + api + .retrieveAllPermissions() + .toPromise() + .catch((error) => thunkApi.rejectWithValue(error.response)) +); diff --git a/src/state/permissions/types.ts b/src/state/permissions/types.ts new file mode 100644 index 000000000..633b58617 --- /dev/null +++ b/src/state/permissions/types.ts @@ -0,0 +1,6 @@ +import { PermissionDTO } from "../../generated"; +import { ApiResponse } from "../types"; + +export type IPermissionsState = { + getAll: ApiResponse>; +}; diff --git a/src/state/store.ts b/src/state/store.ts index fc45015d8..9e1606814 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -14,6 +14,7 @@ import { medicalSlice } from "./medicals"; import { opdSlice } from "./opds"; import { operationSlice } from "./operations"; import { patientSlice } from "./patients"; +import { permissionSlice } from "./permissions"; import { priceSlice } from "./prices"; import { summarySlice } from "./summary"; import { supplierSlice } from "./suppliers"; @@ -42,6 +43,7 @@ const reducer = combineReducers({ exams: examSlice.reducer, bills: billSlice.reducer, prices: priceSlice.reducer, + permissions: permissionSlice.reducer, visits: visitSlice.reducer, operations: operationSlice.reducer, diseaseTypes: diseaseTypeSlice.reducer, diff --git a/src/state/usergroups/index.ts b/src/state/usergroups/index.ts index 4403a13f3..03416f8b9 100644 --- a/src/state/usergroups/index.ts +++ b/src/state/usergroups/index.ts @@ -1,3 +1,3 @@ -export * from "./thunk"; export * from "./slice"; +export * from "./thunk"; export * from "./types"; diff --git a/src/state/usergroups/initial.ts b/src/state/usergroups/initial.ts index 1130e0549..2b128c253 100644 --- a/src/state/usergroups/initial.ts +++ b/src/state/usergroups/initial.ts @@ -7,7 +7,12 @@ export const initial: IUserGroupState = { status: "IDLE", data: new Array(), }), + currentGroup: new ApiResponse({ + status: "IDLE", + data: {} as UserGroupDTO, + }), create: new ApiResponse({ status: "IDLE" }), update: new ApiResponse({ status: "IDLE" }), delete: new ApiResponse({ status: "IDLE" }), + setPermission: new ApiResponse({ status: "IDLE" }), }; diff --git a/src/state/usergroups/slice.ts b/src/state/usergroups/slice.ts index c8e59ec53..a6a8189a3 100644 --- a/src/state/usergroups/slice.ts +++ b/src/state/usergroups/slice.ts @@ -1,8 +1,8 @@ import { createSlice } from "@reduxjs/toolkit"; +import { isEmpty } from "lodash"; +import { ApiResponse } from "state/types"; import { initial } from "./initial"; import * as thunks from "./thunk"; -import { ApiResponse } from "state/types"; -import { isEmpty } from "lodash"; export const userGroupSlice = createSlice({ name: "userGroups", @@ -26,11 +26,22 @@ export const userGroupSlice = createSlice({ }) .addCase(thunks.getUserGroups.fulfilled, (state, action) => { state.groupList = isEmpty(action.payload) - ? ApiResponse.empty() : ApiResponse.value(action.payload); + ? ApiResponse.empty() + : ApiResponse.value(action.payload); }) .addCase(thunks.getUserGroups.rejected, (state, action) => { state.groupList = ApiResponse.error(action.payload); }) + // Get User Groups + .addCase(thunks.getUserGroup.pending, (state) => { + state.groupList = ApiResponse.loading(); + }) + .addCase(thunks.getUserGroup.fulfilled, (state, action) => { + state.currentGroup = ApiResponse.value(action.payload); + }) + .addCase(thunks.getUserGroup.rejected, (state, action) => { + state.groupList = ApiResponse.error(action.payload); + }) // Create User Group .addCase(thunks.createUserGroup.pending, (state) => { state.create = ApiResponse.loading(); @@ -55,7 +66,7 @@ export const userGroupSlice = createSlice({ .addCase(thunks.deleteUserGroup.pending, (state) => { state.delete = ApiResponse.loading(); }) - .addCase(thunks.deleteUserGroup.fulfilled, (state, action) => { + .addCase(thunks.deleteUserGroup.fulfilled, (state) => { state.delete.status = "SUCCESS"; }) .addCase(thunks.deleteUserGroup.rejected, (state, action) => { diff --git a/src/state/usergroups/thunk.ts b/src/state/usergroups/thunk.ts index eecb2b27a..39dc14647 100644 --- a/src/state/usergroups/thunk.ts +++ b/src/state/usergroups/thunk.ts @@ -41,3 +41,37 @@ export const deleteUserGroup = createAsyncThunk( .toPromise() .catch((error) => thunkApi.rejectWithValue(error.response)) ); + +export const getUserGroup = createAsyncThunk( + "userGroups/getUserGroup", + async (groupCode: string, thunkApi) => + api + // GET /users/groups/{group_code} + .getUserGroup({ groupCode }) + .toPromise() + .catch((error) => thunkApi.rejectWithValue(error.response)) +); + +export const assignPermission = createAsyncThunk( + "userGroups/setUserGroupPermission", + async ( + { permissionId, groupCode }: { permissionId: number; groupCode: string }, + thunkApi + ) => + api + .assignPermission({ groupCode, id: permissionId }) + .toPromise() + .catch((error) => thunkApi.rejectWithValue(error.response)) +); + +export const revokePermission = createAsyncThunk( + "userGroups/setUserGroupPermission", + async ( + { permissionId, groupCode }: { permissionId: number; groupCode: string }, + thunkApi + ) => + api + .revokePermission({ groupCode, id: permissionId }) + .toPromise() + .catch((error) => thunkApi.rejectWithValue(error.response)) +); \ No newline at end of file diff --git a/src/state/usergroups/types.ts b/src/state/usergroups/types.ts index 8cc643da2..60eb05cee 100644 --- a/src/state/usergroups/types.ts +++ b/src/state/usergroups/types.ts @@ -3,7 +3,9 @@ import { ApiResponse } from "../types"; export type IUserGroupState = { groupList: ApiResponse>; + currentGroup: ApiResponse; create: ApiResponse; update: ApiResponse; delete: ApiResponse; + setPermission: ApiResponse; }; diff --git a/src/state/users/slice.ts b/src/state/users/slice.ts index e3399993e..a79b1dcb4 100644 --- a/src/state/users/slice.ts +++ b/src/state/users/slice.ts @@ -77,5 +77,9 @@ export const userSlice = createSlice({ }), }); -export const { createUserReset, updateUserReset, deleteUserReset } = - userSlice.actions; +export const { + createUserReset, + updateUserReset, + deleteUserReset, + getUserByIdReset, +} = userSlice.actions; diff --git a/src/types.ts b/src/types.ts index 7b5c6bfff..37bd233f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ import { IUserGroupState } from "./state/usergroups/types"; import { IVaccineState } from "./state/vaccines/types"; import { ISupplierState } from "./state/suppliers/types"; import { ITypesState } from "./state/types/types"; +import { IPermissionsState } from "./state/permissions/types"; export interface IState { main: IMainState; @@ -54,6 +55,7 @@ export interface IState { vaccines: IVaccineState; types: ITypesState; suppliers: ISupplierState; + permissions: IPermissionsState; } export enum FIELD_VALIDATION {