feat(settings): add Logs as a dedicated tab in General settings (#21180)
## What & why The audit-log viewer lived as a full-screen page reachable only via a "View Logs" button buried in the **Security** tab. This surfaces it as the **third tab in General settings** (`General | Security | Logs`), consistent with the other tabs. ## Changes - **Relocated** the event-logs module `pages/settings/security/event-logs/` → `modules/settings/event-logs/` and render it as tab content instead of a `FullScreenContainer` page. Dropped `SettingsPath.EventLogs`, its route, and the fullscreen handling in favor of the `general#logs` hash tab. - **Security tab:** removed the "View Logs" entry; kept the log-retention setting there. - **In-tab gating** (shown to users with the Security permission): Enterprise upgrade card when not entitled, a clear "ClickHouse not configured" placeholder otherwise (derived from client config), and the query is skipped when disabled. Replaces a bespoke error component that string-matched error messages with the shared `SettingsEmptyPlaceholder` / `SettingsEnterpriseFeatureGateCard`. - **Layout:** boxed content column with the table selector + filters grouped in a `Card` and the results table below, matching settings conventions. Kept the existing fixed filters (page/event name, member, period) rather than recreating the record-view filter chips (those are tightly coupled to record/view context). Frontend + `twenty-shared` only — no changes to the log query or data. ## Test plan - [x] `npx nx typecheck twenty-front` and `npx nx lint twenty-front` pass - [x] Settings → General shows three tabs; Logs is the third; breadcrumb stays "Workspace / General" - [x] With Enterprise + ClickHouse: table selector, filters, refresh, and the paginated table work - [x] Non-Enterprise: Enterprise upgrade card shown; no failing query fires - [ ] Enterprise without ClickHouse: shows the "ClickHouse not configured" placeholder - [ ] Security tab still shows the log-retention setting and the "View Logs" button is gone - [ ] A user without the Security permission sees neither the Security nor Logs tab
This commit is contained in:
@@ -27,11 +27,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
# Pull base images through Google's Docker Hub mirror — avoids Docker Hub
|
||||
# rate limits and needs no credentials (this repo is public).
|
||||
- name: Configure Docker Hub mirror
|
||||
run: |
|
||||
echo '{"registry-mirrors":["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
- name: Run compose
|
||||
run: |
|
||||
echo "Patching docker-compose.yml..."
|
||||
@@ -102,11 +103,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
# Pull base images through Google's Docker Hub mirror — avoids Docker Hub
|
||||
# rate limits and needs no credentials (this repo is public).
|
||||
- name: Configure Docker Hub mirror
|
||||
run: |
|
||||
echo '{"registry-mirrors":["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
- name: Create frontend placeholder
|
||||
run: |
|
||||
mkdir -p packages/twenty-front/build
|
||||
|
||||
@@ -7348,6 +7348,13 @@ export type EnterpriseSubscriptionStatusQueryVariables = Exact<{ [key: string]:
|
||||
|
||||
export type EnterpriseSubscriptionStatusQuery = { __typename?: 'Query', enterpriseSubscriptionStatus?: { __typename?: 'EnterpriseSubscriptionStatusDTO', status: string, licensee?: string | null, expiresAt?: string | null, cancelAt?: string | null, currentPeriodEnd?: string | null, isCancellationScheduled: boolean } | null };
|
||||
|
||||
export type EventLogsQueryVariables = Exact<{
|
||||
input: EventLogQueryInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null, isCustom?: boolean | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
|
||||
|
||||
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
||||
input: UpdateLabPublicFeatureFlagInput;
|
||||
}>;
|
||||
@@ -8197,6 +8204,7 @@ export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind
|
||||
export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode<EnterpriseCheckoutSessionQuery, EnterpriseCheckoutSessionQueryVariables>;
|
||||
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
|
||||
export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"isCancellationScheduled"}}]}}]}}]} as unknown as DocumentNode<EnterpriseSubscriptionStatusQuery, EnterpriseSubscriptionStatusQueryVariables>;
|
||||
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
|
||||
export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
|
||||
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
|
||||
export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
|
||||
|
||||
@@ -435,14 +435,6 @@ const SettingsSecurityApprovedAccessDomain = lazy(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsEventLogs = lazy(() =>
|
||||
import('~/pages/settings/security/event-logs/SettingsEventLogs').then(
|
||||
(module) => ({
|
||||
default: module.SettingsEventLogs,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsNewEmailingDomain = lazy(() =>
|
||||
import('~/pages/settings/emailing-domains/SettingsNewEmailingDomain').then(
|
||||
(module) => ({
|
||||
@@ -934,7 +926,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
path={SettingsPath.NewApprovedAccessDomain}
|
||||
element={<SettingsSecurityApprovedAccessDomain />}
|
||||
/>
|
||||
<Route path={SettingsPath.EventLogs} element={<SettingsEventLogs />} />
|
||||
</Route>
|
||||
|
||||
{isAdminPageEnabled && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
autoUpdate,
|
||||
@@ -67,7 +67,8 @@ const StyledFloatingContainer = styled.div`
|
||||
`;
|
||||
|
||||
export type SettingsDatePickerInputProps = {
|
||||
label: string;
|
||||
label?: string;
|
||||
instanceId?: string;
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
@@ -75,6 +76,7 @@ export type SettingsDatePickerInputProps = {
|
||||
|
||||
export const SettingsDatePickerInput = ({
|
||||
label,
|
||||
instanceId,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
@@ -83,6 +85,9 @@ export const SettingsDatePickerInput = ({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const generatedId = useId();
|
||||
|
||||
const pickerInstanceId = instanceId ?? label ?? generatedId;
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
open: isOpen,
|
||||
@@ -97,7 +102,7 @@ export const SettingsDatePickerInput = ({
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
listenerId: `settings-date-picker-${label}`,
|
||||
listenerId: `settings-date-picker-${pickerInstanceId}`,
|
||||
callback: handleClose,
|
||||
enabled: isOpen,
|
||||
excludedClickOutsideIds: [
|
||||
@@ -145,7 +150,7 @@ export const SettingsDatePickerInput = ({
|
||||
|
||||
return (
|
||||
<StyledInputContainer ref={containerRef}>
|
||||
<StyledLabel>{label}</StyledLabel>
|
||||
{label && <StyledLabel>{label}</StyledLabel>}
|
||||
<StyledInput
|
||||
ref={refs.setReference}
|
||||
hasValue={isDefined(value)}
|
||||
@@ -165,7 +170,7 @@ export const SettingsDatePickerInput = ({
|
||||
>
|
||||
<OverlayContainer>
|
||||
<DateTimePicker
|
||||
instanceId={`settings-date-picker-${label}`}
|
||||
instanceId={`settings-date-picker-${pickerInstanceId}`}
|
||||
date={zonedDateTime}
|
||||
onChange={handleDateTimeChange}
|
||||
onClose={handleDateTimeClose}
|
||||
|
||||
+66
-68
@@ -1,5 +1,7 @@
|
||||
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -10,7 +12,6 @@ import { type SelectOption } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { EventLogTable } from '~/generated-metadata/graphql';
|
||||
import { type EventLogFiltersState } from '~/pages/settings/security/event-logs/SettingsEventLogs';
|
||||
|
||||
import { EventLogDatePickerInput } from './EventLogDatePickerInput';
|
||||
|
||||
@@ -20,17 +21,20 @@ type EventLogFiltersProps = {
|
||||
onChange: (filters: EventLogFiltersState) => void;
|
||||
};
|
||||
|
||||
const StyledFiltersContainer = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
const StyledFiltersGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
`;
|
||||
|
||||
const StyledFilterItem = styled.div`
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
const StyledFullWidthField = styled.div`
|
||||
grid-column: 1 / -1;
|
||||
`;
|
||||
|
||||
const StyledPeriodRow = styled.div`
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
`;
|
||||
|
||||
export const EventLogFilters = ({
|
||||
@@ -82,7 +86,7 @@ export const EventLogFilters = ({
|
||||
};
|
||||
|
||||
const eventLabel =
|
||||
table === EventLogTable.PAGEVIEW ? t`Page Name` : t`Event Type`;
|
||||
table === EventLogTable.PAGEVIEW ? t`Page name` : t`Event type`;
|
||||
|
||||
const userWorkspaceOptions: SelectOption<string | null>[] = [
|
||||
{ label: t`All Members`, value: null, Icon: IconUser },
|
||||
@@ -106,70 +110,64 @@ export const EventLogFilters = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledFiltersContainer>
|
||||
<StyledFilterItem>
|
||||
<TextInput
|
||||
label={eventLabel}
|
||||
value={value.eventType ?? ''}
|
||||
onChange={handleEventTypeChange}
|
||||
placeholder={t`Filter by event`}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledFilterItem>
|
||||
<StyledFiltersGrid>
|
||||
<TextInput
|
||||
label={eventLabel}
|
||||
value={value.eventType ?? ''}
|
||||
onChange={handleEventTypeChange}
|
||||
placeholder={t`Filter by event`}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<StyledFilterItem>
|
||||
<Select
|
||||
dropdownId="event-log-user-workspace-filter"
|
||||
label={t`Workspace Member`}
|
||||
value={value.userWorkspaceId ?? null}
|
||||
options={userWorkspaceOptions}
|
||||
onChange={handleUserWorkspaceChange}
|
||||
fullWidth
|
||||
withSearchInput
|
||||
/>
|
||||
</StyledFilterItem>
|
||||
<Select
|
||||
dropdownId="event-log-user-workspace-filter"
|
||||
label={t`Workspace members`}
|
||||
value={value.userWorkspaceId ?? null}
|
||||
options={userWorkspaceOptions}
|
||||
onChange={handleUserWorkspaceChange}
|
||||
fullWidth
|
||||
withSearchInput
|
||||
/>
|
||||
|
||||
<StyledFilterItem>
|
||||
<EventLogDatePickerInput
|
||||
label={t`Start Date`}
|
||||
value={value.dateRange?.start}
|
||||
onChange={handleStartDateChange}
|
||||
/>
|
||||
</StyledFilterItem>
|
||||
|
||||
<StyledFilterItem>
|
||||
<EventLogDatePickerInput
|
||||
label={t`End Date`}
|
||||
value={value.dateRange?.end}
|
||||
onChange={handleEndDateChange}
|
||||
/>
|
||||
</StyledFilterItem>
|
||||
<StyledFullWidthField>
|
||||
<InputLabel>{t`Period`}</InputLabel>
|
||||
<StyledPeriodRow>
|
||||
<EventLogDatePickerInput
|
||||
instanceId="event-log-start-date"
|
||||
value={value.dateRange?.start}
|
||||
onChange={handleStartDateChange}
|
||||
placeholder={t`Start date`}
|
||||
/>
|
||||
<EventLogDatePickerInput
|
||||
instanceId="event-log-end-date"
|
||||
value={value.dateRange?.end}
|
||||
onChange={handleEndDateChange}
|
||||
placeholder={t`End date`}
|
||||
/>
|
||||
</StyledPeriodRow>
|
||||
</StyledFullWidthField>
|
||||
|
||||
{table === EventLogTable.OBJECT_EVENT && (
|
||||
<>
|
||||
<StyledFilterItem>
|
||||
<Select
|
||||
dropdownId="event-log-object-type-filter"
|
||||
label={t`Object Type`}
|
||||
value={value.objectMetadataId ?? null}
|
||||
options={objectMetadataOptions}
|
||||
onChange={handleObjectMetadataIdChange}
|
||||
fullWidth
|
||||
withSearchInput
|
||||
/>
|
||||
</StyledFilterItem>
|
||||
<Select
|
||||
dropdownId="event-log-object-type-filter"
|
||||
label={t`Object type`}
|
||||
value={value.objectMetadataId ?? null}
|
||||
options={objectMetadataOptions}
|
||||
onChange={handleObjectMetadataIdChange}
|
||||
fullWidth
|
||||
withSearchInput
|
||||
/>
|
||||
|
||||
<StyledFilterItem>
|
||||
<TextInput
|
||||
label={t`Record ID`}
|
||||
value={value.recordId ?? ''}
|
||||
onChange={handleRecordIdChange}
|
||||
placeholder={t`Filter by record ID`}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledFilterItem>
|
||||
<TextInput
|
||||
label={t`Record ID`}
|
||||
value={value.recordId ?? ''}
|
||||
onChange={handleRecordIdChange}
|
||||
placeholder={t`Filter by record ID`}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledFiltersContainer>
|
||||
</StyledFiltersGrid>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -22,8 +22,8 @@ import {
|
||||
import {
|
||||
type ColumnConfig,
|
||||
getColumnsForEventLogTable,
|
||||
} from '~/pages/settings/security/event-logs/utils/getColumnsForEventLogTable';
|
||||
import { EventLogJsonCell } from '~/pages/settings/security/event-logs/components/EventLogJsonCell';
|
||||
} from '@/settings/event-logs/utils/getColumnsForEventLogTable';
|
||||
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
|
||||
|
||||
type EventLogResultsTableProps = {
|
||||
records: EventLogRecord[];
|
||||
@@ -0,0 +1,194 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
|
||||
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
|
||||
import { EventLogFilters } from '@/settings/event-logs/components/EventLogFilters';
|
||||
import { EventLogResultsTable } from '@/settings/event-logs/components/EventLogResultsTable';
|
||||
import { EventLogTableSelector } from '@/settings/event-logs/components/EventLogTableSelector';
|
||||
import { useEventLogs } from '@/settings/event-logs/hooks/useQueryEventLogs';
|
||||
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconRefresh } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { Card } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { EventLogTable } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledCardContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledSelectorRow = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledSelectorGrow = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledResults = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledRecordCount = styled.span`
|
||||
align-self: flex-end;
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
// The results table scrolls internally and loads more as you reach the bottom,
|
||||
// so it needs a bounded height.
|
||||
const StyledTableWrapper = styled.div`
|
||||
height: 480px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const RECORDS_PER_PAGE = 100;
|
||||
|
||||
export const SettingsLogs = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
|
||||
|
||||
const hasEnterpriseAccess =
|
||||
currentWorkspace?.hasValidSignedEnterpriseKey === true;
|
||||
|
||||
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
|
||||
EventLogTable.PAGEVIEW,
|
||||
);
|
||||
const [filters, setFilters] = useState<EventLogFiltersState>({});
|
||||
|
||||
const isApplicationLog = selectedTable === EventLogTable.APPLICATION_LOG;
|
||||
const canQuery =
|
||||
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
|
||||
|
||||
const {
|
||||
records,
|
||||
totalCount,
|
||||
hasNextPage,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
} = useEventLogs(
|
||||
{
|
||||
table: selectedTable,
|
||||
filters: {
|
||||
eventType: filters.eventType,
|
||||
userWorkspaceId: filters.userWorkspaceId,
|
||||
dateRange: filters.dateRange
|
||||
? {
|
||||
start: filters.dateRange.start?.toISOString(),
|
||||
end: filters.dateRange.end?.toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
recordId: filters.recordId,
|
||||
objectMetadataId: filters.objectMetadataId,
|
||||
},
|
||||
first: RECORDS_PER_PAGE,
|
||||
},
|
||||
{ skip: !canQuery },
|
||||
);
|
||||
|
||||
const handleTableChange = (table: EventLogTable) => {
|
||||
setSelectedTable(table);
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const handleFiltersChange = (newFilters: EventLogFiltersState) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const renderResults = () => {
|
||||
if (!isApplicationLog && !hasEnterpriseAccess) {
|
||||
return (
|
||||
<SettingsEnterpriseFeatureGateCard
|
||||
title={t`Enterprise feature`}
|
||||
description={t`Upgrade to Enterprise to access this log type.`}
|
||||
buttonTitle={t`Activate`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isClickHouseConfigured) {
|
||||
return (
|
||||
<SettingsEmptyPlaceholder>
|
||||
{t`Audit logs require ClickHouse to be configured. Please contact your administrator.`}
|
||||
</SettingsEmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
return (
|
||||
<SettingsEmptyPlaceholder>
|
||||
{t`Something went wrong while loading audit logs. Please try again.`}
|
||||
</SettingsEmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledResults>
|
||||
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
|
||||
<StyledTableWrapper>
|
||||
<EventLogResultsTable
|
||||
records={records}
|
||||
loading={loading}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={loadMore}
|
||||
selectedTable={selectedTable}
|
||||
/>
|
||||
</StyledTableWrapper>
|
||||
</StyledResults>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card rounded fullWidth>
|
||||
<StyledCardContent>
|
||||
<StyledSelectorRow>
|
||||
<StyledSelectorGrow>
|
||||
<EventLogTableSelector
|
||||
value={selectedTable}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</StyledSelectorGrow>
|
||||
<IconButton
|
||||
Icon={IconRefresh}
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
ariaLabel={t`Refresh`}
|
||||
onClick={() => {
|
||||
if (canQuery) {
|
||||
void refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StyledSelectorRow>
|
||||
<EventLogFilters
|
||||
table={selectedTable}
|
||||
value={filters}
|
||||
onChange={handleFiltersChange}
|
||||
/>
|
||||
</StyledCardContent>
|
||||
</Card>
|
||||
|
||||
{renderResults()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+6
-2
@@ -6,7 +6,7 @@ import {
|
||||
type EventLogQueryResult,
|
||||
type EventLogRecord,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { GET_EVENT_LOGS } from '~/pages/settings/security/event-logs/graphql/queries/getEventLogs';
|
||||
import { GET_EVENT_LOGS } from '@/settings/event-logs/graphql/queries/getEventLogs';
|
||||
|
||||
type EventLogsData = {
|
||||
eventLogs: EventLogQueryResult;
|
||||
@@ -16,13 +16,17 @@ type EventLogsVariables = {
|
||||
input: EventLogQueryInput;
|
||||
};
|
||||
|
||||
export const useEventLogs = (input: EventLogQueryInput) => {
|
||||
export const useEventLogs = (
|
||||
input: EventLogQueryInput,
|
||||
options?: { skip?: boolean },
|
||||
) => {
|
||||
const { data, loading, error, refetch, fetchMore } = useQuery<
|
||||
EventLogsData,
|
||||
EventLogsVariables
|
||||
>(GET_EVENT_LOGS, {
|
||||
variables: { input },
|
||||
fetchPolicy: 'network-only',
|
||||
skip: options?.skip,
|
||||
});
|
||||
|
||||
const records = data?.eventLogs.records ?? ([] as EventLogRecord[]);
|
||||
@@ -0,0 +1,10 @@
|
||||
export type EventLogFiltersState = {
|
||||
eventType?: string;
|
||||
userWorkspaceId?: string;
|
||||
dateRange?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
};
|
||||
recordId?: string;
|
||||
objectMetadataId?: string;
|
||||
};
|
||||
+18
-53
@@ -1,6 +1,5 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
@@ -26,8 +25,6 @@ import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
import {
|
||||
H2Title,
|
||||
@@ -37,7 +34,6 @@ import {
|
||||
IconMail,
|
||||
IconTrash,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Card, Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
|
||||
@@ -57,16 +53,6 @@ const StyledSectionContainer = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledLinkContainer = styled.div`
|
||||
> a {
|
||||
text-decoration: none;
|
||||
|
||||
&[data-disabled='true'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SettingsSecuritySettings = () => {
|
||||
const { t } = useLingui();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
@@ -259,7 +245,7 @@ export const SettingsSecuritySettings = () => {
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Audit Logs`}
|
||||
description={t`View workspace activity logs`}
|
||||
description={t`Configure how long audit logs are retained`}
|
||||
adornment={
|
||||
<Tag
|
||||
text={t`Enterprise`}
|
||||
@@ -271,44 +257,23 @@ export const SettingsSecuritySettings = () => {
|
||||
/>
|
||||
{hasEnterpriseAccess ? (
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentButton
|
||||
Icon={IconHistory}
|
||||
title={t`Workspace Events`}
|
||||
description={
|
||||
!isClickHouseConfigured
|
||||
? t`ClickHouse is required for audit logs. Contact your administrator.`
|
||||
: t`View and filter events, page views, object changes`
|
||||
}
|
||||
Button={
|
||||
<StyledLinkContainer>
|
||||
<Link
|
||||
to={getSettingsPath(SettingsPath.EventLogs)}
|
||||
data-disabled={!isEventLogsEnabled}
|
||||
>
|
||||
<Button
|
||||
title={t`View Logs`}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!isEventLogsEnabled}
|
||||
/>
|
||||
</Link>
|
||||
</StyledLinkContainer>
|
||||
}
|
||||
/>
|
||||
{isEventLogsEnabled && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsOptionCardContentCounter
|
||||
Icon={IconClockHour8}
|
||||
title={t`Log retention`}
|
||||
description={t`Number of days to retain audit logs (30-1095 days)`}
|
||||
value={currentWorkspace?.eventLogRetentionDays ?? 90}
|
||||
onChange={handleEventLogRetentionDaysChange}
|
||||
minValue={30}
|
||||
maxValue={1095}
|
||||
showButtons={false}
|
||||
/>
|
||||
</>
|
||||
{isEventLogsEnabled ? (
|
||||
<SettingsOptionCardContentCounter
|
||||
Icon={IconClockHour8}
|
||||
title={t`Log retention`}
|
||||
description={t`Number of days to retain audit logs (30-1095 days)`}
|
||||
value={currentWorkspace?.eventLogRetentionDays ?? 90}
|
||||
onChange={handleEventLogRetentionDaysChange}
|
||||
minValue={30}
|
||||
maxValue={1095}
|
||||
showButtons={false}
|
||||
/>
|
||||
) : (
|
||||
<SettingsOptionCardContentButton
|
||||
Icon={IconHistory}
|
||||
title={t`Audit Logs`}
|
||||
description={t`ClickHouse is required for audit logs. Contact your administrator.`}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -13,11 +13,7 @@ export const useShowFullscreen = () => {
|
||||
location,
|
||||
'settings/' + SettingsPath.RestPlayground + '/*',
|
||||
) ||
|
||||
isMatchingLocation(
|
||||
location,
|
||||
'settings/' + SettingsPath.GraphQLPlayground,
|
||||
) ||
|
||||
isMatchingLocation(location, 'settings/' + SettingsPath.EventLogs)
|
||||
isMatchingLocation(location, 'settings/' + SettingsPath.GraphQLPlayground)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLay
|
||||
import { SettingsTabBar } from '@/settings/components/layout/SettingsTabBar';
|
||||
import { useSettingsActiveTabId } from '@/settings/components/layout/useSettingsActiveTabId';
|
||||
import { SettingsWorkspaceDomainCard } from '@/settings/domains/components/SettingsWorkspaceDomainCard';
|
||||
import { SettingsLogs } from '@/settings/event-logs/components/SettingsLogs';
|
||||
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
import { SettingsSecuritySettings } from '@/settings/security/components/SettingsSecuritySettings';
|
||||
import { NameField } from '@/settings/workspace/components/NameField';
|
||||
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { H2Title, IconKey, IconSettings } from 'twenty-ui/display';
|
||||
import { H2Title, IconHistory, IconKey, IconSettings } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -20,6 +21,7 @@ const SETTINGS_GENERAL_TABS_INSTANCE_ID = 'settings-general-tabs';
|
||||
|
||||
const GENERAL_TAB_GENERAL = 'general';
|
||||
const GENERAL_TAB_SECURITY = 'security';
|
||||
const GENERAL_TAB_LOGS = 'logs';
|
||||
|
||||
export const SettingsGeneral = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -35,7 +37,10 @@ export const SettingsGeneral = () => {
|
||||
const tabs = [
|
||||
{ id: GENERAL_TAB_GENERAL, title: t`General`, Icon: IconSettings },
|
||||
...(hasSecurityPermission
|
||||
? [{ id: GENERAL_TAB_SECURITY, title: t`Security`, Icon: IconKey }]
|
||||
? [
|
||||
{ id: GENERAL_TAB_SECURITY, title: t`Security`, Icon: IconKey },
|
||||
{ id: GENERAL_TAB_LOGS, title: t`Logs`, Icon: IconHistory },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -45,7 +50,11 @@ export const SettingsGeneral = () => {
|
||||
);
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
if (hasSecurityPermission && activeTabId === GENERAL_TAB_SECURITY) {
|
||||
if (activeTabId === GENERAL_TAB_LOGS) {
|
||||
return <SettingsLogs />;
|
||||
}
|
||||
|
||||
if (activeTabId === GENERAL_TAB_SECURITY) {
|
||||
return <SettingsSecuritySettings />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { IconAlertTriangle, IconRefresh } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
import { EventLogTable } from '~/generated-metadata/graphql';
|
||||
|
||||
import { EventLogFilters } from './components/EventLogFilters';
|
||||
import { EventLogResultsTable } from './components/EventLogResultsTable';
|
||||
import { EventLogTableSelector } from './components/EventLogTableSelector';
|
||||
import { useEventLogs } from './hooks/useQueryEventLogs';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledHeaderRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledRecordCount = styled.span`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledTableWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledErrorContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
justify-content: center;
|
||||
padding: ${themeCssVariables.spacing[8]};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledErrorIcon = styled.div`
|
||||
color: ${themeCssVariables.color.orange};
|
||||
`;
|
||||
|
||||
const StyledErrorTitle = styled.h3`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledErrorMessage = styled.p`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
export type EventLogFiltersState = {
|
||||
eventType?: string;
|
||||
userWorkspaceId?: string;
|
||||
dateRange?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
};
|
||||
recordId?: string;
|
||||
objectMetadataId?: string;
|
||||
};
|
||||
|
||||
const RECORDS_PER_PAGE = 100;
|
||||
|
||||
export const SettingsEventLogs = () => {
|
||||
const { t } = useLingui();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
|
||||
EventLogTable.PAGEVIEW,
|
||||
);
|
||||
const [filters, setFilters] = useState<EventLogFiltersState>({});
|
||||
|
||||
const {
|
||||
records,
|
||||
totalCount,
|
||||
hasNextPage,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
} = useEventLogs({
|
||||
table: selectedTable,
|
||||
filters: {
|
||||
eventType: filters.eventType,
|
||||
userWorkspaceId: filters.userWorkspaceId,
|
||||
dateRange: filters.dateRange
|
||||
? {
|
||||
start: filters.dateRange.start?.toISOString(),
|
||||
end: filters.dateRange.end?.toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
recordId: filters.recordId,
|
||||
objectMetadataId: filters.objectMetadataId,
|
||||
},
|
||||
first: RECORDS_PER_PAGE,
|
||||
});
|
||||
|
||||
const handleTableChange = (table: EventLogTable) => {
|
||||
setSelectedTable(table);
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const handleFiltersChange = (newFilters: EventLogFiltersState) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleExitFullScreen = () => {
|
||||
navigateSettings(SettingsPath.Security);
|
||||
};
|
||||
|
||||
const recordCount = records.length;
|
||||
|
||||
const getErrorContent = () => {
|
||||
if (!isDefined(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage = error.message || '';
|
||||
const isClickHouseError = errorMessage.includes('ClickHouse');
|
||||
const isEntitlementError =
|
||||
errorMessage.includes('Enterprise') ||
|
||||
errorMessage.includes('entitlement');
|
||||
|
||||
if (isClickHouseError) {
|
||||
return {
|
||||
title: t`ClickHouse Not Configured`,
|
||||
message: t`Audit logs require ClickHouse to be configured. Please contact your administrator to set up ClickHouse.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isEntitlementError) {
|
||||
return {
|
||||
title: t`Enterprise Feature`,
|
||||
message: t`Audit logs are available with an Enterprise subscription. Please upgrade to access this feature.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t`Error Loading Audit Logs`,
|
||||
message:
|
||||
errorMessage || t`An unexpected error occurred. Please try again.`,
|
||||
};
|
||||
};
|
||||
|
||||
const errorContent = getErrorContent();
|
||||
|
||||
return (
|
||||
<FullScreenContainer
|
||||
exitFullScreen={handleExitFullScreen}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.General),
|
||||
},
|
||||
{
|
||||
children: <Trans>Security</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Security),
|
||||
},
|
||||
{ children: <Trans>Audit Logs</Trans> },
|
||||
]}
|
||||
>
|
||||
<StyledContainer>
|
||||
{isDefined(errorContent) ? (
|
||||
<StyledErrorContainer>
|
||||
<StyledErrorIcon>
|
||||
<IconAlertTriangle size={48} />
|
||||
</StyledErrorIcon>
|
||||
<StyledErrorTitle>{errorContent.title}</StyledErrorTitle>
|
||||
<StyledErrorMessage>{errorContent.message}</StyledErrorMessage>
|
||||
<Button
|
||||
title={t`Go Back`}
|
||||
variant="secondary"
|
||||
onClick={handleExitFullScreen}
|
||||
/>
|
||||
</StyledErrorContainer>
|
||||
) : (
|
||||
<>
|
||||
<StyledHeader>
|
||||
<EventLogTableSelector
|
||||
value={selectedTable}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
<EventLogFilters
|
||||
table={selectedTable}
|
||||
value={filters}
|
||||
onChange={handleFiltersChange}
|
||||
/>
|
||||
<StyledHeaderRow>
|
||||
<StyledRecordCount>
|
||||
{t`${recordCount} of ${totalCount} records`}
|
||||
</StyledRecordCount>
|
||||
<Button
|
||||
Icon={IconRefresh}
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() => refetch()}
|
||||
title={t`Refresh`}
|
||||
/>
|
||||
</StyledHeaderRow>
|
||||
</StyledHeader>
|
||||
<StyledTableWrapper>
|
||||
<EventLogResultsTable
|
||||
records={records}
|
||||
loading={loading}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={loadMore}
|
||||
selectedTable={selectedTable}
|
||||
/>
|
||||
</StyledTableWrapper>
|
||||
</>
|
||||
)}
|
||||
</StyledContainer>
|
||||
</FullScreenContainer>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard';
|
||||
import { EventLogsGraphqlApiExceptionFilter } from 'src/engine/core-modules/event-logs/filters/event-logs-graphql-api-exception.filter';
|
||||
import { ForbiddenExceptionGraphqlFilter } from 'src/engine/core-modules/event-logs/filters/forbidden-exception-graphql.filter';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
@@ -37,7 +36,6 @@ export class EventLogsResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
EnterpriseFeaturesEnabledGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.SECURITY),
|
||||
)
|
||||
@Query(() => EventLogQueryResult)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
import {
|
||||
@@ -76,6 +77,7 @@ export class EventLogsService {
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
@@ -84,7 +86,7 @@ export class EventLogsService {
|
||||
workspaceId: string,
|
||||
input: EventLogQueryInput,
|
||||
): Promise<EventLogQueryResult> {
|
||||
await this.validateAccess(workspaceId);
|
||||
await this.validateAccess(workspaceId, input.table);
|
||||
|
||||
if (!ALLOWED_TABLES.includes(input.table)) {
|
||||
throw new BadRequestException(`Invalid table: ${input.table}`);
|
||||
@@ -171,7 +173,10 @@ export class EventLogsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async validateAccess(workspaceId: string): Promise<void> {
|
||||
private async validateAccess(
|
||||
workspaceId: string,
|
||||
table: EventLogTable,
|
||||
): Promise<void> {
|
||||
if (!this.clickHouseService.getMainClient()) {
|
||||
throw new EventLogsException(
|
||||
'Audit logs require ClickHouse to be configured. Please set the CLICKHOUSE_URL environment variable.',
|
||||
@@ -179,6 +184,17 @@ export class EventLogsService {
|
||||
);
|
||||
}
|
||||
|
||||
if (table === EventLogTable.APPLICATION_LOG) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.enterprisePlanService.isValid()) {
|
||||
throw new EventLogsException(
|
||||
'Audit logs require an Enterprise subscription.',
|
||||
EventLogsExceptionCode.NO_ENTITLEMENT,
|
||||
);
|
||||
}
|
||||
|
||||
const hasEntitlement = await this.billingService.hasEntitlement(
|
||||
workspaceId,
|
||||
BillingEntitlementKey.AUDIT_LOGS,
|
||||
|
||||
@@ -67,8 +67,8 @@ export enum SettingsPath {
|
||||
WebhookDetail = 'api-webhooks/webhooks/:webhookId',
|
||||
Integrations = 'integrations',
|
||||
Security = 'general#security',
|
||||
Logs = 'general#logs',
|
||||
NewSSOIdentityProvider = 'security/sso/new',
|
||||
EventLogs = 'security/event-logs',
|
||||
|
||||
AdminPanel = 'admin-panel',
|
||||
AdminPanelEnterprise = 'admin-panel#enterprise',
|
||||
|
||||
Reference in New Issue
Block a user