Skip to content
Open
7 changes: 5 additions & 2 deletions packages/twenty-front/src/generated-metadata/graphql.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/twenty-front/src/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4382,6 +4382,7 @@ export type UpdateWorkflowVersionStepInput = {

export type UpdateWorkspaceInput = {
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
allowRequests?: InputMaybe<Scalars['Boolean']>;
customDomain?: InputMaybe<Scalars['String']>;
defaultRoleId?: InputMaybe<Scalars['UUID']>;
displayName?: InputMaybe<Scalars['String']>;
Expand Down Expand Up @@ -4716,6 +4717,7 @@ export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
allowImpersonation: Scalars['Boolean'];
allowRequests: Scalars['Boolean'];
billingSubscriptions: Array<BillingSubscription>;
createdAt: Scalars['DateTime'];
currentBillingSubscription?: Maybe<BillingSubscription>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const mockWorkspace = {
},
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
allowRequests: true,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type CurrentWorkspace = Pick<
| 'fastModel'
| 'smartModel'
| 'editableProfileFields'
| 'allowRequests'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
workspaceCustomApplication: Pick<Application, 'id'> | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
],
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
allowRequests: true,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
getLogoUrlFromDomainName,
isDefined,
} from 'twenty-shared/utils';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilState } from 'recoil';

