From d0f45ef7216bf16c638fc6ba54561d1861585c9f Mon Sep 17 00:00:00 2001 From: shedoev Date: Thu, 5 Nov 2020 16:04:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?#742=20[NEW]=20=D0=A3=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/server/v1/protocols.js | 28 +++ app/api/server/v1/users.js | 22 +++ app/lib/server/functions/index.js | 1 + app/lib/server/functions/saveUser.js | 176 ++++++++++++++++++ app/models/server/models/Protocols.js | 16 ++ app/protocols/client/views/Protocol.js | 56 +++++- .../views/participants/AddParticipant.js | 124 ++++++++++++ .../views/participants/CreateParticipant.js | 54 ++++++ .../views/participants/ParticipantForm.js | 100 ++++++++++ .../client/views/participants/Participants.js | 146 +++++++++++++++ app/protocols/server/index.js | 2 + .../methods/addParticipantToProtocol.js | 14 ++ .../methods/deleteParticipantFromProtocol.js | 24 +++ packages/rocketchat-i18n/i18n/ru.i18n.json | 7 + 14 files changed, 760 insertions(+), 10 deletions(-) create mode 100644 app/protocols/client/views/participants/AddParticipant.js create mode 100644 app/protocols/client/views/participants/CreateParticipant.js create mode 100644 app/protocols/client/views/participants/ParticipantForm.js create mode 100644 app/protocols/client/views/participants/Participants.js create mode 100644 app/protocols/server/methods/addParticipantToProtocol.js create mode 100644 app/protocols/server/methods/deleteParticipantFromProtocol.js diff --git a/app/api/server/v1/protocols.js b/app/api/server/v1/protocols.js index ca1fc61d239dc..6d803381b17fd 100644 --- a/app/api/server/v1/protocols.js +++ b/app/api/server/v1/protocols.js @@ -1,6 +1,7 @@ import { API } from '../api'; import { findProtocols, findProtocol } from '../lib/protocols'; import { hasPermission } from '../../../authorization'; +import { Users } from '../../../models'; API.v1.addRoute('protocols.list', { authRequired: true }, { get() { @@ -28,3 +29,30 @@ API.v1.addRoute('protocols.findOne', { authRequired: true }, { return API.v1.success(Promise.await(findProtocol(query._id))); }, }); + +API.v1.addRoute('protocols.participants', { authRequired: true }, { + get() { + if (!hasPermission(this.userId, 'manage-protocols')) { + return API.v1.unauthorized(); + } + + const { offset, count } = this.getPaginationItems(); + const { sort, fields, query } = this.parseJsonQuery(); + + const protocol = Promise.await(findProtocol(query._id)); + + const users = Users.find({ _id: { $in: protocol.participants } }, { + sort: sort || { username: 1 }, + skip: offset, + limit: count, + fields, + }).fetch(); + + return API.v1.success({ + users, + count: users.length, + offset, + total: Users.find(query).count(), + }); + }, +}); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 49cec0da7de49..452791fbd5709 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -11,6 +11,7 @@ import { getURL } from '../../../utils'; import { validateCustomFields, saveUser, + saveParticipant, saveCustomFieldsWithoutValidation, checkUsernameAvailability, setUserAvatar, @@ -115,6 +116,27 @@ API.v1.addRoute('users.deleteOwnAccount', { authRequired: true }, { }, }); +API.v1.addRoute('users.createParticipant', { authRequired: true }, { + post() { + check(this.bodyParams, { + email: String, + name: String, + surname: String, + patronymic: String, + organization: Match.Maybe(String), + position: Match.Maybe(String), + phone: Match.Maybe(String), + workingGroup: Match.Maybe(String), + }); + + const newUserId = saveParticipant(this.userId, this.bodyParams); + + const { fields } = this.parseJsonQuery(); + + return API.v1.success({ user: Users.findOneById(newUserId, { fields }) }); + }, +}); + API.v1.addRoute('users.getAvatar', { authRequired: false }, { get() { const user = this.getUserFromParams(); diff --git a/app/lib/server/functions/index.js b/app/lib/server/functions/index.js index ecb2881083d2e..35a9801ef0003 100644 --- a/app/lib/server/functions/index.js +++ b/app/lib/server/functions/index.js @@ -23,6 +23,7 @@ export { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; export { saveCustomFields } from './saveCustomFields'; export { saveCustomFieldsWithoutValidation } from './saveCustomFieldsWithoutValidation'; export { saveUser } from './saveUser'; +export { saveParticipant } from './saveUser'; export { sendMessage } from './sendMessage'; export { setEmail } from './setEmail'; export { setRealName, _setRealName } from './setRealName'; diff --git a/app/lib/server/functions/saveUser.js b/app/lib/server/functions/saveUser.js index e9b6088f7e327..682ab9e6cb65a 100644 --- a/app/lib/server/functions/saveUser.js +++ b/app/lib/server/functions/saveUser.js @@ -197,6 +197,79 @@ function validateUserEditing(userId, userData) { } } +function validateParticipantData(userId, userData) { + if (userData._id && userId !== userData._id && !hasPermission(userId, 'edit-other-user-info')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + + if (!userData._id && !hasPermission(userId, 'create-user')) { + throw new Meteor.Error('error-action-not-allowed', 'Adding user is not allowed', { + method: 'insertOrUpdateUser', + action: 'Adding_user', + }); + } + + // if (userData.roles && _.indexOf(userData.roles, 'admin') >= 0 && !hasPermission(userId, 'assign-admin-role')) { + // throw new Meteor.Error('error-action-not-allowed', 'Assigning admin is not allowed', { + // method: 'insertOrUpdateUser', + // action: 'Assign_admin', + // }); + // } + + if (!userData._id && !s.trim(userData.username)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Username is required', { + method: 'insertOrUpdateUser', + field: 'Username', + }); + } + + // if (userData.roles) { + // validateUserRoles(userId, userData); + // } + + // let nameValidation; + + // try { + // nameValidation = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); + // } catch (e) { + // nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + // } + + // if (userData.username && !nameValidation.test(userData.username)) { + // throw new Meteor.Error('error-input-is-not-a-valid-field', `${ _.escape(userData.username) } is not a valid username`, { + // method: 'insertOrUpdateUser', + // input: userData.username, + // field: 'Username', + // }); + // } + + // if (!userData._id && !userData.password && !userData.setRandomPassword) { + // throw new Meteor.Error('error-the-field-is-required', 'The field Password is required', { + // method: 'insertOrUpdateUser', + // field: 'Password', + // }); + // } + + if (!userData._id) { + if (!checkUsernameAvailability(userData.username)) { + throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.username) } is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.username, + }); + } + + if (userData.email && !checkEmailAvailability(userData.email)) { + throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.email) } is already in use :(`, { + method: 'insertOrUpdateUser', + field: userData.email, + }); + } + } +} + const handleOrganization = (updateUser, organization) => { if (organization) { if (organization.trim()) { @@ -446,3 +519,106 @@ export const saveUser = function(userId, userData) { return true; }; + +const getLoginExample = (surname, name, patronymic) => { + if (!surname || !name) { + return ''; + } + + const translit = (str) => { + const L = { + 'А':'A','а':'a','Б':'B','б':'b','В':'V','в':'v','Г':'G','г':'g', + 'Д':'D','д':'d','Е':'E','е':'e','Ё':'Yo','ё':'yo','Ж':'Zh','ж':'zh', + 'З':'Z','з':'z','И':'I','и':'i','Й':'Y','й':'y','К':'K','к':'k', + 'Л':'L','л':'l','М':'M','м':'m','Н':'N','н':'n','О':'O','о':'o', + 'П':'P','п':'p','Р':'R','р':'r','С':'S','с':'s','Т':'T','т':'t', + 'У':'U','у':'u','Ф':'F','ф':'f','Х':'Kh','х':'kh','Ц':'Ts','ц':'ts', + 'Ч':'Ch','ч':'ch','Ш':'Sh','ш':'sh','Щ':'Sch','щ':'sch','Ъ':'"','ъ':'"', + 'Ы':'Y','ы':'y','Ь':"'",'ь':"'",'Э':'E','э':'e','Ю':'Yu','ю':'yu', + 'Я':'Ya','я':'ya' + }; + let reg = ''; + for (const kr in L) { + reg += kr; + } + reg = new RegExp('[' + reg + ']', 'g'); + const translate = function(a) { + return a in L ? L[a] : a; + }; + return str.replace(reg, translate).toLowerCase(); + }; + + const capitalizeFirstLetter = (translitString) => { + return translitString.charAt(0).toUpperCase() + translitString.slice(1); + }; + + const patron = patronymic ? patronymic[0] : ''; + return capitalizeFirstLetter(translit(surname + name[0] + patron)); +}; + +export const saveParticipant = function(userId, userData) { + userData.username = getLoginExample(userData.surname, userData.name, userData.patronymic); + + validateParticipantData(userId, userData); + + userData.password = passwordPolicy.generatePassword(); + userData.requirePasswordChange = true; + + delete userData.setRandomPassword; + + if (!userData._id) { + validateEmailDomain(userData.email); + + // insert user + const createUser = { + username: userData.username, + password: userData.password, + joinDefaultChannels: false, + }; + if (userData.email) { + createUser.email = userData.email; + } + + const _id = Accounts.createUser(createUser); + + const updateUser = { + $set: { + // roles: userData.roles || ['user'], + surname: userData.surname, + ...typeof userData.name !== 'undefined' && { name: userData.name }, + ...typeof userData.patronymic !== 'undefined' && { patronymic: userData.patronymic }, + settings: userData.settings || {}, + participant: true, + }, + }; + + if (typeof userData.requirePasswordChange !== 'undefined') { + updateUser.$set.requirePasswordChange = userData.requirePasswordChange; + } + + updateUser.$set['emails.0.verified'] = false; + + handleOrganization(updateUser, userData.organization); + handlePosition(updateUser, userData.position); + handlePhone(updateUser, userData.phone); + handleWorkingGroup(updateUser, userData.workingGroup); + + Meteor.users.update({ _id }, updateUser); + + userData._id = _id; + + if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { + const gravatarUrl = Gravatar.imageUrl(userData.email, { default: '404', size: 200, secure: true }); + + try { + setUserAvatar(userData, gravatarUrl, '', 'url'); + } catch (e) { + // Ignore this error for now, as it not being successful isn't bad + } + } + + return _id; + } + + return true; +}; diff --git a/app/models/server/models/Protocols.js b/app/models/server/models/Protocols.js index 8bb860d345fa8..38515af36b765 100644 --- a/app/models/server/models/Protocols.js +++ b/app/models/server/models/Protocols.js @@ -122,6 +122,22 @@ class Protocols extends Base { return itemData._id; } + addParticipant(protocolId, userId) { + return this.update({ + _id: protocolId, + participants: { $ne: userId }, + }, { + $addToSet: { participants: userId }, + }); + } + + removeParticipantById(protocolId, userId) { + const data = this.findOne({ _id: protocolId }); + + if (data.participants) { + this.update({ _id: protocolId }, { $pull: { participants: userId }}); + } + } } export default new Protocols(); diff --git a/app/protocols/client/views/Protocol.js b/app/protocols/client/views/Protocol.js index 31689fb11669f..60aff5e94f5d2 100644 --- a/app/protocols/client/views/Protocol.js +++ b/app/protocols/client/views/Protocol.js @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import {Box, Button, Field, Icon, Scrollable, Tabs} from '@rocket.chat/fuselage'; +import React, { useCallback, useMemo, useState } from 'react'; +import { Box, Button, ButtonGroup, Field, Icon, Label } from '@rocket.chat/fuselage'; import Page from '../../../../client/components/basic/Page'; import { useTranslation } from '../../../../client/contexts/TranslationContext'; @@ -13,6 +13,9 @@ import { AddSection } from './AddSection'; import { AddItem } from './AddItem'; import { EditSection } from './EditSection'; import { EditItem } from './EditItem'; +import { Participants } from '../views/participants/Participants'; +import { AddParticipant } from '../views/participants/AddParticipant'; +import { CreateParticipant } from '../views/participants/CreateParticipant'; export function ProtocolPage() { const t = useTranslation(); @@ -90,20 +93,47 @@ export function ProtocolPage() { }); }, [router]); + const onParticipantsClick = useCallback((context) => () => { + router.push({ id: protocolId, context: context }); + }, [router]); + + const onAddParticipantClick = useCallback((context) => () => { + router.push({ + id: protocolId, + context: context + }); + }, [router]); + + const goBack = () => { + window.history.back(); + }; + return - + + + + + - - - - {data.place} - - + + + + + + {title} + + + {data.place} + - @@ -116,12 +146,18 @@ export function ProtocolPage() { { context === 'new-item' && t('Item_Add') } { context === 'edit-section' && t('Section_Info') } { context === 'edit-item' && t('Item_Info') } + { context === 'participants' && t('Participants') } + { context === 'add-participant' && t('Participant_Add') } + { context === 'create-participant' && t('Participant_Create') } {context === 'new-section' && } {context === 'new-item' && } {context === 'edit-section' && } {context === 'edit-item' && } + {context === 'participants' && } + {context === 'add-participant' && } + {context === 'create-participant' && } } ; } diff --git a/app/protocols/client/views/participants/AddParticipant.js b/app/protocols/client/views/participants/AddParticipant.js new file mode 100644 index 0000000000000..d335d4fa233fa --- /dev/null +++ b/app/protocols/client/views/participants/AddParticipant.js @@ -0,0 +1,124 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Button, ButtonGroup, Icon, TextInput, Tile, Scrollable } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { css } from '@rocket.chat/css-in-js'; +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import { useEndpointData } from '../../../../../client/hooks/useEndpointData'; +import { useMethod } from '../../../../../client/contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../../../client/contexts/ToastMessagesContext'; +import VerticalBar from '../../../../../client/components/basic/VerticalBar'; + +const clickable = css` + cursor: pointer; + // border-bottom: 2px solid #F2F3F5 !important; + + &:hover, + &:focus { + background: #F7F8FA; + } + `; + +const SearchByText = ({ setParams, ...props }) => { + const t = useTranslation(); + const [text, setText] = useState(''); + const handleChange = useCallback((event) => setText(event.currentTarget.value), []); + + useEffect(() => { + setParams({ text }); + }, [setParams, text]); + + return e.preventDefault(), [])} display='flex' flexDirection='column' {...props}> + } onChange={handleChange} value={text} /> + ; +}; + +const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1); + +const useQuery = ({ text, itemsPerPage, current }, [column, direction]) => useMemo(() => ({ + fields: JSON.stringify({ name: 1, username: 1, emails: 1, + surname: 1, patronymic: 1, organization: 1, position: 1, phone: 1 }), + query: JSON.stringify({ + $or: [ + { 'emails.address': { $regex: text || '', $options: 'i' } }, + { username: { $regex: text || '', $options: 'i' } }, + { name: { $regex: text || '', $options: 'i' } }, + { surname: { $regex: text || '', $options: 'i' } }, + ], + $and: [ + { type: { $ne: 'bot' } } + ] + }), + sort: JSON.stringify({ [column]: sortDir(direction), usernames: column === 'name' ? sortDir(direction) : undefined }), + ...itemsPerPage && { count: itemsPerPage }, + ...current && { offset: current }, +}), [text, itemsPerPage, current, column, direction]); + +export function AddParticipant({ protocolId, close, onCreateParticipant }) { + console.log('AddParticipant') + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 }); + const [sort, setSort] = useState(['surname', 'asc']); + + const debouncedParams = useDebouncedValue(params, 500); + const debouncedSort = useDebouncedValue(sort, 500); + const query = useQuery(debouncedParams, debouncedSort); + + const data = useEndpointData('users.list', query) || { users: []}; + + const insertOrUpdateSection = useMethod('addParticipantToProtocol'); + + const saveAction = useCallback(async (userId) => { + await insertOrUpdateSection(protocolId, userId); + }, [dispatchToastMessage, insertOrUpdateSection]); + + const handleSave = useCallback((userId) => async () => { + try { + await saveAction(userId); + dispatchToastMessage({ type: 'success', message: t('Participant_Added_Successfully') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [dispatchToastMessage, saveAction, t]); + + const User = (user) => + {user.surname} {user.name} {user.patronymic} + {user.position}, {user.organization} + ; + + return + + {data.users && !data.users.length + ? <> + + {t('No_data_found')} + + {params.text !== '' && + } + + : <> + + + {data + ? data.users.map((props, index) => ) + : <> + } + + + + + + + } + ; +} diff --git a/app/protocols/client/views/participants/CreateParticipant.js b/app/protocols/client/views/participants/CreateParticipant.js new file mode 100644 index 0000000000000..7fefe54ac9123 --- /dev/null +++ b/app/protocols/client/views/participants/CreateParticipant.js @@ -0,0 +1,54 @@ +import React, { useCallback, useMemo } from 'react'; +import { Box, Button, Field } from '@rocket.chat/fuselage'; +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import { useEndpointData } from '../../../../../client/hooks/useEndpointData'; +import { useForm } from '../../../../../client/hooks/useForm'; +import { useEndpointAction } from '../../../../../client/hooks/useEndpointAction'; +import ParticipantForm from './ParticipantForm'; + +export function CreateParticipant({ goTo, close, ...props }) { + const t = useTranslation(); + + const roleData = useEndpointData('roles.list', '') || {}; + + const { + values, + handlers, + reset, + hasUnsavedChanges, + } = useForm({ + surname: '', + name: '', + patronymic: '', + organization: '', + position: '', + phone: '', + email: '', + workingGroup: '', + }); + + // TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely. + const saveQuery = useMemo(() => values, [JSON.stringify(values)]); + + const saveAction = useEndpointAction('POST', 'users.createParticipant', saveQuery, t('Participant_Created_Successfully')); + + const handleSave = useCallback(async () => { + const result = await saveAction(); + if (result.success) { + goTo('add-participant')(); + } + }, [goTo, saveAction]); + + const availableRoles = useMemo(() => (roleData && roleData.roles ? roleData.roles.map(({ _id, description }) => [_id, description || _id]) : []), [roleData]); + + const append = useMemo(() => + + + + + + + , [hasUnsavedChanges, close, t, handleSave]); + + return ; +} diff --git a/app/protocols/client/views/participants/ParticipantForm.js b/app/protocols/client/views/participants/ParticipantForm.js new file mode 100644 index 0000000000000..4e7d2f5a13cdf --- /dev/null +++ b/app/protocols/client/views/participants/ParticipantForm.js @@ -0,0 +1,100 @@ +import React, { useCallback, useMemo } from 'react'; +import { + Field, + TextInput, + Icon, + FieldGroup, + Select, +} from '@rocket.chat/fuselage'; + +import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import { isEmail } from '../../../../../app/utils/lib/isEmail.js'; +import VerticalBar from '../../../../../client/components/basic/VerticalBar'; + +export default function ParticipantForm({ formValues, formHandlers, availableRoles, append, prepend, ...props }) { + const t = useTranslation(); + + const { + surname, + name, + patronymic, + organization, + position, + phone, + email, + workingGroup, + } = formValues; + + const { + handleSurname, + handleName, + handlePatronymic, + handleOrganization, + handlePosition, + handlePhone, + handleEmail, + handleWorkingGroup, + } = formHandlers; + + const workingGroupOptions = useMemo(() => [ + ['Не выбрано', t('Not_chosen')], + ['Члены рабочей группы', 'Члены рабочей группы'], + ['Представители субъектов Российской Федерации', 'Представители субъектов Российской Федерации'], + ['Иные участники', 'Иные участники'], + ], [t]); + + return e.preventDefault(), [])} { ...props }> + + { prepend } + {useMemo(() => + {t('Surname')} + + + + , [t, surname, handleSurname])} + {useMemo(() => + {t('Name')} + + + + , [t, name, handleName])} + {useMemo(() => + {t('Patronymic')} + + + + , [t, patronymic, handlePatronymic])} + {useMemo(() => + {t('Organization')} + + + + , [t, organization, handleOrganization])} + {useMemo(() => + {t('Position')} + + + + , [t, position, handlePosition])} + {useMemo(() => + {t('Phone_number')} + + + + , [t, phone, handlePhone])} + {useMemo(() => + {t('Email')} + + 0 ? 'error' : undefined} onChange={handleEmail} addon={}/> + + , [t, email, handleEmail])} + {useMemo(() => + {t('Working_group')} + +