Skip to content
Snippets Groups Projects
Unverified Commit 7bd248dc authored by Douglas Fabris's avatar Douglas Fabris Committed by GitHub
Browse files

[FIX] Sanitize customUserStatus and fix infinite loop (#25449)

parent 035307c9
No related merge requests found
Showing
with 317 additions and 261 deletions
import { Button, ButtonGroup, TextInput, Field, Select, SelectOption } from '@rocket.chat/fuselage';
import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactElement, SyntheticEvent, useCallback, useState } from 'react';
import VerticalBar from '../../../components/VerticalBar';
type AddCustomUserStatusProps = {
goToNew: (id: string) => () => void;
close: () => void;
onChange: () => void;
};
function AddCustomUserStatus({ goToNew, close, onChange, ...props }: AddCustomUserStatusProps): ReactElement {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const [name, setName] = useState('');
const [statusType, setStatusType] = useState('online');
const saveStatus = useMethod('insertOrUpdateUserStatus');
const handleSave = useCallback(async () => {
try {
const result = await saveStatus({
name,
statusType,
});
dispatchToastMessage({
type: 'success',
message: t('Custom_User_Status_Updated_Successfully'),
});
goToNew(result)();
onChange();
} catch (error) {
dispatchToastMessage({ type: 'error', message: String(error) });
}
}, [dispatchToastMessage, goToNew, name, onChange, saveStatus, statusType, t]);
const presenceOptions: SelectOption[] = [
['online', t('Online')],
['busy', t('Busy')],
['away', t('Away')],
['offline', t('Offline')],
];
return (
<VerticalBar.ScrollableContent {...props}>
<Field>
<Field.Label>{t('Name')}</Field.Label>
<Field.Row>
<TextInput
value={name}
onChange={(e: SyntheticEvent<HTMLInputElement>): void => setName(e.currentTarget.value)}
placeholder={t('Name')}
/>
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Presence')}</Field.Label>
<Field.Row>
<Select
value={statusType}
onChange={(value): void => setStatusType(value)}
placeholder={t('Presence')}
options={presenceOptions}
/>
</Field.Row>
</Field>
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button mie='x4' onClick={close}>
{t('Cancel')}
</Button>
<Button primary onClick={handleSave} disabled={name === ''}>
{t('Save')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>
</VerticalBar.ScrollableContent>
);
}
export default AddCustomUserStatus;
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { CSSProperties, Dispatch, ReactElement, SetStateAction, useMemo } from 'react';
import FilterByText from '../../../components/FilterByText';
import GenericTable from '../../../components/GenericTable';
import { GenericTableCell } from '../../../components/GenericTable/V2/GenericTableCell';
import { GenericTableHeaderCell } from '../../../components/GenericTable/V2/GenericTableHeaderCell';
import { GenericTableRow } from '../../../components/GenericTable/V2/GenericTableRow';
import MarkdownText from '../../../components/MarkdownText';
const style: CSSProperties = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' };
export type paramsType = {
text?: string;
current?: number;
itemsPerPage?: 25 | 50 | 100;
};
type statusType = { _id: string; name: string; statusType: string };
export type SortType = ['name' | 'statusType', 'asc' | 'desc'];
export type DataType = {
statuses: {
_id: string;
name: string;
statusType: string;
}[];
total?: number;
};
type CustomUserStatusProps = {
data: DataType;
sort: SortType;
onClick: (id: string, status: statusType) => () => void;
onHeaderClick: (sort: SortType[0]) => void;
setParams: Dispatch<SetStateAction<paramsType>>;
params: paramsType;
};
function CustomUserStatus({ data, sort, onClick, onHeaderClick, setParams, params }: CustomUserStatusProps): ReactElement {
const t = useTranslation();
const header = useMemo(
() =>
[
<GenericTableHeaderCell key='name' direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name'>
{t('Name')}
</GenericTableHeaderCell>,
<GenericTableHeaderCell
key='presence'
direction={sort[1]}
active={sort[0] === 'statusType'}
onClick={onHeaderClick}
sort='statusType'
>
{t('Presence')}
</GenericTableHeaderCell>,
].filter(Boolean),
[onHeaderClick, sort, t],
);
const renderRow = (status: statusType): ReactElement => {
const { _id, name, statusType } = status;
return (
<GenericTableRow
key={_id}
onKeyDown={onClick(_id, status)}
onClick={onClick(_id, status)}
tabIndex={0}
role='link'
action
qa-user-id={_id}
>
<GenericTableCell fontScale='p2' color='default' style={style}>
<MarkdownText content={name} parseEmoji={true} variant='inline' />
</GenericTableCell>
<GenericTableCell fontScale='p2' color='default' style={style}>
{statusType}
</GenericTableCell>
</GenericTableRow>
);
};
return (
<GenericTable
header={header}
renderRow={renderRow}
results={data?.statuses ?? []}
total={data?.total ?? 0}
setParams={setParams}
params={params}
renderFilter={({ onChange, ...props }): ReactElement => <FilterByText onChange={(params): void => onChange?.(params)} {...props} />}
/>
);
}
export default CustomUserStatus;
import { IUserStatus } from '@rocket.chat/core-typings';
import { Button, ButtonGroup, TextInput, Field, Select, Icon, SelectOption } from '@rocket.chat/fuselage';
import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback, useState, useMemo, useEffect, ReactElement, SyntheticEvent } from 'react';
import { useSetModal, useRoute, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback, ReactElement } from 'react';
import { useForm, Controller } from 'react-hook-form';
import GenericModal from '../../../components/GenericModal';
import VerticalBar from '../../../components/VerticalBar';
type EditCustomUserStatusProps = {
close: () => void;
onChange: () => void;
data?: {
_id: string;
name: string;
statusType: string;
};
type CustomUserStatusFormProps = {
onClose: () => void;
onReload: () => void;
status?: IUserStatus;
};
export function EditCustomUserStatus({ close, onChange, data, ...props }: EditCustomUserStatusProps): ReactElement {
const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFormProps): ReactElement => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const { _id, name, statusType } = status || {};
const setModal = useSetModal();
const route = useRoute('custom-user-status');
const dispatchToastMessage = useToastMessageDispatch();
const { _id, name: previousName, statusType: previousStatusType } = data || {};
const [name, setName] = useState(() => data?.name ?? '');
const [statusType, setStatusType] = useState(() => data?.statusType ?? '');
useEffect(() => {
setName(previousName || '');
setStatusType(previousStatusType || '');
}, [previousName, previousStatusType, _id]);
const {
register,
control,
handleSubmit,
formState: { isDirty, errors },
} = useForm({
defaultValues: { name: status?.name ?? '', statusType: status?.statusType ?? '' },
});
const saveStatus = useMethod('insertOrUpdateUserStatus');
const deleteStatus = useMethod('deleteCustomUserStatus');
const hasUnsavedChanges = useMemo(
() => previousName !== name || previousStatusType !== statusType,
[name, previousName, previousStatusType, statusType],
const handleSave = useCallback(
async (data) => {
try {
await saveStatus({ _id, previousName: name, previousStatusType: statusType, ...data });
dispatchToastMessage({
type: 'success',
message: t('Custom_User_Status_Updated_Successfully'),
});
onReload();
route.push({});
} catch (error) {
dispatchToastMessage({ type: 'error', message: String(error) });
}
},
[saveStatus, _id, name, statusType, route, dispatchToastMessage, t, onReload],
);
const handleSave = useCallback(async () => {
try {
await saveStatus({
_id,
previousName,
previousStatusType,
name,
statusType,
});
dispatchToastMessage({
type: 'success',
message: t('Custom_User_Status_Updated_Successfully'),
});
onChange();
} catch (error) {
dispatchToastMessage({ type: 'error', message: String(error) });
}
}, [saveStatus, _id, previousName, previousStatusType, name, statusType, dispatchToastMessage, t, onChange]);
const handleDeleteButtonClick = useCallback(() => {
const handleClose = (): void => {
const handleDeleteStatus = useCallback(() => {
const handleCancel = (): void => {
setModal(null);
close();
onChange();
};
const handleDelete = async (): Promise<void> => {
try {
await deleteStatus(_id);
setModal(() => (
<GenericModal variant='success' onClose={handleClose} onConfirm={handleClose}>
{t('Custom_User_Status_Has_Been_Deleted')}
</GenericModal>
));
dispatchToastMessage({ type: 'success', message: t('Custom_User_Status_Has_Been_Deleted') });
onReload();
route.push({});
} catch (error) {
dispatchToastMessage({ type: 'error', message: String(error) });
onChange();
} finally {
setModal(null);
}
};
const handleCancel = (): void => {
setModal(null);
};
setModal(() => (
<GenericModal variant='danger' onConfirm={handleDelete} onCancel={handleCancel} onClose={handleCancel} confirmText={t('Delete')}>
{t('Custom_User_Status_Delete_Warning')}
</GenericModal>
));
}, [_id, close, deleteStatus, dispatchToastMessage, onChange, setModal, t]);
}, [_id, route, deleteStatus, dispatchToastMessage, onReload, setModal, t]);
const presenceOptions: SelectOption[] = [
['online', t('Online')],
......@@ -95,50 +84,50 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }: EditCu
];
return (
<VerticalBar.ScrollableContent {...props}>
<VerticalBar.ScrollableContent>
<Field>
<Field.Label>{t('Name')}</Field.Label>
<Field.Row>
<TextInput
value={name}
onChange={(e: SyntheticEvent<HTMLInputElement>): void => setName(e.currentTarget.value)}
placeholder={t('Name')}
/>
<TextInput {...register('name', { required: true })} placeholder={t('Name')} />
</Field.Row>
{errors?.name && <Field.Error>{t('error-the-field-is-required', { field: t('Name') })}</Field.Error>}
</Field>
<Field>
<Field.Label>{t('Presence')}</Field.Label>
<Field.Row>
<Select
value={statusType}
onChange={(value): void => setStatusType(value)}
placeholder={t('Presence')}
options={presenceOptions}
<Controller
name='statusType'
control={control}
rules={{ required: true }}
render={({ field }): ReactElement => <Select {...field} placeholder={t('Presence')} options={presenceOptions} />}
/>
</Field.Row>
{errors?.statusType && <Field.Error>{t('error-the-field-is-required', { field: t('Presence') })}</Field.Error>}
</Field>
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button onClick={close}>{t('Cancel')}</Button>
<Button primary onClick={handleSave} disabled={!hasUnsavedChanges}>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary onClick={handleSubmit(handleSave)} disabled={!isDirty}>
{t('Save')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button primary danger onClick={handleDeleteButtonClick}>
<Icon name='trash' mie='x4' />
{t('Delete')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>
{_id && (
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button primary danger onClick={handleDeleteStatus}>
<Icon name='trash' mie='x4' />
{t('Delete')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>
)}
</VerticalBar.ScrollableContent>
);
}
};
export default EditCustomUserStatus;
export default CustomUserStatusForm;
import { Box, Button, ButtonGroup, Skeleton, Throbber, InputBox } from '@rocket.chat/fuselage';
import { IUserStatus } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup, Skeleton, Throbber, InputBox, Callout } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo, FC } from 'react';
import React, { useMemo, ReactElement } from 'react';
import { AsyncStatePhase } from '../../../hooks/useAsyncState';
import { useEndpointData } from '../../../hooks/useEndpointData';
import EditCustomUserStatus from './EditCustomUserStatus';
import CustomUserStatusForm from './CustomUserStatusForm';
type EditCustomUserStatusWithDataProps = {
_id: string | undefined;
close: () => void;
onChange: () => void;
type CustomUserStatusFormWithDataProps = {
_id?: IUserStatus['_id'];
onClose: () => void;
onReload: () => void;
};
export const EditCustomUserStatusWithData: FC<EditCustomUserStatusWithDataProps> = ({ _id, onChange, ...props }) => {
const CustomUserStatusFormWithData = ({ _id, onReload, onClose }: CustomUserStatusFormWithDataProps): ReactElement => {
const t = useTranslation();
const query = useMemo(() => ({ query: JSON.stringify({ _id }) }), [_id]);
const { value: data, phase: state, error, reload } = useEndpointData('custom-user-status.list', query);
const handleReload = (): void => {
onReload?.();
reload?.();
};
if (!_id) {
return <CustomUserStatusForm onReload={handleReload} onClose={onClose} />;
}
if (state === AsyncStatePhase.LOADING) {
return (
<Box pb='x20'>
<Box p='x20'>
<Skeleton mbs='x8' />
<InputBox.Skeleton w='full' />
<Skeleton mbs='x8' />
......@@ -44,18 +54,13 @@ export const EditCustomUserStatusWithData: FC<EditCustomUserStatusWithDataProps>
if (error || !data || data.statuses.length < 1) {
return (
<Box fontScale='h2' pb='x20'>
{t('Custom_User_Status_Error_Invalid_User_Status')}
<Box p='x20'>
<Callout type='danger'>{t('Custom_User_Status_Error_Invalid_User_Status')}</Callout>
</Box>
);
}
const handleChange = (): void => {
onChange?.();
reload?.();
};
return <EditCustomUserStatus data={data.statuses[0]} onChange={handleChange} {...props} />;
return <CustomUserStatusForm status={data.statuses[0]} onReload={handleReload} onClose={onClose} />;
};
export default EditCustomUserStatusWithData;
export default CustomUserStatusFormWithData;
import { Button, Icon } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useRoute, useRouteParameter, usePermission, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo, useState, useCallback, ReactNode } from 'react';
import React, { useCallback, ReactNode, useRef } from 'react';
import Page from '../../../components/Page';
import VerticalBar from '../../../components/VerticalBar';
import { useEndpointData } from '../../../hooks/useEndpointData';
import { AsyncStatePhase } from '../../../lib/asyncState';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
import AddCustomUserStatus from './AddCustomUserStatus';
import CustomUserStatus, { paramsType, SortType } from './CustomUserStatus';
import EditCustomUserStatusWithData from './EditCustomUserStatusWithData';
import CustomUserStatusFormWithData from './CustomUserStatusFormWithData';
import CustomUserStatusTable from './CustomUserStatusTable';
function CustomUserStatusRoute(): ReactNode {
const CustomUserStatusRoute = (): ReactNode => {
const t = useTranslation();
const route = useRoute('custom-user-status');
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const canManageUserStatus = usePermission('manage-user-status');
const t = useTranslation();
const [params, setParams] = useState<paramsType>(() => ({ text: '', current: 0, itemsPerPage: 25 }));
const [sort, setSort] = useState<SortType>(() => ['name', 'asc']);
const { text, itemsPerPage, current } = useDebouncedValue(params, 500);
const [column, direction] = useDebouncedValue(sort, 500);
const query = useMemo(
() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[text, itemsPerPage, current, column, direction],
);
const { reload, ...result } = useEndpointData('custom-user-status.list', query);
const handleItemClick = (id: string) => (): void => {
const handleItemClick = (id: string): void => {
route.push({
context: 'edit',
id,
});
};
const handleHeaderClick = (id: SortType[0]): void => {
setSort(([sortBy, sortDirection]) => {
if (sortBy === id) {
return [id, sortDirection === 'asc' ? 'desc' : 'asc'];
}
return [id, 'asc'];
});
};
const handleNewButtonClick = useCallback(() => {
route.push({ context: 'new' });
}, [route]);
......@@ -62,18 +30,16 @@ function CustomUserStatusRoute(): ReactNode {
route.push({});
}, [route]);
const handleChange = useCallback(() => {
reload();
const reload = useRef(() => null);
const handleReload = useCallback(() => {
reload.current();
}, [reload]);
if (!canManageUserStatus) {
return <NotAuthorizedPage />;
}
if (result.phase === AsyncStatePhase.LOADING || result.phase === AsyncStatePhase.REJECTED) {
return null;
}
return (
<Page flexDirection='row'>
<Page name='admin-custom-user-status'>
......@@ -83,30 +49,20 @@ function CustomUserStatusRoute(): ReactNode {
</Button>
</Page.Header>
<Page.Content>
<CustomUserStatus
setParams={setParams}
params={params}
onHeaderClick={handleHeaderClick}
data={result.value}
onClick={handleItemClick}
sort={sort}
/>
<CustomUserStatusTable reload={reload} onClick={handleItemClick} />
</Page.Content>
</Page>
{context && (
<VerticalBar flexShrink={0}>
<VerticalBar.Header>
{context === 'edit' && t('Custom_User_Status_Edit')}
{context === 'new' && t('Custom_User_Status_Add')}
{context === 'edit' ? t('Custom_User_Status_Edit') : t('Custom_User_Status_Add')}
<VerticalBar.Close onClick={handleClose} />
</VerticalBar.Header>
{context === 'edit' && <EditCustomUserStatusWithData _id={id} close={handleClose} onChange={handleChange} />}
{context === 'new' && <AddCustomUserStatus goToNew={handleItemClick} close={handleClose} onChange={handleChange} />}
<CustomUserStatusFormWithData _id={id} onClose={handleClose} onReload={handleReload} />
</VerticalBar>
)}
</Page>
);
}
};
export default CustomUserStatusRoute;
import { IUserStatus } from '@rocket.chat/core-typings';
import { TableRow, TableCell } from '@rocket.chat/fuselage';
import React, { CSSProperties, ReactElement } from 'react';
import MarkdownText from '../../../../components/MarkdownText';
const style: CSSProperties = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' };
type CustomUserStatusRowProps = {
status: IUserStatus;
onClick: (id: string) => void;
};
const CustomUserStatusRow = ({ status, onClick }: CustomUserStatusRowProps): ReactElement => {
const { _id, name, statusType } = status;
return (
<TableRow
key={_id}
onKeyDown={(): void => onClick(_id)}
onClick={(): void => onClick(_id)}
tabIndex={0}
role='link'
action
qa-user-id={_id}
>
<TableCell fontScale='p2' color='default' style={style}>
<MarkdownText content={name} parseEmoji={true} variant='inline' />
</TableCell>
<TableCell fontScale='p2' color='default' style={style}>
{statusType}
</TableCell>
</TableRow>
);
};
export default CustomUserStatusRow;
import { States, StatesIcon, StatesTitle, Pagination } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { ReactElement, useState, useMemo, MutableRefObject, useEffect } from 'react';
import FilterByText from '../../../../components/FilterByText';
import {
GenericTable,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableBody,
GenericTableLoadingTable,
} from '../../../../components/GenericTable';
import { usePagination } from '../../../../components/GenericTable/hooks/usePagination';
import { useSort } from '../../../../components/GenericTable/hooks/useSort';
import { useEndpointData } from '../../../../hooks/useEndpointData';
import { AsyncStatePhase } from '../../../../lib/asyncState';
import CustomUserStatusRow from './CustomUserStatusRow';
type CustomUserStatusProps = {
reload: MutableRefObject<() => void>;
onClick: (id: string) => void;
};
const CustomUserStatus = ({ reload, onClick }: CustomUserStatusProps): ReactElement | null => {
const t = useTranslation();
const [text, setText] = useState('');
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const { sortBy, sortDirection, setSort } = useSort<'name' | 'statusType'>('name');
const query = useDebouncedValue(
useMemo(
() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
count: itemsPerPage,
offset: current,
}),
[text, itemsPerPage, current, sortBy, sortDirection],
),
500,
);
const { value, reload: reloadEndpoint, phase } = useEndpointData('custom-user-status.list', query);
useEffect(() => {
reload.current = reloadEndpoint;
}, [reload, reloadEndpoint]);
if (phase === AsyncStatePhase.REJECTED) {
return null;
}
return (
<>
<FilterByText onChange={({ text }): void => setText(text)} />
{value?.statuses.length === 0 && (
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_results_found')}</StatesTitle>
</States>
)}
{value?.statuses && value.statuses.length > 0 && (
<>
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
{t('Name')}
</GenericTableHeaderCell>
<GenericTableHeaderCell
key='presence'
direction={sortDirection}
active={sortBy === 'statusType'}
onClick={setSort}
sort='statusType'
>
{t('Presence')}
</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{phase === AsyncStatePhase.LOADING && <GenericTableLoadingTable headerCells={2} />}
{value?.statuses.map((status) => (
<CustomUserStatusRow key={status._id} status={status} onClick={onClick} />
))}
</GenericTableBody>
</GenericTable>
{phase === AsyncStatePhase.RESOLVED && (
<Pagination
current={current}
itemsPerPage={itemsPerPage}
count={value?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
)}
</>
)}
</>
);
};
export default CustomUserStatus;
export { default } from './CustomUserStatusTable';
export interface IUserStatus {
[x: string]: any;
_id: string;
name: string;
statusType: string;
}
import type { IUserStatus } from '@rocket.chat/core-typings';
import type { PaginatedRequest } from '../helpers/PaginatedRequest';
import type { PaginatedResult } from '../helpers/PaginatedResult';
export type CustomUserStatusEndpoints = {
'custom-user-status.list': {
GET: (params: PaginatedRequest<{ query: string }>) => PaginatedResult<{
statuses: {
_id: string;
name: string;
statusType: string;
}[];
statuses: IUserStatus[];
}>;
};
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment