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
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:
81
apps/meteor/client/views/admin/users/AdminInviteUsers.tsx
Normal file
81
apps/meteor/client/views/admin/users/AdminInviteUsers.tsx
Normal 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;
|
||||
29
apps/meteor/client/views/admin/users/AdminUserCreated.tsx
Normal file
29
apps/meteor/client/views/admin/users/AdminUserCreated.tsx
Normal 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;
|
||||
564
apps/meteor/client/views/admin/users/AdminUserForm.tsx
Normal file
564
apps/meteor/client/views/admin/users/AdminUserForm.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
136
apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx
Normal file
136
apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
apps/meteor/client/views/admin/users/AdminUserUpgrade.tsx
Normal file
32
apps/meteor/client/views/admin/users/AdminUserUpgrade.tsx
Normal 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;
|
||||
201
apps/meteor/client/views/admin/users/AdminUsersPage.tsx
Normal file
201
apps/meteor/client/views/admin/users/AdminUsersPage.tsx
Normal 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;
|
||||
16
apps/meteor/client/views/admin/users/AdminUsersRoute.tsx
Normal file
16
apps/meteor/client/views/admin/users/AdminUsersRoute.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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} />;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SeatsCapUsage';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
217
apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
Normal file
217
apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
1
apps/meteor/client/views/admin/users/UsersTable/index.ts
Normal file
1
apps/meteor/client/views/admin/users/UsersTable/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './UsersTable';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
10
apps/meteor/client/views/admin/users/hooks/useSmtpQuery.ts
Normal file
10
apps/meteor/client/views/admin/users/hooks/useSmtpQuery.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
28
apps/meteor/client/views/admin/users/useSeatsCap.ts
Normal file
28
apps/meteor/client/views/admin/users/useSeatsCap.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const useShowVoipExtension = () => useSetting('VoIP_TeamCollab_SIP_Integration_Enabled', false);
|
||||
Reference in New Issue
Block a user