Files
twenty/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts
T
Marie b31f84fbb8 fix(server): workspace member permissions and profile onboarding (#19786)
## Summary

Aligns **workspace member** editing and **onboarding** with how the
product is actually used: profile and other “settings” fields go through
**`updateWorkspaceMemberSettings`**, while **`/graphql`** record APIs
follow **object-level** permissions for the `workspaceMember` object.

## Product behaviour

### Completing “Create profile” onboarding

Users who must create a profile (empty name at sign-up) get
`ONBOARDING_CREATE_PROFILE_PENDING` set. The onboarding UI saves the
name with **`updateWorkspaceMemberSettings`**, not with a workspace
record **`updateOne`**.

**Before:** The server only cleared the pending flag on
**`workspaceMember.updateOne`**, so the flag could stay set and
onboarding appeared stuck.

**After:** Clearing the profile step runs when
**`updateWorkspaceMemberSettings`** persists an update that includes a
**name** (same rules as before: non-empty name parts). Onboarding can
advance normally after **Continue** on Create profile.

### Two ways to change workspace member data

| Path | Typical use | Who can change what |
|------|----------------|---------------------|
| **`updateWorkspaceMemberSettings`** (metadata API) | Standard member
fields the app treats as “my profile / preferences” (name,
avatar-related settings, locale, time zone, etc.) | **Always** your
**own** workspace member. Changing **another** member still requires
**Workspace members** in role settings (`WORKSPACE_MEMBERS`). Custom
fields are **not** allowed on this endpoint (unchanged). |
| **`/graphql`** record mutations on **`workspaceMember`** | Custom
fields, integrations, anything that goes through the generic record API
| **`WorkspaceMember`** is special-cased in permissions: **read** stays
**on** for everyone, but **update / create / delete** require
**`WORKSPACE_MEMBERS`**, including updating **your own** row via
`/graphql`. So a **Member** without that permission cannot fix their
name through **`updateWorkspaceMember`**; they use **Settings** /
**`updateWorkspaceMemberSettings`** instead. |

This matches **`WorkspaceRolesPermissionsCacheService`**: for the
workspace member object, `canReadObjectRecords` is always true;
`canUpdateObjectRecords` (and delete-related flags) follow
**`WORKSPACE_MEMBERS`**.

### Hooks and delete side-effects

- Removed **`workspaceMember.updateOne`** pre-query hook and
**`WorkspaceMemberPreQueryHookService`**: they duplicated the same rules
the permission cache already enforces for `/graphql`.
- **`WorkspaceMember.deleteOne`** pre-hook still tells users to remove
members via the dedicated flow; the post-hook only runs the
**`deleteUserWorkspace`** side-effect when a member row is actually
removed—**no** extra settings-permission check there, since only callers
that already passed **object** delete permission can remove the row.

## Tests

- **`workspace-members.integration-spec.ts`**: clarifies and extends
coverage so **`/graphql`** **`updateOne`** is denied for **own** record
on a **standard** name field and on a **custom** field when the role
lacks **`WORKSPACE_MEMBERS`**.

## Implementation notes

- **`OnboardingService.completeOnboardingProfileStepIfNameProvided`**
centralises the “clear profile pending if name present” logic;
**`UserResolver.updateWorkspaceMemberSettings`** calls it after save,
using the typed update payload’s **`name`** (no cast).
- **`UserWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace`**:
drops a redundant **`coreEntityCacheService.invalidate`**;
**`updateWorkspaceMemberSettings`** still invalidates the user-workspace
cache after the mutation.
2026-04-17 09:58:34 +00:00

276 lines
7.6 KiB
TypeScript

import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import request from 'supertest';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const APPLE_WORKSPACE_INVITE_HASH = 'apple.dev-invite-hash';
describe('updateWorkspaceMemberSettings and profile onboarding', () => {
let newUserAccessToken: string | undefined;
let originalIsPublicInviteLinkEnabled: boolean;
beforeAll(async () => {
const currentWorkspaceQuery = gql`
query CurrentWorkspacePublicInvite {
currentWorkspace {
isPublicInviteLinkEnabled
}
}
`;
const response = await makeMetadataAPIRequest(
{
query: currentWorkspaceQuery,
variables: {},
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.currentWorkspace).toBeDefined();
originalIsPublicInviteLinkEnabled =
response.body.data.currentWorkspace.isPublicInviteLinkEnabled;
});
afterAll(async () => {
const restoreMutation = gql`
mutation RestoreWorkspacePublicInvite($data: UpdateWorkspaceInput!) {
updateWorkspace(data: $data) {
id
isPublicInviteLinkEnabled
}
}
`;
await makeMetadataAPIRequest(
{
query: restoreMutation,
variables: {
data: {
isPublicInviteLinkEnabled: originalIsPublicInviteLinkEnabled,
},
},
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
});
afterEach(async () => {
if (!newUserAccessToken) {
return;
}
await deleteUser({
accessToken: newUserAccessToken,
expectToFail: false,
});
newUserAccessToken = undefined;
});
it('should clear PROFILE_CREATION onboarding after saving first and last name via updateWorkspaceMemberSettings', async () => {
const client = request(`http://localhost:${APP_PORT}`);
const enablePublicInviteLinkMutation = {
query: `
mutation UpdateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(enablePublicInviteLinkMutation)
.expect(200)
.expect((response) => {
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspace.isPublicInviteLinkEnabled).toBe(
true,
);
});
const uniqueEmail = `profile-onboarding-${randomUUID()}@example.com`;
const password = 'Password123!';
const signUpInWorkspaceMutation = gql`
mutation SignUpInWorkspace(
$email: String!
$password: String!
$workspaceInviteHash: String
$workspaceId: UUID
) {
signUpInWorkspace(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspaceId: $workspaceId
) {
loginToken {
token
}
workspace {
id
workspaceUrls {
subdomainUrl
}
}
}
}
`;
const signUpResponse = await makeMetadataAPIRequest(
{
query: signUpInWorkspaceMutation,
variables: {
email: uniqueEmail,
password,
workspaceInviteHash: APPLE_WORKSPACE_INVITE_HASH,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
},
undefined,
);
expect(signUpResponse.status).toBe(200);
expect(signUpResponse.body.errors).toBeUndefined();
const signUpPayload = signUpResponse.body.data.signUpInWorkspace;
expect(signUpPayload.loginToken.token).toBeDefined();
await testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const origin =
signUpPayload.workspace.workspaceUrls?.subdomainUrl ??
'http://localhost:3001';
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
loginToken: signUpPayload.loginToken.token,
origin,
expectToFail: false,
});
newUserAccessToken =
authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
const currentUserWithOnboardingQuery = gql`
query CurrentUserWithOnboarding {
currentUser {
id
onboardingStatus
}
}
`;
const beforeNameUpdateResponse = await makeMetadataAPIRequest(
{
query: currentUserWithOnboardingQuery,
variables: {},
},
newUserAccessToken,
);
expect(beforeNameUpdateResponse.status).toBe(200);
expect(beforeNameUpdateResponse.body.errors).toBeUndefined();
expect(beforeNameUpdateResponse.body.data.currentUser.onboardingStatus).toBe(
OnboardingStatus.PROFILE_CREATION,
);
const workspaceMemberQuery = gql`
query WorkspaceMemberForProfileOnboarding(
$workspaceMemberFilter: WorkspaceMemberFilterInput!
) {
workspaceMember(filter: $workspaceMemberFilter) {
id
}
}
`;
const workspaceMemberResponse = await makeGraphqlAPIRequest(
{
query: workspaceMemberQuery,
variables: {
workspaceMemberFilter: {
userEmail: {
eq: uniqueEmail,
},
},
},
},
newUserAccessToken,
);
expect(workspaceMemberResponse.status).toBe(200);
expect(workspaceMemberResponse.body.errors).toBeUndefined();
const workspaceMemberId =
workspaceMemberResponse.body.data.workspaceMember?.id;
expect(workspaceMemberId).toBeDefined();
const updateWorkspaceMemberSettingsMutation = gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`;
const updateNameResponse = await makeMetadataAPIRequest(
{
query: updateWorkspaceMemberSettingsMutation,
variables: {
input: {
workspaceMemberId,
update: {
name: {
firstName: 'Onboarding',
lastName: 'Integration',
},
},
},
},
},
newUserAccessToken,
);
expect(updateNameResponse.status).toBe(200);
expect(updateNameResponse.body.errors).toBeUndefined();
expect(updateNameResponse.body.data.updateWorkspaceMemberSettings).toBe(true);
const afterNameUpdateResponse = await makeMetadataAPIRequest(
{
query: currentUserWithOnboardingQuery,
variables: {},
},
newUserAccessToken,
);
expect(afterNameUpdateResponse.status).toBe(200);
expect(afterNameUpdateResponse.body.errors).toBeUndefined();
expect(
afterNameUpdateResponse.body.data.currentUser.onboardingStatus,
).not.toBe(OnboardingStatus.PROFILE_CREATION);
expect(afterNameUpdateResponse.body.data.currentUser.onboardingStatus).toBe(
OnboardingStatus.COMPLETED,
);
});
});