From 9445355b20ea87ffef1794801672ede0de9898af Mon Sep 17 00:00:00 2001 From: Ryan Mathis Date: Thu, 29 Jul 2021 08:49:44 -0700 Subject: [PATCH] Added roles management --- .../main/resources/i18n/messages.properties | 19 +++- ui/public/assets/schema/roles/role.json | 15 +++ ui/src/app/App.js | 2 + ui/src/app/admin/Roles.js | 32 ++++++ ui/src/app/admin/component/RoleForm.js | 68 ++++++++++++ ui/src/app/admin/container/EditRole.js | 91 +++++++++++++++ ui/src/app/admin/container/MetadataActions.js | 2 +- ui/src/app/admin/container/NewRole.js | 79 +++++++++++++ ui/src/app/admin/container/RoleList.js | 78 +++++++++++++ ui/src/app/admin/hoc/RoleProvider.js | 20 ++++ ui/src/app/admin/hoc/RolesProvider.js | 42 +++++++ ui/src/app/admin/hooks.js | 20 ++++ .../components}/DeleteConfirmation.js | 2 +- .../components}/DeleteConfirmation.test.js | 2 +- ui/src/app/core/components/Header.js | 6 +- .../core/components/UserConfirmation.test.js | 2 +- ui/src/app/form/FormManager.js | 105 ++++++++++++++++++ ui/src/app/form/Schema.js | 24 ++++ .../filter/component/MetadataFilters.js | 2 +- .../metadata/view/MetadataAttributeList.js | 2 +- 20 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 ui/public/assets/schema/roles/role.json create mode 100644 ui/src/app/admin/Roles.js create mode 100644 ui/src/app/admin/component/RoleForm.js create mode 100644 ui/src/app/admin/container/EditRole.js create mode 100644 ui/src/app/admin/container/NewRole.js create mode 100644 ui/src/app/admin/container/RoleList.js create mode 100644 ui/src/app/admin/hoc/RoleProvider.js create mode 100644 ui/src/app/admin/hoc/RolesProvider.js create mode 100644 ui/src/app/admin/hooks.js rename ui/src/app/{metadata/component => core/components}/DeleteConfirmation.js (93%) rename ui/src/app/{metadata/component => core/components}/DeleteConfirmation.test.js (97%) create mode 100644 ui/src/app/form/FormManager.js create mode 100644 ui/src/app/form/Schema.js diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 1c103c695..749267367 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -66,6 +66,10 @@ action.custom-entity-attributes=Custom Entity Attributes action.enable=Enable action.disable=Disable +action.add-new-role=Add new role +action.roles=Roles +action.source-role=Role + value.enabled=Enabled value.disabled=Disabled value.current=Current @@ -483,6 +487,16 @@ label.by=By label.source=Metadata Source label.provider=Metadata Provider +label.roles-management=Role Management +label.new-role=New Role +label.role-name=Role Name +label.role-description=Role Description +label.role=Role + +message.delete-role-title=Delete Role? + +message.delete-role-body=You are requesting to delete a role. If you complete this process the role will be removed. This cannot be undone. Do you wish to continue? + message.delete-user-title=Delete User? message.delete-user-body=You are requesting to delete a user. If you complete this process the user will be removed. This cannot be undone. Do you wish to continue? @@ -669,4 +683,7 @@ tooltip.match=A regular expression against which the entityID is evaluated. tooltip.remove-existing-formats=Whether to remove any existing formats from a role if any are added by the filter (unmodified roles will be untouched regardless of this setting) tooltip.nameid-formats-format=Format tooltip.nameid-formats-value=Value -tooltip.nameid-formats-type=Type \ No newline at end of file +tooltip.nameid-formats-type=Type + +tooltip.role-name=Role Name +tooltip.role-description=Role Description \ No newline at end of file diff --git a/ui/public/assets/schema/roles/role.json b/ui/public/assets/schema/roles/role.json new file mode 100644 index 000000000..8145fae88 --- /dev/null +++ b/ui/public/assets/schema/roles/role.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "title": "label.role-name", + "description": "tooltip.role-name", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } +} \ No newline at end of file diff --git a/ui/src/app/App.js b/ui/src/app/App.js index 5d0c4e1e4..c65324bb9 100644 --- a/ui/src/app/App.js +++ b/ui/src/app/App.js @@ -26,6 +26,7 @@ import { NewProvider } from './metadata/new/NewProvider'; import { Filter } from './metadata/Filter'; import { Contention } from './metadata/contention/ContentionContext'; import { SessionModal } from './core/user/SessionModal'; +import { Roles } from './admin/Roles'; import Button from 'react-bootstrap/Button'; @@ -79,6 +80,7 @@ function App() { + diff --git a/ui/src/app/admin/Roles.js b/ui/src/app/admin/Roles.js new file mode 100644 index 000000000..08daed90d --- /dev/null +++ b/ui/src/app/admin/Roles.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { Switch, Route, useRouteMatch, Redirect } from 'react-router-dom'; +import { RolesProvider } from './hoc/RolesProvider'; +import { NewRole } from './container/NewRole'; +import { EditRole } from './container/EditRole'; +import { RoleList } from './container/RoleList'; + +export function Roles() { + + let { path } = useRouteMatch(); + + return ( + <> + + + + {(roles, onDelete) => + + } + + } /> + + + } /> + + + } /> + + + + ); +} \ No newline at end of file diff --git a/ui/src/app/admin/component/RoleForm.js b/ui/src/app/admin/component/RoleForm.js new file mode 100644 index 000000000..074cb5b09 --- /dev/null +++ b/ui/src/app/admin/component/RoleForm.js @@ -0,0 +1,68 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; +import Form from '@rjsf/bootstrap-4'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSpinner, faSave } from '@fortawesome/free-solid-svg-icons'; +import Translate from '../../i18n/components/translate'; + +import { useRoleUiSchema } from '../hooks'; +import { fields, widgets } from '../../form/component'; +import { templates } from '../../form/component'; +import { FormContext, setFormDataAction, setFormErrorAction } from '../../form/FormManager'; + +function ErrorListTemplate() { + return (<>); +} + +export function RoleForm({ role = {}, errors = [], loading = false, schema, onSave, onCancel }) { + + const { dispatch } = React.useContext(FormContext); + const onChange = ({ formData, errors }) => { + dispatch(setFormDataAction(formData)); + dispatch(setFormErrorAction(errors)); + }; + + const uiSchema = useRoleUiSchema(); + + return (<> +
+
+ + + + +
+
+
+
+
onChange(form)} + schema={schema} + uiSchema={uiSchema} + FieldTemplate={templates.FieldTemplate} + ObjectFieldTemplate={templates.ObjectFieldTemplate} + ArrayFieldTemplate={templates.ArrayFieldTemplate} + fields={fields} + widgets={widgets} + liveValidate={true} + ErrorList={ErrorListTemplate}> + <> +
+
+
+
+ ) +} +/**/ \ No newline at end of file diff --git a/ui/src/app/admin/container/EditRole.js b/ui/src/app/admin/container/EditRole.js new file mode 100644 index 000000000..cc8d802ed --- /dev/null +++ b/ui/src/app/admin/container/EditRole.js @@ -0,0 +1,91 @@ +import React from 'react'; + +import { Prompt, useHistory } from 'react-router'; +import { useParams } from 'react-router-dom'; +import Translate from '../../i18n/components/translate'; +import { useRoles } from '../hooks'; +import { Schema } from '../../form/Schema'; +import { FormManager } from '../../form/FormManager'; + +import { RoleForm } from '../component/RoleForm'; +import { RoleProvider } from '../hoc/RoleProvider'; +import { createNotificationAction, NotificationTypes, useNotificationDispatcher } from '../../notifications/hoc/Notifications'; +import { useTranslator } from '../../i18n/hooks'; + +export function EditRole() { + + const { id } = useParams(); + + const notifier = useNotificationDispatcher(); + const translator = useTranslator(); + + const history = useHistory(); + + const { put, response, loading } = useRoles(); + + const [blocking, setBlocking] = React.useState(false); + + async function save(role) { + let toast; + const resp = await put(``, role); + if (response.ok) { + gotoDetail({ refresh: true }); + toast = createNotificationAction(`Updated role successfully.`, NotificationTypes.SUCCESS); + } else { + toast = createNotificationAction(`${resp.errorCode} - ${translator(resp.errorMessage)}`, NotificationTypes.ERROR); + } + if (toast) { + notifier(toast); + } + }; + + const cancel = () => { + gotoDetail(); + }; + + const gotoDetail = (state = null) => { + setBlocking(false); + history.push(`/roles`, state); + }; + + return ( +
+ + `message.unsaved-editor` + } + /> +
+
+
+
+ Edit role +
+
+
+
+ + {(role) => + + {(schema) => + <>{role && + + {(data, errors) => + save(data)} + onCancel={() => cancel()} />} + + }} + + } + +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/app/admin/container/MetadataActions.js b/ui/src/app/admin/container/MetadataActions.js index 80f43d56a..4500354f4 100644 --- a/ui/src/app/admin/container/MetadataActions.js +++ b/ui/src/app/admin/container/MetadataActions.js @@ -1,5 +1,5 @@ import React from 'react'; -import { DeleteConfirmation } from '../../metadata/component/DeleteConfirmation'; +import { DeleteConfirmation } from '../../core/components/DeleteConfirmation'; import { useMetadataEntity } from '../../metadata/hooks/api'; import { NotificationContext, createNotificationAction } from '../../notifications/hoc/Notifications'; diff --git a/ui/src/app/admin/container/NewRole.js b/ui/src/app/admin/container/NewRole.js new file mode 100644 index 000000000..6d1abfc56 --- /dev/null +++ b/ui/src/app/admin/container/NewRole.js @@ -0,0 +1,79 @@ +import React from 'react'; + +import { Prompt, useHistory } from 'react-router'; +import Translate from '../../i18n/components/translate'; +import { useRoles } from '../hooks'; +import { Schema } from '../../form/Schema'; +import { FormManager } from '../../form/FormManager'; +import { RoleForm } from '../component/RoleForm'; + +import { createNotificationAction, NotificationTypes, useNotificationDispatcher } from '../../notifications/hoc/Notifications'; +import { useTranslator } from '../../i18n/hooks'; + +export function NewRole() { + const history = useHistory(); + const notifier = useNotificationDispatcher(); + const translator = useTranslator(); + + const { post, response, loading } = useRoles({}); + + const [blocking, setBlocking] = React.useState(false); + + async function save(role) { + let toast; + const resp = await post(``, role); + if (response.ok) { + gotoDetail({ refresh: true }); + toast = createNotificationAction(`Added role successfully.`, NotificationTypes.SUCCESS); + } else { + toast = createNotificationAction(`${resp.errorCode} - ${translator(resp.errorMessage)}`, NotificationTypes.ERROR); + } + if (toast) { + notifier(toast); + } + }; + + const cancel = () => { + gotoDetail(); + }; + + const gotoDetail = (state = null) => { + setBlocking(false); + history.push(`/roles`, state); + }; + + return ( +
+ + `message.unsaved-editor` + } + /> +
+
+
+
+ Add a new role +
+
+
+
+ + {(schema) => + + {(data, errors) => + save(data)} + onCancel={() => cancel()} />} + } + +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/app/admin/container/RoleList.js b/ui/src/app/admin/container/RoleList.js new file mode 100644 index 000000000..e48851f90 --- /dev/null +++ b/ui/src/app/admin/container/RoleList.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { faEdit, faPlusCircle, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import Button from 'react-bootstrap/Button'; +import { Link } from 'react-router-dom'; + +import { Translate } from '../../i18n/components/translate'; + +import { DeleteConfirmation } from '../../core/components/DeleteConfirmation'; + +export function RoleList({ roles, onDelete }) { + + const remove = (id) => { + onDelete(id); + } + + return ( + + {(block) => +
+
+
+
+ + Roles Management + +
+
+
+ +   + Add new role + +
+
+ + + + + + + + + {(roles?.length > 0) ? roles.map((role, i) => + + + + + ) : + + } + +
+ Role Name + Actions
{role.name} + + + + Edit + + + +
No roles defined.
+
+
+
+
+
+ } +
+ ); +} \ No newline at end of file diff --git a/ui/src/app/admin/hoc/RoleProvider.js b/ui/src/app/admin/hoc/RoleProvider.js new file mode 100644 index 000000000..ce4000cf8 --- /dev/null +++ b/ui/src/app/admin/hoc/RoleProvider.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { useRole } from '../hooks'; + +export function RoleProvider({ id, children }) { + + const [role, setRole] = React.useState(); + const { get, response } = useRole(id); + + async function loadRole() { + const role = await get(``); + if (response.ok) { + setRole(role); + } + } + + /*eslint-disable react-hooks/exhaustive-deps*/ + React.useEffect(() => { loadRole() }, []); + + return (<>{children(role)}); +} \ No newline at end of file diff --git a/ui/src/app/admin/hoc/RolesProvider.js b/ui/src/app/admin/hoc/RolesProvider.js new file mode 100644 index 000000000..e87d96539 --- /dev/null +++ b/ui/src/app/admin/hoc/RolesProvider.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { useRoles } from '../hooks'; +import { createNotificationAction, NotificationTypes, useNotificationDispatcher } from '../../notifications/hoc/Notifications'; +import { useTranslator } from '../../i18n/hooks'; + +export function RolesProvider({ children, cache = 'no-cache' }) { + + const [roles, setRoles] = React.useState([]); + + const notifier = useNotificationDispatcher(); + const translator = useTranslator(); + + const { get, del, response, loading } = useRoles({ + cachePolicy: cache + }); + + async function loadRoles() { + const list = await get(``); + if (response.ok) { + setRoles(list); + } + } + + async function removeRole(id) { + let toast; + const resp = await del(`/${id}`); + if (response.ok) { + loadRoles(); + toast = createNotificationAction(`Deleted role successfully.`, NotificationTypes.SUCCESS); + } else { + toast = createNotificationAction(`${resp.errorCode} - ${translator(resp.errorMessage)}`, NotificationTypes.ERROR); + } + if (toast) { + notifier(toast); + } + } + + /*eslint-disable react-hooks/exhaustive-deps*/ + React.useEffect(() => { loadRoles() }, []); + + return (<>{children(roles, removeRole, loading)}); +} \ No newline at end of file diff --git a/ui/src/app/admin/hooks.js b/ui/src/app/admin/hooks.js new file mode 100644 index 000000000..919be8e4e --- /dev/null +++ b/ui/src/app/admin/hooks.js @@ -0,0 +1,20 @@ +import useFetch from 'use-http'; +import API_BASE_PATH from '../App.constant'; + +export function useRoles(opts = { cachePolicy: 'no-cache' }) { + return useFetch(`${API_BASE_PATH}/admin/roles`, opts); +} + +export function useRole(id) { + return useFetch(`${API_BASE_PATH}/admin/roles/${id}`, { + cachePolicy: 'no-cache' + }); +} + +export function useRoleUiSchema() { + return { + description: { + 'ui:widget': 'textarea' + } + }; +} \ No newline at end of file diff --git a/ui/src/app/metadata/component/DeleteConfirmation.js b/ui/src/app/core/components/DeleteConfirmation.js similarity index 93% rename from ui/src/app/metadata/component/DeleteConfirmation.js rename to ui/src/app/core/components/DeleteConfirmation.js index a0f2f8c09..ac699063e 100644 --- a/ui/src/app/metadata/component/DeleteConfirmation.js +++ b/ui/src/app/core/components/DeleteConfirmation.js @@ -37,7 +37,7 @@ export function DeleteConfirmation({ children, body, title }) {

- You are deleting an entity. This cannot be undone. Continue? + You are deleting an entity. This cannot be undone. Continue?

diff --git a/ui/src/app/metadata/component/DeleteConfirmation.test.js b/ui/src/app/core/components/DeleteConfirmation.test.js similarity index 97% rename from ui/src/app/metadata/component/DeleteConfirmation.test.js rename to ui/src/app/core/components/DeleteConfirmation.test.js index 8aa4a57ee..76abf8dd6 100644 --- a/ui/src/app/metadata/component/DeleteConfirmation.test.js +++ b/ui/src/app/core/components/DeleteConfirmation.test.js @@ -28,7 +28,7 @@ test('Delete confirmation', () => { {(block) => } , - container); + container); }); const initiator = container.querySelector('button'); diff --git a/ui/src/app/core/components/Header.js b/ui/src/app/core/components/Header.js index 54d0c402c..b464dffe6 100644 --- a/ui/src/app/core/components/Header.js +++ b/ui/src/app/core/components/Header.js @@ -6,7 +6,7 @@ import Navbar from 'react-bootstrap/Navbar'; import Dropdown from 'react-bootstrap/Dropdown'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTh, faSignOutAlt, faPlusCircle, faCube, faCubes } from '@fortawesome/free-solid-svg-icons'; +import { faTh, faSignOutAlt, faPlusCircle, faCube, faCubes, faUserTag } from '@fortawesome/free-solid-svg-icons'; import Translate from '../../i18n/components/translate'; import { useTranslator } from '../../i18n/hooks'; @@ -41,6 +41,10 @@ export function Header () { + + + + } diff --git a/ui/src/app/core/components/UserConfirmation.test.js b/ui/src/app/core/components/UserConfirmation.test.js index 9c2835d9b..58f73c3b8 100644 --- a/ui/src/app/core/components/UserConfirmation.test.js +++ b/ui/src/app/core/components/UserConfirmation.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { render, fireEvent, screen } from '@testing-library/react'; import { UserConfirmation, ConfirmWindow } from './UserConfirmation'; jest.mock('../../i18n/hooks', () => ({ diff --git a/ui/src/app/form/FormManager.js b/ui/src/app/form/FormManager.js new file mode 100644 index 000000000..41b39d520 --- /dev/null +++ b/ui/src/app/form/FormManager.js @@ -0,0 +1,105 @@ +import React from 'react'; + +const initialState = { + data: {}, + errors: [] +}; + +const FormContext = React.createContext(); + +const { Provider, Consumer } = FormContext; + +export const FormActions = { + SET_FORM_ERROR: 'set form error', + SET_FORM_DATA: 'set form data' +}; + +export const setFormDataAction = (payload) => { + return { + type: FormActions.SET_FORM_DATA, + payload + } +} + +export const setFormErrorAction = (errors) => { + return { + type: FormActions.SET_FORM_ERROR, + payload: errors + } +} + +function reducer(state, action) { + switch (action.type) { + + case FormActions.SET_FORM_ERROR: + return { + ...state, + errors: action.payload + }; + case FormActions.SET_FORM_DATA: + return { + ...state, + data: action.payload + }; + default: + return state; + } +} + +/*eslint-disable react-hooks/exhaustive-deps*/ +function FormManager({ children, initial = {} }) { + + const data = { + ...initial + }; + + const [state, dispatch] = React.useReducer(reducer, { + ...initialState, + data + }); + const contextValue = React.useMemo(() => ({ state, dispatch }), [state, dispatch]); + + return ( + + {children(state.data, state.errors)} + + ); +} + +function useFormErrors() { + const { state } = React.useContext(FormContext); + const { errors } = state; + + return errors; +} + +function useFormContext() { + return React.useContext(FormContext); +} + +function useFormDispatcher() { + const { dispatch } = useFormContext(); + return dispatch; +} + +function useFormState() { + const { state } = useFormContext(); + return state; +} + +function useFormData() { + const { data } = useFormContext(); + return data; +} + +export { + useFormErrors, + useFormContext, + useFormDispatcher, + useFormState, + useFormData, + FormManager, + FormContext, + Provider as FormProvider, + Consumer as FormConsumer +}; \ No newline at end of file diff --git a/ui/src/app/form/Schema.js b/ui/src/app/form/Schema.js new file mode 100644 index 000000000..92aefe325 --- /dev/null +++ b/ui/src/app/form/Schema.js @@ -0,0 +1,24 @@ +import React from 'react'; +import useFetch from 'use-http'; + +export function Schema({ path, children }) { + + const [schema, setSchema] = React.useState({}); + + + const { get, response } = useFetch(path, { + cachePolicy: 'no-cache' + }); + + async function loadSchema() { + const list = await get(``); + if (response.ok) { + setSchema(list); + } + } + + /*eslint-disable react-hooks/exhaustive-deps*/ + React.useEffect(() => { loadSchema() }, []); + + return (<>{children(schema)}); +} \ No newline at end of file diff --git a/ui/src/app/metadata/domain/filter/component/MetadataFilters.js b/ui/src/app/metadata/domain/filter/component/MetadataFilters.js index d38c97013..26c63f131 100644 --- a/ui/src/app/metadata/domain/filter/component/MetadataFilters.js +++ b/ui/src/app/metadata/domain/filter/component/MetadataFilters.js @@ -1,6 +1,6 @@ import React from 'react'; import { useMetadataFilters } from '../../../hooks/api'; -import { DeleteConfirmation } from '../../../component/DeleteConfirmation'; +import { DeleteConfirmation } from '../../../../core/components/DeleteConfirmation'; import { NotificationContext, createNotificationAction } from '../../../../notifications/hoc/Notifications'; export const MetadataFiltersContext = React.createContext(); diff --git a/ui/src/app/metadata/view/MetadataAttributeList.js b/ui/src/app/metadata/view/MetadataAttributeList.js index f585f805f..69e6754f6 100644 --- a/ui/src/app/metadata/view/MetadataAttributeList.js +++ b/ui/src/app/metadata/view/MetadataAttributeList.js @@ -8,7 +8,7 @@ import { Link } from 'react-router-dom'; import { Translate } from '../../i18n/components/translate'; import { useTranslator } from '../../i18n/hooks'; -import { DeleteConfirmation } from '../component/DeleteConfirmation'; +import { DeleteConfirmation } from '../../core/components/DeleteConfirmation'; export function MetadataAttributeList ({entities, onDelete}) {