feat(server): workspace opt-out of root-domain directory listing (#22423)
## What Lets a workspace opt out of being surfaced in the multi-workspace root-domain (app.twenty.com) picker via **email-domain discovery**. Adds `isDirectoryListingEnabled` (default `true`) on the workspace. When `false`, the workspace is filtered out of the approved-access-domain branch of `findAvailableWorkspacesByEmail`, so a user whose email domain matches an approved access domain no longer sees the workspace in the sign-up picker. ## Scope of the opt-out (deliberately narrow) The filter is applied **only** to the approved-access-domain discovery source: - **Members** (`availableWorkspacesForSignIn`) — never filtered; they keep access. - **Explicit invitations** — never filtered; the intent is one-to-one. - **Approved-access-domain discovery** — the only "listing" source, gated by the flag. A hidden workspace stays fully reachable by members and invited users via the direct workspace subdomain; it just isn't advertised in the global picker. > Open question for review: do we also want a stronger mode that hides the workspace from the root-domain picker even for existing members (forcing them to use the subdomain directly)? That would additionally filter the member/invitation sources and is a larger behavior change — not included here. ## Changes **Backend** - `workspace.entity.ts` — new `isDirectoryListingEnabled` column (`@Field`, default `true`). - `user-workspace.service.ts` — filter the approved-access-domain branch on the flag. - `update-workspace-input.ts` — expose the field on `updateWorkspace`. - `workspace.service.ts` — `PermissionFlagType.SECURITY` (same as the other discovery/security toggles). - Fast instance command adding the column (default `true`, so no existing workspace is hidden). **Frontend** - Settings > Security: a **"List in workspace directory"** toggle (shown only in multi-workspace mode) that flips the flag via `updateWorkspace`, mirroring the existing `isInternalMessagesImportEnabled` toggle. - Threaded the field through the current-user fragment, `CurrentWorkspace` type, and mock data. - Regenerated the metadata + client-sdk GraphQL types (`generated-metadata`, `twenty-client-sdk/.../generated`) — generated against a server booted from this branch. ## Verification - `tsgo` typecheck: 0 errors. `oxlint`: 0/0. `oxfmt`: clean. - Codegen diff verified to contain **only** the new field (no unrelated drift). - Tests not run locally; CI covers unit/integration + the codegen/migration freshness checks. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
+3
@@ -19,6 +19,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceDiscoverability } from 'src/engine/core-modules/workspace/types/workspace-discoverability.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
@@ -654,6 +655,7 @@ describe('UserWorkspaceService', () => {
|
||||
displayName: 'Workspace 2',
|
||||
logo: 'logo2.png',
|
||||
workspaceSSOIdentityProviders: [],
|
||||
workspaceDiscoverability: WorkspaceDiscoverability.PUBLIC,
|
||||
} as unknown as WorkspaceEntity;
|
||||
|
||||
const user = {
|
||||
@@ -714,6 +716,7 @@ describe('UserWorkspaceService', () => {
|
||||
displayName: 'Workspace 1',
|
||||
logo: 'logo1.png',
|
||||
workspaceSSOIdentityProviders: [],
|
||||
workspaceDiscoverability: WorkspaceDiscoverability.PUBLIC,
|
||||
} as unknown as WorkspaceEntity;
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
+20
-3
@@ -26,6 +26,7 @@ import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceDiscoverability } from 'src/engine/core-modules/workspace/types/workspace-discoverability.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
@@ -355,27 +356,41 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
},
|
||||
});
|
||||
|
||||
// HIDDEN workspaces are never advertised in the root-domain picker, even to
|
||||
// their own members — they must sign in from the workspace URL directly.
|
||||
const alreadyMemberWorkspaces = user
|
||||
? user.userWorkspaces.map(({ workspace }) => ({ workspace }))
|
||||
? user.userWorkspaces
|
||||
.map(({ workspace }) => ({ workspace }))
|
||||
.filter(
|
||||
({ workspace }) =>
|
||||
workspace.workspaceDiscoverability !==
|
||||
WorkspaceDiscoverability.HIDDEN,
|
||||
)
|
||||
: [];
|
||||
|
||||
const alreadyMemberWorkspacesIds = alreadyMemberWorkspaces.map(
|
||||
({ workspace }) => workspace.id,
|
||||
);
|
||||
|
||||
// Email-domain discovery is the only "listing" source: PUBLIC only.
|
||||
const workspacesFromApprovedAccessDomain = (
|
||||
await this.approvedAccessDomainService.findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain(
|
||||
getDomainFromEmailOrThrow(email),
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
({ workspace }) => !alreadyMemberWorkspacesIds.includes(workspace.id),
|
||||
({ workspace }) =>
|
||||
!alreadyMemberWorkspacesIds.includes(workspace.id) &&
|
||||
workspace.workspaceDiscoverability ===
|
||||
WorkspaceDiscoverability.PUBLIC,
|
||||
)
|
||||
.map(({ workspace }) => ({ workspace }));
|
||||
|
||||
const workspacesFromApprovedAccessDomainIds =
|
||||
workspacesFromApprovedAccessDomain.map(({ workspace }) => workspace.id);
|
||||
|
||||
// HIDDEN removes the picker convenience only; invited users can still join
|
||||
// through the direct invitation link, which carries its own token.
|
||||
const workspacesFromInvitations = (
|
||||
await this.workspaceInvitationService.findInvitationsByEmail(email)
|
||||
)
|
||||
@@ -384,7 +399,9 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
![
|
||||
...alreadyMemberWorkspacesIds,
|
||||
...workspacesFromApprovedAccessDomainIds,
|
||||
].includes(workspace.id),
|
||||
].includes(workspace.id) &&
|
||||
workspace.workspaceDiscoverability !==
|
||||
WorkspaceDiscoverability.HIDDEN,
|
||||
)
|
||||
.map((appToken) => ({
|
||||
workspace: appToken.workspace,
|
||||
|
||||
+7
@@ -3,6 +3,7 @@ import { Field, InputType } from '@nestjs/graphql';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { WorkspaceDiscoverability } from 'src/engine/core-modules/workspace/types/workspace-discoverability.type';
|
||||
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput {
|
||||
@@ -49,6 +51,11 @@ export class UpdateWorkspaceInput {
|
||||
@IsOptional()
|
||||
isPublicInviteLinkEnabled?: boolean;
|
||||
|
||||
@Field(() => WorkspaceDiscoverability, { nullable: true })
|
||||
@IsEnum(WorkspaceDiscoverability)
|
||||
@IsOptional()
|
||||
workspaceDiscoverability?: WorkspaceDiscoverability;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
@@ -98,6 +98,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
eventLogRetentionDays: PermissionFlagType.SECURITY,
|
||||
inviteHash: PermissionFlagType.WORKSPACE_MEMBERS,
|
||||
isPublicInviteLinkEnabled: PermissionFlagType.SECURITY,
|
||||
workspaceDiscoverability: PermissionFlagType.SECURITY,
|
||||
allowImpersonation: PermissionFlagType.SECURITY,
|
||||
isGoogleAuthEnabled: PermissionFlagType.SECURITY,
|
||||
isMicrosoftAuthEnabled: PermissionFlagType.SECURITY,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Ordered privacy levels controlling how a workspace surfaces in the
|
||||
// root-domain (app.twenty.com) sign-in picker. Each level hides strictly more
|
||||
// than the previous one:
|
||||
// - PUBLIC: discoverable by anyone whose email domain matches an approved access domain
|
||||
// - MEMBERS_AND_INVITEES: hidden from email-domain discovery, still shown to members and invited users
|
||||
// - HIDDEN: never listed in the picker; members and invited users sign in from the workspace URL directly
|
||||
export enum WorkspaceDiscoverability {
|
||||
PUBLIC = 'PUBLIC',
|
||||
MEMBERS_AND_INVITEES = 'MEMBERS_AND_INVITEES',
|
||||
HIDDEN = 'HIDDEN',
|
||||
}
|
||||
+1
@@ -11,6 +11,7 @@ export const fromWorkspaceEntityToFlat = (
|
||||
inviteHash: entity.inviteHash,
|
||||
allowImpersonation: entity.allowImpersonation,
|
||||
isPublicInviteLinkEnabled: entity.isPublicInviteLinkEnabled,
|
||||
workspaceDiscoverability: entity.workspaceDiscoverability,
|
||||
trashRetentionDays: entity.trashRetentionDays,
|
||||
eventLogRetentionDays: entity.eventLogRetentionDays,
|
||||
activationStatus: entity.activationStatus,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-v
|
||||
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceDiscoverability } from 'src/engine/core-modules/workspace/types/workspace-discoverability.type';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
@@ -57,6 +58,10 @@ registerEnumType(WorkspaceActivationStatus, {
|
||||
name: 'WorkspaceActivationStatus',
|
||||
});
|
||||
|
||||
registerEnumType(WorkspaceDiscoverability, {
|
||||
name: 'WorkspaceDiscoverability',
|
||||
});
|
||||
|
||||
@Check(
|
||||
'onboarded_workspace_requires_default_role',
|
||||
`"activationStatus" IN ('PENDING_CREATION', 'ONGOING_CREATION') OR "defaultRoleId" IS NOT NULL`,
|
||||
@@ -113,6 +118,15 @@ export class WorkspaceEntity {
|
||||
@Column({ default: true })
|
||||
isPublicInviteLinkEnabled: boolean;
|
||||
|
||||
@Field(() => WorkspaceDiscoverability)
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enumName: 'workspace_discoverability_enum',
|
||||
enum: WorkspaceDiscoverability,
|
||||
default: WorkspaceDiscoverability.PUBLIC,
|
||||
})
|
||||
workspaceDiscoverability: WorkspaceDiscoverability;
|
||||
|
||||
@Field()
|
||||
@Column({ type: 'integer', default: 14 })
|
||||
trashRetentionDays: number;
|
||||
|
||||
Reference in New Issue
Block a user