9cb21e71fa
## 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>
128 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
};
|