diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index 0f2ab51ce1..32241f9645 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -2356,6 +2356,7 @@ type PublicDomain {
id: UUID!
domain: String!
isValidated: Boolean!
+ applicationId: UUID
createdAt: DateTime!
}
@@ -3327,7 +3328,8 @@ type Mutation {
updateLabPublicFeatureFlag(input: UpdateLabPublicFeatureFlagInput!): FeatureFlag!
enablePostgresProxy: PostgresCredentials!
disablePostgresProxy: PostgresCredentials!
- createPublicDomain(domain: String!): PublicDomain!
+ createPublicDomain(domain: String!, applicationId: String): PublicDomain!
+ updatePublicDomain(domain: String!, applicationId: String): PublicDomain!
deletePublicDomain(domain: String!): Boolean!
checkPublicDomainValidRecords(domain: String!): DomainValidRecords
createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 4919e7ef7f..38dec16073 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -2026,6 +2026,7 @@ export interface PublicDomain {
id: Scalars['UUID']
domain: Scalars['String']
isValidated: Scalars['Boolean']
+ applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
__typename: 'PublicDomain'
}
@@ -2856,6 +2857,7 @@ export interface Mutation {
enablePostgresProxy: PostgresCredentials
disablePostgresProxy: PostgresCredentials
createPublicDomain: PublicDomain
+ updatePublicDomain: PublicDomain
deletePublicDomain: Scalars['Boolean']
checkPublicDomainValidRecords?: DomainValidRecords
createEmailingDomain: EmailingDomain
@@ -5035,6 +5037,7 @@ export interface PublicDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
isValidated?: boolean | number
+ applicationId?: boolean | number
createdAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
@@ -5922,7 +5925,8 @@ export interface MutationGenqlSelection{
updateLabPublicFeatureFlag?: (FeatureFlagGenqlSelection & { __args: {input: UpdateLabPublicFeatureFlagInput} })
enablePostgresProxy?: PostgresCredentialsGenqlSelection
disablePostgresProxy?: PostgresCredentialsGenqlSelection
- createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String']} })
+ createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
+ updatePublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
deletePublicDomain?: { __args: {domain: Scalars['String']} }
checkPublicDomainValidRecords?: (DomainValidRecordsGenqlSelection & { __args: {domain: Scalars['String']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index d715db5293..8ae7417cd7 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -4616,6 +4616,9 @@ export default {
"isValidated": [
6
],
+ "applicationId": [
+ 3
+ ],
"createdAt": [
4
],
@@ -8573,6 +8576,21 @@ export default {
"domain": [
1,
"String!"
+ ],
+ "applicationId": [
+ 1
+ ]
+ }
+ ],
+ "updatePublicDomain": [
+ 258,
+ {
+ "domain": [
+ 1,
+ "String!"
+ ],
+ "applicationId": [
+ 1
]
}
],
diff --git a/packages/twenty-docs/developers/extend/api.mdx b/packages/twenty-docs/developers/extend/api.mdx
index 1fcb95f917..e450972f04 100644
--- a/packages/twenty-docs/developers/extend/api.mdx
+++ b/packages/twenty-docs/developers/extend/api.mdx
@@ -37,7 +37,7 @@ Both are available as REST and GraphQL. GraphQL adds batch upserts and the abili
Authorization: Bearer YOUR_API_KEY
```
-Create an API key in **Settings → API & Webhooks → + Create key**. Copy it immediately — it's shown once. Keys can be scoped to a specific role under **Settings → Roles → Assignment tab** to limit what they can access.
+Create an API key in **Settings → API & Webhooks → + Create key**. Copy it immediately — it's shown once. Keys can be scoped to a specific role under **Settings → Members → Roles → Assignment tab** to limit what they can access.
diff --git a/packages/twenty-docs/developers/extend/capabilities/apis.mdx b/packages/twenty-docs/developers/extend/capabilities/apis.mdx
index e3b9b8e37b..08216f0110 100644
--- a/packages/twenty-docs/developers/extend/capabilities/apis.mdx
+++ b/packages/twenty-docs/developers/extend/capabilities/apis.mdx
@@ -83,7 +83,7 @@ Your API key grants access to sensitive data. Don't share it with untrusted serv
For better security, assign a specific role to limit access:
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Click on the role to assign
3. Open the **Assignment** tab
4. Under **API Keys**, click **+ Assign to API key**
diff --git a/packages/twenty-docs/user-guide/ai/capabilities/permissions-access-control.mdx b/packages/twenty-docs/user-guide/ai/capabilities/permissions-access-control.mdx
index afa8063e51..17c3fc4ba7 100644
--- a/packages/twenty-docs/user-guide/ai/capabilities/permissions-access-control.mdx
+++ b/packages/twenty-docs/user-guide/ai/capabilities/permissions-access-control.mdx
@@ -9,7 +9,7 @@ AI agents respect your existing permission structure. This is particularly impor
## Assign a Role to an AI Agent
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Click on the role you want to assign
3. Open the **Assignment** tab
4. Under **AI Agents**, click **+ Assign to AI agent**
diff --git a/packages/twenty-docs/user-guide/ai/how-tos/ai-faq.mdx b/packages/twenty-docs/user-guide/ai/how-tos/ai-faq.mdx
index e0c03e2fb9..c36d3540f1 100644
--- a/packages/twenty-docs/user-guide/ai/how-tos/ai-faq.mdx
+++ b/packages/twenty-docs/user-guide/ai/how-tos/ai-faq.mdx
@@ -17,7 +17,7 @@ description: Frequently asked questions about AI features in Twenty.
- AI agents will operate under the permission system. You can assign specific roles to AI agents under **Settings → Roles**, giving you full control over what data they can access and what actions they can perform.
+ AI agents will operate under the permission system. You can assign specific roles to AI agents under **Settings → Members → Roles**, giving you full control over what data they can access and what actions they can perform.
diff --git a/packages/twenty-docs/user-guide/ai/overview.mdx b/packages/twenty-docs/user-guide/ai/overview.mdx
index 5d7aa93f91..a9ecd46329 100644
--- a/packages/twenty-docs/user-guide/ai/overview.mdx
+++ b/packages/twenty-docs/user-guide/ai/overview.mdx
@@ -44,7 +44,7 @@ Extend your workflows with AI-powered actions and autonomous agents.
AI agents will be managed through the existing permissions system:
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Configure which data each AI agent can access
3. Set read/write permissions per object
diff --git a/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-other-crms.mdx b/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-other-crms.mdx
index 5b261ff33b..42de0b9bf5 100644
--- a/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-other-crms.mdx
+++ b/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-other-crms.mdx
@@ -204,7 +204,7 @@ After importing data, complete your workspace configuration:
- Test each one before relying on it
### Configure Roles and Permissions
-- Set up roles in **Settings → Roles**
+- Set up roles in **Settings → Members → Roles**
- Assign users to appropriate roles
### Connect Email and Calendar
diff --git a/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-self-hosted-to-cloud.mdx b/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-self-hosted-to-cloud.mdx
index 4694aef5c0..1dbb113a06 100644
--- a/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-self-hosted-to-cloud.mdx
+++ b/packages/twenty-docs/user-guide/data-migration/how-tos/migrating-from-self-hosted-to-cloud.mdx
@@ -121,7 +121,7 @@ After importing data, manually recreate:
- Test each workflow before relying on it
### Roles and Permissions
-- Configure roles in **Settings → Roles**
+- Configure roles in **Settings → Members → Roles**
- Assign users to appropriate roles
### Integrations
diff --git a/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx b/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx
index 217453aee8..3cb8840592 100644
--- a/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx
+++ b/packages/twenty-docs/user-guide/permissions-access/capabilities/permissions.mdx
@@ -13,7 +13,7 @@ Twenty's permission system allows you to control access to three main areas:
To create a new role:
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Under **All Roles**, click on **+ Create Role**
3. Enter a role name
4. In the default **Permissions** tab, [configure permissions](#customize-permissions)
@@ -23,7 +23,7 @@ To create a new role:
To delete a role:
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Click on the role you want to remove
3. Open the **Settings** tab, then click **Delete Role**
4. Click **Confirm** in the modal
@@ -35,12 +35,12 @@ If a role is deleted, any workspace member assigned to it will be automatically
## Assign Roles to Members
### View Current Assignments
-- Go to **Settings → Roles**
+- Go to **Settings → Members → Roles**
- See all roles and how many members are assigned to each
- View which members have which roles
### Assign a Role to a Member
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Click on the role you want to assign
3. Open the **Assignment** tab
4. Click **+ Assign to member**
@@ -48,7 +48,7 @@ If a role is deleted, any workspace member assigned to it will be automatically
6. Confirm the assignment
### Set Default Role
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. In the **Options** section, find **Default Role**
3. Select which role new members should automatically receive
4. New workspace members will be assigned this role when they join
@@ -164,7 +164,7 @@ Beyond workspace members, roles can also be assigned to **API Keys** and **AI Ag
### Assign a Role to an API Key
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Click on the role you want to assign
3. Open the **Assignment** tab
4. Under **API Keys**, click **+ Assign to API key**
@@ -179,7 +179,7 @@ API keys without an assigned role use default permissions. For tighter security,
### Assign a Role to an AI Agent
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Click on the role you want to assign
3. Open the **Assignment** tab
4. Under **AI Agents**, click **+ Assign to AI agent**
diff --git a/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx b/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx
index ff9a3e55a7..0d5510508a 100644
--- a/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx
+++ b/packages/twenty-docs/user-guide/permissions-access/how-tos/permissions-faq.mdx
@@ -19,7 +19,7 @@ Any workspace member assigned to that role will be automatically reassigned to t
-Go to **Settings → Roles**, find the **Default Role** option, and select which role new members should automatically receive when they join.
+Go to **Settings → Members → Roles**, find the **Default Role** option, and select which role new members should automatically receive when they join.
@@ -64,7 +64,7 @@ Row-level permissions will be available on the **Organization** plan by Q1 2026.
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Select the role
3. Navigate to the object containing the field
4. Set the field permission to **See Field** (without Edit Field)
diff --git a/packages/twenty-docs/user-guide/settings/capabilities/domains-settings.mdx b/packages/twenty-docs/user-guide/settings/capabilities/domains-settings.mdx
index 3909033033..de369d3dc4 100644
--- a/packages/twenty-docs/user-guide/settings/capabilities/domains-settings.mdx
+++ b/packages/twenty-docs/user-guide/settings/capabilities/domains-settings.mdx
@@ -3,10 +3,12 @@ title: Domain Settings
description: Configure workspace domain, approved access domains, and public domains.
---
-Configure domain settings under **Settings → Domains**.
+Domain settings live in three places, depending on what you're configuring.
## Workspace Domain
+Configure under **Settings → General → Workspace Domain**.
+
Edit your subdomain name or set a custom domain for your workspace.
### Customize Domain
@@ -18,6 +20,8 @@ For custom domains, you'll need to configure DNS settings with your domain provi
## Approved Domains
+Configure under **Settings → Members → Access**.
+
Anyone with an email address at these domains is allowed to sign up for this workspace automatically.
### Add Approved Access Domain
@@ -33,12 +37,15 @@ This is useful for allowing your entire team to self-register while keeping the
## Public Domains
-Provision a complete and secure hosting environment on these domains.
+Configure under **Settings → Apps → Developer**.
+
+Provision a complete and secure hosting environment on these domains. A public domain can be bound to a specific app — when bound, only that app's HTTP-routed logic functions are reachable on the domain. Leave the binding empty to expose all workspace HTTP routes.
### Add Public Domain
1. Click **Add Public Domain**
2. Enter the domain you want to use
-3. Configure DNS settings as instructed
-4. Verify the domain
+3. Optionally bind it to an app
+4. Configure DNS settings as instructed
+5. Verify the domain
SSL certificates are automatically provisioned for public domains.
diff --git a/packages/twenty-docs/user-guide/settings/capabilities/member-management.mdx b/packages/twenty-docs/user-guide/settings/capabilities/member-management.mdx
index ade88d72e0..2483a24338 100644
--- a/packages/twenty-docs/user-guide/settings/capabilities/member-management.mdx
+++ b/packages/twenty-docs/user-guide/settings/capabilities/member-management.mdx
@@ -67,7 +67,7 @@ Manage invitations that haven't been accepted:
Allow team members to join automatically based on their email domain:
-1. Go to **Settings → Domains**
+1. Go to **Settings → Members → Access**
2. Add your company domain (e.g., `yourcompany.com`)
3. Anyone with that email domain can join without an invitation
diff --git a/packages/twenty-docs/user-guide/settings/how-tos/settings-faq.mdx b/packages/twenty-docs/user-guide/settings/how-tos/settings-faq.mdx
index c52ee3a131..f72a05cef8 100644
--- a/packages/twenty-docs/user-guide/settings/how-tos/settings-faq.mdx
+++ b/packages/twenty-docs/user-guide/settings/how-tos/settings-faq.mdx
@@ -137,7 +137,7 @@ Yes, you can connect multiple email accounts. Go to **Settings → Accounts** an
-Yes! Go to **Settings → Domains** and click **Customize Domain**. You have two options:
+Yes! Go to **Settings → General → Workspace Domain** and click **Customize Domain**. You have two options:
- **Subdomain**: Use a Twenty subdomain like `yourcompany.twenty.com`
- **Custom domain**: Use your own domain like `crm.yourcompany.com` (requires DNS configuration)
@@ -146,7 +146,7 @@ A subdomain is quick to set up, while a custom domain provides a fully branded e
-You can configure approved access domains so team members with company email addresses can automatically join your workspace. Go to **Settings → Domains** and add your company domain (e.g., `yourcompany.com`).
+You can configure approved access domains so team members with company email addresses can automatically join your workspace. Go to **Settings → Members → Access** and add your company domain (e.g., `yourcompany.com`).
diff --git a/packages/twenty-docs/user-guide/settings/overview.mdx b/packages/twenty-docs/user-guide/settings/overview.mdx
index b9ad2faddd..85962decbc 100644
--- a/packages/twenty-docs/user-guide/settings/overview.mdx
+++ b/packages/twenty-docs/user-guide/settings/overview.mdx
@@ -39,7 +39,7 @@ Add team members to your workspace:
4. Assign appropriate roles
-Before inviting your team, check the default role under **Settings → Roles**. New members are automatically assigned this role when they join.
+Before inviting your team, check the default role under **Settings → Members → Roles**. New members are automatically assigned this role when they join.
## Workspace Settings Checklist
diff --git a/packages/twenty-docs/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline.mdx b/packages/twenty-docs/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline.mdx
index 0d4aaf49a2..ff5ae9183f 100644
--- a/packages/twenty-docs/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline.mdx
+++ b/packages/twenty-docs/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline.mdx
@@ -38,7 +38,7 @@ You need two custom fields on the Opportunities object.
If you don't want users manually editing these calculated fields:
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Select the role to configure
3. Find the Opportunities object
4. Set **Probability** and **Expected Amount** fields to read-only
diff --git a/packages/twenty-docs/user-guide/views-pipelines/how-tos/track-time-in-stage.mdx b/packages/twenty-docs/user-guide/views-pipelines/how-tos/track-time-in-stage.mdx
index dc685fd2d8..43034c0c27 100644
--- a/packages/twenty-docs/user-guide/views-pipelines/how-tos/track-time-in-stage.mdx
+++ b/packages/twenty-docs/user-guide/views-pipelines/how-tos/track-time-in-stage.mdx
@@ -58,7 +58,7 @@ You don't need "Days in" fields for Closed Won and Closed Lost since those are f
If you don't want users manually editing these calculated fields:
-1. Go to **Settings → Roles**
+1. Go to **Settings → Members → Roles**
2. Select the role to configure
3. Find the Opportunities object
4. Set the "Last Entered" and "Days in" fields to read-only
diff --git a/packages/twenty-docs/user-guide/workflows/capabilities/workflow-actions.mdx b/packages/twenty-docs/user-guide/workflows/capabilities/workflow-actions.mdx
index 70bdc239e5..0459776ef4 100644
--- a/packages/twenty-docs/user-guide/workflows/capabilities/workflow-actions.mdx
+++ b/packages/twenty-docs/user-guide/workflows/capabilities/workflow-actions.mdx
@@ -261,5 +261,5 @@ AI Agent actions consume workflow credits based on the AI model used. See [Workf
-AI agents respect role-based permissions. You can assign specific roles to agents under **Settings → Roles** to control what data they can access. See [Permissions](/user-guide/permissions-access/capabilities/permissions) for details.
+AI agents respect role-based permissions. You can assign specific roles to agents under **Settings → Members → Roles** to control what data they can access. See [Permissions](/user-guide/permissions-access/capabilities/permissions) for details.
diff --git a/packages/twenty-docs/user-guide/workflows/how-tos/need-more-help/workflows-faq.mdx b/packages/twenty-docs/user-guide/workflows/how-tos/need-more-help/workflows-faq.mdx
index 7e71a5c4a4..0270d7cf07 100644
--- a/packages/twenty-docs/user-guide/workflows/how-tos/need-more-help/workflows-faq.mdx
+++ b/packages/twenty-docs/user-guide/workflows/how-tos/need-more-help/workflows-faq.mdx
@@ -8,7 +8,7 @@ description: Frequently asked questions about workflows in Twenty.
This is likely a permissions issue. You need access to workflows to create and activate them.
- **Solution**: Contact your workspace administrator to grant you workflow access under **Settings → Roles**.
+ **Solution**: Contact your workspace administrator to grant you workflow access under **Settings → Members → Roles**.
If you don't see the Workflows section at all in your sidebar, this confirms it's a permissions issue.
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 39f9d29ca5..c6de43a5a8 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -2528,6 +2528,7 @@ export type Mutation = {
updatePageLayoutWidget: PageLayoutWidget;
updatePageLayoutWithTabsAndWidgets: PageLayout;
updatePasswordViaResetToken: InvalidatePassword;
+ updatePublicDomain: PublicDomain;
updateSkill: Skill;
updateUserEmail: Scalars['Boolean'];
updateView: View;
@@ -2750,6 +2751,7 @@ export type MutationCreatePageLayoutWidgetArgs = {
export type MutationCreatePublicDomainArgs = {
+ applicationId?: InputMaybe;
domain: Scalars['String'];
};
@@ -3412,6 +3414,12 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
};
+export type MutationUpdatePublicDomainArgs = {
+ applicationId?: InputMaybe;
+ domain: Scalars['String'];
+};
+
+
export type MutationUpdateSkillArgs = {
input: UpdateSkillInput;
};
@@ -4041,6 +4049,7 @@ export type PublicConnectionParametersOutput = {
export type PublicDomain = {
__typename?: 'PublicDomain';
+ applicationId?: Maybe;
createdAt: Scalars['DateTime'];
domain: Scalars['String'];
id: Scalars['UUID'];
@@ -7196,10 +7205,11 @@ export type CheckPublicDomainValidRecordsMutation = { __typename?: 'Mutation', c
export type CreatePublicDomainMutationVariables = Exact<{
domain: Scalars['String'];
+ applicationId?: InputMaybe;
}>;
-export type CreatePublicDomainMutation = { __typename?: 'Mutation', createPublicDomain: { __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string } };
+export type CreatePublicDomainMutation = { __typename?: 'Mutation', createPublicDomain: { __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, applicationId?: string | null, createdAt: string } };
export type DeletePublicDomainMutationVariables = Exact<{
domain: Scalars['String'];
@@ -7208,10 +7218,18 @@ export type DeletePublicDomainMutationVariables = Exact<{
export type DeletePublicDomainMutation = { __typename?: 'Mutation', deletePublicDomain: boolean };
+export type UpdatePublicDomainMutationVariables = Exact<{
+ domain: Scalars['String'];
+ applicationId?: InputMaybe;
+}>;
+
+
+export type UpdatePublicDomainMutation = { __typename?: 'Mutation', updatePublicDomain: { __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, applicationId?: string | null, createdAt: string } };
+
export type FindManyPublicDomainsQueryVariables = Exact<{ [key: string]: never; }>;
-export type FindManyPublicDomainsQuery = { __typename?: 'Query', findManyPublicDomains: Array<{ __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string }> };
+export type FindManyPublicDomainsQuery = { __typename?: 'Query', findManyPublicDomains: Array<{ __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, applicationId?: string | null, createdAt: string }> };
export type CreateEmailingDomainMutationVariables = Exact<{
domain: Scalars['String'];
@@ -8105,9 +8123,10 @@ export const GetApiKeysDocument = {"kind":"Document","definitions":[{"kind":"Ope
export const GetWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhook"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode;
export const GetWebhooksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode;
export const CheckPublicDomainValidRecordsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CheckPublicDomainValidRecords"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkPublicDomainValidRecords"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"validationType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode;
-export const CreatePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreatePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createPublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode;
+export const CreatePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreatePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createPublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode;
export const DeletePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeletePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}}]}]}}]} as unknown as DocumentNode;
-export const FindManyPublicDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode;
+export const UpdatePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdatePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatePublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode;
+export const FindManyPublicDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode;
export const CreateEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"driver"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EmailingDomainDriver"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}},{"kind":"Argument","name":{"kind":"Name","value":"driver"},"value":{"kind":"Variable","name":{"kind":"Name","value":"driver"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"driver"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verificationRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode;
export const DeleteEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode;
export const VerifyEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"driver"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode;
diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
index 29f43a47be..e6f560d102 100644
--- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
+++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
@@ -136,12 +136,6 @@ const SettingsWorkspaceEmailGroupChannelDetail = lazy(() =>
})),
);
-const SettingsDomains = lazy(() =>
- import('~/pages/settings/domains/SettingsDomains').then((module) => ({
- default: module.SettingsDomains,
- })),
-);
-
const SettingsSubdomainPage = lazy(() =>
import('~/pages/settings/domains/SettingsSubdomainPage').then((module) => ({
default: module.SettingsSubdomainPage,
@@ -549,32 +543,30 @@ const SettingsUpdates = lazy(() =>
})),
);
-const SettingsRoles = lazy(() =>
- import('~/pages/settings/roles/SettingsRoles').then((module) => ({
- default: module.SettingsRoles,
- })),
-);
-
const SettingsRoleCreate = lazy(() =>
- import('~/pages/settings/roles/SettingsRoleCreate').then((module) => ({
- default: module.SettingsRoleCreate,
- })),
+ import('~/pages/settings/members/roles/SettingsRoleCreate').then(
+ (module) => ({
+ default: module.SettingsRoleCreate,
+ }),
+ ),
);
const SettingsRoleEdit = lazy(() =>
- import('~/pages/settings/roles/SettingsRoleEdit').then((module) => ({
+ import('~/pages/settings/members/roles/SettingsRoleEdit').then((module) => ({
default: module.SettingsRoleEdit,
})),
);
const SettingsRoleObjectLevel = lazy(() =>
- import('~/pages/settings/roles/SettingsRoleObjectLevel').then((module) => ({
- default: module.SettingsRoleObjectLevel,
- })),
+ import('~/pages/settings/members/roles/SettingsRoleObjectLevel').then(
+ (module) => ({
+ default: module.SettingsRoleObjectLevel,
+ }),
+ ),
);
const SettingsRoleAddObjectLevel = lazy(() =>
- import('~/pages/settings/roles/SettingsRoleAddObjectLevel').then(
+ import('~/pages/settings/members/roles/SettingsRoleAddObjectLevel').then(
(module) => ({
default: module.SettingsRoleAddObjectLevel,
}),
@@ -644,7 +636,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.EmailGroupChannelDetail}
element={}
/>
- } />
}
@@ -763,7 +754,15 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
/>
}
>
- } />
+
+ }
+ />
} />
{
CreatePublicDomainDocument,
);
+ const [updatePublicDomain] = useMutation(UpdatePublicDomainDocument);
+
const [newPublicDomain, setNewPublicDomain] = useState(
selectedPublicDomain?.domain ?? '',
);
+ // Holds the chosen application before the public domain is created.
+ // Once selectedPublicDomain exists, the dropdown reads from it directly.
+ const [draftApplicationId, setDraftApplicationId] = useState(
+ null,
+ );
+
+ const selectedApplicationId = isDefined(selectedPublicDomain)
+ ? (selectedPublicDomain.applicationId ?? null)
+ : draftApplicationId;
+
const [newPublicDomainError, setNewPublicDomainError] = useState<
string | undefined
>(undefined);
@@ -73,6 +88,20 @@ export const SettingPublicDomain = () => {
FindManyPublicDomainsDocument,
);
+ const { data: applicationsData } = useQuery(FindManyApplicationsDocument);
+
+ const applicationPinnedOption: SelectOption = {
+ value: null,
+ label: t`Workspace (all apps)`,
+ };
+
+ const applicationOptions: SelectOption[] = (
+ applicationsData?.findManyApplications ?? []
+ ).map((application) => ({
+ value: application.id,
+ label: application.name,
+ }));
+
const [deletePublicDomain] = useMutation(DeletePublicDomainDocument);
const { isLoading, publicDomainRecords, checkPublicDomainRecords } =
@@ -89,7 +118,7 @@ export const SettingPublicDomain = () => {
enqueueSuccessSnackBar({
message: t`Public domain successfully deleted`,
});
- navigate(SettingsPath.Domains);
+ navigate(SettingsPath.Applications);
refetchPublicDomains();
},
onError: (error) =>
@@ -116,7 +145,10 @@ export const SettingPublicDomain = () => {
setNewPublicDomainError(undefined);
await createPublicDomain({
- variables: { domain: newPublicDomain },
+ variables: {
+ domain: newPublicDomain,
+ applicationId: draftApplicationId,
+ },
onCompleted: (data) => {
setSelectedPublicDomain(data.createPublicDomain);
enqueueSuccessSnackBar({
@@ -132,6 +164,35 @@ export const SettingPublicDomain = () => {
});
};
+ const onApplicationChange = async (nextApplicationId: string | null) => {
+ if (!isDefined(selectedPublicDomain)) {
+ setDraftApplicationId(nextApplicationId);
+ return;
+ }
+
+ if (nextApplicationId === (selectedPublicDomain.applicationId ?? null)) {
+ return;
+ }
+
+ await updatePublicDomain({
+ variables: {
+ domain: selectedPublicDomain.domain,
+ applicationId: nextApplicationId,
+ },
+ onCompleted: (data) => {
+ setSelectedPublicDomain(data.updatePublicDomain);
+ enqueueSuccessSnackBar({
+ message: t`Public domain updated successfully`,
+ });
+ refetchPublicDomains();
+ },
+ onError: (error) =>
+ enqueueErrorSnackBar({
+ apolloError: error,
+ }),
+ });
+ };
+
return (
{
href: getSettingsPath(SettingsPath.Workspace),
},
{
- children: Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ children: Apps,
+ href: getSettingsPath(SettingsPath.Applications),
},
{ children: Public Domain },
]}
actionButton={
navigate(SettingsPath.Domains)}
+ onCancel={() => navigate(SettingsPath.Applications)}
isSaveDisabled={loading || isDefined(selectedPublicDomain)}
onSave={onCreate}
/>
@@ -209,6 +270,21 @@ export const SettingPublicDomain = () => {
)}
+
);
diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx
index 8dc89d49a9..94bcefaffa 100644
--- a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx
+++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx
@@ -70,14 +70,14 @@ export const SettingsCustomDomain = () => {
href: getSettingsPath(SettingsPath.Workspace),
},
{
- children: Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ children: General,
+ href: getSettingsPath(SettingsPath.Workspace),
},
{ children: Custom Domain },
]}
actionButton={
navigate(SettingsPath.Domains)}
+ onCancel={() => navigate(SettingsPath.Workspace)}
isSaveDisabled={isSaveDisabled}
isLoading={isSubmitting}
onSave={handleSave}
diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx
index e31cfd248e..722e15071f 100644
--- a/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx
+++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx
@@ -49,14 +49,14 @@ export const SettingsSubdomain = () => {
href: getSettingsPath(SettingsPath.Workspace),
},
{
- children: Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ children: General,
+ href: getSettingsPath(SettingsPath.Workspace),
},
{ children: Subdomain },
]}
actionButton={
navigate(SettingsPath.Domains)}
+ onCancel={() => navigate(SettingsPath.Workspace)}
isSaveDisabled={isSaveDisabled}
isLoading={isSubmitting}
onSave={handleSave}
diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts
index 39783c900d..a63f0b6314 100644
--- a/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts
+++ b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts
@@ -1,11 +1,12 @@
import { gql } from '@apollo/client';
export const CREATE_PUBLIC_DOMAIN = gql`
- mutation CreatePublicDomain($domain: String!) {
- createPublicDomain(domain: $domain) {
+ mutation CreatePublicDomain($domain: String!, $applicationId: String) {
+ createPublicDomain(domain: $domain, applicationId: $applicationId) {
id
domain
isValidated
+ applicationId
createdAt
}
}
diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/mutations/updatePublicDomain.ts b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/updatePublicDomain.ts
new file mode 100644
index 0000000000..f4ab423a3a
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/updatePublicDomain.ts
@@ -0,0 +1,13 @@
+import { gql } from '@apollo/client';
+
+export const UPDATE_PUBLIC_DOMAIN = gql`
+ mutation UpdatePublicDomain($domain: String!, $applicationId: String) {
+ updatePublicDomain(domain: $domain, applicationId: $applicationId) {
+ id
+ domain
+ isValidated
+ applicationId
+ createdAt
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts b/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts
index 3ffda31462..f98370d5c1 100644
--- a/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts
+++ b/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts
@@ -6,6 +6,7 @@ export const FIND_MANY_PUBLIC_DOMAINS = gql`
id
domain
isValidated
+ applicationId
createdAt
}
}
diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx
index 8395b1256c..4a23f88b9f 100644
--- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx
+++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx
@@ -26,7 +26,6 @@ import {
IconHelpCircle,
IconHierarchy2,
IconKey,
- IconLock,
IconMail,
IconMessage,
IconPlug,
@@ -36,7 +35,6 @@ import {
IconSparkles,
IconUserCircle,
IconUsers,
- IconWorld,
} from 'twenty-ui/display';
import { PermissionFlagType } from '~/generated-metadata/graphql';
@@ -134,18 +132,6 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconUsers,
isHidden: !permissionMap[PermissionFlagType.WORKSPACE_MEMBERS],
},
- {
- label: t`Roles`,
- path: SettingsPath.Roles,
- Icon: IconLock,
- isHidden: !permissionMap[PermissionFlagType.ROLES],
- },
- {
- label: t`Domains`,
- path: SettingsPath.Domains,
- Icon: IconWorld,
- isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
- },
{
label: t`Billing`,
path: SettingsPath.Billing,
diff --git a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx
deleted file mode 100644
index 2da1a8b88f..0000000000
--- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesContainer.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { SettingsPath } from 'twenty-shared/types';
-
-import { getSettingsPath } from 'twenty-shared/utils';
-
-import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
-
-import { SettingsRolesList } from '@/settings/roles/components/SettingsRolesList';
-import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
-import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
-import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
-import { Trans, useLingui } from '@lingui/react/macro';
-import { H3Title } from 'twenty-ui/display';
-
-export const SettingsRolesContainer = () => {
- const { t } = useLingui();
-
- const settingsRolesIsLoading = useAtomStateValue(settingsRolesIsLoadingState);
-
- if (settingsRolesIsLoading) {
- return null;
- }
-
- return (
- }
- links={[
- {
- children: Workspace,
- href: getSettingsPath(SettingsPath.Workspace),
- },
- { children: Roles },
- ]}
- >
-
-
-
-
- );
-};
diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx
index 2d9aa27045..80c5b60393 100644
--- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx
+++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx
@@ -95,6 +95,10 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
+ {
+ children: t`Members`,
+ href: getSettingsPath(SettingsPath.WorkspaceMembersPage),
+ },
{
children: t`Roles`,
href: getSettingsPath(SettingsPath.Roles),
diff --git a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx
index 1d1fd53544..4996e35b1f 100644
--- a/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx
+++ b/packages/twenty-front/src/modules/settings/roles/role/components/SettingsRole.tsx
@@ -141,6 +141,10 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
+ {
+ children: t`Members`,
+ href: getSettingsPath(SettingsPath.WorkspaceMembersPage),
+ },
{
children: t`Roles`,
href: getSettingsPath(SettingsPath.Roles),
diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx
index 0a32d97b92..f25b00aa9e 100644
--- a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx
+++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useCreateWorkspaceInvitation.test.tsx
@@ -1,5 +1,6 @@
import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation';
import { renderHook } from '@testing-library/react';
+import { GetWorkspaceInvitationsDocument } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const mutationCallSpy = jest.fn();
@@ -29,8 +30,8 @@ describe('useCreateWorkspaceInvitation', () => {
);
expect(mutationCallSpy).toHaveBeenCalledWith({
- onCompleted: expect.any(Function),
onError: expect.any(Function),
+ refetchQueries: [GetWorkspaceInvitationsDocument],
variables: params,
});
});
@@ -46,8 +47,8 @@ describe('useCreateWorkspaceInvitation', () => {
);
expect(mutationCallSpy).toHaveBeenCalledWith({
- onCompleted: expect.any(Function),
onError: expect.any(Function),
+ refetchQueries: [GetWorkspaceInvitationsDocument],
variables: params,
});
});
diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx
index 8a3b1d2800..6930b2e775 100644
--- a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx
+++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useDeleteWorkspaceInvitation.test.tsx
@@ -1,5 +1,6 @@
import { useDeleteWorkspaceInvitation } from '@/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
import { renderHook } from '@testing-library/react';
+import { GetWorkspaceInvitationsDocument } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const mutationCallSpy = jest.fn();
@@ -29,8 +30,8 @@ describe('useDeleteWorkspaceInvitation', () => {
);
expect(mutationCallSpy).toHaveBeenCalledWith({
- onCompleted: expect.any(Function),
onError: expect.any(Function),
+ refetchQueries: [GetWorkspaceInvitationsDocument],
variables: params,
});
});
diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx
index f029eb520d..3fc84b2aec 100644
--- a/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx
+++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/__tests__/useResendWorkspaceInvitation.test.tsx
@@ -1,5 +1,6 @@
import { useResendWorkspaceInvitation } from '@/workspace-invitation/hooks/useResendWorkspaceInvitation';
import { renderHook } from '@testing-library/react';
+import { GetWorkspaceInvitationsDocument } from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const mutationCallSpy = jest.fn();
@@ -29,8 +30,8 @@ describe('useResendWorkspaceInvitation', () => {
);
expect(mutationCallSpy).toHaveBeenCalledWith({
- onCompleted: expect.any(Function),
onError: expect.any(Function),
+ refetchQueries: [GetWorkspaceInvitationsDocument],
variables: params,
});
});
diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts b/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts
index e56d21fb8c..6f668e48ad 100644
--- a/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts
+++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/useCreateWorkspaceInvitation.ts
@@ -2,15 +2,13 @@ import { useMutation } from '@apollo/client/react';
import {
type SendInvitationsMutationVariables,
SendInvitationsDocument,
+ GetWorkspaceInvitationsDocument,
} from '~/generated-metadata/graphql';
-import { workspaceInvitationsState } from '@/workspace-invitation/states/workspaceInvitationsStates';
-import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useCreateWorkspaceInvitation = () => {
const [sendInvitationsMutation] = useMutation(SendInvitationsDocument);
- const setWorkspaceInvitations = useSetAtomState(workspaceInvitationsState);
const { enqueueErrorSnackBar } = useSnackBar();
const sendInvitation = async (
@@ -18,12 +16,7 @@ export const useCreateWorkspaceInvitation = () => {
) => {
return await sendInvitationsMutation({
variables,
- onCompleted: (data) => {
- setWorkspaceInvitations((workspaceInvitations) => [
- ...workspaceInvitations,
- ...data.sendInvitations.result,
- ]);
- },
+ refetchQueries: [GetWorkspaceInvitationsDocument],
onError: (error) => {
enqueueErrorSnackBar({ apolloError: error });
},
diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts b/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts
index 538fb64202..9f1bd27d58 100644
--- a/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts
+++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation.ts
@@ -2,9 +2,8 @@ import { useMutation } from '@apollo/client/react';
import {
type DeleteWorkspaceInvitationMutationVariables,
DeleteWorkspaceInvitationDocument,
+ GetWorkspaceInvitationsDocument,
} from '~/generated-metadata/graphql';
-import { workspaceInvitationsState } from '@/workspace-invitation/states/workspaceInvitationsStates';
-import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useDeleteWorkspaceInvitation = () => {
@@ -12,7 +11,6 @@ export const useDeleteWorkspaceInvitation = () => {
DeleteWorkspaceInvitationDocument,
);
- const setWorkspaceInvitations = useSetAtomState(workspaceInvitationsState);
const { enqueueErrorSnackBar } = useSnackBar();
const deleteWorkspaceInvitation = async ({
@@ -22,13 +20,7 @@ export const useDeleteWorkspaceInvitation = () => {
variables: {
appTokenId,
},
- onCompleted: () => {
- setWorkspaceInvitations((workspaceInvitations) =>
- workspaceInvitations.filter(
- (workspaceInvitation) => workspaceInvitation.id !== appTokenId,
- ),
- );
- },
+ refetchQueries: [GetWorkspaceInvitationsDocument],
onError: (error) => {
enqueueErrorSnackBar({ apolloError: error });
},
diff --git a/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts b/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts
index 3716850e19..0fad83aae5 100644
--- a/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts
+++ b/packages/twenty-front/src/modules/workspace-invitation/hooks/useResendWorkspaceInvitation.ts
@@ -2,9 +2,8 @@ import { useMutation } from '@apollo/client/react';
import {
type ResendWorkspaceInvitationMutationVariables,
ResendWorkspaceInvitationDocument,
+ GetWorkspaceInvitationsDocument,
} from '~/generated-metadata/graphql';
-import { workspaceInvitationsState } from '@/workspace-invitation/states/workspaceInvitationsStates';
-import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useResendWorkspaceInvitation = () => {
@@ -12,7 +11,6 @@ export const useResendWorkspaceInvitation = () => {
ResendWorkspaceInvitationDocument,
);
- const setWorkspaceInvitations = useSetAtomState(workspaceInvitationsState);
const { enqueueErrorSnackBar } = useSnackBar();
const resendInvitation = async ({
@@ -22,14 +20,7 @@ export const useResendWorkspaceInvitation = () => {
variables: {
appTokenId,
},
- onCompleted: (data) => {
- setWorkspaceInvitations((workspaceInvitations) => [
- ...data.resendWorkspaceInvitation.result,
- ...workspaceInvitations.filter(
- (workspaceInvitation) => workspaceInvitation.id !== appTokenId,
- ),
- ]);
- },
+ refetchQueries: [GetWorkspaceInvitationsDocument],
onError: (error) => {
enqueueErrorSnackBar({ apolloError: error });
},
diff --git a/packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts b/packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts
deleted file mode 100644
index 0b92e3fb09..0000000000
--- a/packages/twenty-front/src/modules/workspace-invitation/states/workspaceInvitationsStates.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { type WorkspaceInvitation } from '@/workspace-member/types/WorkspaceMember';
-import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
-
-export const workspaceInvitationsState = createAtomState<
- Omit[]
->({
- key: 'workspaceInvitationsState',
- defaultValue: [],
-});
diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx
index 512ae658d0..2dbdb6c1e2 100644
--- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx
@@ -2,11 +2,13 @@ import { useLingui } from '@lingui/react/macro';
import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { SettingsWorkspaceDomainCard } from '@/settings/domains/components/SettingsWorkspaceDomainCard';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
import { SettingsWorkspaceEmailGroupSection } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSection';
import { NameField } from '@/settings/workspace/components/NameField';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
+import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types';
@@ -17,6 +19,10 @@ import { Section } from 'twenty-ui/layout';
export const SettingsWorkspace = () => {
const { t } = useLingui();
+ const isMultiWorkspaceEnabled = useAtomStateValue(
+ isMultiWorkspaceEnabledState,
+ );
+
const isEmailGroupEnabled = useAtomStateValue(isEmailGroupEnabledState);
const isEmailGroupFeatureEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
@@ -44,9 +50,16 @@ export const SettingsWorkspace = () => {
-
+ {isMultiWorkspaceEnabled && (
+
+ )}
{showEmailGroupSection && }
-
diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx
index c3d725a0bb..896a0c2f77 100644
--- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx
+++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx
@@ -4,6 +4,7 @@ import {
StyledActionTableCell,
StyledNameTableCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
+import { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
@@ -41,6 +42,7 @@ import {
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { ApplicationDisplay } from '@/applications/components/ApplicationDisplay';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
+import { SettingsEmailingDomains } from '~/pages/settings/emailing-domains/SettingsEmailingDomains';
const StyledButtonContainer = styled.div`
display: flex;
@@ -74,6 +76,13 @@ export const SettingsApplicationsDeveloperTab = () => {
FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
);
+ const isPublicDomainEnabled = useIsFeatureEnabled(
+ FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
+ );
+ const isEmailingDomainEnabled = useIsFeatureEnabled(
+ FeatureFlagKey.IS_EMAILING_DOMAIN_ENABLED,
+ );
+
const [marketplaceAppSearchTerm, setMarketplaceAppSearchTerm] = useState('');
const [myAppsSearchTerm, setMyAppsSearchTerm] = useState('');
@@ -195,6 +204,26 @@ export const SettingsApplicationsDeveloperTab = () => {
)}
+ {isEmailingDomainEnabled && (
+
+ )}
+
+ {isPublicDomainEnabled && (
+
+ )}
+
{!isMarketplaceSettingTabVisible && (
{
- const { t } = useLingui();
- const featureFlags = useFeatureFlagsMap();
-
- const isPublicDomainEnabled =
- featureFlags[FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED];
-
- const isEmailingDomainsEnabled = useIsFeatureEnabled(
- FeatureFlagKey.IS_EMAILING_DOMAIN_ENABLED,
- );
-
- return (
- Workspace,
- href: getSettingsPath(SettingsPath.Workspace),
- },
- { children: Domains },
- ]}
- >
-
-
-
-
-
-
-
-
- {isEmailingDomainsEnabled && (
-
-
-
- )}
- {isPublicDomainEnabled && (
-
-
-
- )}
-
-
-
- );
-};
diff --git a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomainDetail.tsx b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomainDetail.tsx
index 6914b0632e..33fb7400cc 100644
--- a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomainDetail.tsx
+++ b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomainDetail.tsx
@@ -48,7 +48,7 @@ export const SettingsEmailingDomainDetail = () => {
},
{
children: Emailing Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ href: getSettingsPath(SettingsPath.Applications),
},
{ children: emailingDomain.domain },
]}
diff --git a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx
index e9d2b50a50..8af3e3d1b2 100644
--- a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx
+++ b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx
@@ -114,7 +114,7 @@ export const SettingsNewEmailingDomain = () => {
title={t`New Emailing Domain`}
actionButton={
navigate(SettingsPath.Domains)}
+ onCancel={() => navigate(SettingsPath.Applications)}
onSave={handleSave}
isSaveDisabled={!canSave}
/>
@@ -125,12 +125,12 @@ export const SettingsNewEmailingDomain = () => {
href: getSettingsPath(SettingsPath.Workspace),
},
{
- children: Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ children: Apps,
+ href: getSettingsPath(SettingsPath.Applications),
},
{
children: Emailing Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ href: getSettingsPath(SettingsPath.Applications),
},
{ children: New Emailing Domain },
]}
diff --git a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx
index 5f3c45f5c6..72ef55f56f 100644
--- a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx
+++ b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx
@@ -1,543 +1,73 @@
-import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
-import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
-import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
-import { isNonEmptyArray } from '@sniptt/guards';
-import { useContext, useEffect, useMemo, useState } from 'react';
-import { useInView } from 'react-intersection-observer';
-import { useDebounce } from 'use-debounce';
+import { SettingsPath } from 'twenty-shared/types';
+import { getSettingsPath } from 'twenty-shared/utils';
+import { IconKey, IconLock, IconUsers } from 'twenty-ui/display';
-import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
-import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
-import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
-import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
+import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
-import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
-import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
-import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
-import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
-import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
-import { Table } from '@/ui/layout/table/components/Table';
-import { TableHeader } from '@/ui/layout/table/components/TableHeader';
-import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
-import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
-import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
-import { useQuery } from '@apollo/client/react';
-import {
- AppPath,
- CoreObjectNameSingular,
- SettingsPath,
-} from 'twenty-shared/types';
-import {
- generateILikeFiltersForCompositeFields,
- getSettingsPath,
- isDefined,
-} from 'twenty-shared/utils';
-import {
- AppTooltip,
- Avatar,
- H2Title,
- IconArrowUpRight,
- IconChevronRight,
- IconHierarchy2,
- IconListDetails,
- IconMail,
- IconReload,
- IconTrash,
- Status,
- TooltipDelay,
-} from 'twenty-ui/display';
-import { Button, IconButton, SearchInput } from 'twenty-ui/input';
-import { Section } from 'twenty-ui/layout';
-import { MenuItem } from 'twenty-ui/navigation';
-import { GetWorkspaceInvitationsDocument } from '~/generated-metadata/graphql';
+import { TabList } from '@/ui/layout/tab-list/components/TabList';
+import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
+import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
+import { PermissionFlagType } from '~/generated-metadata/graphql';
+import { SettingsWorkspaceMembersAccessTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersAccessTab';
+import { SettingsWorkspaceMembersRolesTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab';
+import { SettingsWorkspaceMembersTeamTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab';
-import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
-import { useSettingsAllRoles } from '@/settings/roles/hooks/useSettingsAllRoles';
-import { TableCell } from '@/ui/layout/table/components/TableCell';
-import { TableRow } from '@/ui/layout/table/components/TableRow';
-import { useDeleteWorkspaceInvitation } from '@/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
-import { useResendWorkspaceInvitation } from '@/workspace-invitation/hooks/useResendWorkspaceInvitation';
-import { workspaceInvitationsState } from '@/workspace-invitation/states/workspaceInvitationsStates';
-import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
-import { useNavigateApp } from '~/hooks/useNavigateApp';
-import { useNavigateSettings } from '~/hooks/useNavigateSettings';
-import { normalizeSearchText } from '~/utils/normalizeSearchText';
+const MEMBERS_TAB_LIST_ID = 'members-tab-list';
-const StyledButtonContainer = styled.div`
- align-items: center;
- display: flex;
- flex-direction: row;
- flex-shrink: 0;
- gap: ${themeCssVariables.spacing[1]};
- margin-left: ${themeCssVariables.spacing[2]};
-`;
-
-const StyledExpiresInHeader = styled.span`
- white-space: nowrap;
-`;
-
-const StyledTableContainer = styled.div<{ hasMoreRows?: boolean }>`
- > div {
- border-bottom: ${({ hasMoreRows }) =>
- hasMoreRows
- ? 'none'
- : `1px solid ${themeCssVariables.border.color.light}`};
- }
-`;
-
-const StyledIconWrapper = styled.div`
- align-items: center;
- display: flex;
- margin-right: ${themeCssVariables.spacing[2]};
-`;
-
-const StyledTextContainerWithEllipsis = styled.div`
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`;
-
-const StyledSearchContainer = styled.div`
- align-items: center;
- display: flex;
- gap: ${themeCssVariables.spacing[2]};
- padding-bottom: ${themeCssVariables.spacing[2]};
-`;
-
-const StyledTableRows = styled.div`
- padding-bottom: ${themeCssVariables.spacing[2]};
- padding-top: ${themeCssVariables.spacing[2]};
-`;
-
-const StyledChevronWrapper = styled.div`
- align-items: center;
- color: ${themeCssVariables.font.color.secondary};
- display: flex;
- justify-content: flex-end;
- width: 100%;
-`;
+const MEMBERS_TAB_TEAM_ID = 'team';
+const MEMBERS_TAB_ACCESS_ID = 'access';
+const MEMBERS_TAB_ROLES_ID = 'roles';
export const SettingsWorkspaceMembers = () => {
- const { theme } = useContext(ThemeContext);
const { t } = useLingui();
- const { enqueueErrorSnackBar } = useSnackBar();
- const navigateApp = useNavigateApp();
- const navigateSettings = useNavigateSettings();
- const { closeDropdown } = useCloseDropdown();
- const [isFetchingMore, setIsFetchingMore] = useState(false);
- const [searchFilter, setSearchFilter] = useState('');
- const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
- const [debouncedSearchFilter] = useDebounce(searchFilter, 300);
- const roles = useSettingsAllRoles();
+ const hasRolesPermission = useHasPermissionFlag(PermissionFlagType.ROLES);
- const rolesById = new Map();
- roles.forEach((role) => {
- rolesById.set(role.id, role);
- });
-
- const searchServerFilter = useMemo(() => {
- if (!debouncedSearchFilter?.trim()) return undefined;
-
- const normalizedSearchTerm = normalizeSearchText(debouncedSearchFilter);
- const nameFilters = generateILikeFiltersForCompositeFields(
- normalizedSearchTerm,
- 'name',
- ['firstName', 'lastName'],
- );
-
- return {
- or: [
- ...nameFilters,
- { userEmail: { ilike: `%${normalizedSearchTerm}%` } },
- ],
- };
- }, [debouncedSearchFilter]);
-
- const {
- records: workspaceMembers,
- fetchMoreRecords,
- hasNextPage,
- loading,
- } = useFindManyRecords({
- objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
- filter: searchServerFilter,
- });
-
- const { resendInvitation } = useResendWorkspaceInvitation();
- const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation();
-
- const currentWorkspace = useAtomStateValue(currentWorkspaceState);
-
- const workspaceInvitations = useAtomStateValue(workspaceInvitationsState);
- const setWorkspaceInvitations = useSetAtomState(workspaceInvitationsState);
-
- const handleSearchChange = (text: string) => {
- setSearchFilter(text);
- };
-
- const { data: invitationsData, error: invitationsError } = useQuery(
- GetWorkspaceInvitationsDocument,
+ const activeTabId = useAtomComponentStateValue(
+ activeTabIdComponentState,
+ MEMBERS_TAB_LIST_ID,
);
- useSnackBarOnQueryError(invitationsError);
+ const tabs = [
+ { id: MEMBERS_TAB_TEAM_ID, title: t`Team`, Icon: IconUsers },
+ { id: MEMBERS_TAB_ACCESS_ID, title: t`Access`, Icon: IconKey },
+ ...(hasRolesPermission
+ ? [{ id: MEMBERS_TAB_ROLES_ID, title: t`Roles`, Icon: IconLock }]
+ : []),
+ ];
- useEffect(() => {
- if (invitationsData) {
- setWorkspaceInvitations(invitationsData?.findWorkspaceInvitations ?? []);
- }
- }, [invitationsData, setWorkspaceInvitations]);
-
- const handleRemoveWorkspaceInvitation = async (appTokenId: string) => {
- const result = await deleteWorkspaceInvitation({ appTokenId });
- if (isDefined(result.error)) {
- enqueueErrorSnackBar({
- message: t`Error deleting invitation`,
- options: {
- duration: 2000,
- },
- });
+ const renderActiveTabContent = () => {
+ switch (activeTabId) {
+ case MEMBERS_TAB_ACCESS_ID:
+ return ;
+ case MEMBERS_TAB_ROLES_ID:
+ return hasRolesPermission ? (
+
+ ) : (
+
+ );
+ default:
+ return ;
}
};
- const handleResendWorkspaceInvitation = async (appTokenId: string) => {
- const result = await resendInvitation({ appTokenId });
- if (isDefined(result.error)) {
- enqueueErrorSnackBar({
- message: t`Error resending invitation`,
- options: {
- duration: 2000,
- },
- });
- }
- };
-
- const { ref: fetchMoreRef } = useInView({
- onChange: async (inView) => {
- if (inView && hasNextPage && !loading && !isFetchingMore) {
- setIsFetchingMore(true);
- await fetchMoreRecords();
- setIsFetchingMore(false);
- }
- },
- delay: 100,
- rootMargin: '1000px',
- threshold: 0,
- });
-
- const getExpiresAtText = (expiresAt: string) => {
- const msLeft = new Date(expiresAt).getTime() - Date.now();
-
- if (msLeft <= 0) return t`Expired`;
-
- const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
-
- if (daysLeft === 1) return t`1 day`;
-
- return t`${daysLeft} days`;
- };
-
- const optimizedWorkspaceMembers = useMemo(() => {
- if (!searchFilter.trim()) {
- return workspaceMembers;
- }
-
- const normalizedSearchTerm = normalizeSearchText(searchFilter);
- const searchTerms = normalizedSearchTerm.split(/\s+/);
-
- return workspaceMembers.filter((member) => {
- const firstName = normalizeSearchText(member.name.firstName);
- const lastName = normalizeSearchText(member.name.lastName);
- const email = normalizeSearchText(member.userEmail);
- const fullName = `${firstName} ${lastName}`.trim();
-
- return searchTerms.every(
- (term) =>
- firstName.includes(term) ||
- lastName.includes(term) ||
- fullName.includes(term) ||
- email.includes(term),
- );
- });
- }, [workspaceMembers, searchFilter]);
-
return (
- <>
-
- Workspace,
- href: getSettingsPath(SettingsPath.Workspace),
- },
- { children: Members },
- ]}
- >
-
- {currentWorkspace?.inviteHash &&
- currentWorkspace?.isPublicInviteLinkEnabled && (
-
- )}
-
-
-
- {isNonEmptyArray(workspaceInvitations) && (
-
-
-
-
- Email
-
-
- Role
-
-
-
- Expires in
-
-
-
-
-
- {workspaceInvitations?.map((workspaceInvitation) => (
-
-
-
-
-
-
- {workspaceInvitation.email}
-
-
-
-
-
- {rolesById.get(workspaceInvitation.roleId ?? '')
- ?.label ?? t`Default role`}
-
-
-
-
-
-
-
- {
- handleResendWorkspaceInvitation(
- workspaceInvitation.id,
- );
- }}
- variant="tertiary"
- size="medium"
- Icon={IconReload}
- />
- {
- handleRemoveWorkspaceInvitation(
- workspaceInvitation.id,
- );
- }}
- variant="tertiary"
- size="medium"
- Icon={IconTrash}
- />
-
-
-
- ))}
-
-
-
- )}
-
-
-
-
-
-
- }
- dropdownPlacement="bottom-end"
- dropdownOffset={{ y: 8 }}
- dropdownComponents={
-
-
-
-
- }
- />
-
-
-
-
-
- Name
-
-
- Email
-
-
-
-
- {optimizedWorkspaceMembers.length > 0 ? (
- optimizedWorkspaceMembers.map((workspaceMember) => (
- {
- if (
- currentWorkspaceMember?.id === workspaceMember.id
- ) {
- return;
- }
- navigateSettings(SettingsPath.WorkspaceMemberPage, {
- workspaceMemberId: workspaceMember.id,
- });
- }}
- >
-
-
-
-
-
- {workspaceMember.name.firstName +
- ' ' +
- workspaceMember.name.lastName}
-
-
-
-
-
- {workspaceMember.userEmail}
-
-
-
-
- {currentWorkspaceMember?.id !==
- workspaceMember.id && (
-
- )}
-
-
-
- ))
- ) : (
-
- {!searchFilter
- ? t`No members`
- : t`No members match your search`}
-
- )}
-
- {hasNextPage && (
-
-
-
-
-
-
-
- )}
-
-
-
-
-
- >
+ Workspace,
+ href: getSettingsPath(SettingsPath.Workspace),
+ },
+ { children: Members },
+ ]}
+ >
+
+
+ {renderActiveTabContent()}
+
+
);
};
diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx b/packages/twenty-front/src/pages/settings/members/roles/SettingsRoleAddObjectLevel.tsx
similarity index 92%
rename from packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx
rename to packages/twenty-front/src/pages/settings/members/roles/SettingsRoleAddObjectLevel.tsx
index ef574dbf27..a9de81ca06 100644
--- a/packages/twenty-front/src/pages/settings/roles/SettingsRoleAddObjectLevel.tsx
+++ b/packages/twenty-front/src/pages/settings/members/roles/SettingsRoleAddObjectLevel.tsx
@@ -54,6 +54,14 @@ export const SettingsRoleAddObjectLevel = () => {
},
]
: [
+ {
+ children: t`Workspace`,
+ href: getSettingsPath(SettingsPath.Workspace),
+ },
+ {
+ children: t`Members`,
+ href: getSettingsPath(SettingsPath.WorkspaceMembersPage),
+ },
{ children: t`Roles`, href: getSettingsPath(SettingsPath.Roles) },
{
children: settingsDraftRole.label ?? '',
diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoleCreate.tsx b/packages/twenty-front/src/pages/settings/members/roles/SettingsRoleCreate.tsx
similarity index 100%
rename from packages/twenty-front/src/pages/settings/roles/SettingsRoleCreate.tsx
rename to packages/twenty-front/src/pages/settings/members/roles/SettingsRoleCreate.tsx
diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoleEdit.tsx b/packages/twenty-front/src/pages/settings/members/roles/SettingsRoleEdit.tsx
similarity index 100%
rename from packages/twenty-front/src/pages/settings/roles/SettingsRoleEdit.tsx
rename to packages/twenty-front/src/pages/settings/members/roles/SettingsRoleEdit.tsx
diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoleObjectLevel.tsx b/packages/twenty-front/src/pages/settings/members/roles/SettingsRoleObjectLevel.tsx
similarity index 100%
rename from packages/twenty-front/src/pages/settings/roles/SettingsRoleObjectLevel.tsx
rename to packages/twenty-front/src/pages/settings/members/roles/SettingsRoleObjectLevel.tsx
diff --git a/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoleCreate.stories.tsx b/packages/twenty-front/src/pages/settings/members/roles/__stories__/SettingsRoleCreate.stories.tsx
similarity index 80%
rename from packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoleCreate.stories.tsx
rename to packages/twenty-front/src/pages/settings/members/roles/__stories__/SettingsRoleCreate.stories.tsx
index f54b157a4d..bce52b4c30 100644
--- a/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoleCreate.stories.tsx
+++ b/packages/twenty-front/src/pages/settings/members/roles/__stories__/SettingsRoleCreate.stories.tsx
@@ -6,14 +6,14 @@ import {
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
-import { SettingsRoleCreate } from '~/pages/settings/roles/SettingsRoleCreate';
+import { SettingsRoleCreate } from '~/pages/settings/members/roles/SettingsRoleCreate';
const meta: Meta = {
title: 'Pages/Settings/Roles/SettingsRoleCreate',
component: SettingsRoleCreate,
decorators: [PageDecorator],
args: {
- routePath: '/settings/roles/create',
+ routePath: '/settings/members/roles/create',
},
parameters: {
msw: graphqlMocks,
diff --git a/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoleEdit.stories.tsx b/packages/twenty-front/src/pages/settings/members/roles/__stories__/SettingsRoleEdit.stories.tsx
similarity index 81%
rename from packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoleEdit.stories.tsx
rename to packages/twenty-front/src/pages/settings/members/roles/__stories__/SettingsRoleEdit.stories.tsx
index ed19f99b19..24bd3511f0 100644
--- a/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoleEdit.stories.tsx
+++ b/packages/twenty-front/src/pages/settings/members/roles/__stories__/SettingsRoleEdit.stories.tsx
@@ -6,14 +6,14 @@ import {
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
-import { SettingsRoleEdit } from '~/pages/settings/roles/SettingsRoleEdit';
+import { SettingsRoleEdit } from '~/pages/settings/members/roles/SettingsRoleEdit';
const meta: Meta = {
title: 'Pages/Settings/Roles/SettingsRoleEdit',
component: SettingsRoleEdit,
decorators: [PageDecorator],
args: {
- routePath: '/settings/roles/:roleId',
+ routePath: '/settings/members/roles/:roleId',
routeParams: {
':roleId': '1',
},
diff --git a/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersAccessTab.tsx b/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersAccessTab.tsx
new file mode 100644
index 0000000000..6c6c1794a9
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersAccessTab.tsx
@@ -0,0 +1,249 @@
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { styled } from '@linaria/react';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useQuery } from '@apollo/client/react';
+import { isNonEmptyArray } from '@sniptt/guards';
+import { formatDistanceToNow } from 'date-fns';
+import { useContext, useMemo } from 'react';
+
+import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { useSettingsAllRoles } from '@/settings/roles/hooks/useSettingsAllRoles';
+import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { Table } from '@/ui/layout/table/components/Table';
+import { TableCell } from '@/ui/layout/table/components/TableCell';
+import { TableHeader } from '@/ui/layout/table/components/TableHeader';
+import { TableRow } from '@/ui/layout/table/components/TableRow';
+import { useDeleteWorkspaceInvitation } from '@/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
+import { useResendWorkspaceInvitation } from '@/workspace-invitation/hooks/useResendWorkspaceInvitation';
+import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
+import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
+import { isDefined } from 'twenty-shared/utils';
+import {
+ AppTooltip,
+ H2Title,
+ IconMail,
+ IconReload,
+ IconTrash,
+ Status,
+ TooltipDelay,
+} from 'twenty-ui/display';
+import { IconButton } from 'twenty-ui/input';
+import { Section } from 'twenty-ui/layout';
+import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
+import { GetWorkspaceInvitationsDocument } from '~/generated-metadata/graphql';
+import { dateLocaleState } from '~/localization/states/dateLocaleState';
+
+const StyledButtonContainer = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-shrink: 0;
+ gap: ${themeCssVariables.spacing[1]};
+ margin-left: ${themeCssVariables.spacing[2]};
+`;
+
+const StyledExpiresInHeader = styled.span`
+ white-space: nowrap;
+`;
+
+const StyledTableContainer = styled.div`
+ > div {
+ border-bottom: 1px solid ${themeCssVariables.border.color.light};
+ }
+`;
+
+const StyledIconWrapper = styled.div`
+ align-items: center;
+ display: flex;
+ margin-right: ${themeCssVariables.spacing[2]};
+`;
+
+const StyledTextContainerWithEllipsis = styled.div`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const StyledTableRows = styled.div`
+ padding-bottom: ${themeCssVariables.spacing[2]};
+ padding-top: ${themeCssVariables.spacing[2]};
+`;
+
+export const SettingsWorkspaceMembersAccessTab = () => {
+ const { theme } = useContext(ThemeContext);
+ const { t } = useLingui();
+ const { enqueueErrorSnackBar } = useSnackBar();
+ const roles = useSettingsAllRoles();
+ const { localeCatalog } = useAtomStateValue(dateLocaleState);
+
+ const rolesById = useMemo(
+ () => new Map(roles.map((role) => [role.id, role])),
+ [roles],
+ );
+
+ const { resendInvitation } = useResendWorkspaceInvitation();
+ const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation();
+
+ const currentWorkspace = useAtomStateValue(currentWorkspaceState);
+
+ const { data: invitationsData, error: invitationsError } = useQuery(
+ GetWorkspaceInvitationsDocument,
+ );
+
+ useSnackBarOnQueryError(invitationsError);
+
+ const workspaceInvitations = invitationsData?.findWorkspaceInvitations ?? [];
+
+ const handleRemoveWorkspaceInvitation = async (appTokenId: string) => {
+ const result = await deleteWorkspaceInvitation({ appTokenId });
+ if (isDefined(result.error)) {
+ enqueueErrorSnackBar({
+ message: t`Error deleting invitation`,
+ options: {
+ duration: 2000,
+ },
+ });
+ }
+ };
+
+ const handleResendWorkspaceInvitation = async (appTokenId: string) => {
+ const result = await resendInvitation({ appTokenId });
+ if (isDefined(result.error)) {
+ enqueueErrorSnackBar({
+ message: t`Error resending invitation`,
+ options: {
+ duration: 2000,
+ },
+ });
+ }
+ };
+
+ const getExpiresAtText = (expiresAt: string) => {
+ const expirationDate = new Date(expiresAt);
+ if (expirationDate.getTime() <= Date.now()) return t`Expired`;
+ return formatDistanceToNow(expirationDate, { locale: localeCatalog });
+ };
+
+ return (
+ <>
+ {currentWorkspace?.inviteHash &&
+ currentWorkspace?.isPublicInviteLinkEnabled && (
+
+ )}
+
+
+
+ {isNonEmptyArray(workspaceInvitations) && (
+
+
+
+
+ Email
+
+
+ Role
+
+
+
+ Expires in
+
+
+
+
+
+ {workspaceInvitations.map((workspaceInvitation) => (
+
+
+
+
+
+
+ {workspaceInvitation.email}
+
+
+
+
+
+ {rolesById.get(workspaceInvitation.roleId ?? '')
+ ?.label ?? t`Default role`}
+
+
+
+
+
+
+
+ {
+ handleResendWorkspaceInvitation(
+ workspaceInvitation.id,
+ );
+ }}
+ variant="tertiary"
+ size="medium"
+ Icon={IconReload}
+ />
+ {
+ handleRemoveWorkspaceInvitation(
+ workspaceInvitation.id,
+ );
+ }}
+ variant="tertiary"
+ size="medium"
+ Icon={IconTrash}
+ />
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab.tsx b/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab.tsx
new file mode 100644
index 0000000000..8936f8eab2
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab.tsx
@@ -0,0 +1,15 @@
+import { SettingsRolesList } from '@/settings/roles/components/SettingsRolesList';
+import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
+import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+
+export const SettingsWorkspaceMembersRolesTab = () => {
+ const settingsRolesIsLoading = useAtomStateValue(settingsRolesIsLoadingState);
+
+ return (
+ <>
+
+ {!settingsRolesIsLoading && }
+ >
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab.tsx b/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab.tsx
new file mode 100644
index 0000000000..698cfa22d6
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab.tsx
@@ -0,0 +1,313 @@
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { styled } from '@linaria/react';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useContext, useMemo, useState } from 'react';
+import { useInView } from 'react-intersection-observer';
+import { useDebounce } from 'use-debounce';
+
+import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
+import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
+import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
+import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
+import { Table } from '@/ui/layout/table/components/Table';
+import { TableHeader } from '@/ui/layout/table/components/TableHeader';
+import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
+import {
+ AppPath,
+ CoreObjectNameSingular,
+ SettingsPath,
+} from 'twenty-shared/types';
+import { generateILikeFiltersForCompositeFields } from 'twenty-shared/utils';
+import {
+ AppTooltip,
+ Avatar,
+ H2Title,
+ IconArrowUpRight,
+ IconChevronRight,
+ IconHierarchy2,
+ IconListDetails,
+ TooltipDelay,
+} from 'twenty-ui/display';
+import { Button, SearchInput } from 'twenty-ui/input';
+import { Section } from 'twenty-ui/layout';
+import { MenuItem } from 'twenty-ui/navigation';
+
+import { TableCell } from '@/ui/layout/table/components/TableCell';
+import { TableRow } from '@/ui/layout/table/components/TableRow';
+import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
+import { useNavigateApp } from '~/hooks/useNavigateApp';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
+import { normalizeSearchText } from '~/utils/normalizeSearchText';
+
+const StyledTableContainer = styled.div<{ hasMoreRows?: boolean }>`
+ > div {
+ border-bottom: ${({ hasMoreRows }) =>
+ hasMoreRows
+ ? 'none'
+ : `1px solid ${themeCssVariables.border.color.light}`};
+ }
+`;
+
+const StyledIconWrapper = styled.div`
+ align-items: center;
+ display: flex;
+ margin-right: ${themeCssVariables.spacing[2]};
+`;
+
+const StyledTextContainerWithEllipsis = styled.div`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const StyledSearchContainer = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${themeCssVariables.spacing[2]};
+ padding-bottom: ${themeCssVariables.spacing[2]};
+`;
+
+const StyledTableRows = styled.div`
+ padding-bottom: ${themeCssVariables.spacing[2]};
+ padding-top: ${themeCssVariables.spacing[2]};
+`;
+
+const StyledChevronWrapper = styled.div`
+ align-items: center;
+ color: ${themeCssVariables.font.color.secondary};
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+`;
+
+export const SettingsWorkspaceMembersTeamTab = () => {
+ const { theme } = useContext(ThemeContext);
+ const { t } = useLingui();
+ const navigateApp = useNavigateApp();
+ const navigateSettings = useNavigateSettings();
+ const { closeDropdown } = useCloseDropdown();
+ const [isFetchingMore, setIsFetchingMore] = useState(false);
+ const [searchFilter, setSearchFilter] = useState('');
+ const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
+
+ const [debouncedSearchFilter] = useDebounce(searchFilter, 300);
+
+ const searchServerFilter = useMemo(() => {
+ if (!debouncedSearchFilter?.trim()) return undefined;
+
+ const normalizedSearchTerm = normalizeSearchText(debouncedSearchFilter);
+ const nameFilters = generateILikeFiltersForCompositeFields(
+ normalizedSearchTerm,
+ 'name',
+ ['firstName', 'lastName'],
+ );
+
+ return {
+ or: [
+ ...nameFilters,
+ { userEmail: { ilike: `%${normalizedSearchTerm}%` } },
+ ],
+ };
+ }, [debouncedSearchFilter]);
+
+ const {
+ records: workspaceMembers,
+ fetchMoreRecords,
+ hasNextPage,
+ loading,
+ } = useFindManyRecords({
+ objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
+ filter: searchServerFilter,
+ });
+
+ const handleSearchChange = (text: string) => {
+ setSearchFilter(text);
+ };
+
+ const { ref: fetchMoreRef } = useInView({
+ onChange: async (inView) => {
+ if (inView && hasNextPage && !loading && !isFetchingMore) {
+ setIsFetchingMore(true);
+ await fetchMoreRecords();
+ setIsFetchingMore(false);
+ }
+ },
+ delay: 100,
+ rootMargin: '1000px',
+ threshold: 0,
+ });
+
+ const optimizedWorkspaceMembers = useMemo(() => {
+ if (!searchFilter.trim()) {
+ return workspaceMembers;
+ }
+
+ const normalizedSearchTerm = normalizeSearchText(searchFilter);
+ const searchTerms = normalizedSearchTerm.split(/\s+/);
+
+ return workspaceMembers.filter((member) => {
+ const firstName = normalizeSearchText(member.name.firstName);
+ const lastName = normalizeSearchText(member.name.lastName);
+ const email = normalizeSearchText(member.userEmail);
+ const fullName = `${firstName} ${lastName}`.trim();
+
+ return searchTerms.every(
+ (term) =>
+ firstName.includes(term) ||
+ lastName.includes(term) ||
+ fullName.includes(term) ||
+ email.includes(term),
+ );
+ });
+ }, [workspaceMembers, searchFilter]);
+
+ return (
+
+
+
+
+
+ }
+ dropdownPlacement="bottom-end"
+ dropdownOffset={{ y: 8 }}
+ dropdownComponents={
+
+
+ {
+ navigateApp(AppPath.RecordIndexPage, {
+ objectNamePlural: 'workspaceMembers',
+ });
+ closeDropdown('workspace-members-open-dropdown');
+ }}
+ />
+ {
+ navigateSettings(SettingsPath.ObjectDetail, {
+ objectNamePlural: 'workspaceMembers',
+ });
+ closeDropdown('workspace-members-open-dropdown');
+ }}
+ />
+
+
+ }
+ />
+
+
+
+
+
+ Name
+
+
+ Email
+
+
+
+
+ {optimizedWorkspaceMembers.length > 0 ? (
+ optimizedWorkspaceMembers.map((workspaceMember) => (
+ {
+ if (currentWorkspaceMember?.id === workspaceMember.id) {
+ return;
+ }
+ navigateSettings(SettingsPath.WorkspaceMemberPage, {
+ workspaceMemberId: workspaceMember.id,
+ });
+ }}
+ >
+
+
+
+
+
+ {workspaceMember.name.firstName +
+ ' ' +
+ workspaceMember.name.lastName}
+
+
+
+
+
+ {workspaceMember.userEmail}
+
+
+
+
+ {currentWorkspaceMember?.id !== workspaceMember.id && (
+
+ )}
+
+
+
+ ))
+ ) : (
+
+ {!searchFilter
+ ? t`No members`
+ : t`No members match your search`}
+
+ )}
+
+ {hasNextPage && (
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx b/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx
deleted file mode 100644
index 5e82559e97..0000000000
--- a/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { SettingsRolesContainer } from '@/settings/roles/components/SettingsRolesContainer';
-import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
-
-export const SettingsRoles = () => {
- return (
- <>
-
-
- >
- );
-};
diff --git a/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoles.stories.tsx b/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoles.stories.tsx
deleted file mode 100644
index e75cbeae66..0000000000
--- a/packages/twenty-front/src/pages/settings/roles/__stories__/SettingsRoles.stories.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { type Meta, type StoryObj } from '@storybook/react-vite';
-
-import {
- PageDecorator,
- type PageDecoratorArgs,
-} from '~/testing/decorators/PageDecorator';
-import { graphqlMocks } from '~/testing/graphqlMocks';
-
-import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
-import { getOperationName } from '~/utils/getOperationName';
-import { graphql, HttpResponse } from 'msw';
-import { SettingsRoles } from '~/pages/settings/roles/SettingsRoles';
-
-const meta: Meta = {
- title: 'Pages/Settings/Roles/SettingsRoles',
- component: SettingsRoles,
- decorators: [PageDecorator],
- args: {
- routePath: '/settings/roles',
- },
- parameters: {
- msw: graphqlMocks,
- },
-};
-
-export default meta;
-
-export type Story = StoryObj;
-
-export const Default: Story = {};
-
-export const NoRoles: Story = {
- parameters: {
- msw: {
- handlers: [
- graphql.query(getOperationName(GET_ROLES) ?? '', () => {
- return HttpResponse.json({
- data: {
- getRoles: [],
- },
- });
- }),
- ...graphqlMocks.handlers,
- ],
- },
- },
-};
diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx
index ac585120c3..7656eebcb4 100644
--- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx
+++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx
@@ -66,7 +66,7 @@ export const SettingsSecurityApprovedAccessDomain = () => {
enqueueSuccessSnackBar({
message: t`Please check your email for a verification link.`,
});
- navigate(SettingsPath.Domains);
+ navigate(SettingsPath.WorkspaceMembersPage);
},
onError: (error) => {
enqueueErrorSnackBar({
@@ -87,7 +87,7 @@ export const SettingsSecurityApprovedAccessDomain = () => {
title={t`New Approved Access Domain`}
actionButton={
navigate(SettingsPath.Domains)}
+ onCancel={() => navigate(SettingsPath.WorkspaceMembersPage)}
isSaveDisabled={form.formState.isSubmitting}
/>
}
@@ -97,8 +97,8 @@ export const SettingsSecurityApprovedAccessDomain = () => {
href: getSettingsPath(SettingsPath.Workspace),
},
{
- children: Domains,
- href: getSettingsPath(SettingsPath.Domains),
+ children: Members,
+ href: getSettingsPath(SettingsPath.WorkspaceMembersPage),
},
{ children: New Approved Access Domain },
]}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain.ts
new file mode 100644
index 0000000000..06c069ba91
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain.ts
@@ -0,0 +1,33 @@
+import { QueryRunner } from 'typeorm';
+
+import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
+import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
+
+@RegisteredInstanceCommand('2.4.0', 1798000003000)
+export class AddApplicationIdToPublicDomainFastInstanceCommand
+ implements FastInstanceCommand
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."publicDomain" ADD "applicationId" uuid`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_PUBLIC_DOMAIN_APPLICATION_ID" ON "core"."publicDomain" ("applicationId")`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."publicDomain" ADD CONSTRAINT "FK_39f1ad35993f3994cd5400e81a0" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."publicDomain" DROP CONSTRAINT "FK_39f1ad35993f3994cd5400e81a0"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX "core"."IDX_PUBLIC_DOMAIN_APPLICATION_ID"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."publicDomain" DROP COLUMN "applicationId"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
index 8b1a8d8e2f..63e5b3d29c 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
@@ -30,6 +30,7 @@ import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/
import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1777966965588-backfill-application-variable-universal-identifier';
import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings';
import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type';
+import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -62,4 +63,5 @@ export const INSTANCE_COMMANDS = [
TransformApplicationVariableToSyncableEntityFastInstanceCommand,
BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand,
AddEmailGroupChannelTypeFastInstanceCommand,
+ AddApplicationIdToPublicDomainFastInstanceCommand,
];
diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
index 1f3b09a0db..1fedf717ab 100644
--- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
@@ -63,7 +63,7 @@ export class ApprovedAccessDomainService {
const link = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
- pathname: getSettingsPath(SettingsPath.Domains),
+ pathname: getSettingsPath(SettingsPath.WorkspaceMembersPage),
searchParams: {
wtdId: approvedAccessDomain.id,
validationToken: this.generateUniqueHash(approvedAccessDomain),
diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
index 32a3ad11f7..556aa9a682 100644
--- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
@@ -310,7 +310,7 @@ describe('ApprovedAccessDomainService', () => {
expect(workspaceDomainsService.buildWorkspaceURL).toHaveBeenCalledWith({
workspace: workspace,
- pathname: getSettingsPath(SettingsPath.Domains),
+ pathname: getSettingsPath(SettingsPath.WorkspaceMembersPage),
searchParams: { validationToken: expect.any(String) },
});
diff --git a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts
index bcad13bec7..8a8a09cc07 100644
--- a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts
@@ -85,14 +85,34 @@ export class WorkspaceDomainsService {
}
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
- if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
- return this.getDefaultWorkspace();
- }
+ const { workspace } = await this.resolveWorkspaceAndPublicDomain(origin);
+ return workspace;
+ }
+
+ async resolveWorkspaceAndPublicDomain(origin: string): Promise<{
+ workspace: WorkspaceEntity | undefined;
+ publicDomain: PublicDomainEntity | null;
+ }> {
const { subdomain, domain } =
this.domainServerConfigService.getSubdomainAndDomainFromUrl(origin);
- if (!domain && !subdomain) return;
+ if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
+ // Single-workspace: workspace is always the default. Still resolve a
+ // matching public domain so the route trigger can scope by application.
+ const publicDomain = isDefined(domain)
+ ? await this.publicDomainRepository.findOne({ where: { domain } })
+ : null;
+
+ return {
+ workspace: await this.getDefaultWorkspace(),
+ publicDomain: publicDomain ?? null,
+ };
+ }
+
+ if (!domain && !subdomain) {
+ return { workspace: undefined, publicDomain: null };
+ }
const where = isDefined(domain) ? { customDomain: domain } : { subdomain };
@@ -103,18 +123,21 @@ export class WorkspaceDomainsService {
})) ?? undefined;
if (isDefined(workspaceFromCustomDomainOrSubdomain) || !isDefined(domain)) {
- return workspaceFromCustomDomainOrSubdomain;
+ return {
+ workspace: workspaceFromCustomDomainOrSubdomain,
+ publicDomain: null,
+ };
}
- const publicDomainFromCustomDomain =
- await this.publicDomainRepository.findOne({
- where: {
- domain,
- },
- relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'],
- });
+ const publicDomain = await this.publicDomainRepository.findOne({
+ where: { domain },
+ relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'],
+ });
- return publicDomainFromCustomDomain?.workspace;
+ return {
+ workspace: publicDomain?.workspace ?? undefined,
+ publicDomain: publicDomain ?? null,
+ };
}
private getCustomWorkspaceUrl(customDomain: string) {
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
index 9c674895ef..7b37ed30a9 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
@@ -50,10 +50,8 @@ export class RouteTriggerService {
}> {
const host = `${request.protocol}://${request.get('host')}`;
- const workspace =
- await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
- host,
- );
+ const { workspace, publicDomain } =
+ await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain(host);
assertIsDefinedOrThrow(
workspace,
@@ -63,11 +61,15 @@ export class RouteTriggerService {
),
);
+ // App-scoped public domain → restrict matches to that app's logic functions.
+ const applicationId = publicDomain?.applicationId ?? null;
+
const logicFunctionsWithHttpRouteTrigger =
await this.logicFunctionRepository.find({
where: {
workspaceId: workspace.id,
httpRouteTriggerSettings: Not(IsNull()),
+ ...(isDefined(applicationId) ? { applicationId } : {}),
},
});
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/dtos/create-public-domain.input.ts b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/create-public-domain.input.ts
new file mode 100644
index 0000000000..594ce0556a
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/create-public-domain.input.ts
@@ -0,0 +1,16 @@
+import { ArgsType, Field } from '@nestjs/graphql';
+
+import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
+
+@ArgsType()
+export class CreatePublicDomainInput {
+ @Field(() => String)
+ @IsString()
+ @IsNotEmpty()
+ domain: string;
+
+ @Field(() => String, { nullable: true })
+ @IsOptional()
+ @IsUUID()
+ applicationId?: string | null;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts
index 4701522e08..0455a3993d 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/public-domain.dto.ts
@@ -15,6 +15,9 @@ export class PublicDomainDTO {
@Field({ nullable: false })
isValidated: boolean;
+ @Field(() => UUIDScalarType, { nullable: true })
+ applicationId: string | null;
+
@Field()
createdAt: Date;
}
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/dtos/update-public-domain.input.ts b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/update-public-domain.input.ts
new file mode 100644
index 0000000000..645e386489
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/dtos/update-public-domain.input.ts
@@ -0,0 +1,16 @@
+import { ArgsType, Field } from '@nestjs/graphql';
+
+import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
+
+@ArgsType()
+export class UpdatePublicDomainInput {
+ @Field(() => String)
+ @IsString()
+ @IsNotEmpty()
+ domain: string;
+
+ @Field(() => String, { nullable: true })
+ @IsOptional()
+ @IsUUID()
+ applicationId?: string | null;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts
index 8848ae6d5b..004511da25 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain-exception-filter.ts
@@ -19,6 +19,7 @@ export class PublicDomainExceptionFilter implements ExceptionFilter {
case PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN:
throw new UserInputError(exception);
case PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND:
+ case PublicDomainExceptionCode.APPLICATION_NOT_FOUND:
throw new NotFoundError(exception);
default:
assertUnreachable(exception.code);
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts
index b1c9bf0255..3ef2dd1165 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.entity.ts
@@ -4,14 +4,20 @@ import {
Column,
CreateDateColumn,
Entity,
+ Index,
+ JoinColumn,
+ ManyToOne,
PrimaryGeneratedColumn,
+ type Relation,
UpdateDateColumn,
} from 'typeorm';
+import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'publicDomain', schema: 'core' })
@ObjectType('PublicDomain')
+@Index('IDX_PUBLIC_DOMAIN_APPLICATION_ID', ['applicationId'])
export class PublicDomainEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -27,4 +33,14 @@ export class PublicDomainEntity extends WorkspaceRelatedEntity {
@Column({ type: 'boolean', default: false, nullable: false })
isValidated: boolean;
+
+ @Column({ type: 'uuid', nullable: true })
+ applicationId: string | null;
+
+ @ManyToOne(() => ApplicationEntity, {
+ onDelete: 'CASCADE',
+ nullable: true,
+ })
+ @JoinColumn({ name: 'applicationId' })
+ application: Relation | null;
}
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts
index aca6b2e5de..b9c61bf009 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.exception.ts
@@ -8,6 +8,7 @@ export enum PublicDomainExceptionCode {
PUBLIC_DOMAIN_ALREADY_REGISTERED = 'PUBLIC_DOMAIN_ALREADY_REGISTERED',
DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN = 'DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN',
PUBLIC_DOMAIN_NOT_FOUND = 'PUBLIC_DOMAIN_NOT_FOUND',
+ APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
}
const getPublicDomainExceptionUserFriendlyMessage = (
@@ -20,6 +21,8 @@ const getPublicDomainExceptionUserFriendlyMessage = (
return msg`This domain is already registered as a custom domain.`;
case PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND:
return msg`Public domain not found.`;
+ case PublicDomainExceptionCode.APPLICATION_NOT_FOUND:
+ return msg`Application not found in this workspace.`;
default:
assertUnreachable(code);
}
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts
index ba7dfbb78d..23e3d0496c 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
+import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { PublicDomainResolver } from 'src/engine/core-modules/public-domain/public-domain.resolver';
@@ -13,7 +14,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
@Module({
imports: [
- NestjsQueryTypeOrmModule.forFeature([PublicDomainEntity, WorkspaceEntity]),
+ NestjsQueryTypeOrmModule.forFeature([
+ PublicDomainEntity,
+ WorkspaceEntity,
+ ApplicationEntity,
+ ]),
DnsManagerModule,
PermissionsModule,
],
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts
index 59c74b558b..eb49981450 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts
@@ -11,8 +11,10 @@ import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/dom
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
+import { CreatePublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/create-public-domain.input';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/public-domain.input';
+import { UpdatePublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/update-public-domain.input';
import { PublicDomainExceptionFilter } from 'src/engine/core-modules/public-domain/public-domain-exception-filter';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import {
@@ -54,12 +56,25 @@ export class PublicDomainResolver {
@Mutation(() => PublicDomainDTO)
async createPublicDomain(
- @Args() { domain }: PublicDomainInput,
+ @Args() { domain, applicationId }: CreatePublicDomainInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise {
return this.publicDomainService.createPublicDomain({
domain,
workspace: currentWorkspace,
+ applicationId: applicationId ?? null,
+ });
+ }
+
+ @Mutation(() => PublicDomainDTO)
+ async updatePublicDomain(
+ @Args() { domain, applicationId }: UpdatePublicDomainInput,
+ @AuthWorkspace() currentWorkspace: WorkspaceEntity,
+ ): Promise {
+ return this.publicDomainService.updatePublicDomainApplication({
+ domain,
+ workspace: currentWorkspace,
+ applicationId: applicationId ?? null,
});
}
diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts
index aa951ab9e4..66c53c4170 100644
--- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts
@@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
+import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
+import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
@@ -23,6 +25,8 @@ export class PublicDomainService {
private readonly publicDomainRepository: Repository,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository,
+ @InjectRepository(ApplicationEntity)
+ private readonly applicationRepository: Repository,
) {}
async deletePublicDomain({
@@ -47,17 +51,30 @@ export class PublicDomainService {
async createPublicDomain({
domain,
workspace,
+ applicationId,
}: {
domain: string;
workspace: WorkspaceEntity;
+ applicationId: string | null;
}): Promise {
const formattedDomain = domain.trim().toLowerCase();
- if (
- await this.workspaceRepository.findOneBy({
- customDomain: formattedDomain,
- })
- ) {
+ const [workspaceWithCustomDomain, existingPublicDomain, application] =
+ await Promise.all([
+ this.workspaceRepository.findOneBy({ customDomain: formattedDomain }),
+ this.publicDomainRepository.findOneBy({
+ domain: formattedDomain,
+ workspaceId: workspace.id,
+ }),
+ isDefined(applicationId)
+ ? this.applicationRepository.findOneBy({
+ id: applicationId,
+ workspaceId: workspace.id,
+ })
+ : Promise.resolve(null),
+ ]);
+
+ if (isDefined(workspaceWithCustomDomain)) {
throw new PublicDomainException(
'Domain already used for workspace custom domain',
PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN,
@@ -67,12 +84,7 @@ export class PublicDomainService {
);
}
- if (
- await this.publicDomainRepository.findOneBy({
- domain: formattedDomain,
- workspaceId: workspace.id,
- })
- ) {
+ if (isDefined(existingPublicDomain)) {
throw new PublicDomainException(
'Public domain already registered',
PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED,
@@ -82,9 +94,17 @@ export class PublicDomainService {
);
}
+ if (isDefined(applicationId) && !isDefined(application)) {
+ throw new PublicDomainException(
+ 'Application not found in this workspace',
+ PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
+ );
+ }
+
const publicDomain = this.publicDomainRepository.create({
domain: formattedDomain,
workspaceId: workspace.id,
+ applicationId,
});
await this.dnsManagerService.registerHostname(formattedDomain, {
@@ -94,7 +114,7 @@ export class PublicDomainService {
try {
await this.publicDomainRepository.insert(
publicDomain as QueryDeepPartialEntity<
- Omit
+ Omit
>,
);
} catch (error) {
@@ -108,6 +128,49 @@ export class PublicDomainService {
return publicDomain;
}
+ async updatePublicDomainApplication({
+ domain,
+ workspace,
+ applicationId,
+ }: {
+ domain: string;
+ workspace: WorkspaceEntity;
+ applicationId: string | null;
+ }): Promise {
+ const formattedDomain = domain.trim().toLowerCase();
+
+ const [publicDomain, application] = await Promise.all([
+ this.publicDomainRepository.findOneBy({
+ domain: formattedDomain,
+ workspaceId: workspace.id,
+ }),
+ isDefined(applicationId)
+ ? this.applicationRepository.findOneBy({
+ id: applicationId,
+ workspaceId: workspace.id,
+ })
+ : Promise.resolve(null),
+ ]);
+
+ if (!isDefined(publicDomain)) {
+ throw new PublicDomainException(
+ `Public domain ${domain} not found`,
+ PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND,
+ );
+ }
+
+ if (isDefined(applicationId) && !isDefined(application)) {
+ throw new PublicDomainException(
+ 'Application not found in this workspace',
+ PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
+ );
+ }
+
+ publicDomain.applicationId = applicationId;
+
+ return this.publicDomainRepository.save(publicDomain);
+ }
+
async checkPublicDomainValidRecords(
publicDomain: PublicDomainEntity,
domainValidRecords?: DomainValidRecords,
diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts
index 389452bfde..7896430e06 100644
--- a/packages/twenty-shared/src/types/SettingsPath.ts
+++ b/packages/twenty-shared/src/types/SettingsPath.ts
@@ -23,15 +23,14 @@ export enum SettingsPath {
WorkspaceMembersPage = 'members',
WorkspaceMemberPage = 'members/:workspaceMemberId',
Workspace = 'general',
+ Subdomain = 'general/subdomain',
+ CustomDomain = 'general/custom-domain',
EmailGroupChannelDetail = 'general/email-group/:messageChannelId',
NewEmailGroupChannel = 'general/new-email-group',
- Domains = 'domains',
- Subdomain = 'domains/subdomain',
- CustomDomain = 'domains/custom-domain',
- PublicDomain = 'domains/public-domain',
- NewApprovedAccessDomain = 'domains/approved-access-domain/new',
- NewEmailingDomain = 'domains/emailing-domain/new',
- EmailingDomainDetail = 'domains/emailing-domain/:domainId',
+ PublicDomain = 'applications/public-domain',
+ NewApprovedAccessDomain = 'members/approved-access-domain/new',
+ NewEmailingDomain = 'applications/emailing-domain/new',
+ EmailingDomainDetail = 'applications/emailing-domain/:domainId',
Updates = 'updates',
AI = 'ai',
AiUsageUserDetail = 'ai/usage/user/:userWorkspaceId',
@@ -85,9 +84,9 @@ export enum SettingsPath {
AdminPanelApplicationRegistrationDetail = 'admin-panel/applications/registrations/:applicationRegistrationId',
AdminPanelWorkspaceChatThread = 'admin-panel/workspaces/:workspaceId/threads/:threadId',
- Roles = 'roles',
- RoleCreate = 'roles/create',
- RoleDetail = 'roles/:roleId',
- RoleObjectLevel = 'roles/:roleId/object/:objectMetadataId',
- RoleAddObjectLevel = 'roles/:roleId/add-object-permission',
+ Roles = 'members/roles',
+ RoleCreate = 'members/roles/create',
+ RoleDetail = 'members/roles/:roleId',
+ RoleObjectLevel = 'members/roles/:roleId/object/:objectMetadataId',
+ RoleAddObjectLevel = 'members/roles/:roleId/add-object-permission',
}