snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
19
apps/meteor/client/views/audit/AuditLogPage.tsx
Normal file
19
apps/meteor/client/views/audit/AuditLogPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AuditLogTable from './components/AuditLogTable';
|
||||
|
||||
const AuditLogPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={t('Message_auditing_log')} />
|
||||
<PageContent>
|
||||
<AuditLogTable />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogPage;
|
||||
61
apps/meteor/client/views/audit/AuditPage.tsx
Normal file
61
apps/meteor/client/views/audit/AuditPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Callout, Margins, States, StatesIcon, StatesSubtitle, StatesTitle, Tabs, TabsItem } from '@rocket.chat/fuselage';
|
||||
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AuditForm from './components/AuditForm';
|
||||
import AuditResult from './components/AuditResult';
|
||||
import { useAuditMutation } from './hooks/useAuditMutation';
|
||||
import { useAuditTab } from './hooks/useAuditTab';
|
||||
import ListSkeleton from '../../components/ListSkeleton';
|
||||
import { getErrorMessage } from '../../lib/errorHandling';
|
||||
|
||||
const AuditPage = () => {
|
||||
const [type, setType] = useAuditTab();
|
||||
const [selectedRoom, setSelectedRoom] = useState<IRoom | undefined>();
|
||||
const auditMutation = useAuditMutation(type);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Page background='room'>
|
||||
<PageHeader title={t('Message_auditing')} />
|
||||
<Tabs>
|
||||
<TabsItem selected={type === ''} onClick={() => setType('')}>
|
||||
{t('Rooms')}
|
||||
</TabsItem>
|
||||
<TabsItem selected={type === 'u'} onClick={() => setType('u')}>
|
||||
{t('Users')}
|
||||
</TabsItem>
|
||||
<TabsItem selected={type === 'd'} onClick={() => setType('d')}>
|
||||
{t('Direct_Messages')}
|
||||
</TabsItem>
|
||||
<TabsItem selected={type === 'l'} onClick={() => setType('l')}>
|
||||
{t('Omnichannel')}
|
||||
</TabsItem>
|
||||
</Tabs>
|
||||
<PageScrollableContentWithShadow marginBlock={-4}>
|
||||
<Margins block={4}>
|
||||
<AuditForm key={type} type={type} setSelectedRoom={setSelectedRoom} onSubmit={auditMutation.mutate} />
|
||||
{selectedRoom?.encrypted && type === '' ? (
|
||||
<Callout type='warning' icon='circle-exclamation' marginBlock='x16'>
|
||||
<Box fontScale='p2b'>{t('Encrypted_content_cannot_be_searched_and_audited')}</Box>
|
||||
{t('Encrypted_content_cannot_be_searched_and_audited_subtitle')}
|
||||
</Callout>
|
||||
) : null}
|
||||
{auditMutation.isPending && <ListSkeleton listCount={5} />}
|
||||
{auditMutation.isError && (
|
||||
<States>
|
||||
<StatesIcon name='circle-exclamation' variation='danger' />
|
||||
<StatesTitle>{t('Error')}</StatesTitle>
|
||||
<StatesSubtitle>{getErrorMessage(auditMutation.error)}</StatesSubtitle>
|
||||
</States>
|
||||
)}
|
||||
{auditMutation.isSuccess && <AuditResult messages={auditMutation.data} />}
|
||||
</Margins>
|
||||
</PageScrollableContentWithShadow>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditPage;
|
||||
19
apps/meteor/client/views/audit/SecurityLogsPage.tsx
Normal file
19
apps/meteor/client/views/audit/SecurityLogsPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SecurityLogsTable from './components/SecurityLogsTable';
|
||||
|
||||
const SecurityLogsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={t('Security_logs')} />
|
||||
<PageContent>
|
||||
<SecurityLogsTable />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityLogsPage;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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()],
|
||||
};
|
||||
40
apps/meteor/client/views/audit/components/AppInfoField.tsx
Normal file
40
apps/meteor/client/views/audit/components/AppInfoField.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
78
apps/meteor/client/views/audit/components/AuditForm.tsx
Normal file
78
apps/meteor/client/views/audit/components/AuditForm.tsx
Normal 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;
|
||||
58
apps/meteor/client/views/audit/components/AuditLogEntry.tsx
Normal file
58
apps/meteor/client/views/audit/components/AuditLogEntry.tsx
Normal 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);
|
||||
92
apps/meteor/client/views/audit/components/AuditLogTable.tsx
Normal file
92
apps/meteor/client/views/audit/components/AuditLogTable.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
13
apps/meteor/client/views/audit/components/AuditModalText.tsx
Normal file
13
apps/meteor/client/views/audit/components/AuditModalText.tsx
Normal 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;
|
||||
24
apps/meteor/client/views/audit/components/AuditResult.tsx
Normal file
24
apps/meteor/client/views/audit/components/AuditResult.tsx
Normal 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);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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' }} />,
|
||||
};
|
||||
@@ -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;
|
||||
219
apps/meteor/client/views/audit/components/SecurityLogsTable.tsx
Normal file
219
apps/meteor/client/views/audit/components/SecurityLogsTable.tsx
Normal 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;
|
||||
34
apps/meteor/client/views/audit/components/SettingSelect.tsx
Normal file
34
apps/meteor/client/views/audit/components/SettingSelect.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
46
apps/meteor/client/views/audit/components/tabs/DirectTab.tsx
Normal file
46
apps/meteor/client/views/audit/components/tabs/DirectTab.tsx
Normal 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;
|
||||
@@ -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;
|
||||
43
apps/meteor/client/views/audit/components/tabs/RoomsTab.tsx
Normal file
43
apps/meteor/client/views/audit/components/tabs/RoomsTab.tsx
Normal 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;
|
||||
46
apps/meteor/client/views/audit/components/tabs/UsersTab.tsx
Normal file
46
apps/meteor/client/views/audit/components/tabs/UsersTab.tsx
Normal 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;
|
||||
32
apps/meteor/client/views/audit/hooks/useAuditForm.ts
Normal file
32
apps/meteor/client/views/audit/hooks/useAuditForm.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ILivechatAgent, ILivechatVisitor, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { createEndOfToday, createStartOfToday } from '../utils/dateRange';
|
||||
|
||||
export type AuditFields = {
|
||||
msg: string;
|
||||
dateRange: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
};
|
||||
rid: IRoom['_id'];
|
||||
users: Exclude<IUser['username'], undefined>[];
|
||||
visitor: ILivechatVisitor['_id'];
|
||||
agent: ILivechatAgent['_id'];
|
||||
};
|
||||
|
||||
export const useAuditForm = () => {
|
||||
return useForm<AuditFields>({
|
||||
defaultValues: {
|
||||
msg: '',
|
||||
dateRange: {
|
||||
start: createStartOfToday(),
|
||||
end: createEndOfToday(),
|
||||
},
|
||||
rid: undefined,
|
||||
users: [],
|
||||
visitor: undefined,
|
||||
agent: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
48
apps/meteor/client/views/audit/hooks/useAuditMutation.ts
Normal file
48
apps/meteor/client/views/audit/hooks/useAuditMutation.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { IAuditLog, IMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import type { AuditFields } from './useAuditForm';
|
||||
import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi';
|
||||
|
||||
export const useAuditMutation = (type: IAuditLog['fields']['type']) => {
|
||||
const getAuditMessages = useEndpoint('POST', '/v1/audit.messages');
|
||||
const getOmnichannelAuditMessages = useEndpoint('POST', '/v1/audit.omnichannelMessages');
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['audit'] as const,
|
||||
|
||||
mutationFn: async ({ msg, dateRange, rid, users, visitor, agent }: AuditFields): Promise<IMessage[]> => {
|
||||
const startDate = (dateRange.start ?? new Date(0)).toISOString();
|
||||
const endDate = (dateRange.end ?? new Date()).toISOString();
|
||||
|
||||
if (type === 'l') {
|
||||
const { messages } = await getOmnichannelAuditMessages({
|
||||
type,
|
||||
msg,
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
// TODO: the Omnichannel audit form collects visitor/agent (required fields) but they are
|
||||
// dropped here — carried over from the original DDP flow. Forwarding them would let the
|
||||
// omnichannel audit filter by the selected visitor/agent. Left for investigation.
|
||||
visitor: '',
|
||||
agent: '',
|
||||
});
|
||||
return messages.map((message) => mapMessageFromApi(message));
|
||||
}
|
||||
|
||||
const { messages } = await getAuditMessages({
|
||||
type,
|
||||
msg,
|
||||
startDate,
|
||||
endDate,
|
||||
rid,
|
||||
users,
|
||||
visitor,
|
||||
agent,
|
||||
});
|
||||
return messages.map((message) => mapMessageFromApi(message));
|
||||
},
|
||||
});
|
||||
};
|
||||
27
apps/meteor/client/views/audit/hooks/useAuditTab.ts
Normal file
27
apps/meteor/client/views/audit/hooks/useAuditTab.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IAuditLog } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRoute, useRouteParameter } from '@rocket.chat/ui-contexts';
|
||||
import type { SetStateAction } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const typeToTabMap: Record<IAuditLog['fields']['type'], string> = {
|
||||
'': 'rooms',
|
||||
'u': 'users',
|
||||
'd': 'direct',
|
||||
'l': 'omnichannel',
|
||||
};
|
||||
|
||||
const tabToTabMap = new Map(Object.entries(typeToTabMap).map(([type, tab]) => [tab, type as IAuditLog['fields']['type']]));
|
||||
|
||||
export const useAuditTab = () => {
|
||||
const tab = useRouteParameter('tab');
|
||||
const type = useMemo(() => tabToTabMap.get(tab ?? 'rooms') ?? '', [tab]);
|
||||
|
||||
const auditRoute = useRoute('audit-home');
|
||||
|
||||
const setType = useStableCallback((newType: SetStateAction<IAuditLog['fields']['type']>) => {
|
||||
auditRoute.replace({ tab: typeToTabMap[typeof newType === 'function' ? newType(type) : newType] ?? 'rooms' });
|
||||
});
|
||||
|
||||
return [type, setType] as const;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
export const useSendTelemetryMutation = () => {
|
||||
const sendTelemetry = useEndpoint('POST', '/v1/statistics.telemetry');
|
||||
|
||||
return useMutation({
|
||||
mutationFn: sendTelemetry,
|
||||
onError: (error) => {
|
||||
console.warn(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingSelectOptions } from './useSettingSelectOptions';
|
||||
|
||||
// TODO: check if the return of items matches the settings we mocked
|
||||
describe('useSettingSelectOptions', () => {
|
||||
it('should return the ordered list of options', async () => {
|
||||
const { result } = renderHook(() => useSettingSelectOptions(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('test1', true)
|
||||
.withSetting('test2', true)
|
||||
.withSetting('test3', true)
|
||||
.withSetting('test4', true)
|
||||
.withSetting('test5', true)
|
||||
.withSetting('test6', true)
|
||||
.withSetting('test7', true)
|
||||
.withSetting('test8', true)
|
||||
.withSetting('test9', true)
|
||||
.withSetting('test10', true)
|
||||
.withSetting('test11', true)
|
||||
.withSetting('test12', true)
|
||||
.withSetting('test13', true)
|
||||
.withSetting('test14', true)
|
||||
.withSetting('test15', true)
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][0]).toEqual({ _id: 'test1', label: 'test1', value: 'test1' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][1]).toEqual({ _id: 'test2', label: 'test2', value: 'test2' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][2]).toEqual({ _id: 'test3', label: 'test3', value: 'test3' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][3]).toEqual({ _id: 'test4', label: 'test4', value: 'test4' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][4]).toEqual({ _id: 'test5', label: 'test5', value: 'test5' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][5]).toEqual({ _id: 'test6', label: 'test6', value: 'test6' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][6]).toEqual({ _id: 'test7', label: 'test7', value: 'test7' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][7]).toEqual({ _id: 'test8', label: 'test8', value: 'test8' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][8]).toEqual({ _id: 'test9', label: 'test9', value: 'test9' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][9]).toEqual({ _id: 'test10', label: 'test10', value: 'test10' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][10]).toEqual({ _id: 'test11', label: 'test11', value: 'test11' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][11]).toEqual({ _id: 'test12', label: 'test12', value: 'test12' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][12]).toEqual({ _id: 'test13', label: 'test13', value: 'test13' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][13]).toEqual({ _id: 'test14', label: 'test14', value: 'test14' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][14]).toEqual({ _id: 'test15', label: 'test15', value: 'test15' }));
|
||||
|
||||
await waitFor(() => expect(result.current?.data?.pages[0]).toHaveLength(15));
|
||||
});
|
||||
|
||||
it('should return the list of filtered options', async () => {
|
||||
const { result } = renderHook(() => useSettingSelectOptions('TeSt1'), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('test1', true)
|
||||
.withSetting('test2', true)
|
||||
.withSetting('test3', true)
|
||||
.withSetting('test4', true)
|
||||
.withSetting('test5', true)
|
||||
.withSetting('test6', true)
|
||||
.withSetting('test7', true)
|
||||
.withSetting('test8', true)
|
||||
.withSetting('test9', true)
|
||||
.withSetting('test10', true)
|
||||
.withSetting('test11', true)
|
||||
.withSetting('test12', true)
|
||||
.withSetting('test13', true)
|
||||
.withSetting('test14', true)
|
||||
.withSetting('test15', true)
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][0]).toEqual({ _id: 'test1', label: 'test1', value: 'test1' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][1]).toEqual({ _id: 'test10', label: 'test10', value: 'test10' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][2]).toEqual({ _id: 'test11', label: 'test11', value: 'test11' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][3]).toEqual({ _id: 'test12', label: 'test12', value: 'test12' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][4]).toEqual({ _id: 'test13', label: 'test13', value: 'test13' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][5]).toEqual({ _id: 'test14', label: 'test14', value: 'test14' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][6]).toEqual({ _id: 'test15', label: 'test15', value: 'test15' }));
|
||||
await waitFor(() => expect(result.current?.data?.pages[0]).toHaveLength(7));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useSettings } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type SettingSelectOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
_id: string;
|
||||
};
|
||||
|
||||
export const useSettingSelectOptions = (filter = '') => {
|
||||
const settings = useSettings();
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (start = 0): Promise<SettingSelectOption[]> => {
|
||||
return settings
|
||||
.map(({ _id }) => ({ label: _id, value: _id, _id }))
|
||||
.filter(({ label }) => label.toUpperCase().includes(filter.toUpperCase()))
|
||||
.slice(start, start + 50);
|
||||
},
|
||||
[filter, settings],
|
||||
);
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['settings', filter],
|
||||
queryFn: ({ pageParam }) => fetchData(pageParam),
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
||||
if (lastPage.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPageParam + 1;
|
||||
},
|
||||
getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => {
|
||||
if (firstPageParam <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
return firstPageParam - 1;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
});
|
||||
};
|
||||
9
apps/meteor/client/views/audit/utils/dateRange.ts
Normal file
9
apps/meteor/client/views/audit/utils/dateRange.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { startOfDay, endOfDay } from 'date-fns';
|
||||
|
||||
export type DateRange = {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
};
|
||||
|
||||
export const createStartOfToday = () => startOfDay(new Date());
|
||||
export const createEndOfToday = () => endOfDay(new Date());
|
||||
@@ -0,0 +1 @@
|
||||
export const getTypeTranslation = (type: 'app' | 'system') => (type === 'app' ? 'App' : 'System');
|
||||
Reference in New Issue
Block a user