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

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

View File

@@ -0,0 +1,20 @@
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 './AppInfoField.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,88 @@
import type { AppSubscriptionStatus } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { StoryObj, Meta } from '@storybook/react';
import { AppInfoField } from './AppInfoField';
export default {
component: AppInfoField,
args: {
appId: 'app-id',
},
decorators: [
mockAppRoot()
.withEndpoint('GET', '/apps/:id', () => ({
app: {
name: 'App Name',
id: '',
iconFileData: '',
appRequestStats: {
appId: '',
totalSeen: 0,
totalUnseen: 0,
},
author: {
name: '',
homepage: '',
support: '',
},
description: '',
privacyPolicySummary: '',
detailedDescription: {
raw: '',
rendered: '',
},
detailedChangelog: {
raw: '',
rendered: '',
},
categories: [],
version: '',
price: 0,
purchaseType: 'buy' as const,
pricingPlans: [],
iconFileContent: '',
isSubscribed: false,
bundledIn: [],
marketplaceVersion: '',
// Recursive typem expect an App type here
latest: undefined as any,
subscriptionInfo: {
typeOf: '',
status: 'Active' as AppSubscriptionStatus,
statusFromBilling: false,
isSeatBased: false,
seats: 0,
maxSeats: 0,
license: {
license: '',
version: 0,
expireDate: '',
},
startDate: '',
periodEnd: '',
endDate: '',
externallyManaged: false,
isSubscribedViaBundle: false,
},
tosLink: '',
privacyLink: '',
modifiedAt: '',
permissions: [],
languages: [],
createdDate: '',
private: false,
documentationUrl: '',
migrated: false,
},
}))
.withTranslations('en', 'core', { App_name: 'App Name' })
.buildStoryDecorator(),
],
} satisfies Meta<typeof AppInfoField>;
export const Default: StoryObj<typeof AppInfoField> = {};
export const NoAppInfo: StoryObj<typeof AppInfoField> = {
decorators: [mockAppRoot().withTranslations('en', 'core', { App_id: 'App Id' }).buildStoryDecorator()],
};

View File

@@ -0,0 +1,40 @@
import { Skeleton } from '@rocket.chat/fuselage';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import AuditModalField from './AuditModalField';
import AuditModalLabel from './AuditModalLabel';
import AuditModalText from './AuditModalText';
export type AppInfoFieldProps = {
appId: string;
};
// This is a separate component to encapsulate its logic and in the future expand it to a field that shows more info on the App
export const AppInfoField = ({ appId }: AppInfoFieldProps) => {
const t = useTranslation();
const getAppInfo = useEndpoint('GET', `/apps/:id`, { id: appId });
const { data, isLoading, isSuccess } = useQuery({
queryKey: ['getAppInfo', appId],
queryFn: async () => {
return getAppInfo();
},
});
return (
<>
<AuditModalField>
<AuditModalLabel>{t('Actor')}</AuditModalLabel>
<AuditModalText>{t('App')}</AuditModalText>
</AuditModalField>
<AuditModalField>
<AuditModalLabel>{isSuccess && data ? t('App_name') : t('App_id')}</AuditModalLabel>
{isLoading && <Skeleton />}
{isSuccess && data ? <AuditModalText>{data.app.name}</AuditModalText> : <AuditModalText>{appId}</AuditModalText>}
</AuditModalField>
</>
);
};

View File

@@ -0,0 +1,32 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useFormatDate } from '../../../hooks/useFormatDate';
export type AuditFiltersDisplayProps = {
users?: IUser['username'][];
room?: IRoom['name'];
startDate?: Date;
endDate?: Date;
filters?: string;
};
const AuditFiltersDisplay = ({ users, room, startDate, endDate, filters }: AuditFiltersDisplayProps) => {
const formatDate = useFormatDate();
const { t } = useTranslation();
return (
<Box display='flex' flexDirection='column' alignItems='stretch' withTruncatedText>
<Box withTruncatedText>{users?.length ? users.map((user) => `@${user}`).join(' : ') : `#${room}`}</Box>
{startDate && endDate ? (
<Box withTruncatedText>
{formatDate(startDate)} {t('Date_to')} {formatDate(endDate)} {/* TODO: fix this translation */}
</Box>
) : null}
{filters ? <Box withTruncatedText>{filters}</Box> : null}
</Box>
);
};
export default AuditFiltersDisplay;

View File

@@ -0,0 +1,78 @@
import type { IAuditLog, IRoom } from '@rocket.chat/core-typings';
import { Box, Field, FieldLabel, FieldRow, FieldError, TextInput, Button, ButtonGroup } from '@rocket.chat/fuselage';
import type { Dispatch, SetStateAction } from 'react';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { AuditFields } from '../hooks/useAuditForm';
import { useAuditForm } from '../hooks/useAuditForm';
import { useSendTelemetryMutation } from '../hooks/useSendTelemetryMutation';
import DateRangePicker from './forms/DateRangePicker';
import DirectTab from './tabs/DirectTab';
import OmnichannelTab from './tabs/OmnichannelTab';
import RoomsTab from './tabs/RoomsTab';
import UsersTab from './tabs/UsersTab';
export type AuditFormProps = {
type: IAuditLog['fields']['type'];
onSubmit?: (payload: { type: IAuditLog['fields']['type'] } & AuditFields) => void;
setSelectedRoom: Dispatch<SetStateAction<IRoom | undefined>>;
};
const AuditForm = ({ type, onSubmit, setSelectedRoom }: AuditFormProps) => {
const { t } = useTranslation();
const form = useAuditForm();
const { control, handleSubmit, register } = form;
const { field: dateRangeField, fieldState: dateRangeFieldState } = useController({ name: 'dateRange', control });
const sendTelemetryMutation = useSendTelemetryMutation();
const submit = () => {
sendTelemetryMutation.mutate({
params: [{ eventName: 'updateCounter', settingsId: 'Message_Auditing_Apply_Count', timestamp: Date.now() }],
});
onSubmit?.({ type, ...form.getValues() });
};
return (
<form onSubmit={handleSubmit(submit)}>
<Box display='flex' flexDirection='row' justifyContent='stretch' marginInline={-4}>
<Field flexShrink={1} marginInline={4}>
<FieldLabel>{t('Message')}</FieldLabel>
<FieldRow>
<TextInput placeholder={t('Search')} {...register('msg')} />
</FieldRow>
</Field>
<Field flexShrink={1} marginInline={4}>
<FieldLabel>{t('Date')}</FieldLabel>
<FieldRow>
<DateRangePicker value={dateRangeField.value} onChange={dateRangeField.onChange} display='flex' flexGrow={1} />
{dateRangeFieldState.error?.type === 'required' && <FieldError>{t('Required_field', { field: t('Date') })}</FieldError>}
{dateRangeFieldState.error?.type === 'validate' && <FieldError>{dateRangeFieldState.error.message}</FieldError>}
</FieldRow>
</Field>
</Box>
<Box display='flex' flexDirection='row' alignItems='flex-start'>
{type === '' && <RoomsTab form={form} setSelectedRoom={setSelectedRoom} />}
{type === 'u' && <UsersTab form={form} />}
{type === 'd' && <DirectTab form={form} />}
{type === 'l' && <OmnichannelTab form={form} />}
<Box flexShrink={0} marginBlockStart={28} marginInlineStart={8}>
<ButtonGroup align='end'>
<Button secondary onClick={() => window.print()}>
{t('Export')} {t('PDF')}
</Button>
<Button primary type='submit'>
{t('Apply')}
</Button>
</ButtonGroup>
</Box>
</Box>
</form>
);
};
export default AuditForm;

View File

@@ -0,0 +1,58 @@
import type { IAuditLog } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import AuditFiltersDisplay from './AuditFiltersDisplay';
import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime';
export type AuditLogEntryProps = { value: IAuditLog };
const AuditLogEntry = ({ value: { u, results, ts, _id, fields } }: AuditLogEntryProps) => {
const formatDateAndTime = useFormatDateAndTime();
const { t } = useTranslation();
const { username, name, avatarETag } = u;
const { msg, users, room, startDate, endDate, type, filters } = fields;
const when = useMemo(() => formatDateAndTime(ts), [formatDateAndTime, ts]);
const mediaQuery = useMediaQuery('(min-width: 1024px)');
return (
<GenericTableRow key={_id} tabIndex={0} role='link'>
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
{username && <UserAvatar size={mediaQuery ? 'x28' : 'x40'} title={username} username={username} etag={avatarETag} />}
<Box display='flex' withTruncatedText marginInline={8}>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' withTruncatedText color='default'>
{name || username}
</Box>
{name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{' '}
{`@${username}`}{' '}
</Box>
)}
</Box>
</Box>
</Box>
</GenericTableCell>
<GenericTableCell fontScale='p2m' color='hint' withTruncatedText>
{type === 'room_member_list' ? t('Room_members_list') : msg}
</GenericTableCell>
<GenericTableCell withTruncatedText>{when}</GenericTableCell>
<GenericTableCell withTruncatedText>{results}</GenericTableCell>
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
<AuditFiltersDisplay users={users} room={room} startDate={startDate} endDate={endDate} filters={filters} />
</GenericTableCell>
</GenericTableRow>
);
};
export default memo(AuditLogEntry);

View File

@@ -0,0 +1,92 @@
import type { IAuditLog } from '@rocket.chat/core-typings';
import { Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { GenericTable, GenericTableHeaderCell, GenericTableBody, GenericTableLoadingRow, GenericTableHeader } from '@rocket.chat/ui-client';
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import AuditLogEntry from './AuditLogEntry';
import GenericNoResults from '../../../components/GenericNoResults';
import { createEndOfToday, createStartOfToday } from '../utils/dateRange';
import type { DateRange } from '../utils/dateRange';
import DateRangePicker from './forms/DateRangePicker';
const AuditLogTable = () => {
const t = useTranslation();
const [dateRange, setDateRange] = useState<DateRange>(() => ({
start: createStartOfToday(),
end: createEndOfToday(),
}));
const getAudits = useEndpoint('GET', '/v1/audit.auditions');
const { data, isLoading, isSuccess } = useQuery({
queryKey: ['audits', dateRange],
queryFn: async (): Promise<IAuditLog[]> => {
const { start, end } = dateRange;
const { auditions } = await getAudits({
startDate: (start ?? new Date(0)).toISOString(),
endDate: (end ?? new Date()).toISOString(),
});
// the REST payload serializes Date fields to strings; deserialize back to Date so the entry
// components can keep their IAuditLog (Date) typings instead of force-casting per row
return auditions.map((audition): IAuditLog => ({
...audition,
ts: new Date(audition.ts),
_updatedAt: new Date(audition._updatedAt),
fields: {
...audition.fields,
startDate: audition.fields.startDate ? new Date(audition.fields.startDate) : undefined,
endDate: audition.fields.endDate ? new Date(audition.fields.endDate) : undefined,
},
}));
},
meta: {
apiErrorToastMessage: true,
},
});
const headers = (
<>
<GenericTableHeaderCell>{t('User')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Looked_for')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('When')}</GenericTableHeaderCell>
<GenericTableHeaderCell width={80}>{t('Results')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Filters_applied')}</GenericTableHeaderCell>
</>
);
return (
<>
<Field alignSelf='stretch'>
<FieldLabel>{t('Date')}</FieldLabel>
<FieldRow>
<DateRangePicker display='flex' flexGrow={1} value={dateRange} onChange={setDateRange} />
</FieldRow>
</Field>
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingRow cols={4} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data.length === 0 && <GenericNoResults />}
{isSuccess && data.length > 0 && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data.map((auditLog) => (
<AuditLogEntry key={auditLog._id} value={auditLog} />
))}
</GenericTableBody>
</GenericTable>
)}
</>
);
};
export default AuditLogTable;

View File

@@ -0,0 +1,50 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { MessageDivider } from '@rocket.chat/fuselage';
import { MessageTypes } from '@rocket.chat/message-types';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { Fragment, memo } from 'react';
import RoomMessage from '../../../components/message/variants/RoomMessage';
import SystemMessage from '../../../components/message/variants/SystemMessage';
import { useFormatDate } from '../../../hooks/useFormatDate';
import { isMessageNewDay } from '../../room/MessageList/lib/isMessageNewDay';
export type AuditMessageListProps = {
messages: IMessage[];
};
const AuditMessageList = ({ messages }: AuditMessageListProps) => {
const formatDate = useFormatDate();
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
return (
<>
{messages.map((message, index, { [index - 1]: previous }) => {
const newDay = isMessageNewDay(message, previous);
const system = MessageTypes.isSystemMessage(message);
return (
<Fragment key={message._id}>
{newDay && <MessageDivider>{formatDate(message.ts)}</MessageDivider>}
{!system && (
<RoomMessage
message={message}
sequential={false}
unread={false}
mention={false}
all={false}
ignoredUser={false}
showUserAvatar={showUserAvatar}
/>
)}
{system && <SystemMessage message={message} showUserAvatar={showUserAvatar} />}
</Fragment>
);
})}
</>
);
};
export default memo(AuditMessageList);

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AuditModalFieldProps = ComponentPropsWithoutRef<typeof Box>;
const AuditModalField = (props: AuditModalFieldProps) => <Box marginBlock={12} {...props} />;
export default AuditModalField;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type AuditModalLabelProps = ComponentPropsWithoutRef<typeof Box>;
const AuditModalLabel = (props: AuditModalLabelProps) => <Box marginBlockEnd={4} fontScale='p2m' color='titles-labels' {...props} />;
export default AuditModalLabel;

View File

@@ -0,0 +1,13 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
const wordBreak = css`
word-break: break-word;
`;
export type AuditModalTextProps = ComponentPropsWithoutRef<typeof Box>;
const AuditModalText = (props: AuditModalTextProps) => <Box fontScale='p2' color='default' className={wordBreak} {...props} />;
export default AuditModalText;

View File

@@ -0,0 +1,24 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { memo } from 'react';
import AuditMessageList from './AuditMessageList';
import GenericNoResults from '../../../components/GenericNoResults';
export type AuditResultProps = {
className?: string;
messages: IMessage[];
};
const AuditResult = ({ className, messages }: AuditResultProps) => {
if (messages.length === 0) {
return <GenericNoResults />;
}
return (
<div className={className} role='list' aria-live='polite'>
<AuditMessageList messages={messages} />
</div>
);
};
export default memo(AuditResult);

View File

@@ -0,0 +1,20 @@
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 './SecurityLogDisplayModal.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,30 @@
import type { StoryObj, Meta } from '@storybook/react';
import SecurityLogDisplayModal from './SecurityLogDisplayModal';
export default {
component: SecurityLogDisplayModal,
args: {
timestamp: 'Thursday, 20-Mar-25 17:17:46',
actor: {
type: 'user',
_id: 'user-id',
username: 'username',
useragent: 'useragent',
ip: '127.0.0.1',
},
setting: 'Show_message_in_email_notification',
changedFrom: 'false',
changedTo: 'true',
},
} satisfies Meta<typeof SecurityLogDisplayModal>;
export const Default: StoryObj<typeof SecurityLogDisplayModal> = {};
export const system: StoryObj<typeof SecurityLogDisplayModal> = {
render: (args) => <SecurityLogDisplayModal {...args} actor={{ type: 'system', reason: 'update' }} />,
};
export const app: StoryObj<typeof SecurityLogDisplayModal> = {
render: (args) => <SecurityLogDisplayModal {...args} actor={{ type: 'app', _id: 'app-id' }} />,
};

View File

@@ -0,0 +1,85 @@
import type { IAuditServerUserActor, IAuditServerSystemActor, IAuditServerAppActor } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { GenericModal } from '@rocket.chat/ui-client';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { AppInfoField } from './AppInfoField';
import AuditModalField from './AuditModalField';
import AuditModalLabel from './AuditModalLabel';
import AuditModalText from './AuditModalText';
type SecurityLogDisplayProps = {
timestamp: string;
actor: IAuditServerUserActor | IAuditServerSystemActor | IAuditServerAppActor;
setting: string;
changedFrom: string;
changedTo: string;
onCancel: () => void;
};
const SecurityLogDisplayModal = ({ timestamp, actor, setting, changedFrom, changedTo, onCancel }: SecurityLogDisplayProps) => {
const { t } = useTranslation();
return (
<GenericModal maxHeight={550} icon={null} onClose={onCancel} title={t('Setting_change')}>
<AuditModalField>
<AuditModalLabel>{t('Timestamp')}</AuditModalLabel>
<AuditModalText>{format(new Date(timestamp), 'MMMM d yyyy, h:mm:ss a')}</AuditModalText>
</AuditModalField>
{actor.type === 'user' && (
<AuditModalField>
<AuditModalLabel>{t('Actor')}</AuditModalLabel>
<Box display='flex' alignItems='center'>
{actor.type === 'user' && <UserAvatar size='x24' userId={actor._id} />}
<Box
marginInline={actor.type === 'user' ? 8 : 0}
fontScale='p2m'
display='flex'
flexDirection='column'
alignSelf='center'
withTruncatedText
>
{actor.username}
</Box>
</Box>
</AuditModalField>
)}
{actor.type === 'app' && <AppInfoField appId={actor._id} />}
{actor.type === 'system' && (
<>
<AuditModalField>
<AuditModalLabel>{t('Actor')}</AuditModalLabel>
<AuditModalText>{t('System')}</AuditModalText>
</AuditModalField>
<AuditModalField>
<AuditModalLabel>{t('Reason')}</AuditModalLabel>
<AuditModalText>{actor.reason}</AuditModalText>
</AuditModalField>
</>
)}
<AuditModalField>
<AuditModalLabel>{t('Setting')}</AuditModalLabel>
<AuditModalText>{t(setting)}</AuditModalText>
</AuditModalField>
<AuditModalField>
<AuditModalLabel>{t('Changed_from')}</AuditModalLabel>
<AuditModalText>{changedFrom}</AuditModalText>
</AuditModalField>
<AuditModalField>
<AuditModalLabel>{t('Changed_to')}</AuditModalLabel>
<AuditModalText>{changedTo}</AuditModalText>
</AuditModalField>
</GenericModal>
);
};
export default SecurityLogDisplayModal;

View File

@@ -0,0 +1,219 @@
import type { IAuditServerAppActor, IAuditServerSystemActor, IAuditServerUserActor } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup, Field, FieldLabel, Margins, Pagination } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableLoadingRow,
GenericTableRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useEndpoint, useSetModal } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import SecurityLogDisplayModal from './SecurityLogDisplayModal';
import { SettingSelect } from './SettingSelect';
import DateRangePicker from './forms/DateRangePicker';
import GenericNoResults from '../../../components/GenericNoResults';
import type { DateRange } from '../utils/dateRange';
import { getTypeTranslation } from '../utils/getAppTypeTranslation';
const SecurityLogsTable = () => {
const { t } = useTranslation();
const [setting, setSetting] = useState('');
const setModal = useSetModal();
const [dateRange, setDateRange] = useState<DateRange>(() => ({
start: undefined,
end: undefined,
}));
const [query, setQuery] = useState({
start: new Date(0).toISOString(),
end: new Date().toISOString(),
settingId: '',
});
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const handleClearFilters = () => {
setSetting('');
setDateRange({ start: undefined, end: undefined });
setQuery({
start: new Date(0).toISOString(),
end: new Date().toISOString(),
settingId: '',
});
onSetCurrent(0);
};
const handleApplyFilters = () => {
const { start, end } = dateRange;
setQuery({
start: start?.toISOString() ?? new Date(0).toISOString(),
end: end?.toISOString() ?? new Date().toISOString(),
settingId: setting,
});
onSetCurrent(0);
};
const handleItemClick = ({
actor,
timestamp,
setting,
changedFrom,
changedTo,
}: {
actor: IAuditServerUserActor | IAuditServerSystemActor | IAuditServerAppActor;
timestamp: string;
setting: unknown;
changedFrom: string;
changedTo: string;
}) => {
setModal(
<SecurityLogDisplayModal
timestamp={timestamp}
actor={actor}
setting={String(setting)}
changedFrom={changedFrom}
changedTo={changedTo}
onCancel={() => setModal(null)}
/>,
);
};
const getAudits = useEndpoint('GET', '/v1/audit.settings');
const { data, isLoading, isSuccess } = useQuery({
queryKey: ['audit.settings', query, itemsPerPage, current],
queryFn: async () => {
return getAudits({ ...query, ...(itemsPerPage && { count: itemsPerPage }), ...(current && { offset: current }) });
},
});
return (
<>
<Box marginBlock={16} display='flex' flexWrap='wrap' alignItems='flex-end'>
<Field width='unset' flexShrink={1} flexGrow={1}>
<Margins inline={6}>
<FieldLabel>{t('Date')}</FieldLabel>
<DateRangePicker display='flex' flexGrow={1} value={dateRange} onChange={setDateRange} />
</Margins>
</Field>
<Field width={300}>
<Margins inline={6}>
<FieldLabel>{t('Setting')}</FieldLabel>
<SettingSelect value={setting} onChange={setSetting} />
</Margins>
</Field>
<ButtonGroup>
<Margins blockStart={12} inline={6}>
<Button secondary onClick={handleClearFilters}>
{t('Clear_filters')}
</Button>
<Button primary onClick={handleApplyFilters}>
{t('Apply_filters')}
</Button>
</Margins>
</ButtonGroup>
</Box>
{isLoading && (
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell>{t('Actor')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Setting')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Changed_from')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Changed_to')}</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingRow cols={5} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data.total === 0 && (
<GenericNoResults
title={t('No_results_found')}
description={t('Try_different_filters')}
buttonTitle={t('Clear_filters')}
buttonAction={handleClearFilters}
/>
)}
{isSuccess && data.total > 0 && (
<GenericTable striped>
<GenericTableHeader>
<GenericTableHeaderCell>{t('Actor')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Setting')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Changed_from')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Changed_to')}</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{data.events.map((item) => {
const setting = item.data.find((item) => item.key === 'id')?.value;
const previous = item.data.find((item) => item.key === 'previous')?.value || t('Empty');
const current = item.data.find((item) => item.key === 'current')?.value || t('Empty');
return (
<GenericTableRow
key={item._id}
role='link'
action
tabIndex={0}
height={44}
onClick={() =>
handleItemClick({
actor: item.actor,
timestamp: item.ts,
setting,
changedFrom: String(previous),
changedTo: String(current),
})
}
>
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
{item.actor.type === 'user' && (
<Box marginInlineEnd={4}>
<UserAvatar size='x24' userId={item.actor._id} />
</Box>
)}
<Box fontScale='p2m' withTruncatedText color='default'>
{item.actor.type === 'user' ? item.actor.username : t(getTypeTranslation(item.actor.type))}
</Box>
</Box>
</GenericTableCell>
<GenericTableCell withTruncatedText>{format(new Date(item.ts), 'MMMM d yyyy, h:mm:ss a')}</GenericTableCell>
<GenericTableCell withTruncatedText title={setting && String(setting)}>
{setting && String(setting)}
</GenericTableCell>
<GenericTableCell withTruncatedText>{String(previous)}</GenericTableCell>
<GenericTableCell withTruncatedText>{String(current)}</GenericTableCell>
</GenericTableRow>
);
})}
</GenericTableBody>
</GenericTable>
)}
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
);
};
export default SecurityLogsTable;

View File

@@ -0,0 +1,34 @@
import { Option, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSettingSelectOptions } from '../hooks/useSettingSelectOptions';
export const SettingSelect = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>('');
const debouncedFilter = useDebouncedValue(filter, 500);
const { data, fetchNextPage, isFetchingNextPage } = useSettingSelectOptions(debouncedFilter);
const flattenedData = data?.pages.flatMap((page) => page) || [];
return (
<PaginatedSelectFiltered
flexShrink={1}
value={value}
onChange={(val) => onChange(val)}
placeholder={t('All_Settings')}
filter={filter}
setFilter={setFilter as (value: string | number | undefined) => void}
options={flattenedData}
endReached={() => !isFetchingNextPage && fetchNextPage({ cancelRefetch: true })}
renderItem={({ label, ...props }) => (
<Option {...props} title={t(label)}>
{label}
</Option>
)}
/>
);
};

View File

@@ -0,0 +1,77 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Actor
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
App
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
App_id
</div>
<span
class="rcx-skeleton rcx-skeleton--text"
/>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
app-id
</div>
</div>
</div>
</body>
`;
exports[`renders NoAppInfo without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Actor
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
App
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
App Id
</div>
<span
class="rcx-skeleton rcx-skeleton--text"
/>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
app-id
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,412 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal rcx-css-9ju0e2"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Setting_change
</h2>
</div>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Timestamp
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
March 20 2025, 5:17:46 PM
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Actor
</div>
<div
class="rcx-box rcx-box--full rcx-css-127j9mz"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x24"
>
<img
alt="Avatar"
aria-hidden="true"
class="rcx-avatar__element rcx-avatar__element--x24"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
/>
</figure>
<div
class="rcx-box rcx-box--full rcx-css-2f8n8y"
>
username
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Setting
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
Show_message_in_email_notification
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Changed_from
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
false
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Changed_to
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
true
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
/>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders app without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_1_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal rcx-css-9ju0e2"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_1_-title"
>
Setting_change
</h2>
</div>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Timestamp
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
March 20 2025, 5:17:46 PM
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Actor
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
App
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
App_id
</div>
<span
class="rcx-skeleton rcx-skeleton--text"
/>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
app-id
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Setting
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
Show_message_in_email_notification
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Changed_from
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
false
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Changed_to
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
true
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
/>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`renders system without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_2_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal rcx-css-9ju0e2"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_2_-title"
>
Setting_change
</h2>
</div>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Timestamp
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
March 20 2025, 5:17:46 PM
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Actor
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
System
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Reason
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
update
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Setting
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
Show_message_in_email_notification
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Changed_from
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
false
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-op9h3g"
>
<div
class="rcx-box rcx-box--full rcx-css-1xvc5bb"
>
Changed_to
</div>
<div
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1i2cn7r"
>
true
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
/>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1,204 @@
import { Box, InputBox, Margins } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericMenu } from '@rocket.chat/ui-client';
import { startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, subDays, subWeeks, subMonths, parseISO } from 'date-fns';
import type { ComponentProps, SetStateAction, ChangeEvent } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { DateRange } from '../../utils/dateRange';
const formatToDateInput = (date: Date | undefined) => {
if (!date) {
return '';
}
const yearPart = date.getFullYear().toString().padStart(4, '0').slice(0, 4);
const monthPart = (date.getMonth() + 1).toString().padStart(2, '0').slice(0, 2);
const dayPart = date.getDate().toString().padStart(2, '0').slice(0, 2);
return `${yearPart}-${monthPart}-${dayPart}`;
};
const parseFromStartDateInput = (date: string) => {
if (!date) {
return undefined;
}
return startOfDay(parseISO(date));
};
const parseFromEndDateInput = (date: string) => {
if (!date) {
return undefined;
}
return endOfDay(parseISO(date));
};
type DateRangeAction =
| SetStateAction<DateRange>
| 'today'
| 'yesterday'
| 'this-week'
| 'last-week'
| 'this-month'
| 'last-month'
| { newStart: string }
| { newEnd: string };
const dateRangeReducer = (state: DateRange, action: DateRangeAction): DateRange => {
const now = new Date();
switch (action) {
case 'today': {
return {
start: startOfDay(now),
end: endOfDay(now),
};
}
case 'yesterday': {
const yesterday = subDays(now, 1);
return {
start: startOfDay(yesterday),
end: endOfDay(yesterday),
};
}
case 'this-week': {
return {
start: startOfWeek(now),
end: endOfDay(now),
};
}
case 'last-week': {
const lastWeek = subWeeks(now, 1);
return {
start: startOfWeek(lastWeek),
end: endOfWeek(lastWeek),
};
}
case 'this-month': {
return {
start: startOfMonth(now),
end: endOfDay(now),
};
}
case 'last-month': {
const lastMonth = subMonths(now, 1);
return {
start: startOfMonth(lastMonth),
end: endOfMonth(lastMonth),
};
}
default:
if (typeof action === 'object' && 'newStart' in action) {
if (action.newStart === formatToDateInput(state.start)) {
return state;
}
return { ...state, start: parseFromStartDateInput(action.newStart) };
}
if (typeof action === 'object' && 'newEnd' in action) {
if (action.newEnd === formatToDateInput(state.end)) {
return state;
}
return { ...state, end: parseFromEndDateInput(action.newEnd) };
}
const newState = typeof action === 'function' ? action(state) : action;
return newState;
}
};
export type DateRangePickerProps = Omit<ComponentProps<typeof Box>, 'value' | 'onChange'> & {
value?: DateRange;
onChange?: (dateRange: DateRange) => void;
};
const minDate = (a: Date, b: Date) => (a.getTime() < b.getTime() ? a : b);
const DateRangePicker = ({ value, onChange, ...props }: DateRangePickerProps) => {
const dispatch = useStableCallback((action: DateRangeAction): void => {
const newRange = dateRangeReducer(value ?? { start: undefined, end: undefined }, action);
onChange?.(newRange);
});
const handleChangeStart = useStableCallback(({ currentTarget }: ChangeEvent<HTMLInputElement>) => {
dispatch({ newStart: currentTarget.value });
});
const handleChangeEnd = useStableCallback(({ currentTarget }: ChangeEvent<HTMLInputElement>) => {
dispatch({ newEnd: currentTarget.value });
});
const startDate = useMemo(() => formatToDateInput(value?.start), [value?.start]);
const endDate = useMemo(() => formatToDateInput(value?.end), [value?.end]);
const maxStartDate = useMemo(() => {
return formatToDateInput(value?.end ? minDate(value.end, new Date()) : new Date());
}, [value?.end]);
const minEndDate = startDate;
const maxEndDate = useMemo(() => formatToDateInput(new Date()), []);
const { t } = useTranslation();
const presets = useMemo(
() => [
{
id: 'today',
icon: 'history' as const,
content: t('Today'),
onClick: () => dispatch('today'),
},
{
id: 'yesterday',
icon: 'history' as const,
content: t('Yesterday'),
onClick: () => dispatch('yesterday'),
},
{
id: 'thisWeek',
icon: 'history' as const,
content: t('This_week'),
onClick: () => dispatch('this-week'),
},
{
id: 'previousWeek',
icon: 'history' as const,
content: t('Previous_week'),
onClick: () => dispatch('last-week'),
},
{
id: 'thisMonth',
icon: 'history' as const,
content: t('This_month'),
onClick: () => dispatch('this-month'),
},
{
id: 'lastMonth',
icon: 'history' as const,
content: t('Previous_month'),
onClick: () => dispatch('last-month'),
},
],
[dispatch, t],
);
return (
<Box marginInline={-4} alignItems='center' {...props}>
<Margins inline={4}>
<InputBox type='date' value={startDate} max={maxStartDate} flexGrow={1} height={20} onChange={handleChangeStart} />
<InputBox type='date' min={minEndDate} value={endDate} max={maxEndDate} flexGrow={1} height={20} onChange={handleChangeEnd} />
<GenericMenu title={t('Date_range_presets')} items={presets} small />
</Margins>
</Box>
);
};
export default DateRangePicker;

View File

@@ -0,0 +1,39 @@
import { AutoComplete, Option } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
export type VisitorAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'>;
const VisitorAutoComplete = ({ value, onChange, ...props }: VisitorAutoCompleteProps) => {
const [filter, setFilter] = useState('');
const performVisitorSearch = useEndpoint('GET', '/v1/livechat/visitors.autocomplete');
const visitorAutocompleteQueryResult = useQuery({
queryKey: ['audit', 'visitors', filter],
queryFn: () => performVisitorSearch({ selector: JSON.stringify({ term: filter ?? '' }) }),
});
const options = useMemo(
() => visitorAutocompleteQueryResult.data?.items.map((user) => ({ value: user._id, label: user.name ?? user.username })) ?? [],
[visitorAutocompleteQueryResult.data],
);
return (
<AutoComplete
{...props}
value={value}
onChange={onChange}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { label } }) => <>{label}</>}
renderItem={({ value, ...props }) => <Option key={value} {...props} />}
options={options}
/>
);
};
export default memo(VisitorAutoComplete);

View File

@@ -0,0 +1,46 @@
import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage';
import type { UseFormReturn } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import UserAutoCompleteMultiple from '../../../../components/UserAutoCompleteMultiple';
import type { AuditFields } from '../../hooks/useAuditForm';
export type DirectTabProps = {
form: UseFormReturn<AuditFields>;
};
const DirectTab = ({ form: { control } }: DirectTabProps) => {
const { t } = useTranslation();
const { field: usersField, fieldState: usersFieldState } = useController({
name: 'users',
control,
rules: {
required: true,
validate: (value) => {
if (value.length < 2) {
return t('Select_at_least_two_users');
}
},
},
});
return (
<Field flexShrink={1}>
<FieldLabel>{t('Users')}</FieldLabel>
<FieldRow>
<UserAutoCompleteMultiple
value={usersField.value}
error={usersFieldState.error?.message}
onChange={usersField.onChange}
placeholder={t('Username_Placeholder')}
/>
</FieldRow>
{usersFieldState.error?.type === 'required' && <FieldError>{t('Required_field', { field: t('Users') })}</FieldError>}
{usersFieldState.error?.type === 'validate' && <FieldError>{usersFieldState.error.message}</FieldError>}
</Field>
);
};
export default DirectTab;

View File

@@ -0,0 +1,66 @@
import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage';
import type { UseFormReturn } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AutoCompleteAgent from '../../../omnichannel/components/AutoCompleteAgent';
import type { AuditFields } from '../../hooks/useAuditForm';
import VisitorAutoComplete from '../forms/VisitorAutoComplete';
export type OmnichannelTabProps = {
form: UseFormReturn<AuditFields>;
};
const OmnichannelTab = ({ form: { control } }: OmnichannelTabProps) => {
const { t } = useTranslation();
const { field: visitorField, fieldState: visitorFieldState } = useController({
name: 'visitor',
control,
rules: { required: true },
});
const { field: agentField, fieldState: agentFieldState } = useController({
name: 'agent',
control,
rules: { required: true },
});
return (
<>
<Field flexShrink={1}>
<FieldLabel flexGrow={0}>{t('Visitor')}</FieldLabel>
<FieldRow>
<VisitorAutoComplete
value={visitorField.value}
error={!!visitorFieldState.error}
onChange={visitorField.onChange}
placeholder={t('Username_Placeholder')}
/>
</FieldRow>
{visitorFieldState.error?.type === 'required' && <FieldError>{t('Required_field', { field: t('Visitor') })}</FieldError>}
{visitorFieldState.error?.type === 'validate' && <FieldError>{visitorFieldState.error.message}</FieldError>}
</Field>
<Field flexShrink={1} marginInlineStart={8}>
<FieldLabel flexGrow={0}>{t('Agent')}</FieldLabel>
<FieldRow>
<AutoCompleteAgent
error={(() => {
if (agentFieldState.error?.type === 'required') {
return t('Required_field', { field: t('Agent') });
}
return agentFieldState.error?.message;
})()}
value={agentField.value}
onChange={agentField.onChange}
placeholder={t('Username_Placeholder')}
/>
</FieldRow>
{agentFieldState.error?.type === 'required' && <FieldError>{t('Required_field', { field: t('Agent') })}</FieldError>}
{agentFieldState.error?.type === 'validate' && <FieldError>{agentFieldState.error.message}</FieldError>}
</Field>
</>
);
};
export default OmnichannelTab;

View File

@@ -0,0 +1,43 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Field, FieldLabel, FieldRow, FieldError, Icon } from '@rocket.chat/fuselage';
import type { Dispatch, SetStateAction } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import RoomAutoComplete from '../../../../components/RoomAutoComplete';
import type { AuditFields } from '../../hooks/useAuditForm';
export type RoomsTabProps = {
form: UseFormReturn<AuditFields>;
setSelectedRoom: Dispatch<SetStateAction<IRoom | undefined>>;
};
const RoomsTab = ({ form: { control }, setSelectedRoom }: RoomsTabProps) => {
const { t } = useTranslation();
const { field: ridField, fieldState: ridFieldState } = useController({ name: 'rid', control, rules: { required: true } });
return (
<Field flexShrink={1}>
<FieldLabel>{t('Channel_name')}</FieldLabel>
<FieldRow>
<RoomAutoComplete
scope='admin'
setSelectedRoom={setSelectedRoom}
value={ridField.value}
error={!!ridFieldState.error}
placeholder={t('Channel_Name_Placeholder')}
onChange={ridField.onChange}
renderRoomIcon={({ encrypted }) =>
encrypted ? <Icon name='key' color='danger' title={t('Encrypted_content_will_not_appear_search')} /> : null
}
/>
</FieldRow>
{ridFieldState.error?.type === 'required' && <FieldError>{t('Required_field', { field: t('Channel_name') })}</FieldError>}
{ridFieldState.error?.type === 'validate' && <FieldError>{ridFieldState.error.message}</FieldError>}
</Field>
);
};
export default RoomsTab;

View File

@@ -0,0 +1,46 @@
import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage';
import type { UseFormReturn } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import UserAutoCompleteMultiple from '../../../../components/UserAutoCompleteMultiple';
import type { AuditFields } from '../../hooks/useAuditForm';
export type UsersTabProps = {
form: UseFormReturn<AuditFields>;
};
const UsersTab = ({ form: { control } }: UsersTabProps) => {
const { t } = useTranslation();
const { field: usersField, fieldState: usersFieldState } = useController({
name: 'users',
control,
rules: {
required: true,
validate: (value) => {
if (value.length < 1) {
return t('Required_field', { field: t('Users') });
}
},
},
});
return (
<Field flexShrink={1}>
<FieldLabel>{t('Users')}</FieldLabel>
<FieldRow>
<UserAutoCompleteMultiple
error={usersFieldState.error?.message}
value={usersField.value}
onChange={usersField.onChange}
placeholder={t('Username_Placeholder')}
/>
</FieldRow>
{usersFieldState.error?.type === 'required' && <FieldError>{t('Required_field', { field: t('Users') })}</FieldError>}
{usersFieldState.error?.type === 'validate' && <FieldError>{usersFieldState.error.message}</FieldError>}
</Field>
);
};
export default UsersTab;