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:
Marie
2026-04-17 11:58:34 +02:00
committed by GitHub
parent ba1195d92e
commit b31f84fbb8
11 changed files with 800 additions and 653 deletions
@@ -1266,6 +1266,37 @@ type Analytics {
success: Boolean!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
isValidated: Boolean!
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type EnterpriseLicenseInfoDTO {
isValid: Boolean!
licensee: String
expiresAt: DateTime
subscriptionId: String
}
type EnterpriseSubscriptionStatusDTO {
status: String!
licensee: String
expiresAt: DateTime
cancelAt: DateTime
currentPeriodEnd: DateTime
isCancellationScheduled: Boolean!
}
type BillingSubscriptionSchedulePhaseItem {
price: String!
quantity: Float
@@ -1419,42 +1450,11 @@ type BillingUpdate {
billingSubscriptions: [BillingSubscription!]!
}
type EnterpriseLicenseInfoDTO {
isValid: Boolean!
licensee: String
expiresAt: DateTime
subscriptionId: String
}
type EnterpriseSubscriptionStatusDTO {
status: String!
licensee: String
expiresAt: DateTime
cancelAt: DateTime
currentPeriodEnd: DateTime
isCancellationScheduled: Boolean!
}
type OnboardingStepSuccess {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
isValidated: Boolean!
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type WorkspaceInvitation {
id: UUID!
email: String!
@@ -989,6 +989,41 @@ export interface Analytics {
__typename: 'Analytics'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
isValidated: Scalars['Boolean']
createdAt: Scalars['DateTime']
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface EnterpriseLicenseInfoDTO {
isValid: Scalars['Boolean']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
subscriptionId?: Scalars['String']
__typename: 'EnterpriseLicenseInfoDTO'
}
export interface EnterpriseSubscriptionStatusDTO {
status: Scalars['String']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
cancelAt?: Scalars['DateTime']
currentPeriodEnd?: Scalars['DateTime']
isCancellationScheduled: Scalars['Boolean']
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface BillingSubscriptionSchedulePhaseItem {
price: Scalars['String']
quantity?: Scalars['Float']
@@ -1134,47 +1169,12 @@ export interface BillingUpdate {
__typename: 'BillingUpdate'
}
export interface EnterpriseLicenseInfoDTO {
isValid: Scalars['Boolean']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
subscriptionId?: Scalars['String']
__typename: 'EnterpriseLicenseInfoDTO'
}
export interface EnterpriseSubscriptionStatusDTO {
status: Scalars['String']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
cancelAt?: Scalars['DateTime']
currentPeriodEnd?: Scalars['DateTime']
isCancellationScheduled: Scalars['Boolean']
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface OnboardingStepSuccess {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'OnboardingStepSuccess'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
isValidated: Scalars['Boolean']
createdAt: Scalars['DateTime']
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface WorkspaceInvitation {
id: Scalars['UUID']
email: Scalars['String']
@@ -4239,6 +4239,45 @@ export interface AnalyticsGenqlSelection{
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
isValidated?: boolean | number
createdAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseLicenseInfoDTOGenqlSelection{
isValid?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
subscriptionId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
status?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
cancelAt?: boolean | number
currentPeriodEnd?: boolean | number
isCancellationScheduled?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingSubscriptionSchedulePhaseItemGenqlSelection{
price?: boolean | number
quantity?: boolean | number
@@ -4386,26 +4425,6 @@ export interface BillingUpdateGenqlSelection{
__scalar?: boolean | number
}
export interface EnterpriseLicenseInfoDTOGenqlSelection{
isValid?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
subscriptionId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
status?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
cancelAt?: boolean | number
currentPeriodEnd?: boolean | number
isCancellationScheduled?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnboardingStepSuccessGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -4413,25 +4432,6 @@ export interface OnboardingStepSuccessGenqlSelection{
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
isValidated?: boolean | number
createdAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface WorkspaceInvitationGenqlSelection{
id?: boolean | number
email?: boolean | number
@@ -7493,6 +7493,38 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
return ApprovedAccessDomain_possibleTypes.includes(obj.__typename)
}
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
}
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
}
const BillingSubscriptionSchedulePhaseItem_possibleTypes: string[] = ['BillingSubscriptionSchedulePhaseItem']
export const isBillingSubscriptionSchedulePhaseItem = (obj?: { __typename?: any } | null): obj is BillingSubscriptionSchedulePhaseItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscriptionSchedulePhaseItem"')
@@ -7621,22 +7653,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
}
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
}
const OnboardingStepSuccess_possibleTypes: string[] = ['OnboardingStepSuccess']
export const isOnboardingStepSuccess = (obj?: { __typename?: any } | null): obj is OnboardingStepSuccess => {
if (!obj?.__typename) throw new Error('__typename is missing in "isOnboardingStepSuccess"')
@@ -7645,22 +7661,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
return ApprovedAccessDomain_possibleTypes.includes(obj.__typename)
}
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const WorkspaceInvitation_possibleTypes: string[] = ['WorkspaceInvitation']
export const isWorkspaceInvitation = (obj?: { __typename?: any } | null): obj is WorkspaceInvitation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceInvitation"')
@@ -39,11 +39,11 @@ export default {
95,
101,
115,
120,
121,
122,
124,
132,
125,
126,
128,
136,
147,
150,
152,
@@ -105,13 +105,13 @@ export default {
1
],
"metadata": [
119
123
],
"on_BillingLicensedProduct": [
128
132
],
"on_BillingMeteredProduct": [
129
133
],
"__typename": [
1
@@ -1700,10 +1700,10 @@ export default {
157
],
"billingSubscriptions": [
131
135
],
"currentBillingSubscription": [
131
135
],
"billingEntitlements": [
253
@@ -2621,259 +2621,36 @@ export default {
1
]
},
"BillingSubscriptionSchedulePhaseItem": {
"price": [
1
],
"quantity": [
11
],
"__typename": [
1
]
},
"BillingSubscriptionSchedulePhase": {
"start_date": [
11
],
"end_date": [
11
],
"items": [
117
],
"__typename": [
1
]
},
"BillingProductMetadata": {
"planKey": [
120
],
"priceUsageBased": [
121
],
"productKey": [
122
],
"__typename": [
1
]
},
"BillingPlanKey": {},
"BillingUsageType": {},
"BillingProductKey": {},
"BillingPriceLicensed": {
"recurringInterval": [
124
],
"unitAmount": [
11
],
"stripePriceId": [
1
],
"priceUsageType": [
121
],
"__typename": [
1
]
},
"SubscriptionInterval": {},
"BillingPriceTier": {
"upTo": [
11
],
"flatAmount": [
11
],
"unitAmount": [
11
],
"__typename": [
1
]
},
"BillingPriceMetered": {
"tiers": [
125
],
"recurringInterval": [
124
],
"stripePriceId": [
1
],
"priceUsageType": [
121
],
"__typename": [
1
]
},
"BillingProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
119
],
"__typename": [
1
]
},
"BillingLicensedProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
119
],
"prices": [
123
],
"__typename": [
1
]
},
"BillingMeteredProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
119
],
"prices": [
126
],
"__typename": [
1
]
},
"BillingSubscriptionItem": {
"ApprovedAccessDomain": {
"id": [
3
],
"hasReachedCurrentPeriodCap": [
6
],
"quantity": [
11
],
"stripePriceId": [
"domain": [
1
],
"billingProduct": [
0
"isValidated": [
6
],
"createdAt": [
4
],
"__typename": [
1
]
},
"BillingSubscription": {
"FileWithSignedUrl": {
"id": [
3
],
"status": [
132
"path": [
1
],
"interval": [
124
"size": [
11
],
"billingSubscriptionItems": [
130
],
"currentPeriodEnd": [
"createdAt": [
4
],
"metadata": [
15
],
"phases": [
118
],
"__typename": [
1
]
},
"SubscriptionStatus": {},
"BillingEndTrialPeriod": {
"status": [
132
],
"hasPaymentMethod": [
6
],
"billingPortalUrl": [
1
],
"__typename": [
1
]
},
"BillingMeteredProductUsage": {
"productKey": [
122
],
"periodStart": [
4
],
"periodEnd": [
4
],
"usedCredits": [
11
],
"grantedCredits": [
11
],
"rolloverCredits": [
11
],
"totalGrantedCredits": [
11
],
"unitPriceCents": [
11
],
"__typename": [
1
]
},
"BillingPlan": {
"planKey": [
120
],
"licensedProducts": [
128
],
"meteredProducts": [
129
],
"__typename": [
1
]
},
"BillingSession": {
"url": [
1
],
@@ -2881,17 +2658,6 @@ export default {
1
]
},
"BillingUpdate": {
"currentBillingSubscription": [
131
],
"billingSubscriptions": [
131
],
"__typename": [
1
]
},
"EnterpriseLicenseInfoDTO": {
"isValid": [
6
@@ -2932,6 +2698,277 @@ export default {
1
]
},
"BillingSubscriptionSchedulePhaseItem": {
"price": [
1
],
"quantity": [
11
],
"__typename": [
1
]
},
"BillingSubscriptionSchedulePhase": {
"start_date": [
11
],
"end_date": [
11
],
"items": [
121
],
"__typename": [
1
]
},
"BillingProductMetadata": {
"planKey": [
124
],
"priceUsageBased": [
125
],
"productKey": [
126
],
"__typename": [
1
]
},
"BillingPlanKey": {},
"BillingUsageType": {},
"BillingProductKey": {},
"BillingPriceLicensed": {
"recurringInterval": [
128
],
"unitAmount": [
11
],
"stripePriceId": [
1
],
"priceUsageType": [
125
],
"__typename": [
1
]
},
"SubscriptionInterval": {},
"BillingPriceTier": {
"upTo": [
11
],
"flatAmount": [
11
],
"unitAmount": [
11
],
"__typename": [
1
]
},
"BillingPriceMetered": {
"tiers": [
129
],
"recurringInterval": [
128
],
"stripePriceId": [
1
],
"priceUsageType": [
125
],
"__typename": [
1
]
},
"BillingProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
123
],
"__typename": [
1
]
},
"BillingLicensedProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
123
],
"prices": [
127
],
"__typename": [
1
]
},
"BillingMeteredProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
123
],
"prices": [
130
],
"__typename": [
1
]
},
"BillingSubscriptionItem": {
"id": [
3
],
"hasReachedCurrentPeriodCap": [
6
],
"quantity": [
11
],
"stripePriceId": [
1
],
"billingProduct": [
0
],
"__typename": [
1
]
},
"BillingSubscription": {
"id": [
3
],
"status": [
136
],
"interval": [
128
],
"billingSubscriptionItems": [
134
],
"currentPeriodEnd": [
4
],
"metadata": [
15
],
"phases": [
122
],
"__typename": [
1
]
},
"SubscriptionStatus": {},
"BillingEndTrialPeriod": {
"status": [
136
],
"hasPaymentMethod": [
6
],
"billingPortalUrl": [
1
],
"__typename": [
1
]
},
"BillingMeteredProductUsage": {
"productKey": [
126
],
"periodStart": [
4
],
"periodEnd": [
4
],
"usedCredits": [
11
],
"grantedCredits": [
11
],
"rolloverCredits": [
11
],
"totalGrantedCredits": [
11
],
"unitPriceCents": [
11
],
"__typename": [
1
]
},
"BillingPlan": {
"planKey": [
124
],
"licensedProducts": [
132
],
"meteredProducts": [
133
],
"__typename": [
1
]
},
"BillingSession": {
"url": [
1
],
"__typename": [
1
]
},
"BillingUpdate": {
"currentBillingSubscription": [
135
],
"billingSubscriptions": [
135
],
"__typename": [
1
]
},
"OnboardingStepSuccess": {
"success": [
6
@@ -2940,43 +2977,6 @@ export default {
1
]
},
"ApprovedAccessDomain": {
"id": [
3
],
"domain": [
1
],
"isValidated": [
6
],
"createdAt": [
4
],
"__typename": [
1
]
},
"FileWithSignedUrl": {
"id": [
3
],
"path": [
1
],
"size": [
11
],
"createdAt": [
4
],
"url": [
1
],
"__typename": [
1
]
},
"WorkspaceInvitation": {
"id": [
3
@@ -6481,10 +6481,10 @@ export default {
}
],
"enterpriseSubscriptionStatus": [
139
120
],
"billingPortalSession": [
136,
140,
{
"returnUrlPath": [
1
@@ -6492,16 +6492,16 @@ export default {
}
],
"listPlans": [
135
139
],
"getMeteredProductsUsage": [
134
138
],
"findWorkspaceInvitations": [
143
],
"getApprovedAccessDomains": [
141
117
],
"getPageLayoutTabs": [
113,
@@ -7533,7 +7533,7 @@ export default {
}
],
"uploadEmailAttachmentFile": [
142,
118,
{
"file": [
388,
@@ -7542,7 +7542,7 @@ export default {
}
],
"uploadAIChatFile": [
142,
118,
{
"file": [
388,
@@ -7551,7 +7551,7 @@ export default {
}
],
"uploadWorkflowFile": [
142,
118,
{
"file": [
388,
@@ -7560,7 +7560,7 @@ export default {
}
],
"uploadWorkspaceLogo": [
142,
118,
{
"file": [
388,
@@ -7569,7 +7569,7 @@ export default {
}
],
"uploadWorkspaceMemberProfilePicture": [
142,
118,
{
"file": [
388,
@@ -7578,7 +7578,7 @@ export default {
}
],
"uploadFilesFieldFile": [
142,
118,
{
"file": [
388,
@@ -7591,7 +7591,7 @@ export default {
}
],
"uploadFilesFieldFileByUniversalIdentifier": [
142,
118,
{
"file": [
388,
@@ -7936,7 +7936,7 @@ export default {
6
],
"setEnterpriseKey": [
138,
119,
{
"enterpriseKey": [
1,
@@ -7945,20 +7945,20 @@ export default {
}
],
"skipSyncEmailOnboardingStep": [
140
142
],
"skipBookOnboardingStep": [
140
142
],
"checkoutSession": [
136,
140,
{
"recurringInterval": [
124,
128,
"SubscriptionInterval!"
],
"plan": [
120,
124,
"BillingPlanKey!"
],
"requirePaymentMethod": [
@@ -7971,19 +7971,19 @@ export default {
}
],
"switchSubscriptionInterval": [
137
141
],
"switchBillingPlan": [
137
141
],
"cancelSwitchBillingPlan": [
137
141
],
"cancelSwitchBillingInterval": [
137
141
],
"setMeteredSubscriptionPrice": [
137,
141,
{
"priceId": [
1,
@@ -7992,10 +7992,10 @@ export default {
}
],
"endSubscriptionTrialPeriod": [
133
137
],
"cancelSwitchMeteredPrice": [
137
141
],
"deleteWorkspaceInvitation": [
1,
@@ -8028,7 +8028,7 @@ export default {
}
],
"createApprovedAccessDomain": [
141,
117,
{
"input": [
420,
@@ -8046,7 +8046,7 @@ export default {
}
],
"validateApprovedAccessDomain": [
141,
117,
{
"input": [
422,
@@ -212,6 +212,35 @@ export class OnboardingService {
);
}
async completeOnboardingProfileStepIfNameProvided({
userId,
workspaceId,
firstName,
lastName,
}: {
userId?: string;
workspaceId: string;
firstName?: string;
lastName?: string;
}) {
if (!isDefined(userId)) {
return;
}
const hasProvidedNamePart =
(isDefined(firstName) && firstName !== '') ||
(isDefined(lastName) && lastName !== '');
if (!hasProvidedNamePart) {
return;
}
await this.setOnboardingCreateProfilePending({
userId,
workspaceId,
value: false,
});
}
async setOnboardingBookOnboardingPending({
workspaceId,
value,
@@ -522,13 +522,20 @@ export class UserResolver {
workspaceMemberRepository.save(workspaceMemberUpdatePayload),
);
if (isDefined(input.update.locale)) {
const targetUserWorkspace =
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
userId: workspaceMember.userId,
workspaceId: workspace.id,
});
await this.onboardingService.completeOnboardingProfileStepIfNameProvided({
userId: workspaceMember.userId,
workspaceId: workspace.id,
firstName: workspaceMemberUpdatePayload.name?.firstName,
lastName: workspaceMemberUpdatePayload.name?.lastName,
});
const targetUserWorkspace =
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
userId: workspaceMember.userId,
workspaceId: workspace.id,
});
if (isDefined(input.update.locale)) {
await this.userWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace(
{
locale: input.update.locale as UserWorkspaceEntity['locale'],
@@ -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 () => {
@@ -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,
});
}
}
@@ -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]),
],
@@ -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;
}
}
@@ -0,0 +1,275 @@
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,
);
});
});
@@ -123,7 +123,7 @@ describe('workspace members permissions', () => {
});
});
it('should deny updateOne on /graphql when member updates themself', async () => {
it('should deny /graphql updateOne on own record for standard field name.firstName when member lacks WORKSPACE_MEMBERS', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
@@ -146,7 +146,31 @@ describe('workspace members permissions', () => {
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny update on custom field for another workspace member on /graphql (member role)', async () => {
it('should deny /graphql updateOne on own record for a custom field when member lacks WORKSPACE_MEMBERS', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: `
id
${customFieldName}
`,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
data: {
[customFieldName]: 'self-custom-value',
},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny /graphql updateOne on another workspace member for a custom field when member lacks WORKSPACE_MEMBERS', async () => {
const customFieldValue = 'Ile-de-france';
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',