diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx index a79aa583d1ec..8f8e9936aada 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelTitle.tsx @@ -11,7 +11,7 @@ type InfoPanelTitleProps = { const isValidIcon = (icon: ReactNode): icon is IconName => typeof icon === 'string'; const InfoPanelTitle = ({ title, icon }: InfoPanelTitleProps) => ( - + {isValidIcon(icon) ? : icon} {title} diff --git a/apps/meteor/client/components/UserCard/UserCardInfo.tsx b/apps/meteor/client/components/UserCard/UserCardInfo.tsx index 8e235670a3dc..2afcf6a37f2c 100644 --- a/apps/meteor/client/components/UserCard/UserCardInfo.tsx +++ b/apps/meteor/client/components/UserCard/UserCardInfo.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ComponentProps } from 'react'; import React from 'react'; const UserCardInfo = (props: ComponentProps): ReactElement => ( - + ); export default UserCardInfo; diff --git a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx index d52d45415c8a..2ec5954332f7 100644 --- a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx @@ -94,8 +94,8 @@ const RoomsTableFilters = ({ setFilters }: { setFilters: Dispatch { + const { t } = useTranslation(); + const router = useRouter(); + + return ( + <> + + + + + + + + + ); +}; + +export default AdminUserCreated; diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 1505cd2b8147..eb9e5048637c 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -52,7 +52,7 @@ type AdminUserFormProps = { roleError: unknown; }; -export type userFormProps = Omit; +export type UserFormProps = Omit; const getInitialValue = ({ data, @@ -64,7 +64,7 @@ const getInitialValue = ({ defaultUserRoles?: IUser['roles']; isSmtpEnabled?: boolean; isNewUserPage?: boolean; -}): userFormProps => ({ +}): UserFormProps => ({ roles: data?.roles ?? defaultUserRoles, name: data?.name ?? '', password: '', @@ -151,7 +151,7 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD }, }); - const handleSaveUser = useMutableCallback(async (userFormPayload: userFormProps) => { + const handleSaveUser = useMutableCallback(async (userFormPayload: UserFormProps) => { const { avatar, passwordConfirmation, ...userFormData } = userFormPayload; if (!isNewUserPage && userData?._id) { diff --git a/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx index e595acc46951..a198be7e9b7e 100644 --- a/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IRole, IUser } from '@rocket.chat/core-typings'; import { isUserFederated } from '@rocket.chat/core-typings'; import { Box, Callout } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; @@ -13,9 +13,12 @@ import AdminUserForm from './AdminUserForm'; type AdminUserFormWithDataProps = { uid: IUser['_id']; onReload: () => void; + context: string; + roleData: { roles: IRole[] } | undefined; + roleError: unknown; }; -const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): ReactElement => { +const AdminUserFormWithData = ({ uid, onReload, context, roleData, roleError }: AdminUserFormWithDataProps): ReactElement => { const t = useTranslation(); const { data, isLoading, isError, refetch } = useUserInfoQuery({ userId: uid }); @@ -40,7 +43,7 @@ const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): R ); } - if (data?.user && isUserFederated(data?.user as unknown as IUser)) { + if (data?.user && isUserFederated(data?.user)) { return ( {t('Edit_Federated_User_Not_Allowed')} @@ -48,7 +51,16 @@ const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): R ); } - return ; + return ( + + ); }; export default AdminUserFormWithData; diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index ab917f112f1d..56641f8959d0 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -1,5 +1,5 @@ import type { LicenseInfo } from '@rocket.chat/core-typings'; -import { Button, ButtonGroup, Callout, ContextualbarIcon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Callout, ContextualbarIcon, Icon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import type { OptionProp } from '@rocket.chat/ui-client'; import { ExternalLink } from '@rocket.chat/ui-client'; @@ -23,6 +23,7 @@ import { useLicenseLimitsByBehavior } from '../../../hooks/useLicenseLimitsByBeh import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction'; import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl'; import AdminInviteUsers from './AdminInviteUsers'; +import AdminUserCreated from './AdminUserCreated'; import AdminUserForm from './AdminUserForm'; import AdminUserFormWithData from './AdminUserFormWithData'; import AdminUserInfoWithData from './AdminUserInfoWithData'; @@ -61,7 +62,7 @@ const AdminUsersPage = (): ReactElement => { const isCreateUserDisabled = useShouldPreventAction('activeUsers'); const getRoles = useEndpoint('GET', '/v1/roles.list'); - const { data } = useQuery(['roles'], async () => getRoles()); + const { data, error } = useQuery(['roles'], async () => getRoles()); const paginationData = usePagination(); const sortData = useSort('name'); @@ -181,14 +182,23 @@ const AdminUsersPage = (): ReactElement => { {context === 'info' && t('User_Info')} {context === 'edit' && t('Edit_User')} - {context === 'new' && t('Add_User')} + {(context === 'new' || context === 'created') && ( + <> + {t('New_user')} + + )} {context === 'invite' && t('Invite_Users')} router.navigate('/admin/users')} /> {context === 'info' && id && } - {context === 'edit' && id && } - {!isRoutePrevented && context === 'new' && } + {context === 'edit' && id && ( + + )} + {!isRoutePrevented && context === 'new' && ( + + )} + {!isRoutePrevented && context === 'created' && id && } {!isRoutePrevented && context === 'invite' && } {isRoutePrevented && } diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index 34d71e6ab371..7463db539b91 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -163,7 +163,7 @@ const UsersTable = ({ divider current={current} itemsPerPage={itemsPerPage} - count={data?.total || 0} + count={data.total || 0} onSetItemsPerPage={setItemsPerPage} onSetCurrent={setCurrent} {...paginationProps} diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index 703c4a4bd8b1..7ddc461cdaa6 100644 --- a/apps/meteor/tests/e2e/administration.spec.ts +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -77,9 +77,11 @@ test.describe.parallel('administration', () => { await poAdmin.tabs.users.btnNewUser.click(); await poAdmin.tabs.users.inputName.type(faker.person.firstName()); await poAdmin.tabs.users.inputUserName.type(faker.internet.userName()); + await poAdmin.tabs.users.inputSetManually.click(); await poAdmin.tabs.users.inputEmail.type(faker.internet.email()); await poAdmin.tabs.users.checkboxVerified.click(); await poAdmin.tabs.users.inputPassword.type('any_password'); + await poAdmin.tabs.users.inputConfirmPassword.type('any_password'); await expect(poAdmin.tabs.users.userRole).toBeVisible(); await poAdmin.tabs.users.btnSave.click(); }); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts b/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts index 5b912be1fd02..750fa26d39d4 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-users.ts @@ -12,7 +12,7 @@ export class AdminFlextabUsers { } get btnSave(): Locator { - return this.page.locator('role=button[name="Save"]'); + return this.page.locator('role=button[name="Add user"]'); } get btnInvite(): Locator { @@ -31,12 +31,20 @@ export class AdminFlextabUsers { return this.page.locator('//label[text()="Email"]/following-sibling::span//input').first(); } + get inputSetManually(): Locator { + return this.page.locator('//label[text()="Set manually"]'); + } + get inputPassword(): Locator { - return this.page.locator('//label[text()="Password"]/following-sibling::span//input'); + return this.page.locator('input[placeholder="Password"]'); + } + + get inputConfirmPassword(): Locator { + return this.page.locator('input[placeholder="Confirm password"]'); } get checkboxVerified(): Locator { - return this.page.locator('//label[text()="Verified"]'); + return this.page.locator('//label[text()="Mark email as verified"]'); } get joinDefaultChannels(): Locator { diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 3c6d1c890d7b..fa411c6f7e47 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -152,9 +152,7 @@ export interface IUser extends IRocketChatRecord { private_key: string; public_key: string; }; - customFields?: { - [key: string]: any; - }; + customFields?: Record; settings?: IUserSettings; defaultRoom?: string; ldap?: boolean; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 7d70fc06d8c8..d54e1455f88f 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -319,6 +319,7 @@ "Add_files_from": "Add files from", "Add_manager": "Add manager", "Add_monitor": "Add monitor", + "Add_more_users": "Add more users", "Add_link": "Add link", "Add_Reaction": "Add reaction", "Add_Role": "Add Role", @@ -6049,6 +6050,8 @@ "You_have_a_new_message": "You have a new message", "You_have_been_muted": "You have been muted and cannot speak in this room", "You_have_been_removed_from__roomName_": "You've been removed from the room {{roomName}}", + "You_have_created_user_one": "You’ve created {{count}} user", + "You_have_created_user_other": "You’ve created {{count}} users", "You_have_joined_a_new_call_with": "You have joined a new call with", "You_have_n_codes_remaining": "You have {{number}} codes remaining.", "You_have_not_verified_your_email": "You have not verified your email.", diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx index 317f56899d05..bd938a7c9168 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx @@ -1,6 +1,5 @@ import { Box, Button } from '@rocket.chat/fuselage'; import { useOutsideClick, useToggle } from '@rocket.chat/fuselage-hooks'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ComponentProps, FormEvent, ReactElement, RefObject } from 'react'; import { useCallback, useRef } from 'react'; @@ -33,7 +32,6 @@ export type OptionProp = { @param selectedOptionsTitle dropdown text after clicking one or more options. For example: 'Rooms (3)' * @param selectedOptions array with clicked options. This is used in the useFilteredTypeRooms hook, to filter the Rooms' table, for example. This array joins all of the individual clicked options from all available MultiSelectCustom components in the page. It helps to create a union filter for all the selections. * @param setSelectedOptions part of an useState hook to set the previous selectedOptions - * @param customSetSelected part of an useState hook to set the individual selected checkboxes from this instance. * @param searchBarText optional text prop that creates a search bar inside the dropdown, when added. * @returns a React Component that should be used with a custom hook for filters, such as useFilteredTypeRooms.tsx. * Check out the following files, for examples: @@ -43,11 +41,11 @@ export type OptionProp = { */ type DropDownProps = { dropdownOptions: OptionProp[]; - defaultTitle: TranslationKey; - selectedOptionsTitle: TranslationKey; + defaultTitle: string; + selectedOptionsTitle: string; selectedOptions: OptionProp[]; setSelectedOptions: (roles: OptionProp[]) => void; - searchBarText?: TranslationKey; + searchBarText?: string; } & ComponentProps; export const MultiSelectCustom = ({ @@ -77,20 +75,26 @@ export const MultiSelectCustom = ({ useOutsideClick([target], onClose); - const onSelect = (item: OptionProp, e?: FormEvent): void => { - e?.stopPropagation(); - item.checked = !item.checked; + const onSelect = useCallback( + (selectedOption: OptionProp, e?: FormEvent): void => { + e?.stopPropagation(); - if (item.checked === true) { - setSelectedOptions([...new Set([...selectedOptions, item])]); - return; - } + if (selectedOption.hasOwnProperty('checked')) { + selectedOption.checked = !selectedOption.checked; - // the user has disabled this option -> remove this from the selected options list - setSelectedOptions(selectedOptions.filter((option: OptionProp) => option.id !== item.id)); - }; + if (selectedOption.checked) { + setSelectedOptions([...new Set([...selectedOptions, selectedOption])]); + return; + } - const count = dropdownOptions.filter((option) => option.checked).length; + // the user has disabled this option -> remove this from the selected options list + setSelectedOptions(selectedOptions.filter((option: OptionProp) => option.id !== selectedOption.id)); + } + }, + [selectedOptions, setSelectedOptions], + ); + + const selectedOptionsCount = dropdownOptions.filter((option) => option.hasOwnProperty('checked') && option.checked).length; return ( @@ -101,7 +105,7 @@ export const MultiSelectCustom = ({ onKeyDown={(e) => (e.code === 'Enter' || e.code === 'Space') && toggleCollapsed(!collapsed)} defaultTitle={defaultTitle} selectedOptionsTitle={selectedOptionsTitle} - selectedOptionsCount={count} + selectedOptionsCount={selectedOptionsCount} maxCount={dropdownOptions.length} {...props} /> diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx index 3a03673bc701..acd1e1eb8d6b 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomAnchor.tsx @@ -7,8 +7,8 @@ import { forwardRef } from 'react'; type MultiSelectCustomAnchorProps = { collapsed: boolean; - defaultTitle: TranslationKey; - selectedOptionsTitle: TranslationKey; + defaultTitle: string; + selectedOptionsTitle: string; selectedOptionsCount: number; maxCount: number; } & ComponentProps; @@ -37,7 +37,7 @@ const MultiSelectCustomAnchor = forwardRef - {isDirty ? `${t(selectedOptionsTitle)} (${selectedOptionsCount})` : t(defaultTitle)} + {isDirty ? `${t(selectedOptionsTitle as TranslationKey)} (${selectedOptionsCount})` : t(defaultTitle as TranslationKey)} ); diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx index 71cb54f81aa5..d45b679c3bd8 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx @@ -1,10 +1,10 @@ -import { Box, CheckBox, Icon, Option, SearchInput, Tile } from '@rocket.chat/fuselage'; +import { Box, CheckBox, Icon, Margins, Option, SearchInput, Tile } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FormEvent } from 'react'; import { Fragment, useCallback, useState } from 'react'; -import type { OptionProp } from './MultiSelectCustom'; +import { type OptionProp } from './MultiSelectCustom'; import { useFilteredOptions } from './useFilteredOptions'; const MultiSelectCustomList = ({ @@ -14,7 +14,7 @@ const MultiSelectCustomList = ({ }: { options: OptionProp[]; onSelected: (item: OptionProp, e?: FormEvent) => void; - searchBarText?: TranslationKey; + searchBarText?: string; }) => { const t = useTranslation(); @@ -25,33 +25,35 @@ const MultiSelectCustomList = ({ const filteredOptions = useFilteredOptions(text, options); return ( - + {searchBarText && ( )} {filteredOptions.map((option) => ( - {option.hasOwnProperty('checked') ? ( + {!option.hasOwnProperty('checked') ? ( + + {t(option.text as TranslationKey)} + + ) : ( - ) : ( - - {t(option.text as TranslationKey)} - )} ))}