Files
twenty/packages/twenty-front/src/modules/settings/accounts/hooks/useMyMessageChannels.ts
T
Charles Bochet 9cb21e71fa feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary

Builds on the messaging infrastructure migration (#18784) by securing
and user-scoping all 4 metadata resolvers:

### DTOs secured
- **ConnectedAccountDTO**: `@HideField()` on `accessToken`,
`refreshToken`, `connectionParameters`, `oidcTokenClaims`
- **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on
`syncCursor`
- **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId`
- **UpdateMessageFolderInputUpdates**: stripped to only `isSynced`
(removed `name`, `syncCursor`, `pendingSyncAction`)

### Resolvers user-scoped via `@AuthUserWorkspaceId()`
- `myConnectedAccounts` — returns only the calling user's accounts (no
permission guard)
- `myMessageChannels(connectedAccountId?)` — returns channels for the
user's connected accounts
- `myCalendarChannels(connectedAccountId?)` — same pattern
- `myMessageFolders(messageChannelId?)` — returns folders through the
ownership chain

### Admin-only listing with permission guard
- `connectedAccounts` query retained with
`SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all
workspace accounts

### Unsafe mutations removed
- Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP
flows create/refresh tokens server-side)
- Removed `create*`/`delete*` mutations from MessageChannel,
CalendarChannel, MessageFolder (managed by sync engine)

### Update mutations restricted with ownership verification
- `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId ===
currentUserWorkspaceId`
- `updateMessageChannel` / `updateCalendarChannel` /
`updateMessageFolder` — verify ownership through connected account chain
- New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in
GraphQL

### `@AuthUserWorkspaceId` decorator hardened
- Added `allowUndefined` option (default: `false`) — throws
`ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key
auth)
- Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined:
true })` where needed
- New user-scoped resolvers enforce non-undefined `userWorkspaceId` at
decorator level

### Exception handler chaining
- `MessageFolderGraphqlApiExceptionInterceptor`,
`MessageChannelGraphqlApiExceptionInterceptor`,
`CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception
handling (ConnectedAccountException, MessageChannelException) for
correct `ForbiddenError` propagation

### Metadata services enhanced
- `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`,
`findByConnectedAccountIds()`, `findByMessageChannelIds()`
- `findBy*ForUser()` methods encapsulate ownership checks before
querying
- `verifyOwnership()` on all 4 services with proper chain validation
- Named parameters throughout for clarity

### Dev seeds for both schemas
- Added JANE to connected account, message channel, calendar channel
workspace seeds
- Created message folder workspace seeds (TIM, JONY, JANE)
- New `seed-metadata-entities.util.ts` seeds core schema tables
(connectedAccount, messageChannel, calendarChannel, messageFolder) with
same IDs as workspace seeds, mapping `accountOwnerId` →
`userWorkspaceId`

### Integration tests (using seeds, not raw SQL)
- 4 test suites (`connected-account`, `message-channel`,
`calendar-channel`, `message-folder`)
- Tests use seeded data IDs from seed constants — no raw SQL
inserts/deletes
- Tests read via GraphQL resolvers
- Tests cover: user scoping, admin permission checks, sensitive field
exclusion, ownership enforcement on mutations

### Frontend migration
- Feature-flag-gated hooks (`useMyConnectedAccounts`,
`useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`)
- When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API
(`POST /metadata`)
- When flag is off: hooks use existing workspace API (`POST /graphql`,
current behavior)
- Settings account pages updated to use new hooks
- `useEffect` extracted to
`SettingsAccountsSelectedMessageChannelEffect` component per project
conventions
- Error messages translated with Lingui

## Test plan
- [x] Server typecheck passes
- [x] Server lint passes
- [x] Server unit tests pass (477 suites, 4269 tests)
- [x] Frontend typecheck passes
- [x] Frontend lint passes
- [x] Integration tests verify user-scoping, ownership enforcement,
hidden fields
- [ ] CI green

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-20 17:22:22 +01:00

128 lines
4.3 KiB
TypeScript

import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type MessageChannel,
MessageChannelSyncStage,
} from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataMessageChannel = {
id: string;
handle: string;
visibility: string;
type: string;
isContactAutoCreationEnabled: boolean;
contactAutoCreationPolicy: string;
messageFolderImportPolicy: string;
excludeNonProfessionalEmails: boolean;
excludeGroupEmails: boolean;
isSyncEnabled: boolean;
syncStatus: string;
syncStage: string;
syncStageStartedAt: string | null;
connectedAccountId: string;
createdAt: string;
updatedAt: string;
};
export const useMyMessageChannels = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { records: workspaceAccounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
skip: isMigrated,
});
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
});
const { records: workspaceChannels, loading: workspaceLoading } =
useFindManyRecords<MessageChannel & { connectedAccount: ConnectedAccount }>(
{
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
in: workspaceAccounts.map((account) => account.id),
},
isSyncEnabled: { eq: true },
syncStage: {
neq: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
},
recordGqlFields,
skip: isMigrated || !workspaceAccounts.length,
},
);
const { data: metadataData, loading: metadataLoading } = useQuery<{
myMessageChannels: MetadataMessageChannel[];
}>(GET_MY_MESSAGE_CHANNELS, {
client: apolloClient,
skip: !isMigrated,
});
const channels = useMemo(() => {
if (!isMigrated) {
return workspaceChannels;
}
if (!metadataData?.myMessageChannels) {
return [];
}
return metadataData.myMessageChannels
.filter(
(channel: MetadataMessageChannel) =>
channel.isSyncEnabled &&
channel.syncStage !== 'PENDING_CONFIGURATION',
)
.map(
(channel: MetadataMessageChannel) =>
({
id: channel.id,
handle: channel.handle,
visibility: channel.visibility,
contactAutoCreationPolicy: channel.contactAutoCreationPolicy,
excludeNonProfessionalEmails: channel.excludeNonProfessionalEmails,
excludeGroupEmails: channel.excludeGroupEmails,
isSyncEnabled: channel.isSyncEnabled,
messageFolders: [],
messageFolderImportPolicy: channel.messageFolderImportPolicy,
syncStatus: channel.syncStatus,
syncStage: channel.syncStage,
syncCursor: '',
syncStageStartedAt: channel.syncStageStartedAt
? new Date(channel.syncStageStartedAt)
: null,
throttleFailureCount: 0,
connectedAccountId: channel.connectedAccountId,
__typename: 'MessageChannel',
}) as MessageChannel,
);
}, [isMigrated, workspaceChannels, metadataData]);
return {
channels,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};