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:
Félix Malfait
2026-06-04 08:47:23 +02:00
committed by GitHub
parent e6614299c6
commit 2ac515894b
21 changed files with 358 additions and 408 deletions
+12 -10
View File
@@ -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}
@@ -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>
);
};
@@ -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,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;
};
@@ -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',