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.
This commit is contained in:
-20
@@ -7,8 +7,6 @@ import { type WorkspacePostQueryHookInstance } from 'src/engine/api/graphql/work
|
||||
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
|
||||
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
|
||||
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
@@ -18,7 +16,6 @@ import {
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
@WorkspaceQueryHook({
|
||||
@@ -32,7 +29,6 @@ export class WorkspaceMemberDeleteOnePostQueryHook
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
) {}
|
||||
|
||||
@@ -52,22 +48,6 @@ export class WorkspaceMemberDeleteOnePostQueryHook
|
||||
|
||||
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
|
||||
|
||||
await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow(
|
||||
{
|
||||
userWorkspaceId: isUserAuthContext(authContext)
|
||||
? authContext.userWorkspaceId
|
||||
: undefined,
|
||||
workspaceMemberId: isUserAuthContext(authContext)
|
||||
? authContext.workspaceMemberId
|
||||
: undefined,
|
||||
targettedWorkspaceMemberId,
|
||||
workspaceId: workspace.id,
|
||||
apiKey: isApiKeyAuthContext(authContext)
|
||||
? authContext.apiKey
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const workspaceMember =
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
async () => {
|
||||
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMemberPreQueryHookService {
|
||||
constructor(
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
) {}
|
||||
|
||||
async validateWorkspaceMemberUpdatePermissionOrThrow({
|
||||
userWorkspaceId,
|
||||
workspaceMemberId,
|
||||
targettedWorkspaceMemberId,
|
||||
workspaceId,
|
||||
apiKey,
|
||||
}: {
|
||||
userWorkspaceId?: string;
|
||||
workspaceMemberId?: string;
|
||||
targettedWorkspaceMemberId?: string;
|
||||
workspaceId: string;
|
||||
apiKey?: FlatApiKey | null;
|
||||
}) {
|
||||
if (isDefined(apiKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userWorkspaceId) {
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.USER_WORKSPACE_NOT_FOUND,
|
||||
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(targettedWorkspaceMemberId) &&
|
||||
workspaceMemberId === targettedWorkspaceMemberId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await this.permissionsService.userHasWorkspaceSettingPermission({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
setting: PermissionFlagType.WORKSPACE_MEMBERS,
|
||||
apiKeyId: apiKey ?? undefined,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new PermissionsException(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
}
|
||||
|
||||
async completeOnboardingProfileStepIfNameProvided({
|
||||
userId,
|
||||
workspaceId,
|
||||
firstName,
|
||||
lastName,
|
||||
}: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}) {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstName === '' && lastName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(firstName) && !isDefined(lastName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.onboardingService.setOnboardingCreateProfilePending({
|
||||
userId,
|
||||
workspaceId,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
-8
@@ -3,10 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceMemberCreateManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook';
|
||||
import { WorkspaceMemberCreateOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook';
|
||||
import { WorkspaceMemberDeleteManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook';
|
||||
@@ -14,15 +12,12 @@ import { WorkspaceMemberDeleteOnePostQueryHook } from 'src/modules/workspace-mem
|
||||
import { WorkspaceMemberDeleteOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook';
|
||||
import { WorkspaceMemberDestroyManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook';
|
||||
import { WorkspaceMemberDestroyOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook';
|
||||
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
|
||||
import { WorkspaceMemberRestoreManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-restore-many.pre-query.hook';
|
||||
import { WorkspaceMemberRestoreOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-restore-one.pre-query.hook';
|
||||
import { WorkspaceMemberUpdateManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-update-many.pre-query.hook';
|
||||
import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
WorkspaceMemberPreQueryHookService,
|
||||
WorkspaceMemberCreateOnePreQueryHook,
|
||||
WorkspaceMemberCreateManyPreQueryHook,
|
||||
WorkspaceMemberDeleteOnePreQueryHook,
|
||||
@@ -32,14 +27,11 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
|
||||
WorkspaceMemberDestroyManyPreQueryHook,
|
||||
WorkspaceMemberRestoreOnePreQueryHook,
|
||||
WorkspaceMemberRestoreManyPreQueryHook,
|
||||
WorkspaceMemberUpdateOnePreQueryHook,
|
||||
WorkspaceMemberUpdateManyPreQueryHook,
|
||||
],
|
||||
imports: [
|
||||
CoreEntityCacheModule,
|
||||
FeatureFlagModule,
|
||||
OnboardingModule,
|
||||
PermissionsModule,
|
||||
UserWorkspaceModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity]),
|
||||
],
|
||||
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
|
||||
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
|
||||
|
||||
@WorkspaceQueryHook(`workspaceMember.updateOne`)
|
||||
export class WorkspaceMemberUpdateOnePreQueryHook
|
||||
implements WorkspacePreQueryHookInstance
|
||||
{
|
||||
constructor(
|
||||
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
authContext: WorkspaceAuthContext,
|
||||
_objectName: string,
|
||||
payload: UpdateOneResolverArgs,
|
||||
): Promise<UpdateOneResolverArgs> {
|
||||
const workspace = authContext.workspace;
|
||||
|
||||
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
|
||||
|
||||
await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow(
|
||||
{
|
||||
userWorkspaceId: isUserAuthContext(authContext)
|
||||
? authContext.userWorkspaceId
|
||||
: undefined,
|
||||
workspaceMemberId: isUserAuthContext(authContext)
|
||||
? authContext.workspaceMemberId
|
||||
: undefined,
|
||||
targettedWorkspaceMemberId: payload.id,
|
||||
workspaceId: workspace.id,
|
||||
apiKey: isApiKeyAuthContext(authContext)
|
||||
? authContext.apiKey
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
await this.workspaceMemberPreQueryHookService.completeOnboardingProfileStepIfNameProvided(
|
||||
{
|
||||
userId: isUserAuthContext(authContext)
|
||||
? authContext.user.id
|
||||
: undefined,
|
||||
workspaceId: workspace.id,
|
||||
firstName: payload.data.name?.firstName,
|
||||
lastName: payload.data.name?.lastName,
|
||||
},
|
||||
);
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user