snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import {
Box,
Button,
ButtonGroup,
States,
StatesAction,
StatesActions,
StatesSubtitle,
StatesTitle,
TextAreaInput,
} from '@rocket.chat/fuselage';
import { validateEmail } from '@rocket.chat/tools';
import { ContextualbarScrollableContent, ContextualbarFooter, ContextualbarContent } from '@rocket.chat/ui-client';
import { useTranslation, useRoute } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
import { useSendInvitationEmailMutation } from './hooks/useSendInvitationEmailMutation';
import { useSmtpQuery } from './hooks/useSmtpQuery';
import { FormSkeleton } from '../../../components/Skeleton';
// TODO: Replace using RHF
const AdminInviteUsers = () => {
const t = useTranslation();
const [text, setText] = useState('');
const getEmails = useCallback((text: string) => text.split(/[\ ,;]+/i).filter((val: string) => validateEmail(val)), []);
const adminRouter = useRoute('admin-settings');
const sendInvitationMutation = useSendInvitationEmailMutation();
const { data, isLoading } = useSmtpQuery();
const handleClick = () => {
sendInvitationMutation.mutate({ emails: getEmails(text) });
};
if (isLoading) {
return (
<ContextualbarContent>
<FormSkeleton />
</ContextualbarContent>
);
}
if (!data?.isSMTPConfigured) {
return (
<ContextualbarScrollableContent>
<States>
<StatesTitle>{t('SMTP_Server_Not_Setup_Title')}</StatesTitle>
<StatesSubtitle>{t('SMTP_Server_Not_Setup_Description')}</StatesSubtitle>
<StatesActions>
<StatesAction role='link' onClick={() => adminRouter.push({ group: 'Email' })}>
{t('Setup_SMTP')}
</StatesAction>
</StatesActions>
</States>
</ContextualbarScrollableContent>
);
}
return (
<>
<ContextualbarScrollableContent>
<Box is='h2' fontScale='h2' marginBlock={8}>
{t('Send_invitation_email')}
</Box>
<Box fontScale='p2' marginBlock={8}>
{t('Send_invitation_email_info')}
</Box>
<TextAreaInput rows={5} flexGrow={0} onChange={(e: ChangeEvent<HTMLInputElement>): void => setText(e.currentTarget.value)} />
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button icon='send' primary onClick={handleClick} disabled={!getEmails(text).length} alignItems='stretch' marginBlock={8}>
{t('Send')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default AdminInviteUsers;

View File

@@ -0,0 +1,29 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { ContextualbarEmptyContent, ContextualbarFooter } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
export type AdminUserCreatedProps = { uid: string };
const AdminUserCreated = ({ uid }: AdminUserCreatedProps) => {
const { t } = useTranslation();
const router = useRouter();
return (
<>
<ContextualbarEmptyContent icon='user' title={t('You_have_created_user')} />
<ContextualbarFooter>
<ButtonGroup stretch>
<Button type='reset' onClick={() => router.navigate(`/admin/users/new`)} flexBasis='0'>
{t('Add_more_users')}
</Button>
<Button primary onClick={() => router.navigate(`/admin/users/info/${uid}`)} flexBasis='0'>
{t('Done')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default AdminUserCreated;

View File

@@ -0,0 +1,564 @@
import type { AvatarObject, IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import {
Field,
FieldLabel,
FieldRow,
FieldError,
FieldHint,
TextInput,
TextAreaInput,
MultiSelectFiltered,
Box,
ToggleSwitch,
Icon,
FieldGroup,
Button,
Callout,
Skeleton,
} from '@rocket.chat/fuselage';
import type { SelectOption } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { UserCreateParamsPOST } from '@rocket.chat/rest-typings';
import { validateEmail } from '@rocket.chat/tools';
import { CustomFieldsForm, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
import {
useAccountsCustomFields,
useSetting,
useEndpoint,
useRouter,
useToastMessageDispatch,
useTranslation,
} from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useId, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans } from 'react-i18next';
import AdminUserSetRandomPasswordContent from './AdminUserSetRandomPasswordContent';
import AdminUserSetRandomPasswordRadios from './AdminUserSetRandomPasswordRadios';
import PasswordFieldSkeleton from './PasswordFieldSkeleton';
import { useSmtpQuery } from './hooks/useSmtpQuery';
import { useShowVoipExtension } from './useShowVoipExtension';
import { parseCSV } from '../../../../lib/utils/parseCSV';
import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor';
import { useEndpointMutation } from '../../../hooks/useEndpointMutation';
import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar';
import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants';
export type AdminUserFormProps = {
userData?: Serialized<IUser>;
onReload: () => void;
context: string;
refetchUserFormData?: () => void;
roleData: { roles: Serialized<IRole>[] } | undefined;
roleError: Error | null;
};
export type UserFormProps = Omit<
UserCreateParamsPOST & { avatar: AvatarObject; passwordConfirmation: string; freeSwitchExtension?: string },
'fields'
>;
const getInitialValue = ({
data,
defaultUserRoles,
isSmtpEnabled,
isVerificationNeeded,
isNewUserPage,
}: {
data?: Serialized<IUser>;
defaultUserRoles?: IUser['roles'];
isSmtpEnabled?: boolean;
isVerificationNeeded?: boolean;
isNewUserPage?: boolean;
}): UserFormProps => ({
roles: data?.roles ?? defaultUserRoles,
name: data?.name ?? '',
password: '',
username: data?.username ?? '',
bio: data?.bio ?? '',
nickname: data?.nickname ?? '',
email: (data?.emails?.length && data.emails[0].address) || '',
verified: isSmtpEnabled && isVerificationNeeded && ((data?.emails?.length && data.emails[0].verified) || false),
setRandomPassword: isNewUserPage && isSmtpEnabled,
requirePasswordChange: isNewUserPage && isSmtpEnabled && (data?.requirePasswordChange ?? true),
customFields: data?.customFields ?? {},
statusText: data?.statusText ?? '',
freeSwitchExtension: data?.freeSwitchExtension ?? '',
...(isNewUserPage && { joinDefaultChannels: true }),
sendWelcomeEmail: isSmtpEnabled,
avatar: '' as AvatarObject,
passwordConfirmation: '',
});
const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleData, roleError, ...props }: AdminUserFormProps) => {
const t = useTranslation();
const router = useRouter();
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const customFieldsMetadata = useAccountsCustomFields();
const defaultRoles = useSetting('Accounts_Registration_Users_Default_Roles', '');
const isVerificationNeeded = useSetting('Accounts_EmailVerification');
const defaultUserRoles = parseCSV(defaultRoles);
const { data, isLoading: isLoadingSmtpStatus } = useSmtpQuery();
const isSmtpEnabled = !!data?.isSMTPConfigured;
const isNewUserPage = context === 'new';
const {
control,
watch,
handleSubmit,
formState: { errors, isDirty },
setValue,
} = useForm({
values: getInitialValue({
data: userData,
defaultUserRoles,
isSmtpEnabled,
isNewUserPage,
isVerificationNeeded: !!isVerificationNeeded,
}),
});
const showVoipExtension = useShowVoipExtension();
const { avatar, username, setRandomPassword, password, name: userFullName } = watch();
const { mutateAsync: eventStats } = useEndpointMutation('POST', '/v1/statistics.telemetry');
const updateUserAction = useEndpoint('POST', '/v1/users.update');
const createUserAction = useEndpoint('POST', '/v1/users.create');
const availableRoles: SelectOption[] = useMemo(
() => roleData?.roles.map(({ _id, name, description }) => [_id, description || name]) || [],
[roleData],
);
const updateAvatar = useUpdateAvatar(avatar, userData?._id || '');
const handleUpdateUser = useMutation({
mutationFn: updateUserAction,
onSuccess: async ({ user: { _id } }) => {
dispatchToastMessage({ type: 'success', message: t('User_updated_successfully') });
await updateAvatar();
router.navigate(`/admin/users/info/${_id}`);
onReload();
refetchUserFormData?.();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleCreateUser = useMutation({
mutationFn: createUserAction,
onSuccess: async ({ user: { _id } }) => {
dispatchToastMessage({ type: 'success', message: t('New_user_manually_created') });
await eventStats({
params: [{ eventName: 'updateCounter', settingsId: 'Manual_Entry_User_Count' }],
});
queryClient.invalidateQueries({
queryKey: ['pendingUsersCount'],
refetchType: 'all',
});
router.navigate(`/admin/users/created/${_id}`);
onReload();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleSaveUser = useStableCallback(async (userFormPayload: UserFormProps) => {
const { avatar, passwordConfirmation, ...userFormData } = userFormPayload;
if (!isNewUserPage && userData?._id) {
return handleUpdateUser.mutateAsync({ userId: userData?._id, data: userFormData });
}
return handleCreateUser.mutateAsync({ ...userFormData, fields: '' });
});
const nameId = useId();
const usernameId = useId();
const voiceExtensionId = useId();
const emailId = useId();
const verifiedId = useId();
const statusTextId = useId();
const bioId = useId();
const nicknameId = useId();
const passwordId = useId();
const rolesId = useId();
const joinDefaultChannelsId = useId();
const sendWelcomeEmailId = useId();
const setRandomPasswordId = useId();
const [showCustomFields, setShowCustomFields] = useState(true);
if (!context) {
return null;
}
return (
<>
<ContextualbarScrollableContent {...props}>
<FieldGroup>
{!isNewUserPage && (
<Field>
<Controller
name='avatar'
control={control}
render={({ field: { onChange } }) => (
<UserAvatarEditor
currentUsername={userData?.username}
username={username}
etag={userData?.avatarETag}
setAvatarObj={onChange}
name={userFullName}
/>
)}
/>
</Field>
)}
{isNewUserPage && <Box color='hint'>{t('Manually_created_users_briefing')}</Box>}
<Field>
<FieldLabel htmlFor={emailId}>{t('Email')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='email'
rules={{
required: t('Required_field', { field: t('Email') }),
validate: (email) => (validateEmail(email) ? undefined : t('error-invalid-email-address')),
}}
render={({ field }) => (
<TextInput
{...field}
id={emailId}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={`${emailId}-error`}
error={errors.email?.message}
flexGrow={1}
/>
)}
/>
</FieldRow>
{errors?.email && (
<FieldError aria-live='assertive' id={`${emailId}-error`}>
{errors.email.message}
</FieldError>
)}
{isLoadingSmtpStatus ? (
<Skeleton width='full' height={26} />
) : (
<>
<FieldRow marginBlockStart={12}>
<Box display='flex' alignItems='center'>
<FieldLabel htmlFor={verifiedId} padding={0} disabled={!isSmtpEnabled || !isVerificationNeeded} margin={0}>
{t('Mark_email_as_verified')}
</FieldLabel>
<Icon
name='info-circled'
size='x20'
marginInlineStart={4}
title={t('Enable_to_bypass_email_verification')}
color='default'
/>
</Box>
<Controller
control={control}
name='verified'
render={({ field: { onChange, value } }) => (
<ToggleSwitch
id={verifiedId}
checked={value}
onChange={onChange}
disabled={!isSmtpEnabled || !isVerificationNeeded}
/>
)}
/>
</FieldRow>
{isVerificationNeeded && !isSmtpEnabled && (
<FieldHint id={`${verifiedId}-hint`}>
<Trans
i18nKey='Send_Email_SMTP_Warning'
components={{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href='/admin/settings/Email' />,
}}
/>
</FieldHint>
)}
{!isVerificationNeeded && (
<FieldHint id={`${verifiedId}-hint`}>
<Trans
i18nKey='Email_verification_isnt_required'
components={{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href='/admin/settings/Accounts' />,
}}
/>
</FieldHint>
)}
</>
)}
</Field>
<Field>
<FieldLabel htmlFor={nameId}>{t('Name')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='name'
rules={{ required: t('Required_field', { field: t('Name') }) }}
render={({ field }) => (
<TextInput
{...field}
id={nameId}
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby={`${nameId}-error`}
error={errors.name?.message}
flexGrow={1}
/>
)}
/>
</FieldRow>
{errors?.name && (
<FieldError aria-live='assertive' id={`${nameId}-error`}>
{errors.name.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={usernameId}>{t('Username')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='username'
rules={{ required: t('Required_field', { field: t('Username') }) }}
render={({ field }) => (
<TextInput
{...field}
id={usernameId}
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby={`${usernameId}-error`}
error={errors.username?.message}
flexGrow={1}
/>
)}
/>
</FieldRow>
{errors?.username && (
<FieldError aria-live='assertive' id={`${usernameId}-error`}>
{errors.username.message}
</FieldError>
)}
</Field>
{showVoipExtension && (
<Field>
<FieldLabel htmlFor={voiceExtensionId}>{t('Voice_call_extension')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='freeSwitchExtension'
render={({ field }) => <TextInput {...field} id={voiceExtensionId} flexGrow={1} />}
/>
</FieldRow>
</Field>
)}
<Field>
{isLoadingSmtpStatus ? (
<PasswordFieldSkeleton />
) : (
<>
<FieldLabel htmlFor={passwordId} marginBlockEnd={8}>
{t('Password')}
</FieldLabel>
<AdminUserSetRandomPasswordRadios
isNewUserPage={isNewUserPage}
setRandomPasswordId={setRandomPasswordId}
control={control}
isSmtpEnabled={isSmtpEnabled}
setValue={setValue}
/>
{!setRandomPassword && (
<AdminUserSetRandomPasswordContent
control={control}
setRandomPassword={setRandomPassword}
isNewUserPage={isNewUserPage}
passwordId={passwordId}
errors={errors}
password={password}
/>
)}
</>
)}
</Field>
<Field>
<FieldLabel htmlFor={rolesId}>{t('Roles')}</FieldLabel>
<FieldRow>
{roleError && <Callout>{roleError.message}</Callout>}
{!roleError && (
<Controller
control={control}
name='roles'
rules={{ required: t('Required_field', { field: t('Roles') }) }}
render={({ field: { onChange, value } }) => (
<MultiSelectFiltered
id={rolesId}
value={value}
onChange={onChange}
flexGrow={1}
placeholder={t('Select_role')}
options={availableRoles}
/>
)}
/>
)}
</FieldRow>
{errors?.roles && <FieldError>{errors.roles.message}</FieldError>}
</Field>
{isNewUserPage && (
<Field>
<Box display='flex' flexDirection='row' alignItems='center' justifyContent='space-between' flexGrow={1}>
<FieldLabel htmlFor={joinDefaultChannelsId}>{t('Join_default_channels')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='joinDefaultChannels'
render={({ field: { ref, onChange, value } }) => (
<ToggleSwitch id={joinDefaultChannelsId} ref={ref} onChange={onChange} checked={value} />
)}
/>
</FieldRow>
</Box>
</Field>
)}
<Field>
{isLoadingSmtpStatus ? (
<Skeleton width='full' height={26} />
) : (
<>
<Box display='flex' flexDirection='row' alignItems='center' justifyContent='space-between' flexGrow={1} marginBlockEnd={8}>
<FieldLabel htmlFor={sendWelcomeEmailId} disabled={!isSmtpEnabled}>
{t('Send_welcome_email')}
</FieldLabel>
<FieldRow>
<Controller
control={control}
name='sendWelcomeEmail'
defaultValue={isSmtpEnabled}
render={({ field: { onChange, value } }) => (
<ToggleSwitch
id={sendWelcomeEmailId}
aria-describedby={`${sendWelcomeEmailId}-hint`}
onChange={onChange}
checked={value}
disabled={!isSmtpEnabled}
/>
)}
/>
</FieldRow>
</Box>
{!isSmtpEnabled && (
<FieldHint id={`${sendWelcomeEmailId}-hint`} marginBlockStart={0}>
<Trans
i18nKey='Send_Email_SMTP_Warning'
components={{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href='/admin/settings/Email' />,
}}
/>
</FieldHint>
)}
</>
)}
</Field>
<Field>
<FieldLabel htmlFor={statusTextId}>{t('StatusMessage')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='statusText'
rules={{
maxLength: { value: USER_STATUS_TEXT_MAX_LENGTH, message: t('Max_length_is', { limit: USER_STATUS_TEXT_MAX_LENGTH }) },
}}
render={({ field }) => (
<TextInput
{...field}
id={statusTextId}
error={errors?.statusText?.message}
aria-invalid={errors.statusText ? 'true' : 'false'}
aria-describedby={`${statusTextId}-error`}
flexGrow={1}
/>
)}
/>
</FieldRow>
{errors?.statusText && (
<FieldError aria-live='assertive' id={`${statusTextId}-error`}>
{errors.statusText.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={bioId}>{t('Bio')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='bio'
rules={{ maxLength: { value: BIO_TEXT_MAX_LENGTH, message: t('Max_length_is', { limit: BIO_TEXT_MAX_LENGTH }) } }}
render={({ field }) => (
<TextAreaInput
{...field}
id={bioId}
rows={3}
error={errors?.bio?.message}
aria-invalid={errors.bio ? 'true' : 'false'}
aria-describedby={`${bioId}-error`}
flexGrow={1}
endAddon={<Icon name='edit' size='x20' alignSelf='center' />}
/>
)}
/>
</FieldRow>
{errors?.bio && (
<FieldError aria-live='assertive' id={`${bioId}-error`}>
{errors.bio.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={nicknameId}>{t('Nickname')}</FieldLabel>
<FieldRow>
<Controller control={control} name='nickname' render={({ field }) => <TextInput {...field} id={nicknameId} flexGrow={1} />} />
</FieldRow>
</Field>
{!!customFieldsMetadata.length && (
<>
<Button
fontScale='c2'
width='x140'
height='x28'
display='flex'
alignItems='center'
justifyContent='center'
onClick={() => setShowCustomFields((prevState) => !prevState)}
>
{showCustomFields ? t('Hide_additional_fields') : t('Show_additional_fields')}
</Button>
{showCustomFields && <CustomFieldsForm formName='customFields' formControl={control} metadata={customFieldsMetadata} />}
</>
)}
</FieldGroup>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSaveUser)} width='100%'>
{isNewUserPage ? t('Add_user') : t('Save_user')}
</Button>
</ContextualbarFooter>
</>
);
};
export default AdminUserForm;

View File

@@ -0,0 +1,63 @@
import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import { Box, Callout } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from 'react-i18next';
import AdminUserForm from './AdminUserForm';
import { FormSkeleton } from '../../../components/Skeleton';
import { useUserInfoQuery } from '../../../hooks/useUserInfoQuery';
export type AdminUserFormWithDataProps = {
uid: IUser['_id'];
onReload: () => void;
context: string;
roleData: { roles: Serialized<IRole>[] } | undefined;
roleError: Error | null;
};
const AdminUserFormWithData = ({ uid, onReload, context, roleData, roleError }: AdminUserFormWithDataProps) => {
const { t } = useTranslation();
const { data, isPending, isError, refetch } = useUserInfoQuery({ userId: uid });
const handleReload = useStableCallback(() => {
onReload();
refetch();
});
if (isPending) {
return (
<Box padding={24}>
<FormSkeleton />
</Box>
);
}
if (isError) {
return (
<Callout margin={16} type='danger'>
{t('User_not_found')}
</Callout>
);
}
if (data?.user && !!data.user.federated) {
return (
<Callout margin={16} type='danger'>
{t('Edit_Federated_User_Not_Allowed')}
</Callout>
);
}
return (
<AdminUserForm
userData={data?.user}
onReload={onReload}
context={context}
refetchUserFormData={handleReload}
roleData={roleData}
roleError={roleError}
/>
);
};
export default AdminUserFormWithData;

View File

@@ -0,0 +1,59 @@
import { ButtonGroup, IconButton } from '@rocket.chat/fuselage';
import { GenericMenu } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AdminUserAction, AdminUserInfoActionsProps } from './hooks/useAdminUserInfoActions';
import { useAdminUserInfoActions } from './hooks/useAdminUserInfoActions';
import { UserInfoAction } from '../../../components/UserInfo';
const AdminUserInfoActions = ({
username,
userId,
isFederatedUser,
isActive,
isAdmin,
tab,
onChange,
onReload,
}: AdminUserInfoActionsProps) => {
const { t } = useTranslation();
const { actions: actionsDefinition, menuActions: menuOptions } = useAdminUserInfoActions({
username,
userId,
isFederatedUser,
isActive,
isAdmin,
tab,
onChange,
onReload,
});
const menu = useMemo(() => {
if (!menuOptions) {
return null;
}
return (
<GenericMenu
key='menu'
button={<IconButton icon='kebab' secondary />}
title={t('More')}
sections={menuOptions}
placement='bottom-end'
small={false}
/>
);
}, [t, menuOptions]);
const actions = useMemo(() => {
const mapAction = ([key, { content, title, icon = 'kebab', onClick, disabled }]: [string, AdminUserAction]) => (
<UserInfoAction key={key} title={title} label={content} onClick={onClick} disabled={disabled} icon={icon} />
);
return [...actionsDefinition.map(mapAction), menu].filter(Boolean);
}, [actionsDefinition, menu]);
return <ButtonGroup align='center'>{actions}</ButtonGroup>;
};
export default AdminUserInfoActions;

View File

@@ -0,0 +1,136 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { ContextualbarContent } from '@rocket.chat/ui-client';
import { useSetting, useRolesDescription, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import AdminUserInfoActions from './AdminUserInfoActions';
import type { AdminUsersTab } from './AdminUsersPage';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import { FormSkeleton } from '../../../components/Skeleton';
import { UserCardRole } from '../../../components/UserCard';
import { UserInfo } from '../../../components/UserInfo';
import { UserStatus } from '../../../components/UserStatus';
import { UserStatusText } from '../../../components/UserStatusText';
import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified';
export type AdminUserInfoWithDataProps = {
uid: IUser['_id'];
onReload: () => void;
tab: AdminUsersTab;
};
const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProps) => {
const t = useTranslation();
const getRoles = useRolesDescription();
const approveManuallyUsers = useSetting('Accounts_ManuallyApproveNewUsers');
const getUsersInfo = useEndpoint('GET', '/v1/users.info');
const query = useMemo(() => ({ userId: uid }), [uid]);
const { data, isPending, error, refetch } = useQuery({
queryKey: ['users', query, 'admin'],
queryFn: async () => {
const usersInfo = await getUsersInfo(query);
return usersInfo;
},
meta: {
apiErrorToastMessage: true,
},
});
const onChange = useStableCallback(() => {
onReload();
refetch();
});
const user = useMemo(() => {
if (!data?.user) {
return;
}
const {
avatarETag,
name,
username,
phone,
createdAt,
roles = [],
status,
statusText,
statusExpiresAt,
bio,
utcOffset,
lastLogin,
nickname,
canViewAllInfo,
reason,
freeSwitchExtension,
abacAttributes,
} = data.user;
return {
avatarETag,
name,
username,
lastLogin,
roles: getRoles(roles).map((role, index) => <UserCardRole key={index}>{role}</UserCardRole>),
bio,
canViewAllInfo,
phone,
utcOffset,
customFields: {
...data.user.customFields,
...(approveManuallyUsers && !data.user.active && data.user.reason ? { Reason: data.user.reason } : undefined),
},
verified: getUserEmailVerified(data.user),
email: getUserEmailAddress(data.user),
createdAt,
status: <UserStatus status={status} />,
customStatus: <UserStatusText status={status} statusText={statusText} statusExpiresAt={statusExpiresAt} />,
nickname,
reason,
freeSwitchExtension,
abacAttributes,
};
}, [approveManuallyUsers, data, getRoles]);
if (isPending) {
return (
<ContextualbarContent>
<FormSkeleton />
</ContextualbarContent>
);
}
if (error || !user || !data?.user) {
return (
<ContextualbarContent paddingBlock={16}>
<Callout type='danger'>{t('User_not_found')}</Callout>
</ContextualbarContent>
);
}
return (
<UserInfo
{...user}
actions={
<AdminUserInfoActions
isActive={data?.user.active}
isAdmin={data?.user.roles?.includes('admin')}
userId={data?.user._id}
username={user.username}
isFederatedUser={!!data.user.federated}
onChange={onChange}
onReload={onReload}
tab={tab}
/>
}
/>
);
};
export default AdminUserInfoWithData;

View File

@@ -0,0 +1,122 @@
import { Box, FieldError, FieldLabel, FieldRow, PasswordInput, ToggleSwitch } from '@rocket.chat/fuselage';
import { PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import type { Control, FieldErrors } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { UserFormProps } from './AdminUserForm';
export type AdminUserSetRandomPasswordContentProps = {
control: Control<UserFormProps, any>;
setRandomPassword: boolean | undefined;
isNewUserPage: boolean;
passwordId: string;
errors: FieldErrors<UserFormProps>;
password: string;
};
const AdminUserSetRandomPasswordContent = ({
control,
setRandomPassword,
isNewUserPage,
passwordId,
errors,
password,
}: AdminUserSetRandomPasswordContentProps) => {
const { t } = useTranslation();
const passwordConfirmationId = useId();
const requirePasswordChangeId = useId();
const passwordVerifierId = useId();
const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation', true);
const passwordPlaceholder = useSetting('Accounts_PasswordPlaceholder', '');
const passwordConfirmationPlaceholder = useSetting('Accounts_ConfirmPasswordPlaceholder', '');
const passwordIsValid = useValidatePassword(password);
return (
<>
<Box
display='flex'
flexDirection='row'
alignItems='center'
justifyContent='space-between'
flexGrow={1}
marginBlockEnd={8}
marginBlockStart={12}
>
<FieldLabel htmlFor={requirePasswordChangeId}>{t('Require_password_change')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='requirePasswordChange'
render={({ field: { ref, onChange, value } }) => (
<ToggleSwitch ref={ref} id={requirePasswordChangeId} disabled={setRandomPassword} checked={value} onChange={onChange} />
)}
/>
</FieldRow>
</Box>
<FieldRow>
<Controller
control={control}
name='password'
rules={{
validate: () => (password?.length && !passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true),
required: isNewUserPage && t('Required_field', { field: t('Password') }),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={passwordId}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={`${passwordId}-error`}
error={errors.password?.message}
flexGrow={1}
placeholder={passwordPlaceholder || t('Password')}
/>
)}
/>
</FieldRow>
{errors?.password && (
<FieldError aria-live='assertive' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
{requiresPasswordConfirmation && (
<FieldRow>
<Controller
control={control}
name='passwordConfirmation'
rules={{
required: isNewUserPage && t('Required_field', { field: t('Confirm_password') }),
deps: ['password'],
validate: (confirmationPassword) => (password !== confirmationPassword ? t('Invalid_confirm_pass') : true),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={passwordConfirmationId}
aria-invalid={errors.passwordConfirmation ? 'true' : 'false'}
aria-describedby={`${passwordConfirmationId}-error`}
error={errors.passwordConfirmation?.message}
flexGrow={1}
placeholder={passwordConfirmationPlaceholder || t('Confirm_password')}
/>
)}
/>
</FieldRow>
)}
{errors?.passwordConfirmation && (
<FieldError aria-live='assertive' id={`${passwordConfirmationId}-error`}>
{errors.passwordConfirmation.message}
</FieldError>
)}
<PasswordVerifier password={password} id={passwordVerifierId} vertical />
</>
);
};
export default AdminUserSetRandomPasswordContent;

View File

@@ -0,0 +1,94 @@
import { Box, FieldHint, FieldLabel, FieldRow, RadioButton } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { Control, UseFormSetValue } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import type { UserFormProps } from './AdminUserForm';
type AdminUserSetRandomPasswordProps = {
isNewUserPage: boolean | undefined;
control: Control<UserFormProps, any>;
isSmtpEnabled: boolean | undefined;
setRandomPasswordId: string;
setValue: UseFormSetValue<UserFormProps>;
};
const AdminUserSetRandomPasswordRadios = ({
isNewUserPage,
control,
isSmtpEnabled,
setRandomPasswordId,
setValue,
}: AdminUserSetRandomPasswordProps) => {
const { t } = useTranslation();
const setPasswordManuallyId = useId();
const handleSetRandomPasswordChange = (onChange: (...event: any[]) => void, value: boolean) => {
setValue('requirePasswordChange', value);
onChange(value);
};
return (
<>
<Box display='flex' flexDirection='row' alignItems='center' flexGrow={1} marginBlockEnd={8}>
<FieldRow marginInlineEnd={8}>
<Controller
control={control}
name='setRandomPassword'
defaultValue={isSmtpEnabled && isNewUserPage}
render={({ field: { ref, onChange, value } }) => (
<RadioButton
ref={ref}
id={setRandomPasswordId}
aria-describedby={`${setRandomPasswordId}-hint`}
checked={value}
onChange={() => handleSetRandomPasswordChange(onChange, true)}
disabled={!isSmtpEnabled}
/>
)}
/>
</FieldRow>
<FieldLabel htmlFor={setRandomPasswordId} alignSelf='center' fontScale='p2' disabled={!isSmtpEnabled}>
{t('Set_randomly_and_send_by_email')}
</FieldLabel>
</Box>
{!isSmtpEnabled && (
<FieldHint id={`${setRandomPasswordId}-hint`} marginBlockEnd={16} marginBlockStart={0}>
<Trans
i18nKey='Send_Email_SMTP_Warning'
components={{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href='/admin/settings/Email' />,
}}
/>
</FieldHint>
)}
<Box display='flex' flexDirection='row' alignItems='center' flexGrow={1}>
<FieldRow marginInlineEnd={8}>
<Controller
control={control}
name='setRandomPassword'
defaultValue={!isNewUserPage}
render={({ field: { ref, onChange, value } }) => (
<RadioButton
ref={ref}
id={setPasswordManuallyId}
aria-describedby={`${setPasswordManuallyId}-hint`}
checked={!value}
onChange={() => handleSetRandomPasswordChange(onChange, false)}
/>
)}
/>
</FieldRow>
<FieldLabel htmlFor={setPasswordManuallyId} alignSelf='center' fontScale='p2'>
{t('Set_manually')}
</FieldLabel>
</Box>
</>
);
};
export default AdminUserSetRandomPasswordRadios;

View File

@@ -0,0 +1,32 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { ContextualbarScrollableContent, ContextualbarFooter, ContextualbarEmptyContent } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl';
const AdminUserUpgrade = () => {
const { t } = useTranslation();
const router = useRouter();
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' });
const openExternalLink = useExternalLink();
return (
<>
<ContextualbarScrollableContent height='full'>
<ContextualbarEmptyContent icon='warning' title={t('Seat_limit_reached')} subtitle={t('Seat_limit_reached_Description')} />
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={() => router.navigate('/admin/users')}>{t('Cancel')}</Button>
<Button primary role='link' onClick={() => openExternalLink(manageSubscriptionUrl)}>
{t('Buy_more_seats')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default AdminUserUpgrade;

View File

@@ -0,0 +1,201 @@
import type { LicenseInfo } from '@rocket.chat/core-typings';
import { Callout, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { OptionProp } from '@rocket.chat/ui-client';
import {
ExternalLink,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarDialog,
usePagination,
useSort,
Page,
PageHeader,
PageContent,
} from '@rocket.chat/ui-client';
import { useRouteParameter, useTranslation, useRouter, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from 'react-i18next';
import AdminInviteUsers from './AdminInviteUsers';
import AdminUserCreated from './AdminUserCreated';
import AdminUserForm from './AdminUserForm';
import AdminUserFormWithData from './AdminUserFormWithData';
import AdminUserInfoWithData from './AdminUserInfoWithData';
import AdminUserUpgrade from './AdminUserUpgrade';
import UsersPageHeaderContent from './UsersPageHeaderContent';
import UsersTable from './UsersTable';
import useFilteredUsers from './hooks/useFilteredUsers';
import usePendingUsersCount from './hooks/usePendingUsersCount';
import { useSeatsCap } from './useSeatsCap';
import { useLicenseLimitsByBehavior } from '../../../hooks/useLicenseLimitsByBehavior';
import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction';
import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl';
export type UsersFilters = {
text: string;
roles: OptionProp[];
};
export type AdminUsersTab = 'all' | 'active' | 'deactivated' | 'pending';
export type UsersTableSortingOption = 'name' | 'username' | 'emails.address' | 'status' | 'active' | 'freeSwitchExtension';
const AdminUsersPage = () => {
const t = useTranslation();
const seatsCap = useSeatsCap();
const isSeatsCapExceeded = useShouldPreventAction('activeUsers');
const { prevent_action: preventAction } = useLicenseLimitsByBehavior() ?? {};
const manageSubscriptionUrl = useCheckoutUrl();
const router = useRouter();
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const isCreateUserDisabled = useShouldPreventAction('activeUsers');
const getRoles = useEndpoint('GET', '/v1/roles.list');
const { data, error } = useQuery({
queryKey: ['roles'],
queryFn: async () => getRoles(),
});
const paginationData = usePagination();
const sortData = useSort<UsersTableSortingOption>('name');
const [tab, setTab] = useState<AdminUsersTab>('all');
const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '', roles: [] });
const searchTerm = useDebouncedValue(userFilters.text, 500);
const prevSearchTerm = useRef('');
const filteredUsersQueryResult = useFilteredUsers({
searchTerm,
prevSearchTerm,
sortData,
paginationData,
tab,
selectedRoles: useMemo(() => userFilters.roles.map((role) => role.id), [userFilters.roles]),
});
const pendingUsersCount = usePendingUsersCount(filteredUsersQueryResult.data?.users);
const handleReload = (): void => {
seatsCap?.reload();
filteredUsersQueryResult?.refetch();
};
const handleTabChange = (tab: AdminUsersTab) => {
setTab(tab);
paginationData.setCurrent(0);
sortData.setSort(tab === 'pending' ? 'active' : 'name', 'asc');
};
const handleCloseContextualbar = useStableCallback(() => router.navigate('/admin/users'));
useEffect(() => {
prevSearchTerm.current = searchTerm;
}, [searchTerm]);
const isRoutePrevented = useMemo(
() => context && ['new', 'invite'].includes(context) && isCreateUserDisabled,
[context, isCreateUserDisabled],
);
const toTranslationKey = (key: keyof LicenseInfo['limits']) => t(`subscription.callout.${key}`);
return (
<Page flexDirection='row'>
<Page>
<PageHeader title={t('Users')}>
<UsersPageHeaderContent isSeatsCapExceeded={isSeatsCapExceeded} seatsCap={seatsCap} />
</PageHeader>
{preventAction?.includes('activeUsers') && (
<Callout type='danger' title={t('subscription.callout.servicesDisruptionsOccurring')} marginBlockEnd={19} marginInline={24}>
<Trans
i18nKey='subscription.callout.description.limitsExceeded'
count={preventAction.length}
values={{ val: preventAction.map(toTranslationKey) }}
>
Your workspace exceeded the <>{preventAction.map(toTranslationKey)}</> license limit.
<ExternalLink
to={manageSubscriptionUrl({
target: 'callout',
action: 'prevent_action',
limits: preventAction.join(','),
})}
>
Manage your subscription
</ExternalLink>
to increase limits.
</Trans>
</Callout>
)}
<Tabs>
<TabsItem selected={!tab || tab === 'all'} onClick={() => handleTabChange('all')}>
{t('All')}
</TabsItem>
<TabsItem selected={tab === 'pending'} onClick={() => handleTabChange('pending')} display='flex' flexDirection='row'>
{`${t('Pending')} `}
{pendingUsersCount.isLoading && <Skeleton variant='circle' height='x16' width='x16' marginInlineStart={8} />}
{pendingUsersCount.isSuccess && `(${pendingUsersCount.data})`}
</TabsItem>
<TabsItem selected={tab === 'active'} onClick={() => handleTabChange('active')}>
{t('Active')}
</TabsItem>
<TabsItem selected={tab === 'deactivated'} onClick={() => handleTabChange('deactivated')}>
{t('Deactivated')}
</TabsItem>
</Tabs>
<PageContent>
<UsersTable
users={filteredUsersQueryResult.data?.users || []}
isLoading={filteredUsersQueryResult.isLoading}
isError={filteredUsersQueryResult.isError}
isSuccess={filteredUsersQueryResult.isSuccess}
total={filteredUsersQueryResult.data?.total || 0}
setUserFilters={setUserFilters}
paginationData={paginationData}
sortData={sortData}
tab={tab}
isSeatsCapExceeded={isSeatsCapExceeded}
roleData={data}
onReload={handleReload}
/>
</PageContent>
</Page>
{context && (
<ContextualbarDialog onClose={handleCloseContextualbar}>
<ContextualbarHeader>
{['new', 'created', 'upgrade'].includes(context) && <ContextualbarIcon name='user-plus' />}
<ContextualbarTitle>
{context === 'info' && t('User_Info')}
{context === 'edit' && t('Edit_User')}
{(context === 'new' || context === 'created') && t('New_user')}
{context === 'invite' && t('Invite_Users')}
</ContextualbarTitle>
<ContextualbarClose onClick={handleCloseContextualbar} />
</ContextualbarHeader>
{context === 'info' && id && <AdminUserInfoWithData uid={id} onReload={handleReload} tab={tab} />}
{context === 'edit' && id && (
<AdminUserFormWithData uid={id} onReload={handleReload} context={context} roleData={data} roleError={error} />
)}
{!isRoutePrevented && context === 'new' && (
<AdminUserForm onReload={handleReload} context={context} roleData={data} roleError={error} />
)}
{!isRoutePrevented && context === 'created' && id && <AdminUserCreated uid={id} />}
{!isRoutePrevented && context === 'invite' && <AdminInviteUsers />}
{isRoutePrevented && <AdminUserUpgrade />}
</ContextualbarDialog>
)}
</Page>
);
};
export default AdminUsersPage;

View File

@@ -0,0 +1,16 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import AdminUsersPage from './AdminUsersPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AdminUsersRoute = () => {
const canViewUserAdministration = usePermission('view-user-administration');
if (!canViewUserAdministration) {
return <NotAuthorizedPage />;
}
return <AdminUsersPage />;
};
export default AdminUsersRoute;

View File

@@ -0,0 +1,17 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
const PasswordFieldSkeleton = () => (
<>
<Skeleton width={65} height={20} />
<Box display='flex' flexDirection='row' justifyContent='space-between'>
<Skeleton width={232} height={26} />
<Skeleton width={20} height={20} variant='circle' />
</Box>
<Box display='flex' flexDirection='row' justifyContent='space-between'>
<Skeleton width={105} height={26} />
<Skeleton width={20} height={20} variant='circle' />
</Box>
</>
);
export default PasswordFieldSkeleton;

View File

@@ -0,0 +1,9 @@
import SeatsCapUsage from './SeatsCapUsage';
export default {
component: SeatsCapUsage,
};
export const Example = () => <SeatsCapUsage members={150} limit={300} />;
export const CloseToLimit = () => <SeatsCapUsage members={270} limit={300} />;
export const ReachedLimit = () => <SeatsCapUsage members={300} limit={300} />;

View File

@@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next';
import { GenericResourceUsage } from '../../../../components/GenericResourceUsage';
export type SeatsCapUsageProps = {
limit: number;
members: number;
};
const SeatsCapUsage = ({ limit, members }: SeatsCapUsageProps) => {
const { t } = useTranslation();
const percentage = Math.max(0, Math.min((100 / limit) * members, 100));
const seatsLeft = Math.max(0, limit - members);
return (
<GenericResourceUsage
title={t('Seats_Available', { seatsLeft })}
value={members}
max={limit}
percentage={percentage}
data-testid='seats-cap-progress-bar'
/>
);
};
export default SeatsCapUsage;

View File

@@ -0,0 +1 @@
export { default } from './SeatsCapUsage';

View File

@@ -0,0 +1,70 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import UsersPageHeaderContent from './UsersPageHeaderContent';
it('should show "Invite" button if has build-register-user permission', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: 1 }} />, {
wrapper: mockAppRoot().withJohnDoe().withPermission('bulk-register-user').build(),
});
expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument();
});
it('should hide "Invite" button if user doesnt have build-register-user permission', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: 1 }} />, {
wrapper: mockAppRoot().withJohnDoe().build(),
});
expect(screen.queryByRole('button', { name: 'Invite' })).not.toBeInTheDocument();
});
it('should show "New User" button if has create-user permission', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: 1 }} />, {
wrapper: mockAppRoot().withJohnDoe().withPermission('create-user').build(),
});
expect(screen.getByRole('button', { name: 'New_user' })).toBeInTheDocument();
});
it('should hide "New User" button if user doesnt have create-user permission', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: 1 }} />, {
wrapper: mockAppRoot().withJohnDoe().withPermission('create-user').build(),
});
expect(screen.getByRole('button', { name: 'New_user' })).toBeInTheDocument();
});
it('should show "Buy more seats" button if seats caps is exceeded', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded seatsCap={{ activeUsers: 1, maxActiveUsers: 1 }} />, {
wrapper: mockAppRoot().withJohnDoe().build(),
});
expect(screen.getByRole('link', { name: 'Buy_more_seats' })).toBeInTheDocument();
});
it('should hide "Buy more seats" button if seats caps is within limits', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: 1 }} />, {
wrapper: mockAppRoot().withJohnDoe().build(),
});
expect(screen.queryByRole('link', { name: 'Buy_more_seats' })).not.toBeInTheDocument();
});
it('should show seats available progress bar', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: 10 }} />, {
wrapper: mockAppRoot().withJohnDoe().build(),
});
expect(screen.getByTestId('seats-cap-progress-bar')).toBeInTheDocument();
});
it('should hide seats available progress bar if theres no limit', () => {
render(<UsersPageHeaderContent isSeatsCapExceeded={false} seatsCap={{ activeUsers: 1, maxActiveUsers: Number.POSITIVE_INFINITY }} />, {
wrapper: mockAppRoot().withJohnDoe().build(),
});
expect(screen.queryByTestId('seats-cap-progress-bar')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,62 @@
import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage';
import { usePermission, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import SeatsCapUsage from './SeatsCapUsage';
import type { SeatCapProps } from './useSeatsCap';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl';
export type UsersPageHeaderContentProps = {
isSeatsCapExceeded: boolean;
seatsCap?: Omit<SeatCapProps, 'reload'>;
};
const UsersPageHeaderContent = ({ isSeatsCapExceeded, seatsCap }: UsersPageHeaderContentProps) => {
const { t } = useTranslation();
const router = useRouter();
const canCreateUser = usePermission('create-user');
const canBulkCreateUser = usePermission('bulk-register-user');
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' });
const openExternalLink = useExternalLink();
const handleNewButtonClick = () => {
router.navigate('/admin/users/new');
};
const handleInviteButtonClick = () => {
router.navigate('/admin/users/invite');
};
return (
<>
{seatsCap && seatsCap.maxActiveUsers < Number.POSITIVE_INFINITY && (
<Margins inline={16}>
<SeatsCapUsage members={seatsCap.activeUsers} limit={seatsCap.maxActiveUsers} />
</Margins>
)}
<ButtonGroup>
{canBulkCreateUser && (
<Button icon='mail' onClick={handleInviteButtonClick} disabled={isSeatsCapExceeded}>
{t('Invite')}
</Button>
)}
{canCreateUser && (
<Button icon='user-plus' onClick={handleNewButtonClick} disabled={isSeatsCapExceeded}>
{t('New_user')}
</Button>
)}
{isSeatsCapExceeded && (
<Button primary role='link' onClick={() => openExternalLink(manageSubscriptionUrl)}>
{t('Buy_more_seats')}
</Button>
)}
</ButtonGroup>
</>
);
};
export default UsersPageHeaderContent;

View File

@@ -0,0 +1,21 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './UsersTable.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: mockAppRoot().build() });
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
// TODO: Needed to skip `button-name` because fuselage‘s `Pagination` buttons are missing names
const results = await axe(container, { rules: { 'button-name': { enabled: false } } });
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,93 @@
import { UserStatus } from '@rocket.chat/core-typings';
import type { Meta } from '@storybook/react';
import UsersTable from './UsersTable';
import { createMockedPagination } from '../../../../../tests/mocks/data';
export default {
component: UsersTable,
} satisfies Meta<typeof UsersTable>;
const mockedUsers = [
{
_id: '1',
username: 'example.user',
name: 'Example User',
emails: [{ address: 'example@rocket.chat', verified: true }],
status: UserStatus.ONLINE,
roles: ['user'],
active: true,
type: '',
},
{
_id: '2',
username: 'john.doe',
name: 'John Doe',
emails: [{ address: 'john@rocket.chat', verified: true }],
status: UserStatus.OFFLINE,
roles: ['admin', 'user'],
active: true,
type: '',
},
{
_id: '3',
username: 'sarah.smith',
name: 'Sarah Smith',
emails: [{ address: 'sarah@rocket.chat', verified: true }],
status: UserStatus.AWAY,
roles: ['user'],
active: true,
type: '',
},
{
_id: '4',
username: 'mike.wilson',
name: 'Mike Wilson',
emails: [{ address: 'mike@rocket.chat', verified: false }],
status: UserStatus.BUSY,
roles: ['user'],
active: true,
type: '',
},
{
_id: '5',
username: 'emma.davis',
name: 'Emma Davis',
emails: [{ address: 'emma@rocket.chat', verified: true }],
status: UserStatus.ONLINE,
roles: ['moderator', 'user'],
active: true,
type: '',
},
];
const paginationData = createMockedPagination(mockedUsers.length, 5);
export const Default = {
args: {
users: mockedUsers,
total: 5,
isLoading: false,
isSuccess: true,
tab: 'all',
paginationData,
},
};
export const Loading = {
args: {
isLoading: true,
paginationData,
},
};
export const NoResults = {
args: {
users: [],
total: 0,
isLoading: false,
isError: false,
isSuccess: true,
paginationData,
},
};

View File

@@ -0,0 +1,217 @@
import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import { Pagination } from '@rocket.chat/fuselage';
import { useStableCallback, useBreakpoints } from '@rocket.chat/fuselage-hooks';
import type { DefaultUserInfo } from '@rocket.chat/rest-typings';
import {
GenericTable,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableBody,
GenericTableLoadingTable,
} from '@rocket.chat/ui-client';
import type { usePagination, useSort } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import type { Dispatch, SetStateAction, MouseEvent, KeyboardEvent } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import UsersTableFilters from './UsersTableFilters';
import UsersTableRow from './UsersTableRow';
import GenericNoResults from '../../../../components/GenericNoResults';
import type { AdminUsersTab, UsersFilters, UsersTableSortingOption } from '../AdminUsersPage';
import { useShowVoipExtension } from '../useShowVoipExtension';
export type UsersTableProps = {
tab: AdminUsersTab;
roleData: { roles: Serialized<IRole>[] } | undefined;
users: Serialized<DefaultUserInfo>[];
total: number;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
onReload: () => void;
setUserFilters: Dispatch<SetStateAction<UsersFilters>>;
paginationData: ReturnType<typeof usePagination>;
sortData: ReturnType<typeof useSort<UsersTableSortingOption>>;
isSeatsCapExceeded: boolean;
};
const UsersTable = ({
users,
total,
isLoading,
isError,
isSuccess,
setUserFilters,
roleData,
tab,
onReload,
paginationData,
sortData,
isSeatsCapExceeded,
}: UsersTableProps) => {
const { t } = useTranslation();
const router = useRouter();
const breakpoints = useBreakpoints();
const isMobile = !breakpoints.includes('xl');
const isLaptop = !breakpoints.includes('xxl');
const showVoipExtension = useShowVoipExtension();
const { current, itemsPerPage, setCurrent, setItemsPerPage, ...paginationProps } = paginationData;
const isKeyboardEvent = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>): event is KeyboardEvent<HTMLElement> => {
return (event as KeyboardEvent<HTMLElement>).key !== undefined;
};
const handleClickOrKeyDown = useStableCallback((id: IUser['_id'], e: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>): void => {
e.stopPropagation();
const keyboardSubmitKeys = ['Enter', ' '];
if (isKeyboardEvent(e) && !keyboardSubmitKeys.includes(e.key)) {
return;
}
router.navigate({
name: 'admin-users',
params: {
context: 'info',
id,
},
});
});
const headers = useMemo(
() => [
<GenericTableHeaderCell
key='name'
direction={sortData?.sortDirection}
active={sortData?.sortBy === 'name'}
onClick={sortData?.setSort}
sort='name'
>
{t('Name')}
</GenericTableHeaderCell>,
<GenericTableHeaderCell
key='username'
direction={sortData?.sortDirection}
active={sortData?.sortBy === 'username'}
onClick={sortData?.setSort}
sort='username'
>
{t('Username')}
</GenericTableHeaderCell>,
!isLaptop && (
<GenericTableHeaderCell
key='email'
direction={sortData?.sortDirection}
active={sortData?.sortBy === 'emails.address'}
onClick={sortData?.setSort}
sort='emails.address'
>
{t('Email')}
</GenericTableHeaderCell>
),
!isLaptop && <GenericTableHeaderCell key='roles'>{t('Roles')}</GenericTableHeaderCell>,
tab === 'all' && !isMobile && (
<GenericTableHeaderCell
key='status'
direction={sortData?.sortDirection}
active={sortData?.sortBy === 'status'}
onClick={sortData?.setSort}
sort='status'
>
{t('Registration_status')}
</GenericTableHeaderCell>
),
tab === 'pending' && !isMobile && (
<GenericTableHeaderCell
key='action'
direction={sortData?.sortDirection}
active={sortData?.sortBy === 'active'}
onClick={sortData?.setSort}
sort='active'
>
{t('Pending_action')}
</GenericTableHeaderCell>
),
tab === 'all' && showVoipExtension && (
<GenericTableHeaderCell
width='x180'
key='freeSwitchExtension'
direction={sortData?.sortDirection}
active={sortData?.sortBy === 'freeSwitchExtension'}
onClick={sortData?.setSort}
sort='freeSwitchExtension'
>
{t('Voice_call_extension')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell key='actions' width={tab === 'pending' ? 'x204' : 'x50'}>
{t('Actions')}
</GenericTableHeaderCell>,
],
[sortData, t, isLaptop, tab, isMobile, showVoipExtension],
);
return (
<>
<UsersTableFilters roleData={roleData} setUsersFilters={setUserFilters} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingTable headerCells={5} />
</GenericTableBody>
</GenericTable>
)}
{isError && (
<GenericNoResults icon='warning' title={t('Something_went_wrong')} buttonTitle={t('Reload_page')} buttonAction={onReload} />
)}
{isSuccess && users.length === 0 && (
<GenericNoResults
icon='user'
title={t('Users_Table_Generic_No_users', { status: tab !== 'all' ? t(tab as TranslationKey) : '' })}
description={t(`Users_Table_no_${tab}_users_description`)}
/>
)}
{isSuccess && users.length > 0 && (
<>
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{users.map((user) => (
<UsersTableRow
key={user._id}
tab={tab}
user={user}
isMobile={isMobile}
isLaptop={isLaptop}
isSeatsCapExceeded={isSeatsCapExceeded}
showVoipExtension={showVoipExtension}
onReload={onReload}
onClick={handleClickOrKeyDown}
/>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={total}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default UsersTable;

View File

@@ -0,0 +1,102 @@
import type { IRole, Serialized } from '@rocket.chat/core-typings';
import { Box, Icon, Margins, TextInput } from '@rocket.chat/fuselage';
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
import type { OptionProp } from '@rocket.chat/ui-client';
import { MultiSelectCustom } from '@rocket.chat/ui-client';
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { UsersFilters } from '../AdminUsersPage';
export type UsersTableFiltersProps = {
setUsersFilters: Dispatch<SetStateAction<UsersFilters>>;
roleData: { roles: Serialized<IRole>[] } | undefined;
};
const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps) => {
const { t } = useTranslation();
const [selectedRoles, setSelectedRoles] = useState<OptionProp[]>([]);
const [text, setText] = useState('');
const handleSearchTextChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setText(event.currentTarget.value);
setUsersFilters({ text: event.currentTarget.value, roles: selectedRoles });
},
[selectedRoles, setUsersFilters],
);
const handleRolesChange = useCallback(
(roles: OptionProp[]) => {
setUsersFilters({ text, roles });
setSelectedRoles(roles);
},
[setUsersFilters, text],
);
const userRolesFilterStructure = useMemo(
() => [
{
id: 'filter_by_role',
text: 'Filter_by_role',
},
{
id: 'all',
text: 'All_roles',
checked: false,
},
...(roleData
? roleData.roles.map((role) => ({
id: role._id,
text: role.description || role.name || role._id,
checked: false,
}))
: []),
],
[roleData],
);
const breakpoints = useBreakpoints();
const isLargeScreenOrBigger = breakpoints.includes('lg');
const fixFiltersSize = isLargeScreenOrBigger ? { maxWidth: 'x224', minWidth: 'x224' } : null;
return (
<Box
marginBlock={16}
is='form'
onSubmit={(event) => {
event.preventDefault();
}}
display='flex'
flexWrap='wrap'
alignItems='center'
>
<Margins inlineEnd={isLargeScreenOrBigger ? 16 : 0}>
<TextInput
placeholder={t('Search_Users')}
endAddon={<Icon name='magnifier' size='x20' />}
onChange={handleSearchTextChange}
value={text}
flexGrow={2}
minWidth='x220'
aria-label={t('Search_Users')}
/>
</Margins>
<Box marginBlock={4} width={isLargeScreenOrBigger ? 'unset' : '100%'}>
<MultiSelectCustom
dropdownOptions={userRolesFilterStructure}
defaultTitle='All_roles'
selectedOptionsTitle='Roles'
setSelectedOptions={handleRolesChange}
selectedOptions={selectedRoles}
searchBarText='Search_roles'
{...fixFiltersSize}
/>
</Box>
</Box>
);
};
export default UsersTableFilters;

View File

@@ -0,0 +1,202 @@
import { UserStatus as Status } from '@rocket.chat/core-typings';
import type { IUser, Serialized } from '@rocket.chat/core-typings';
import { Box, Button } from '@rocket.chat/fuselage';
import type { DefaultUserInfo } from '@rocket.chat/rest-typings';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { GenericMenu, GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import type { KeyboardEvent, MouseEvent } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { UserStatus } from '../../../../components/UserStatus';
import { Roles } from '../../../../stores';
import type { AdminUsersTab } from '../AdminUsersPage';
import { useChangeAdminStatusAction } from '../hooks/useChangeAdminStatusAction';
import { useChangeUserStatusAction } from '../hooks/useChangeUserStatusAction';
import { useDeleteUserAction } from '../hooks/useDeleteUserAction';
import { useResetE2EEKeyAction } from '../hooks/useResetE2EEKeyAction';
import { useResetTOTPAction } from '../hooks/useResetTOTPAction';
import { useSendWelcomeEmailMutation } from '../hooks/useSendWelcomeEmailMutation';
export type UsersTableRowProps = {
user: Serialized<DefaultUserInfo>;
tab: AdminUsersTab;
isMobile: boolean;
isLaptop: boolean;
onReload: () => void;
onClick: (id: IUser['_id'], e: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
isSeatsCapExceeded: boolean;
showVoipExtension: boolean;
};
const UsersTableRow = ({ user, tab, isMobile, isLaptop, isSeatsCapExceeded, showVoipExtension, onClick, onReload }: UsersTableRowProps) => {
const { t } = useTranslation();
const {
_id,
emails,
username = '',
name = '',
roles,
status,
active,
avatarETag,
lastLogin,
type,
freeSwitchExtension,
federated,
} = user;
const registrationStatusText = useMemo(() => {
const usersExcludedFromPending = ['bot', 'app'];
if (!active && lastLogin) {
return t('Deactivated');
}
if (federated) {
return t('Federated');
}
if (!lastLogin && !usersExcludedFromPending.includes(type)) {
return t('Pending');
}
if (active && lastLogin) {
return t('Active');
}
}, [active, lastLogin, t, type, federated]);
const roleNames = Roles.use((state) => {
return roles
?.map((roleId) => state.get(roleId)?.name)
.filter((roleName): roleName is string => !!roleName)
.join(', ');
});
const userId = user._id;
const isAdmin = user.roles?.includes('admin');
const isActive = user.active;
const isFederatedUser = !!user.federated;
const changeAdminStatusAction = useChangeAdminStatusAction(username, isAdmin, onReload);
const changeUserStatusAction = useChangeUserStatusAction(userId, isActive, onReload);
const deleteUserAction = useDeleteUserAction(userId, onReload, onReload);
const resetTOTPAction = useResetTOTPAction(userId);
const resetE2EKeyAction = useResetE2EEKeyAction(userId);
const resendWelcomeEmail = useSendWelcomeEmailMutation();
const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser;
const actions = useMemo(
() => ({
...(isNotPendingDeactivatedNorFederated &&
changeAdminStatusAction && {
changeAdminStatusAction,
}),
...(isNotPendingDeactivatedNorFederated &&
resetE2EKeyAction && {
resetE2EKeyAction,
}),
...(isNotPendingDeactivatedNorFederated && resetTOTPAction && { resetTOTPAction }),
...(changeUserStatusAction &&
!isFederatedUser && {
changeUserStatusAction,
}),
...(deleteUserAction && {
deleteUserAction,
}),
}),
[
changeAdminStatusAction,
changeUserStatusAction,
deleteUserAction,
isFederatedUser,
isNotPendingDeactivatedNorFederated,
resetE2EKeyAction,
resetTOTPAction,
],
);
const menuOptions = Object.entries(actions).map(([_key, item]) => {
return {
...item,
id: item.content || item.title || '',
content: item.content || item.title,
};
});
const handleResendWelcomeEmail = () => resendWelcomeEmail.mutateAsync({ email: emails?.[0].address });
return (
<GenericTableRow onKeyDown={(e) => onClick(_id, e)} onClick={(e) => onClick(_id, e)} tabIndex={0} role='link' action qa-user-id={_id}>
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
{username && <UserAvatar size={isMobile || isLaptop ? 'x28' : 'x40'} username={username} etag={avatarETag} />}
<Box display='flex' flexGrow={1} flexShrink={1} flexBasis='0%' alignSelf='center' alignItems='center' withTruncatedText>
<Box marginInline={8}>
<UserStatus status={status || Status.OFFLINE} />
</Box>
<Box fontScale='p2' withTruncatedText>
{name || username}
</Box>
</Box>
</Box>
</GenericTableCell>
<GenericTableCell>
<Box fontScale='p2m' color='hint' withTruncatedText>
{username}
</Box>
</GenericTableCell>
{!isLaptop && <GenericTableCell withTruncatedText>{emails?.length && emails[0].address}</GenericTableCell>}
{!isLaptop && <GenericTableCell withTruncatedText>{roleNames}</GenericTableCell>}
{tab === 'all' && !isMobile && (
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
{registrationStatusText}
</GenericTableCell>
)}
{tab === 'pending' && !isMobile && (
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
<Box display='flex' flexDirection='row' alignContent='flex-end'>
{active ? t('User_first_log_in') : t('Activation')}
</Box>
</GenericTableCell>
)}
{tab === 'all' && showVoipExtension && (
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
{freeSwitchExtension || t('Not_assigned')}
</GenericTableCell>
)}
<GenericTableCell
onClick={(e): void => {
e.stopPropagation();
}}
>
<Box display='flex' justifyContent='flex-end'>
{tab === 'pending' && (
<>
{active ? (
<Button small secondary onClick={handleResendWelcomeEmail}>
{t('Resend_welcome_email')}
</Button>
) : (
<Button small primary onClick={changeUserStatusAction?.onClick} disabled={isSeatsCapExceeded}>
{t('Activate')}
</Button>
)}
</>
)}
<GenericMenu detached title={t('More_actions')} sections={[{ title: '', items: menuOptions }]} placement='bottom-end' />
</Box>
</GenericTableCell>
</GenericTableRow>
);
};
export default UsersTableRow;

View File

@@ -0,0 +1 @@
export { default } from './UsersTable';

View File

@@ -0,0 +1,160 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { IconProps } from '@rocket.chat/fuselage';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { usePermission, useRoute } from '@rocket.chat/ui-contexts';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AdminUsersTab } from '../AdminUsersPage';
import { useChangeAdminStatusAction } from './useChangeAdminStatusAction';
import { useChangeUserStatusAction } from './useChangeUserStatusAction';
import { useDeleteUserAction } from './useDeleteUserAction';
import { useResetE2EEKeyAction } from './useResetE2EEKeyAction';
import { useResetTOTPAction } from './useResetTOTPAction';
export type AdminUserAction = {
type?: string;
content: string;
icon?: IconProps['name'];
title?: string;
variant?: 'danger';
onClick: () => void;
disabled?: boolean;
};
type AdminUserMenuAction = {
id: string;
title: string;
items: GenericMenuItemProps[];
}[];
export type AdminUserInfoActionsProps = {
username: IUser['username'];
userId: IUser['_id'];
isFederatedUser: IUser['federated'];
isActive: boolean;
isAdmin: boolean;
tab: AdminUsersTab;
onChange: () => void;
onReload: () => void;
};
const useAdminUserInfoActionsSpread = (actions: Record<string, AdminUserAction>, size = 2) => {
const actionSpread = useMemo(() => {
const entries = Object.entries(actions);
const options = entries.slice(0, size);
const slicedOptions = entries.slice(size, entries.length);
const menuActions = slicedOptions.reduce((acc, [_key, item]) => {
const group = item.type ? item.type : '';
const section = acc.find((section: { id: string }) => section.id === group);
const newItem = {
...item,
id: item.content || item.title || '',
content: item.content || item.title,
};
if (section) {
section.items.push(newItem);
return acc;
}
const newSection = { id: group, title: '', items: [newItem] };
acc.push(newSection);
return acc;
}, [] as AdminUserMenuAction);
return { actions: options, menuActions };
}, [size, actions]);
return actionSpread;
};
export const useAdminUserInfoActions = ({
username,
userId,
isFederatedUser,
isActive,
isAdmin,
tab,
onChange,
onReload,
}: AdminUserInfoActionsProps) => {
const { t } = useTranslation();
const directRoute = useRoute('direct');
const userRoute = useRoute('admin-users');
const canDirectMessage = usePermission('create-d');
const canEditOtherUserInfo = usePermission('edit-other-user-info');
const changeAdminStatusAction = useChangeAdminStatusAction(username, isAdmin, onChange);
const changeUserStatusAction = useChangeUserStatusAction(userId, isActive, onChange);
const deleteUserAction = useDeleteUserAction(userId, onChange, onReload);
const resetTOTPAction = useResetTOTPAction(userId);
const resetE2EKeyAction = useResetE2EEKeyAction(userId);
const directMessageClick = useCallback(
() =>
username &&
directRoute.push({
rid: username,
}),
[directRoute, username],
);
const editUserClick = useCallback(
() =>
userRoute.push({
context: 'edit',
id: userId,
}),
[userId, userRoute],
);
const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser;
const options = useMemo(
() => ({
...(canDirectMessage && {
directMessage: {
icon: 'balloon',
content: t('Direct_Message'),
onClick: directMessageClick,
} as AdminUserAction,
}),
...(canEditOtherUserInfo && {
editUsereditUser: {
icon: 'edit',
content: t('Edit'),
title: isFederatedUser ? t('Edit_Federated_User_Not_Allowed') : t('Edit'),
onClick: editUserClick,
disabled: isFederatedUser,
} as AdminUserAction,
}),
...(isNotPendingDeactivatedNorFederated && changeAdminStatusAction && { changeAdminStatusAction }),
...(isNotPendingDeactivatedNorFederated && resetE2EKeyAction && { resetE2EKeyAction }),
...(isNotPendingDeactivatedNorFederated && resetTOTPAction && { resetTOTPAction }),
...(changeUserStatusAction && !isFederatedUser && { changeUserStatusAction }),
...(deleteUserAction && { deleteUserAction }),
}),
[
canDirectMessage,
canEditOtherUserInfo,
changeAdminStatusAction,
changeUserStatusAction,
deleteUserAction,
directMessageClick,
editUserClick,
isFederatedUser,
isNotPendingDeactivatedNorFederated,
resetE2EKeyAction,
resetTOTPAction,
t,
],
);
return useAdminUserInfoActionsSpread(options);
};

View File

@@ -0,0 +1,43 @@
import type { IUser } from '@rocket.chat/core-typings';
import { useToastMessageDispatch, usePermission, useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { AdminUserAction } from './useAdminUserInfoActions';
export const useChangeAdminStatusAction = (
username: IUser['username'] = '',
isAdmin: boolean,
onChange: () => void,
): AdminUserAction | undefined => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const addUserToRoleEndpoint = useEndpoint('POST', '/v1/roles.addUserToRole');
const removeUserFromRoleEndpoint = useEndpoint('POST', '/v1/roles.removeUserFromRole');
const canAssignAdminRole = usePermission('assign-admin-role');
const changeAdminStatus = useCallback(async () => {
try {
if (isAdmin) {
await removeUserFromRoleEndpoint({ roleId: 'admin', username });
dispatchToastMessage({ type: 'success', message: t('User_is_no_longer_an_admin') });
onChange();
return;
}
await addUserToRoleEndpoint({ roleId: 'admin', username });
dispatchToastMessage({ type: 'success', message: t('User_is_now_an_admin') });
onChange();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
}, [isAdmin, removeUserFromRoleEndpoint, username, dispatchToastMessage, addUserToRoleEndpoint, t, onChange]);
return canAssignAdminRole
? {
icon: 'key',
content: isAdmin ? t('Remove_Admin') : t('Make_Admin'),
onClick: changeAdminStatus,
}
: undefined;
};

View File

@@ -0,0 +1,53 @@
import type { IUser } from '@rocket.chat/core-typings';
import { useToastMessageDispatch, useTranslation, useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { AdminUserAction } from './useAdminUserInfoActions';
import { useConfirmOwnerChanges } from './useConfirmOwnerChanges';
export const useChangeUserStatusAction = (userId: IUser['_id'], isActive: boolean, onChange: () => void): AdminUserAction | undefined => {
const t = useTranslation();
const confirmOwnerChanges = useConfirmOwnerChanges();
const dispatchToastMessage = useToastMessageDispatch();
const changeActiveStatusRequest = useEndpoint('POST', '/v1/users.setActiveStatus');
const canEditOtherUserActiveStatus = usePermission('edit-other-user-active-status');
const changeActiveStatusMessage = isActive ? 'User_has_been_deactivated' : 'User_has_been_activated';
const activeStatusQuery = useMemo(
() => ({
userId,
activeStatus: !isActive,
confirmRelinquish: false,
}),
[userId, isActive],
);
const changeActiveStatus = (): Promise<void> =>
confirmOwnerChanges(
async (confirm = false) => {
if (confirm) {
activeStatusQuery.confirmRelinquish = confirm;
}
try {
await changeActiveStatusRequest(activeStatusQuery);
dispatchToastMessage({ type: 'success', message: t(changeActiveStatusMessage) });
onChange();
} catch (error) {
throw error;
}
},
{
confirmText: t('Yes_deactivate_it'),
},
onChange,
);
return canEditOtherUserActiveStatus
? {
icon: 'user',
content: isActive ? t('Deactivate') : t('Activate'),
onClick: changeActiveStatus,
}
: undefined;
};

View File

@@ -0,0 +1,40 @@
import { useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import ConfirmOwnerChangeWarningModal from '../../../../components/ConfirmOwnerChangeModal';
export const useConfirmOwnerChanges = (): ((
action: (confirm?: boolean) => Promise<void>,
modalProps: Pick<ComponentProps<typeof ConfirmOwnerChangeWarningModal>, 'contentTitle' | 'confirmText'>,
onChange: () => void,
) => Promise<void>) => {
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
return async (action, modalProps, onChange): Promise<void> => {
try {
return await action();
} catch (error: any) {
if (error.errorType === 'user-last-owner') {
const { shouldChangeOwner, shouldBeRemoved } = error.details;
const handleConfirm = async (): Promise<void> => {
await action(true);
setModal();
onChange();
};
return setModal(
<ConfirmOwnerChangeWarningModal
{...modalProps}
shouldChangeOwner={shouldChangeOwner}
shouldBeRemoved={shouldBeRemoved}
onConfirm={handleConfirm}
onCancel={(): void => setModal()}
/>,
);
}
dispatchToastMessage({ type: 'error', message: error });
}
};
};

View File

@@ -0,0 +1,75 @@
import type { IUser } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import {
useSetModal,
useToastMessageDispatch,
useRoute,
useSetting,
usePermission,
useEndpoint,
useTranslation,
} from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import type { AdminUserAction } from './useAdminUserInfoActions';
import { useConfirmOwnerChanges } from './useConfirmOwnerChanges';
export const useDeleteUserAction = (userId: IUser['_id'], onChange: () => void, onReload: () => void): AdminUserAction | undefined => {
const t = useTranslation();
const setModal = useSetModal();
const userRoute = useRoute('admin-users');
const canDeleteUser = usePermission('delete-user');
const erasureType = useSetting('Message_ErasureType');
const confirmOwnerChanges = useConfirmOwnerChanges();
const dispatchToastMessage = useToastMessageDispatch();
const handleDeletedUser = (): void => {
setModal();
userRoute.push({});
onReload();
};
const deleteUserQuery = useMemo(() => ({ userId, confirmRelinquish: false }), [userId]);
const deleteUserEndpoint = useEndpoint('POST', '/v1/users.delete');
const deleteUser = (): Promise<void> =>
confirmOwnerChanges(
async (confirm = false) => {
if (confirm) {
deleteUserQuery.confirmRelinquish = confirm;
}
try {
await deleteUserEndpoint(deleteUserQuery);
dispatchToastMessage({ type: 'success', message: t('User_has_been_deleted') });
handleDeletedUser();
} catch (error) {
throw error;
}
},
{
contentTitle: t(`Delete_User_Warning_${erasureType}` as TranslationKey),
confirmText: t('Delete'),
},
onChange,
);
const confirmDeleteUser = useStableCallback(() => {
setModal(
<GenericModal variant='danger' onConfirm={deleteUser} onCancel={(): void => setModal()} confirmText={t('Delete')}>
{t(`Delete_User_Warning_${erasureType}` as TranslationKey)}
</GenericModal>,
);
});
return canDeleteUser
? {
icon: 'trash',
content: t('Delete'),
onClick: confirmDeleteUser,
variant: 'danger',
}
: undefined;
};

View File

@@ -0,0 +1,63 @@
import type { UsersListStatusParamsGET } from '@rocket.chat/rest-typings';
import type { usePagination, useSort } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { MutableRefObject } from 'react';
import { useMemo } from 'react';
import type { AdminUsersTab, UsersTableSortingOption } from '../AdminUsersPage';
type UseFilteredUsersOptions = {
searchTerm: string;
prevSearchTerm: MutableRefObject<string>;
tab: AdminUsersTab;
paginationData: ReturnType<typeof usePagination>;
sortData: ReturnType<typeof useSort<UsersTableSortingOption>>;
selectedRoles: string[];
};
const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab, selectedRoles }: UseFilteredUsersOptions) => {
const { setCurrent, itemsPerPage, current } = paginationData;
const { sortBy, sortDirection } = sortData;
const payload = useMemo(() => {
if (searchTerm !== prevSearchTerm.current) {
setCurrent(0);
}
const listUsersPayload: Partial<Record<AdminUsersTab, UsersListStatusParamsGET>> = {
all: {},
pending: {
type: 'user',
status: 'deactivated',
inactiveReason: ['pending_approval'],
},
active: {
status: 'active',
},
deactivated: {
status: 'deactivated',
inactiveReason: ['deactivated', 'idle_too_long'],
},
};
return {
...listUsersPayload[tab],
searchTerm,
roles: selectedRoles,
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
count: itemsPerPage,
offset: searchTerm === prevSearchTerm.current ? current : 0,
};
}, [current, itemsPerPage, prevSearchTerm, searchTerm, selectedRoles, setCurrent, sortBy, sortDirection, tab]);
const getUsers = useEndpoint('GET', '/v1/users.listByStatus');
const usersListQueryResult = useQuery({
queryKey: ['users.list', payload, tab],
queryFn: async () => getUsers(payload),
meta: {
apiErrorToastMessage: true,
},
});
return usersListQueryResult;
};
export default useFilteredUsers;

View File

@@ -0,0 +1,28 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { DefaultUserInfo, UsersListStatusParamsGET } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
const usePendingUsersCount = (users: Serialized<DefaultUserInfo[]> | undefined) => {
const getUsers = useEndpoint('GET', '/v1/users.listByStatus');
return useQuery({
queryKey: ['pendingUsersCount', users],
queryFn: async () => {
const payload: UsersListStatusParamsGET = {
status: 'deactivated',
inactiveReason: ['pending_approval'],
type: 'user',
count: 1,
};
return getUsers(payload);
},
enabled: !!users,
select: (data) => data?.total,
});
};
export default usePendingUsersCount;

View File

@@ -0,0 +1,41 @@
import type { IUser } from '@rocket.chat/core-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, usePermission, useEndpoint, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import type { AdminUserAction } from './useAdminUserInfoActions';
export const useResetE2EEKeyAction = (userId: IUser['_id']): AdminUserAction | undefined => {
const t = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const canResetE2EEKey = usePermission('edit-other-user-e2ee');
const resetE2EEKeyRequest = useEndpoint('POST', '/v1/users.resetE2EKey');
const resetE2EEKey = useCallback(async () => {
try {
await resetE2EEKeyRequest({ userId });
dispatchToastMessage({ type: 'success', message: t('Users_key_has_been_reset') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal();
}
}, [resetE2EEKeyRequest, setModal, t, userId, dispatchToastMessage]);
const confirmResetE2EEKey = useCallback(() => {
setModal(
<GenericModal variant='danger' onConfirm={resetE2EEKey} onCancel={(): void => setModal()} confirmText={t('Reset')}>
{t('E2E_Reset_Other_Key_Warning')}
</GenericModal>,
);
}, [resetE2EEKey, t, setModal]);
return canResetE2EEKey
? {
icon: 'key',
content: t('Reset_E2EE_password'),
onClick: confirmResetE2EEKey,
}
: undefined;
};

View File

@@ -0,0 +1,42 @@
import type { IUser } from '@rocket.chat/core-typings';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, usePermission, useSetting, useEndpoint, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import type { AdminUserAction } from './useAdminUserInfoActions';
export const useResetTOTPAction = (userId: IUser['_id']): AdminUserAction | undefined => {
const t = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const canResetTOTP = usePermission('edit-other-user-totp');
const twoFactorEnabled = useSetting('Accounts_TwoFactorAuthentication_Enabled');
const resetTOTPRequest = useEndpoint('POST', '/v1/users.resetTOTP');
const resetTOTP = useCallback(async () => {
try {
await resetTOTPRequest({ userId });
dispatchToastMessage({ type: 'success', message: t('Users_TOTP_has_been_reset') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal();
}
}, [resetTOTPRequest, setModal, t, userId, dispatchToastMessage]);
const confirmResetTOTP = useCallback(() => {
setModal(
<GenericModal variant='danger' onConfirm={resetTOTP} onCancel={(): void => setModal()} confirmText={t('Reset')}>
{t('TOTP_Reset_Other_Key_Warning')}
</GenericModal>,
);
}, [resetTOTP, t, setModal]);
return canResetTOTP && twoFactorEnabled
? {
icon: 'key',
content: t('Reset_TOTP'),
onClick: confirmResetTOTP,
}
: undefined;
};

View File

@@ -0,0 +1,29 @@
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
type UseSendInvitationEmailMutationVariables = {
emails: string[];
};
export const useSendInvitationEmailMutation = (): UseMutationResult<
{ success: boolean },
Error,
UseSendInvitationEmailMutationVariables
> => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const sendInvites = useEndpoint('POST', '/v1/sendInvitationEmail');
return useMutation({
mutationFn: async ({ emails }) => {
const result = await sendInvites({ emails });
return result;
},
onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Sending_Invitations') }),
onError: (error) => dispatchToastMessage({ type: 'error', message: error }),
});
};

View File

@@ -0,0 +1,29 @@
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
type useSendWelcomeEmailMutationProps = {
email: string | undefined;
};
export const useSendWelcomeEmailMutation = (): UseMutationResult<null, Error, useSendWelcomeEmailMutationProps> => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const sendWelcomeEmail = useEndpoint('POST', '/v1/users.sendWelcomeEmail');
return useMutation({
mutationFn: async ({ email }) => {
if (!email) {
dispatchToastMessage({ type: 'error', message: t('Welcome_email_failed') });
return null;
}
return sendWelcomeEmail({ email });
},
onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Welcome_email_resent') }),
onError: (error) => dispatchToastMessage({ type: 'error', message: error }),
});
};

View File

@@ -0,0 +1,10 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useSmtpQuery = () => {
const getSmtpConfig = useEndpoint('GET', '/v1/smtp.check');
return useQuery({
queryKey: ['smtp.check'],
queryFn: async () => getSmtpConfig(),
});
};

View File

@@ -0,0 +1,28 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export type SeatCapProps = {
maxActiveUsers: number;
activeUsers: number;
reload: () => void;
};
export const useSeatsCap = (): SeatCapProps | undefined => {
// #TODO: Stop using this endpoint
const fetch = useEndpoint('GET', '/v1/licenses.maxActiveUsers');
const result = useQuery({
queryKey: ['/v1/licenses.maxActiveUsers'],
queryFn: () => fetch(),
});
if (!result.isSuccess) {
return undefined;
}
return {
activeUsers: result.data.activeUsers,
maxActiveUsers: result.data.maxActiveUsers && result.data.maxActiveUsers > 0 ? result.data.maxActiveUsers : Number.POSITIVE_INFINITY,
reload: () => result.refetch(),
};
};

View File

@@ -0,0 +1,3 @@
import { useSetting } from '@rocket.chat/ui-contexts';
export const useShowVoipExtension = () => useSetting('VoIP_TeamCollab_SIP_Integration_Enabled', false);