diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/page.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/page.tsx new file mode 100644 index 000000000..2c7a9936e --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Content from '@/components/content'; +import Auth from '@/lib/AuthCanWrapper'; +import { useGetAsset } from '@/lib/fetch-data'; +import { Button, Result, Skeleton, Space, Tabs } from 'antd'; +import { LeftOutlined } from '@ant-design/icons'; +import { ComponentProps } from 'react'; +import RoleGeneralData from './roleGeneralData'; +import { useRouter } from 'next/navigation'; +import RolePermissions from './rolePermissions'; +import RoleMembers from './role-members'; + +type Items = ComponentProps['items']; + +function RolePage({ params: { roleId } }: { params: { roleId: string } }) { + const router = useRouter(); + const { + data: role, + isLoading, + error, + } = useGetAsset('/roles/{id}', { + params: { path: { id: roleId } }, + }); + + const items: Items = role + ? [ + { + key: 'members', + label: 'Manage Members', + children: , + }, + { key: 'permissions', label: 'Permissions', children: }, + { + key: 'generalData', + label: 'General Data', + children: , + }, + ] + : []; + + if (error) return; + ; + + return ( + + + {role?.name} + + } + > + + + + + ); +} + +export default Auth( + { + action: ['view', 'manage'], + resource: 'Role', + fallbackRedirect: '/', + }, + RolePage, +); diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/role-members.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/role-members.tsx new file mode 100644 index 000000000..832f31963 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/role-members.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { FC, useMemo, useState } from 'react'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { + ApiData, + useDeleteAsset, + useGetAsset, + useInvalidateAsset, + usePostAsset, +} from '@/lib/fetch-data'; +import UserList, { UserListProps } from '@/components/user-list'; +import { Button, Modal, Popconfirm, Tooltip } from 'antd'; + +type Role = ApiData<'/roles', 'get'>[number]; + +const AddUserModal: FC<{ role: Role; open: boolean; close: () => void }> = ({ + role, + open, + close, +}) => { + const { data: users, isLoading: isLoadingUsers } = useGetAsset('/users', {}); + const invalidateRole = useInvalidateAsset('/roles/{id}', { params: { path: { id: role.id } } }); + const { mutateAsync, isLoading: isLoadingMutation } = usePostAsset('/role-mappings', { + onSuccess: invalidateRole, + }); + + type AddUserParams = Parameters>; + const addUsers = async (users: AddUserParams[2], clearIds?: AddUserParams[1]) => { + if (clearIds) clearIds(); + await mutateAsync({ + body: users.map((user) => ({ + userId: user.id, + roleId: role.id, + email: user.email, + lastName: user.lastName, + firstName: user.firstName, + username: user.username, + })), + parseAs: 'text', + }); + }; + + const usersNotInRole = useMemo(() => { + if (!users) return []; + + const usersInRole = new Set(role.members.map((member) => member.userId)); + + return users.filter((user) => !usersInRole.has(user.id)); + }, [users, role.members]); + + return ( + + [ + { + dataIndex: 'id', + render: (_, user) => ( + + + } + /> + + ); +}; + +export default RoleMembers; diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/roleGeneralData.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/roleGeneralData.tsx new file mode 100644 index 000000000..06f7e7702 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/roleGeneralData.tsx @@ -0,0 +1,90 @@ +import Auth from '@/lib/AuthCanWrapper'; +import { toCaslResource } from '@/lib/ability/caslAbility'; +import { useGetAsset, usePutAsset } from '@/lib/fetch-data'; +import { useAuthStore } from '@/lib/iam'; +import { Alert, App, Button, DatePicker, Form, Input, Spin } from 'antd'; +import { FC } from 'react'; +import dayjs from 'dayjs'; +import germanLocale from 'antd/es/date-picker/locale/de_DE'; + +const RoleGeneralData: FC<{ roleId: string }> = ({ roleId }) => { + const { message } = App.useApp(); + const ability = useAuthStore((store) => store.ability); + const [form] = Form.useForm(); + + const { data, isLoading, error } = useGetAsset('/roles/{id}', { + params: { path: { id: roleId } }, + }); + + const { mutateAsync: updateRole, isLoading: putLoading } = usePutAsset('/roles/{id}', { + onError: () => message.open({ type: 'error', content: 'Something went wrong' }), + }); + + if (isLoading || error || !data) return ; + + const role = toCaslResource('Role', data); + + async function submitChanges(values: Record) { + if (typeof values.expirationDayJs === 'object') { + values.expiration = (values.expirationDayJs as dayjs.Dayjs).toISOString(); + delete values.expirationDayJs; + } + + try { + await updateRole({ + params: { path: { id: roleId } }, + body: values, + }); + + // success message has to go here, or else the mutation will stop loading when the message + // disappears + message.open({ type: 'success', content: 'Role updated' }); + } catch (e) {} + } + + return ( +
+ {role.note && ( + <> + +
+ + )} + + + + + + + + + + + + + + + + + ); +}; + +export default Auth( + { + action: ['view', 'manage'], + resource: 'Role', + fallbackRedirect: '/', + }, + RoleGeneralData, +); diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/rolePermissions.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/rolePermissions.tsx new file mode 100644 index 000000000..6998df9b8 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/iam/roles/[roleId]/rolePermissions.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { Divider, Form, Row, Space, Switch, Typography, FloatButton, Tooltip, Spin } from 'antd'; +import { SaveOutlined } from '@ant-design/icons'; +import { ResourceActionType, ResourceType } from '@/lib/ability/caslAbility'; +import { FC } from 'react'; +import { ApiData, usePutAsset } from '@/lib/fetch-data'; + +type PermissionCategory = { + key: string; + title: string; + resource: ResourceType; + permissions: { + key: string; + title: string; + description: string; + permission: ResourceActionType; + }[]; +}; + +const basePermissionOptions: PermissionCategory[] = [ + { + key: 'process', + title: 'PROCESSES', + resource: 'Process', + permissions: [ + { + key: 'process_view', + title: 'View processes', + description: 'Allows a user to view her or his processes. (Enables the Processes view.)', + permission: 'view', + }, + { + key: 'process_manage', + title: 'Manage processes', + description: 'Allows a user to create, modify and delete processes in the Processes view.', + permission: 'manage', + }, + { + key: 'process_share', + title: 'Share processes', + description: 'Allows a user to share processes with different users and groups.', + permission: 'share', + }, + { + key: 'process_admin', + title: 'Administrate processes', + description: 'Allows a user to create, modify, delete and share all PROCEED processes.', + permission: 'admin', + }, + ], + }, + { + key: 'projects', + title: 'PROJECTS', + resource: 'Project', + permissions: [ + { + key: 'View projects', + title: 'View projects', + description: 'Allows a user to view her or his projects. (Enables the Projects view.)', + permission: 'view', + }, + + { + key: 'Manage projects', + title: 'Manage projects', + description: 'Allows a user to create, modify and delete projects in the Projects view.', + permission: 'manage', + }, + + { + key: 'Share projects', + title: 'Share projects', + description: 'Allows a user to share projects with different users and groups.', + permission: 'share', + }, + + { + key: 'Administrate projects', + title: 'Administrate projects', + description: 'Allows a user to create, modify, delete and share all PROCEED projects.', + permission: 'admin', + }, + ], + }, + { + key: 'templates', + title: 'TEMPLATES', + resource: 'Template', + permissions: [ + { + key: 'View templates', + title: 'View templates', + description: 'A,llows a user to view her or his templates. (Enables the Templates view.)', + permission: 'view', + }, + + { + key: 'Manage templates', + title: 'Manage templates', + description: 'A,llows a user to create, modify and delete templates in the Templates view.', + permission: 'manage', + }, + + { + key: 'Share templates', + title: 'Share templates', + description: 'A,llows a user to share templates with different users and groups.', + permission: 'share', + }, + + { + key: 'Administrate templates', + title: 'Administrate templates', + description: 'A,llows a user to create, modify, delete and share all PROCEED templates.', + permission: 'admin', + }, + ], + }, + { + key: 'tasks', + title: 'TASKS', + resource: 'Task', + permissions: [ + { + key: 'View tasks', + title: 'View tasks', + description: 'A,llows a user to view her or his tasks. (Enables the Tasklist view.)', + permission: 'view', + }, + ], + }, + { + key: 'machines', + title: 'MACHINES', + resource: 'Machine', + permissions: [ + { + key: 'View machines', + title: 'View machines', + description: ',Allows a user to view all machines. (Enables the Machines view.)', + permission: 'view', + }, + + { + key: 'Manage machines', + title: 'Manage machines', + description: 'Allows a user to create, modify and delete machines in the Machines view.', + permission: 'manage', + }, + ], + }, + { + key: 'executions', + title: 'EXECUTIONS', + resource: 'Execution', + permissions: [ + { + key: 'View executions', + title: 'View executions', + description: 'Allows a user to view all executions. (Enables the Executions view.)', + permission: 'view', + }, + ], + }, + { + key: 'roles', + title: 'ROLES', + resource: 'Role', + permissions: [ + { + key: 'Manage roles', + title: 'Manage roles', + description: 'Allows a user to create, modify and delete roles. (Enables the IAM view.)', + permission: 'manage', + }, + ], + }, + { + key: 'users', + title: 'Users', + resource: 'User', + permissions: [ + { + key: 'Manage users', + title: 'Manage users', + description: + 'Allows a user to create,, delete and enable/disable users. (Enables the IAM view.)', + permission: 'manage', + }, + + { + key: 'Manage roles of users', + title: 'Manage roles of users', + description: + 'Allows a user to assign roles to a user and to remove roles from a user. (Enables the IAM view.)', + permission: 'manage-roles', + }, + ], + }, + { + key: 'settings', + title: 'SETTINGS', + resource: 'Setting', + permissions: [ + { + key: 'Administrate settings', + title: 'Administrate settings', + description: + 'Allows a user to administrate the settings of the Management System and the Engine. (Enables the Settings view.)', + permission: 'admin', + }, + ], + }, + { + key: 'environment_configurations', + title: 'Environment Configurations', + resource: 'EnvConfig', + permissions: [ + { + key: 'Administrate environment configuration', + title: 'Administrate environment configuration', + description: + 'Allows a user to administrate the environment configuration of the Management System. (Enables the Environment Configuration view.)', + permission: 'admin', + }, + ], + }, + { + key: 'all', + title: 'ALL', + resource: 'All', + permissions: [ + { + key: 'Administrator Permissions', + title: 'Administrator Permissions', + description: + 'Grants a user full administrator permissions for the PROCEED Management System.', + permission: 'admin', + }, + ], + }, +]; + +type Role = ApiData<'/roles', 'get'>[number]; + +// permission mapping to verbs +const PERMISSION_MAPPING = { + none: 0, + view: 1, + update: 2, + create: 4, + delete: 8, + manage: 16, + share: 32, + 'manage-roles': 64, + 'manage-groups': 128, + 'manage-password': 256, + admin: 9007199254740991, +}; + +function permissionChecked(role: Role, subject: ResourceType, action: ResourceActionType) { + if ( + !('permissions' in role && typeof role.permissions === 'object' && subject in role.permissions) + ) + return false; + + // @ts-ignore + const permissionNumber = role.permissions[subject]; + + return !!(PERMISSION_MAPPING[action] & permissionNumber); +} + +const RolePermissions: FC<{ role: Role }> = ({ role }) => { + const { mutateAsync, isLoading } = usePutAsset('/roles/{id}'); + const [form] = Form.useForm(); + + function updateRole(values: any) { + // TODO submit role + const newRole = { + description: role.description, + name: role.name, + permissions: role.permissions, + id: role.id, + note: role.note, + default: role.default, + members: role.members, + expiration: role.expiration, + }; + } + + return ( +
+ {basePermissionOptions.map((permissionCategory) => ( + <> + + {permissionCategory.title} + + {permissionCategory.permissions.map((permission, idx) => ( + <> + + + {permission.title} + {permission.description} + + + + + + {idx < permissionCategory.permissions.length - 1 && ( + + )} + + ))} +
+ + ))} + + : } + onClick={() => !isLoading && form.submit()} + /> + + + ); +}; + +export default RolePermissions; diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/header-actions.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/header-actions.tsx new file mode 100644 index 000000000..f8ce62994 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/iam/roles/header-actions.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { ApiRequestBody, usePostAsset } from '@/lib/fetch-data'; +import { AuthCan } from '@/lib/iamComponents'; +import { ImportOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button, Form, App, Input, Modal } from 'antd'; +import { ComponentProps, FC, ReactNode, useEffect, useState } from 'react'; + +type PostUserField = keyof ApiRequestBody<'/users', 'post'>; + +const modalStructureWithoutPassword: { + dataKey: PostUserField; + label: string; + type: string; +}[] = [ + { + dataKey: 'firstName', + label: 'First Name', + type: 'text', + }, + { + dataKey: 'lastName', + label: 'Last Name', + type: 'text', + }, + { + dataKey: 'username', + label: 'Username Name', + type: 'text', + }, + { + dataKey: 'email', + label: 'Email', + type: 'email', + }, +]; + +const fieldNameToLabel: Record = modalStructureWithoutPassword.reduce( + (acc, curr) => { + acc[curr.dataKey] = curr.label; + return acc; + }, + {} as Record, +); + +const CreateUserModal: FC<{ + modalOpen: boolean; + close: () => void; +}> = ({ modalOpen, close }) => { + const [form] = Form.useForm(); + const { message: messageApi } = App.useApp(); + type ErrorsObject = { [field in PostUserField]?: ReactNode[] }; + const [formatError, setFormatError] = useState({}); + + const { mutateAsync: postUser, isLoading } = usePostAsset('/users', { + onError(e) { + if (!(typeof e === 'object' && e !== null && 'errors' in e)) { + return; + } + + const errors: { [key in PostUserField]?: ReactNode[] } = {}; + + function appendError(key: PostUserField, error: string) { + error = error.replace(key, fieldNameToLabel[key]); + + if (key in errors) { + errors[key]!.push(

{error}

); + } else { + errors[key] = [

{error}

]; + } + } + + for (const error of e.errors as string[]) { + if (error.includes('username')) appendError('username', error); + else if (error.includes('email')) appendError('email', error); + else if (error.includes('firstName')) appendError('firstName', error); + else if (error.includes('lastName')) appendError('lastName', error); + else if (error.includes('password')) appendError('password', error); + } + + setFormatError(errors); + }, + }); + + useEffect(() => { + form.resetFields(); + setFormatError({}); + }, [form, modalOpen]); + + const submitData = async ( + values: Record | 'confirm_password', string>, + ) => { + debugger; + try { + await postUser({ + body: { + email: values.email, + firstName: values.firstName, + lastName: values.lastName, + username: values.username, + password: values.password, + }, + }); + messageApi.success({ content: 'Account created' }); + close(); + } catch (e) { + messageApi.error({ content: 'An error ocurred' }); + } + }; + + return ( + +
+ {modalStructureWithoutPassword.map((formField) => ( + + + + ))} + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('The new password that you entered do not match!')); + }, + }), + ]} + > + + + + + + +
+
+ ); +}; + +const HeaderActions: FC = () => { + const [createUserModalOpen, setCreateUserModalOpen] = useState(false); + + return ( + <> + setCreateUserModalOpen(false)} + /> + + + + + + ); +}; + +export default HeaderActions; diff --git a/src/management-system-v2/app/(dashboard)/iam/roles/page.tsx b/src/management-system-v2/app/(dashboard)/iam/roles/page.tsx index 5ca3a2018..8826f0a89 100644 --- a/src/management-system-v2/app/(dashboard)/iam/roles/page.tsx +++ b/src/management-system-v2/app/(dashboard)/iam/roles/page.tsx @@ -1,3 +1,151 @@ -export default function RolesPage() { - return

Coming soon

; -} +'use client'; + +import React, { FC, useState } from 'react'; +import styles from '@/components/processes.module.scss'; +import cn from 'classnames'; +import { DeleteOutlined } from '@ant-design/icons'; +import { Tooltip, Space, Row, Col, Button, Input, Result, Table, Popconfirm, App } from 'antd'; +import { useGetAsset, useDeleteAsset, ApiData } from '@/lib/fetch-data'; +import { CloseOutlined } from '@ant-design/icons'; +import Auth from '@/lib/AuthCanWrapper'; +import Content from '@/components/content'; +import HeaderActions from './header-actions'; +import useFuzySearch from '@/lib/useFuzySearch'; +import { AuthCan } from '@/lib/iamComponents'; +import Link from 'next/link'; +import { toCaslResource } from '@/lib/ability/caslAbility'; + +type Role = ApiData<'/roles', 'get'>[number]; + +const RolesPage: FC = () => { + const { message: messageApi } = App.useApp(); + + const { error, data: roles, isLoading, refetch: refetchRoles } = useGetAsset('/roles', {}); + const { mutateAsync: deleteRole, isLoading: deletingRole } = useDeleteAsset('/roles/{id}', { + onSuccess: () => refetchRoles(), + onError: () => messageApi.open({ type: 'error', content: 'Something went wrong' }), + }); + + const { + searchQuery, + setSearchQuery, + filteredData: filteredRoles, + } = useFuzySearch(roles || [], ['name'], { + useSearchParams: false, + }); + + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + async function deleteRoles(userIds: string[]) { + setSelectedRowKeys([]); + await Promise.allSettled(userIds.map((id) => deleteRole({ params: { path: { id } } }))); + } + + const columns = [ + { + title: 'Name', + dataIndex: 'name', + key: 'display', + render: (name: string, role: Role) => {name}, + }, + { + title: 'Members', + dataIndex: 'members', + render: (_: any, record: Role) => record.members.length, + key: 'username', + }, + { + dataIndex: 'id', + key: 'tooltip', + title: '', + with: 100, + render: (id: string, role: Role) => + selectedRowKeys.length === 0 ? ( + + + deleteRoles([id])} + > +