feat: New users page pending tab (#31987)

Co-authored-by: Tasso <tasso.evangelista@rocket.chat>
pull/32567/head
Henrique Guimarães Ribeiro 2 years ago committed by GitHub
parent 363a011487
commit 5f95c4ec6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      .changeset/metal-candles-float.md
  2. 1
      apps/meteor/app/api/server/lib/users.ts
  3. 12
      apps/meteor/client/components/Page/PageHeader.tsx
  4. 49
      apps/meteor/client/components/UserInfo/UserInfo.tsx
  5. 63
      apps/meteor/client/hooks/useLicenseLimitsByBehavior.ts
  6. 68
      apps/meteor/client/views/admin/subscription/SubscriptionCalloutLimits.tsx
  7. 5
      apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx
  8. 58
      apps/meteor/client/views/admin/users/AdminUsersPage.tsx
  9. 16
      apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx
  10. 57
      apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx
  11. 146
      apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx
  12. 25
      apps/meteor/client/views/admin/users/hooks/usePendingUsersCount.ts
  13. 29
      apps/meteor/client/views/admin/users/hooks/useSendWelcomeEmailMutation.ts
  14. 3
      packages/core-typings/src/IUser.ts
  15. 7
      packages/i18n/src/locales/en.i18n.json
  16. 7
      packages/rest-typings/src/v1/Ajv.ts
  17. 2
      packages/rest-typings/src/v1/users.ts
  18. 2
      packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts

@ -0,0 +1,13 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-typings": patch
"@rocket.chat/i18n": patch
---
Implemented a new "Pending Users" tab on the users page to list users who have not yet been activated and/or have not logged in for the first time.
Additionally, added a "Pending Action" column to aid administrators in identifying necessary actions for each user. Incorporated a "Reason for Joining" field
into the user info contextual bar, along with a callout for exceeding the seats cap in the users page header. Finally, introduced a new logic to disable user creation buttons upon surpassing the seats cap.

@ -154,6 +154,7 @@ export async function findPaginatedUsersByStatus({
lastLogin: 1,
type: 1,
reason: 1,
federated: 1,
};
const actualSort: Record<string, 1 | -1> = sort || { username: 1 };

@ -24,12 +24,20 @@ const PageHeader: FC<PageHeaderProps> = ({ children = undefined, title, onClickB
<Box
is='header'
borderBlockEndWidth='default'
minHeight='x64'
pb={8}
borderBlockEndColor={borderBlockEndColor ?? border ? 'extra-light' : 'transparent'}
{...props}
>
<Box height='100%' marginInline={24} display='flex' flexDirection='row' flexWrap='wrap' alignItems='center' color='default'>
<Box
height='100%'
marginInline={24}
minHeight='x64'
display='flex'
flexDirection='row'
flexWrap='wrap'
alignItems='center'
color='default'
>
{isMobile && (
<HeaderToolbar>
<SidebarToggler />

@ -39,6 +39,7 @@ type UserInfoProps = UserInfoDataProps & {
verified?: boolean;
actions: ReactElement;
roles: ReactElement[];
reason?: string;
};
const UserInfo = ({
@ -59,6 +60,7 @@ const UserInfo = ({
customFields,
canViewAllInfo,
actions,
reason,
...props
}: UserInfoProps): ReactElement => {
const t = useTranslation();
@ -79,53 +81,47 @@ const UserInfo = ({
<InfoPanel.Section>
{userDisplayName && <InfoPanel.Title icon={status} title={userDisplayName} />}
{statusText && (
<InfoPanel.Text>
<MarkdownText content={statusText} parseEmoji={true} />
<MarkdownText content={statusText} parseEmoji={true} variant='inline' />
</InfoPanel.Text>
)}
</InfoPanel.Section>
<InfoPanel.Section>
{roles.length !== 0 && (
{reason && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Roles')}</InfoPanel.Label>
<UserCardRoles>{roles}</UserCardRoles>
<InfoPanel.Label>{t('Reason_for_joining')}</InfoPanel.Label>
<InfoPanel.Text>{reason}</InfoPanel.Text>
</InfoPanel.Field>
)}
{Number.isInteger(utcOffset) && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Local_Time')}</InfoPanel.Label>
<InfoPanel.Text>{utcOffset && <UTCClock utcOffset={utcOffset} />}</InfoPanel.Text>
</InfoPanel.Field>
)}
{username && username !== name && (
{nickname && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Username')}</InfoPanel.Label>
<InfoPanel.Text data-qa='UserInfoUserName'>{username}</InfoPanel.Text>
<InfoPanel.Label>{t('Nickname')}</InfoPanel.Label>
<InfoPanel.Text>{nickname}</InfoPanel.Text>
</InfoPanel.Field>
)}
{canViewAllInfo && (
{roles.length !== 0 && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Last_login')}</InfoPanel.Label>
<InfoPanel.Text>{lastLogin ? timeAgo(lastLogin) : t('Never')}</InfoPanel.Text>
<InfoPanel.Label>{t('Roles')}</InfoPanel.Label>
<UserCardRoles>{roles}</UserCardRoles>
</InfoPanel.Field>
)}
{name && (
{username && username !== name && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Full_Name')}</InfoPanel.Label>
<InfoPanel.Text>{name}</InfoPanel.Text>
<InfoPanel.Label>{t('Username')}</InfoPanel.Label>
<InfoPanel.Text data-qa='UserInfoUserName'>{username}</InfoPanel.Text>
</InfoPanel.Field>
)}
{nickname && (
{Number.isInteger(utcOffset) && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Nickname')}</InfoPanel.Label>
<InfoPanel.Text>{nickname}</InfoPanel.Text>
<InfoPanel.Label>{t('Local_Time')}</InfoPanel.Label>
<InfoPanel.Text>{utcOffset && <UTCClock utcOffset={utcOffset} />}</InfoPanel.Text>
</InfoPanel.Field>
)}
@ -138,6 +134,13 @@ const UserInfo = ({
</InfoPanel.Field>
)}
{Number.isInteger(utcOffset) && canViewAllInfo && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Last_login')}</InfoPanel.Label>
<InfoPanel.Text>{lastLogin ? timeAgo(lastLogin) : t('Never')}</InfoPanel.Text>
</InfoPanel.Field>
)}
{phone && (
<InfoPanel.Field>
<InfoPanel.Label>{t('Phone')}</InfoPanel.Label>

@ -0,0 +1,63 @@
import type { LicenseBehavior, LicenseLimitKind } from '@rocket.chat/core-typings';
import { validateWarnLimit } from '@rocket.chat/license/src/validation/validateLimit';
import { useLicense } from './useLicense';
type LicenseLimitsByBehavior = Record<LicenseBehavior, LicenseLimitKind[]>;
export const useLicenseLimitsByBehavior = () => {
const result = useLicense({ loadValues: true });
if (result.isLoading || result.isError) {
return null;
}
const { license, limits } = result.data;
if (!license || !limits) {
return null;
}
const keyLimits = Object.keys(limits) as Array<keyof typeof limits>;
// Get the rule with the highest limit that applies to this key
const rules = keyLimits
.map((key) => {
const rule = license.limits[key]
?.filter((limit) => validateWarnLimit(limit.max, limits[key].value ?? 0, limit.behavior))
.reduce<{ max: number; behavior: LicenseBehavior } | null>(
(maxLimit, currentLimit) => (!maxLimit || currentLimit.max > maxLimit.max ? currentLimit : maxLimit),
null,
);
if (!rule) {
return undefined;
}
if (rule.max === 0) {
return undefined;
}
if (rule.max === -1) {
return undefined;
}
return [key, rule.behavior];
})
.filter(Boolean) as Array<[keyof typeof limits, LicenseBehavior]>;
if (!rules.length) {
return null;
}
// Group by behavior
return rules.reduce((acc, [key, behavior]) => {
if (!acc[behavior]) {
acc[behavior] = [];
}
acc[behavior].push(key);
return acc;
}, {} as LicenseLimitsByBehavior);
};

@ -1,80 +1,33 @@
import type { LicenseBehavior } from '@rocket.chat/core-typings';
import type { LicenseInfo } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { validateWarnLimit } from '@rocket.chat/license/src/validation/validateLimit';
import { ExternalLink } from '@rocket.chat/ui-client';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useLicense } from '../../../hooks/useLicense';
import { useLicenseLimitsByBehavior } from '../../../hooks/useLicenseLimitsByBehavior';
import { useCheckoutUrl } from './hooks/useCheckoutUrl';
export const SubscriptionCalloutLimits = () => {
const manageSubscriptionUrl = useCheckoutUrl();
const { t } = useTranslation();
const result = useLicense({ loadValues: true });
if (result.isLoading || result.isError) {
return null;
}
const { license, limits } = result.data;
const licenseLimits = useLicenseLimitsByBehavior();
if (!license || !limits) {
if (!licenseLimits) {
return null;
}
const keyLimits = Object.keys(limits) as Array<keyof typeof limits>;
// Get the rule with the highest limit that applies to this key
const rules = keyLimits
.map((key) => {
const rule = license.limits[key]
?.filter((limit) => validateWarnLimit(limit.max, limits[key].value ?? 0, limit.behavior))
.sort((a, b) => b.max - a.max)[0];
const { prevent_action, disable_modules, invalidate_license, start_fair_policy } = licenseLimits;
if (!rule) {
return undefined;
}
if (rule.max === 0) {
return undefined;
}
if (rule.max === -1) {
return undefined;
}
return [key, rule.behavior];
})
.filter(Boolean) as Array<[keyof typeof limits, LicenseBehavior]>;
if (!rules.length) {
return null;
}
// Group by behavior
const groupedRules = rules.reduce((acc, [key, behavior]) => {
if (!acc[behavior]) {
acc[behavior] = [];
}
acc[behavior].push(key);
return acc;
}, {} as Record<LicenseBehavior, (keyof typeof limits)[]>);
const { prevent_action, disable_modules, invalidate_license, start_fair_policy } = groupedRules;
const map = (key: keyof typeof limits) => t(`subscription.callout.${key}`);
const toTranslationKey = (key: keyof LicenseInfo['limits']) => t(`subscription.callout.${key}`);
return (
<>
{start_fair_policy && (
<Callout type='warning' title={t('subscription.callout.servicesDisruptionsMayOccur')} m={8}>
<Trans i18nKey='subscription.callout.description.limitsReached' count={start_fair_policy.length}>
Your workspace reached the <>{{ val: start_fair_policy.map(map) }}</> limit.
Your workspace reached the <>{{ val: start_fair_policy.map(toTranslationKey) }}</> limit.
<ExternalLink
to={manageSubscriptionUrl({
target: 'callout',
@ -88,10 +41,11 @@ export const SubscriptionCalloutLimits = () => {
</Trans>
</Callout>
)}
{prevent_action && (
<Callout type='danger' title={t('subscription.callout.servicesDisruptionsOccurring')} m={8}>
<Trans i18nKey='subscription.callout.description.limitsExceeded' count={prevent_action.length}>
Your workspace exceeded the <>{{ val: prevent_action.map(map) }}</> license limit.
Your workspace exceeded the <>{{ val: prevent_action.map(toTranslationKey) }}</> license limit.
<ExternalLink
to={manageSubscriptionUrl({
target: 'callout',
@ -109,7 +63,7 @@ export const SubscriptionCalloutLimits = () => {
{disable_modules && (
<Callout type='danger' title={t('subscription.callout.capabilitiesDisabled')} m={8}>
<Trans i18nKey='subscription.callout.description.limitsExceeded' count={disable_modules.length}>
Your workspace exceeded the <>{{ val: disable_modules.map(map) }}</> license limit.
Your workspace exceeded the <>{{ val: disable_modules.map(toTranslationKey) }}</> license limit.
<ExternalLink
to={manageSubscriptionUrl({
target: 'callout',
@ -127,7 +81,7 @@ export const SubscriptionCalloutLimits = () => {
{invalidate_license && (
<Callout type='danger' title={t('subscription.callout.allPremiumCapabilitiesDisabled')} m={8}>
<Trans i18nKey='subscription.callout.description.limitsExceeded' count={disable_modules.length}>
Your workspace exceeded the <>{{ val: invalidate_license.map(map) }}</> license limit.
Your workspace exceeded the <>{{ val: invalidate_license.map(toTranslationKey) }}</> license limit.
<ExternalLink
to={manageSubscriptionUrl({
target: 'callout',

@ -1,4 +1,3 @@
import { isUserFederated } from '@rocket.chat/core-typings';
import type { IUser } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
@ -69,6 +68,7 @@ const AdminUserInfoWithData = ({ uid, onReload }: AdminUserInfoWithDataProps): R
lastLogin,
nickname,
canViewAllInfo,
reason,
} = data.user;
return {
@ -91,6 +91,7 @@ const AdminUserInfoWithData = ({ uid, onReload }: AdminUserInfoWithDataProps): R
status: <UserStatus status={status} />,
statusText,
nickname,
reason,
};
}, [approveManuallyUsers, data, getRoles]);
@ -119,7 +120,7 @@ const AdminUserInfoWithData = ({ uid, onReload }: AdminUserInfoWithDataProps): R
isAdmin={data?.user.roles?.includes('admin')}
userId={data?.user._id}
username={user.username}
isFederatedUser={isUserFederated(data?.user as unknown as IUser)}
isFederatedUser={!!data.user.federated}
onChange={onChange}
onReload={onReload}
/>

@ -1,9 +1,11 @@
import type { IAdminUserTabs } from '@rocket.chat/core-typings';
import { Button, ButtonGroup, ContextualbarIcon, Tabs, TabsItem } from '@rocket.chat/fuselage';
import type { IAdminUserTabs, LicenseInfo } from '@rocket.chat/core-typings';
import { Button, ButtonGroup, Callout, ContextualbarIcon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { ExternalLink } from '@rocket.chat/ui-client';
import { usePermission, useRouteParameter, useTranslation, useRouter } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from 'react-i18next';
import {
Contextualbar,
@ -15,7 +17,9 @@ import {
import { usePagination } from '../../../components/GenericTable/hooks/usePagination';
import { useSort } from '../../../components/GenericTable/hooks/useSort';
import { Page, PageHeader, PageContent } from '../../../components/Page';
import { useLicenseLimitsByBehavior } from '../../../hooks/useLicenseLimitsByBehavior';
import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction';
import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl';
import AdminInviteUsers from './AdminInviteUsers';
import AdminUserForm from './AdminUserForm';
import AdminUserFormWithData from './AdminUserFormWithData';
@ -24,19 +28,24 @@ import AdminUserUpgrade from './AdminUserUpgrade';
import UserPageHeaderContentWithSeatsCap from './UserPageHeaderContentWithSeatsCap';
import UsersTable from './UsersTable';
import useFilteredUsers from './hooks/useFilteredUsers';
import usePendingUsersCount from './hooks/usePendingUsersCount';
import { useSeatsCap } from './useSeatsCap';
export type UsersFilters = {
text: string;
};
export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status';
export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active';
const AdminUsersPage = (): ReactElement => {
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');
@ -47,7 +56,7 @@ const AdminUsersPage = (): ReactElement => {
const isCreateUserDisabled = useShouldPreventAction('activeUsers');
const paginationData = usePagination();
const sortData = useSort<'name' | 'username' | 'emails.address' | 'status'>('name');
const sortData = useSort<UsersTableSortingOptions>('name');
const [tab, setTab] = useState<IAdminUserTabs>('all');
const [userFilters, setUserFilters] = useState<UsersFilters>({ text: '' });
@ -63,11 +72,19 @@ const AdminUsersPage = (): ReactElement => {
tab,
});
const pendingUsersCount = usePendingUsersCount(filteredUsersQueryResult.data?.users);
const handleReload = (): void => {
seatsCap?.reload();
filteredUsersQueryResult?.refetch();
};
const handleTabChangeAndSort = (tab: IAdminUserTabs) => {
setTab(tab);
sortData.setSort(tab === 'pending' ? 'active' : 'name', 'asc');
};
useEffect(() => {
prevSearchTerm.current = searchTerm;
}, [searchTerm]);
@ -77,31 +94,55 @@ const AdminUsersPage = (): ReactElement => {
[context, isCreateUserDisabled],
);
const toTranslationKey = (key: keyof LicenseInfo['limits']) => t(`subscription.callout.${key}`);
return (
<Page flexDirection='row'>
<Page>
<PageHeader title={t('Users')}>
{seatsCap && seatsCap.maxActiveUsers < Number.POSITIVE_INFINITY ? (
<UserPageHeaderContentWithSeatsCap {...seatsCap} />
<UserPageHeaderContentWithSeatsCap isSeatsCapExceeded={isSeatsCapExceeded} {...seatsCap} />
) : (
<ButtonGroup>
{canBulkCreateUser && (
<Button icon='mail' onClick={() => router.navigate('/admin/users/invite')}>
<Button icon='mail' onClick={() => router.navigate('/admin/users/invite')} disabled={isSeatsCapExceeded}>
{t('Invite')}
</Button>
)}
{canCreateUser && (
<Button icon='user-plus' onClick={() => router.navigate('/admin/users/new')}>
<Button icon='user-plus' onClick={() => router.navigate('/admin/users/new')} disabled={isSeatsCapExceeded}>
{t('New_user')}
</Button>
)}
</ButtonGroup>
)}
</PageHeader>
{preventAction?.includes('activeUsers') && (
<Callout type='danger' title={t('subscription.callout.servicesDisruptionsOccurring')} mbe={19} mi={24}>
<Trans i18nKey='subscription.callout.description.limitsExceeded' count={preventAction.length}>
Your workspace exceeded the <>{{ val: 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={() => setTab('all')}>
<TabsItem selected={!tab || tab === 'all'} onClick={() => handleTabChangeAndSort('all')}>
{t('All')}
</TabsItem>
<TabsItem selected={tab === 'pending'} onClick={() => handleTabChangeAndSort('pending')} display='flex' flexDirection='row'>
{`${t('Pending')} `}
{pendingUsersCount.isLoading && <Skeleton variant='circle' height='x16' width='x16' mis={8} />}
{pendingUsersCount.isSuccess && `(${pendingUsersCount.data})`}
</TabsItem>
</Tabs>
<PageContent>
<UsersTable
@ -111,6 +152,7 @@ const AdminUsersPage = (): ReactElement => {
paginationData={paginationData}
sortData={sortData}
tab={tab}
isSeatsCapExceeded={isSeatsCapExceeded}
/>
</PageContent>
</Page>

@ -4,18 +4,20 @@ import type { ReactElement } from 'react';
import React from 'react';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useShouldPreventAction } from '../../../hooks/useShouldPreventAction';
import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl';
import SeatsCapUsage from './SeatsCapUsage';
type UserPageHeaderContentWithSeatsCapProps = {
activeUsers: number;
maxActiveUsers: number;
isSeatsCapExceeded: boolean;
};
const UserPageHeaderContentWithSeatsCap = ({ activeUsers, maxActiveUsers }: UserPageHeaderContentWithSeatsCapProps): ReactElement => {
const isCreateUserDisabled = useShouldPreventAction('activeUsers');
const UserPageHeaderContentWithSeatsCap = ({
isSeatsCapExceeded,
activeUsers,
maxActiveUsers,
}: UserPageHeaderContentWithSeatsCapProps): ReactElement => {
const t = useTranslation();
const router = useRouter();
@ -36,13 +38,13 @@ const UserPageHeaderContentWithSeatsCap = ({ activeUsers, maxActiveUsers }: User
<SeatsCapUsage members={activeUsers} limit={maxActiveUsers} />
</Margins>
<ButtonGroup>
<Button icon='mail' onClick={handleInviteButtonClick}>
<Button icon='mail' onClick={handleInviteButtonClick} disabled={isSeatsCapExceeded}>
{t('Invite')}
</Button>
<Button icon='user-plus' onClick={handleNewButtonClick}>
<Button icon='user-plus' onClick={handleNewButtonClick} disabled={isSeatsCapExceeded}>
{t('New_user')}
</Button>
{isCreateUserDisabled && (
{isSeatsCapExceeded && (
<Button primary role='link' onClick={() => openExternalLink(manageSubscriptionUrl)}>
{t('Buy_more_seats')}
</Button>

@ -1,6 +1,6 @@
import type { IAdminUserTabs, Serialized } from '@rocket.chat/core-typings';
import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
import { useMediaQuery, useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks';
import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings';
import { useRouter, useTranslation } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
@ -28,9 +28,9 @@ type UsersTableProps = {
filteredUsersQueryResult: UseQueryResult<PaginatedResult<{ users: Serialized<DefaultUserInfo>[] }>>;
paginationData: ReturnType<typeof usePagination>;
sortData: ReturnType<typeof useSort<UsersTableSortingOptions>>;
isSeatsCapExceeded: boolean;
};
// TODO: Missing error state
const UsersTable = ({
filteredUsersQueryResult,
setUserFilters,
@ -38,10 +38,14 @@ const UsersTable = ({
onReload,
paginationData,
sortData,
isSeatsCapExceeded,
}: UsersTableProps): ReactElement | null => {
const t = useTranslation();
const router = useRouter();
const mediaQuery = useMediaQuery('(min-width: 1024px)');
const breakpoints = useBreakpoints();
const isMobile = !breakpoints.includes('xl');
const isLaptop = !breakpoints.includes('xxl');
const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult;
@ -76,24 +80,14 @@ const UsersTable = ({
const headers = useMemo(
() => [
<GenericTableHeaderCell w='x240' key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
{t('Name')}
</GenericTableHeaderCell>,
mediaQuery && (
<GenericTableHeaderCell
w='x140'
key='username'
direction={sortDirection}
active={sortBy === 'username'}
onClick={setSort}
sort='username'
>
{t('Username')}
</GenericTableHeaderCell>
),
mediaQuery && (
<GenericTableHeaderCell key='username' direction={sortDirection} active={sortBy === 'username'} onClick={setSort} sort='username'>
{t('Username')}
</GenericTableHeaderCell>,
!isLaptop && (
<GenericTableHeaderCell
w='x120'
key='email'
direction={sortDirection}
active={sortBy === 'emails.address'}
@ -103,18 +97,20 @@ const UsersTable = ({
{t('Email')}
</GenericTableHeaderCell>
),
mediaQuery && (
<GenericTableHeaderCell w='x120' key='roles'>
{t('Roles')}
!isLaptop && <GenericTableHeaderCell key='roles'>{t('Roles')}</GenericTableHeaderCell>,
tab === 'all' && !isMobile && (
<GenericTableHeaderCell key='status' direction={sortDirection} active={sortBy === 'status'} onClick={setSort} sort='status'>
{t('Registration_status')}
</GenericTableHeaderCell>
),
tab === 'all' && (
<GenericTableHeaderCell w='x100' key='status'>
{t('Registration_status')}
tab === 'pending' && !isMobile && (
<GenericTableHeaderCell key='action' direction={sortDirection} active={sortBy === 'active'} onClick={setSort} sort='active'>
{t('Pending_action')}
</GenericTableHeaderCell>
),
<GenericTableHeaderCell key='actions' w={tab === 'pending' ? 'x204' : ''} />,
],
[mediaQuery, setSort, sortBy, sortDirection, t, tab],
[isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab],
);
const handleSearchTextChange = useCallback(
@ -153,7 +149,16 @@ const UsersTable = ({
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data.users.map((user) => (
<UsersTableRow key={user._id} onClick={handleClickOrKeyDown} mediaQuery={mediaQuery} user={user} tab={tab} />
<UsersTableRow
key={user._id}
onClick={handleClickOrKeyDown}
isMobile={isMobile}
isLaptop={isLaptop}
user={user}
onReload={onReload}
tab={tab}
isSeatsCapExceeded={isSeatsCapExceeded}
/>
))}
</GenericTableBody>
</GenericTable>

@ -1,6 +1,6 @@
import { UserStatus as Status } from '@rocket.chat/core-typings';
import type { IAdminUserTabs, IRole, IUser, Serialized } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { Box, Button, Menu, Option } from '@rocket.chat/fuselage';
import type { DefaultUserInfo } from '@rocket.chat/rest-typings';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation } from '@rocket.chat/ui-contexts';
@ -10,16 +10,26 @@ import React, { useMemo } from 'react';
import { Roles } from '../../../../../app/models/client';
import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable';
import { UserStatus } from '../../../../components/UserStatus';
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';
type UsersTableRowProps = {
user: Serialized<DefaultUserInfo>;
onClick: (id: IUser['_id'], e: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => void;
mediaQuery: boolean;
isMobile: boolean;
isLaptop: boolean;
onReload: () => void;
tab: IAdminUserTabs;
isSeatsCapExceeded: boolean;
};
const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps): ReactElement => {
const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSeatsCapExceeded }: UsersTableRowProps): ReactElement => {
const t = useTranslation();
const { _id, emails, username, name, roles, status, active, avatarETag, lastLogin, type } = user;
const registrationStatusText = useMemo(() => {
const usersExcludedFromPending = ['bot', 'app'];
@ -42,6 +52,49 @@ const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps):
.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(userId, 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 menuOptions = {
...(isNotPendingDeactivatedNorFederated &&
changeAdminStatusAction && {
makeAdmin: {
label: { label: changeAdminStatusAction.label, icon: changeAdminStatusAction.icon },
action: changeAdminStatusAction.action,
},
}),
...(isNotPendingDeactivatedNorFederated &&
resetE2EKeyAction && {
resetE2EKey: { label: { label: resetE2EKeyAction.label, icon: resetE2EKeyAction.icon }, action: resetE2EKeyAction.action },
}),
...(isNotPendingDeactivatedNorFederated &&
resetTOTPAction && {
resetTOTP: { label: { label: resetTOTPAction.label, icon: resetTOTPAction.icon }, action: resetTOTPAction.action },
}),
...(changeUserStatusAction &&
!isFederatedUser && {
changeActiveStatus: {
label: { label: changeUserStatusAction.label, icon: changeUserStatusAction.icon },
action: changeUserStatusAction.action,
},
}),
...(deleteUserAction && {
delete: { label: { label: deleteUserAction.label, icon: deleteUserAction.icon }, action: deleteUserAction.action },
}),
};
const handleResendWelcomeEmail = () => resendWelcomeEmail.mutateAsync({ email: emails?.[0].address });
return (
<GenericTableRow
onKeyDown={(e): void => onClick(_id, e)}
@ -53,39 +106,78 @@ const UsersTableRow = ({ user, onClick, mediaQuery, tab }: UsersTableRowProps):
>
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
{username && <UserAvatar size={mediaQuery ? 'x28' : 'x40'} username={username} etag={avatarETag} />}
<Box display='flex' mi={8} withTruncatedText>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' color='default' withTruncatedText>
<Box display='inline' mie='x8'>
<UserStatus status={status || Status.OFFLINE} />
</Box>
{name || username}
</Box>
{!mediaQuery && name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{`@${username}`}
</Box>
)}
{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 mi={8}>
<UserStatus status={status || Status.OFFLINE} />
</Box>
<Box fontScale='p2' withTruncatedText>
{name || username}
</Box>
</Box>
</Box>
</GenericTableCell>
{mediaQuery && (
<GenericTableCell>
<Box fontScale='p2m' color='hint' withTruncatedText>
{username}
</Box>
<Box mi={4} />
<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>
)}
{mediaQuery && <GenericTableCell withTruncatedText>{emails?.length && emails[0].address}</GenericTableCell>}
{mediaQuery && <GenericTableCell withTruncatedText>{roleNames}</GenericTableCell>}
{tab === 'all' && (
{tab === 'pending' && !isMobile && (
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
{registrationStatusText}
<Box display='flex' flexDirection='row' alignContent='flex-end'>
{active ? t('User_first_log_in') : t('Activation')}
</Box>
</GenericTableCell>
)}
<GenericTableCell
display='flex'
justifyContent='flex-end'
onClick={(e): void => {
e.stopPropagation();
}}
>
{tab === 'pending' && (
<>
{active ? (
<Button small secondary onClick={handleResendWelcomeEmail}>
{t('Resend_welcome_email')}
</Button>
) : (
<Button small primary onClick={changeUserStatusAction?.action} disabled={isSeatsCapExceeded}>
{t('Activate')}
</Button>
)}
</>
)}
<Menu
mi={4}
placement='bottom-start'
flexShrink={0}
key='menu'
renderItem={({ label: { label, icon }, ...props }): ReactElement =>
label === 'Delete' ? (
<Option label={label} title={label} icon={icon} variant='danger' {...props} />
) : (
<Option label={label} title={label} icon={icon} {...props} />
)
}
options={menuOptions}
/>
</GenericTableCell>
</GenericTableRow>
);
};

@ -0,0 +1,25 @@
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(
['pendingUsersCount', users],
async () => {
const payload: UsersListStatusParamsGET = {
hasLoggedIn: false,
status: 'deactivated',
type: 'user',
count: 1,
};
return getUsers(payload);
},
{ enabled: !!users, select: (data) => data?.total },
);
};
export default usePendingUsersCount;

@ -0,0 +1,29 @@
import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import type { UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
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(
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 }),
},
);
};

@ -1,5 +1,6 @@
import type { IRocketChatRecord } from './IRocketChatRecord';
import type { IRole } from './IRole';
import type { Serialized } from './Serialized';
import type { UserStatus } from './UserStatus';
export interface ILoginToken {
@ -191,7 +192,7 @@ export interface IRegisterUser extends IUser {
}
export const isRegisterUser = (user: IUser): user is IRegisterUser => user.username !== undefined && user.name !== undefined;
export const isUserFederated = (user: Partial<IUser>) => 'federated' in user && user.federated === true;
export const isUserFederated = (user: Partial<IUser> | Partial<Serialized<IUser>>) => 'federated' in user && user.federated === true;
export type IUserDataEvent = {
id: unknown;

@ -306,6 +306,7 @@
"Action_Available_After_Custom_Content_Added_And_Visible": "This action will become available after the custom content has been added and made visible to everyone",
"Action_not_available_encrypted_content": "{{action}} not available on encrypted content",
"Activate": "Activate",
"Activation": "Activation",
"Active": "Active",
"Active_users": "Active users",
"Activity": "Activity",
@ -4145,6 +4146,7 @@
"pdf_error_message": "Error generating PDF Transcript",
"Peer_Password": "Peer Password",
"Pending": "Pending",
"Pending_action": "Pending action",
"Pending Avatars": "Pending Avatars",
"Pending Files": "Pending Files",
"People": "People",
@ -4350,6 +4352,7 @@
"Real_Estate": "Real Estate",
"Real_Time_Monitoring": "Real-time Monitoring",
"RealName_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of names",
"Reason_for_joining": "Reason for joining",
"Reason_To_Join": "Reason to Join",
"Receive_alerts": "Receive alerts",
"Receive_Group_Mentions": "Receive @all and @here mentions",
@ -4473,6 +4476,7 @@
"Require_password_change": "Require password change",
"Require_Two_Factor_Authentication": "Require Two Factor Authentication",
"Resend_verification_email": "Resend verification email",
"Resend_welcome_email": "Resend welcome email",
"Reset": "Reset",
"Reset_priorities": "Reset priorities",
"Reset_Connection": "Reset Connection",
@ -5588,6 +5592,7 @@
"User_default": "User default",
"User_doesnt_exist": "No user exists by the name of `@%s`.",
"User_e2e_key_was_reset": "User E2E key was reset successfully.",
"User_first_log_in": "User first log in",
"User_has_been_activated": "User has been activated",
"User_has_been_deactivated": "User has been deactivated",
"User_has_been_deleted": "User has been deleted",
@ -5950,6 +5955,8 @@
"Wednesday": "Wednesday",
"Weekly_Active_Users": "Weekly Active Users",
"Welcome": "Welcome <em>%s</em>.",
"Welcome_email_failed": "Failed to resend welcome email",
"Welcome_email_resent": "Welcome email resent",
"Welcome_to": "Welcome to [Site_Name]",
"Welcome_to_workspace": "Welcome to {{Site_Name}}",
"Welcome_to_the": "Welcome to the",

@ -4,6 +4,13 @@ import addFormats from 'ajv-formats';
const ajv = new Ajv({
coerceTypes: true,
});
addFormats(ajv);
ajv.addFormat('basic_email', /^[^@]+@[^@]+$/);
ajv.addFormat(
'rfc_email',
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
);
export { ajv };

@ -114,7 +114,7 @@ export type UserPersonalTokens = Pick<IPersonalAccessToken, 'name' | 'lastTokenP
export type DefaultUserInfo = Pick<
IUser,
'_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag' | 'lastLogin' | 'type'
'_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag' | 'lastLogin' | 'type' | 'federated'
>;
export type UsersEndpoints = {

@ -7,7 +7,7 @@ const UsersSendWelcomeEmailParamsPostSchema = {
properties: {
email: {
type: 'string',
format: 'email',
format: 'basic_email',
},
},
required: ['email'],

Loading…
Cancel
Save