export const getAvatarUrl = (
objectNameSingular: string,
Expand All @@ -20,7 +22,13 @@ export const getAvatarUrl = (
return record.avatarUrl ?? undefined;
}

if (objectNameSingular === CoreObjectNameSingular.Company) {
// eslint-disable-next-line react-hooks/rules-of-hooks
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An eslint-disable comment is being used to suppress the React Hooks rules violation, but this doesn't fix the underlying issue. The code will still fail at runtime because React Hooks cannot be called from regular functions. This suppression should be removed, and the code should be properly refactored to pass the workspace state as a parameter instead of calling the hook.

Copilot uses AI. Check for mistakes.
const [currentWorkspace] = useRecoilState(currentWorkspaceState);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Using useRecoilState inside a regular utility function violates React's Rules of Hooks. This will cause runtime errors because hooks can only be called from React components or custom hooks.

Consider either:

  1. Converting this to a custom hook (rename to useGetAvatarUrl)
  2. Passing currentWorkspace as a parameter to this function
  3. Using Recoil's snapshot_UNSTABLE API for reading state outside React components

Suppressing the ESLint rule doesn't fix the underlying issue - it just hides the warning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts, line 26:

<comment>Using `useRecoilState` inside a regular utility function violates React&#39;s Rules of Hooks. This will cause runtime errors because hooks can only be called from React components or custom hooks.

Consider either:
1. Converting this to a custom hook (rename to `useGetAvatarUrl`)
2. Passing `currentWorkspace` as a parameter to this function
3. Using Recoil&#39;s `snapshot_UNSTABLE` API for reading state outside React components

Suppressing the ESLint rule doesn&#39;t fix the underlying issue - it just hides the warning.</comment>

<file context>
@@ -20,7 +22,13 @@ export const getAvatarUrl = (
 
-  if (objectNameSingular === CoreObjectNameSingular.Company) {
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const [currentWorkspace] = useRecoilState(currentWorkspaceState);
+
+  if (
</file context>

✅ Addressed in 12ae5f7

Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a critical React Hooks violation. The function getAvatarUrl is a utility function (not a React component or custom hook), but it's calling useRecoilState, which is a React Hook. React Hooks can only be called from React components or custom hooks (functions starting with "use").

This will cause runtime errors because getAvatarUrl is called from various places in the codebase that are not within a React component's render cycle. The function needs to be refactored to accept the currentWorkspace value as a parameter instead of calling the hook directly.

Copilot uses AI. Check for mistakes.

if (
objectNameSingular === CoreObjectNameSingular.Company &&
currentWorkspace?.allowRequests === true
) {
return getLogoUrlFromDomainName(
getCompanyDomainName(record as Company) ?? '',
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useRecoilState } from 'recoil';

import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { Card } from 'twenty-ui/layout';
import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
import { IconHttpGet, IconTrash } from 'twenty-ui/display';
import styled from '@emotion/styled';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { useDebouncedCallback } from 'use-debounce';

export const SettingsSecurityOther = () => {
const StyledToggleRequests = styled.div`
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Styled component StyledToggleRequests is defined inside the component function. This causes the styled component to be recreated on every render, leading to performance issues. Move it outside the component, following the pattern used in NameField.tsx in the same directory.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-front/src/modules/settings/workspace/components/SettingsSecurityOther.tsx, line 16:

<comment>Styled component `StyledToggleRequests` is defined inside the component function. This causes the styled component to be recreated on every render, leading to performance issues. Move it outside the component, following the pattern used in `NameField.tsx` in the same directory.</comment>

<file context>
@@ -0,0 +1,115 @@
+import { useDebouncedCallback } from &#39;use-debounce&#39;;
+
+export const SettingsSecurityOther = () =&gt; {
+  const StyledToggleRequests = styled.div`
+    display: flex;
+    flex-direction: column;
</file context>

✅ Addressed in 15642a3

display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;

const { enqueueErrorSnackBar } = useSnackBar();

const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);

const [updateWorkspace] = useUpdateWorkspaceMutation();

const saveWorkspace = useDebouncedCallback(async (value: number) => {
try {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}

await updateWorkspace({
variables: {
input: {
trashRetentionDays: value,
},
},
});
} catch (err) {
enqueueErrorSnackBar({
apolloError: err instanceof ApolloError ? err : undefined,
});
}
}, 500);

const handleTrashRetentionDaysChange = (value: number) => {
if (!currentWorkspace) {
return;
}

if (value === currentWorkspace.trashRetentionDays) {
return;
}

setCurrentWorkspace({
...currentWorkspace,
trashRetentionDays: value,
});

saveWorkspace(value);
};

const handleRequestsChange = async (value: boolean) => {
try {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}
await updateWorkspace({
variables: {
input: {
allowRequests: value,
},
},
});
setCurrentWorkspace({
...currentWorkspace,
allowRequests: value,
});
} catch (err: any) {
enqueueErrorSnackBar({
apolloError: err instanceof ApolloError ? err : undefined,
});
}
};

return (
<StyledToggleRequests>
<Card rounded>
<SettingsOptionCardContentCounter
Icon={IconTrash}
title={t`Erasure of soft-deleted records`}
description={t`Permanent deletion. Enter the number of days.`}
value={currentWorkspace?.trashRetentionDays ?? 14}
onChange={handleTrashRetentionDaysChange}
minValue={0}
showButtons={false}
/>
</Card>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconHttpGet}
title={t`Allow requests to twenty-icons`}
description={t`Grant access to send requests to twenty-icons to show companies' icons.`}
checked={currentWorkspace?.allowRequests ?? false}
onChange={handleRequestsChange}
advancedMode
/>
</Card>
</StyledToggleRequests>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const USER_QUERY_FRAGMENT = gql`
isTwoFactorAuthenticationEnforced
trashRetentionDays
editableProfileFields
allowRequests
}
availableWorkspaces {
...AvailableWorkspacesFragment
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';

import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
import { SettingsSecurityEditableProfileFields } from '@/settings/security/components/SettingsSecurityEditableProfileFields';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsSecurityOther } from '@/settings/workspace/components/SettingsSecurityOther';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { ApolloError } from '@apollo/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import { H2Title, IconLock, IconTrash } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
import { H2Title, IconLock } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';

const StyledContainer = styled.div`
width: 100%;
Expand All @@ -41,52 +37,11 @@ const StyledSection = styled(Section)`

export const SettingsSecurity = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();

const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const authProviders = useRecoilValue(authProvidersState);
const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [updateWorkspace] = useUpdateWorkspaceMutation();

const saveWorkspace = useDebouncedCallback(async (value: number) => {
try {
if (!currentWorkspace?.id) {
throw new Error('User is not logged in');
}

await updateWorkspace({
variables: {
input: {
trashRetentionDays: value,
},
},
});
} catch (err) {
enqueueErrorSnackBar({
apolloError: err instanceof ApolloError ? err : undefined,
});
}
}, 500);

const handleTrashRetentionDaysChange = (value: number) => {
if (!currentWorkspace) {
return;
}

if (value === currentWorkspace.trashRetentionDays) {
return;
}

setCurrentWorkspace({
...currentWorkspace,
trashRetentionDays: value,
});

saveWorkspace(value);
};
const [currentWorkspace] = useRecoilState(currentWorkspaceState);

const hasSsoIdentityProviders = SSOIdentitiesProviders.length > 0;
const hasDirectAuthEnabled =
Expand Down Expand Up @@ -174,17 +129,7 @@ export const SettingsSecurity = () => {
title={t`Other`}
description={t`Other security settings`}
/>
<Card rounded>
<SettingsOptionCardContentCounter
Icon={IconTrash}
title={t`Erasure of soft-deleted records`}
description={t`Permanent deletion. Enter the number of days.`}
value={currentWorkspace?.trashRetentionDays ?? 14}
onChange={handleTrashRetentionDaysChange}
minValue={0}
showButtons={false}
/>
</Card>
<SettingsSecurityOther />
</Section>
</StyledMainContent>
</SettingsPageContainer>
Expand Down
1 change: 1 addition & 0 deletions packages/twenty-front/src/testing/mock-data/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const mockCurrentWorkspace = {
updatedAt: '2023-04-26T10:23:42.33625+00:00',
metadataVersion: 1,
trashRetentionDays: 14,
allowRequests: true,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';

export class AllowRequests1766176556359 implements MigrationInterface {
name = 'AllowRequests1766176556359';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "allowRequests" boolean NOT NULL DEFAULT true`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "allowRequests"`,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,9 @@ export class UpdateWorkspaceInput {
@IsString({ each: true })
@IsOptional()
editableProfileFields?: string[];

@Field({ nullable: true })
@IsBoolean()
@IsOptional()
allowRequests?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
displayName: PermissionFlagType.WORKSPACE,
logo: PermissionFlagType.WORKSPACE,
trashRetentionDays: PermissionFlagType.WORKSPACE,
allowRequests: PermissionFlagType.WORKSPACE,
inviteHash: PermissionFlagType.WORKSPACE_MEMBERS,
isPublicInviteLinkEnabled: PermissionFlagType.SECURITY,
allowImpersonation: PermissionFlagType.SECURITY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,8 @@ export class WorkspaceEntity {
onDelete: 'CASCADE',
})
applications: Relation<Application[]>;

@Field()
@Column({ default: true })
allowRequests: boolean;
}
Loading