From 0113c8ee9f4c631aa5ffbdb83a49a85f7c17a7b8 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:06:29 +0300 Subject: [PATCH] Merge pull request #35936 from overleaf/ii-workspaces-subscription-admin-settings Shared Workspace settings for subscription admins GitOrigin-RevId: 61cc45a48aaf1709905e98b3cccf09b50e2afa84 --- .../app/src/Features/Email/EmailBuilder.mjs | 82 ++++++++ .../Subscription/SubscriptionController.mjs | 1 + services/web/app/src/models/GroupPolicy.mjs | 3 + .../web/frontend/extracted-translations.json | 14 ++ .../dashboard/managed-group-subscriptions.tsx | 17 +- services/web/frontend/js/utils/meta.ts | 1 + services/web/locales/en.json | 14 ++ .../managed-group-subscriptions.test.tsx | 135 +++++++++++++ .../test/unit/src/Email/EmailBuilder.test.mjs | 177 ++++++++++++++++++ .../subscription/dashboard/subscription.ts | 1 + 10 files changed, 440 insertions(+), 5 deletions(-) diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 9db0cbdd00..8a6c3f1ef7 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/services/web/app/src/Features/Email/EmailBuilder.mjs @@ -1300,6 +1300,88 @@ templates.groupAiFeaturesEnabled = NoCTAEmailTemplate({ }, }) +templates.groupSharedWorkspaceDisabledForOwner = ctaTemplate({ + subject() { + return `Your shared workspace has been disabled` + }, + title() { + return `Your projects are back in your personal list.` + }, + greeting(opts) { + return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,' + }, + message(opts) { + const groupName = _.escape(opts.groupName) + return [ + `Your group administrator has disabled the shared workspace for ${groupName}.`, + `Projects you owned in the workspace have been returned to your personal projects list. They’re no longer visible to the whole group — only collaborators you’d actively shared them with still have access.`, + `You don’t need to do anything. You can find your projects in your projects list as usual.`, + ] + }, + ctaText() { + return 'Open your projects' + }, + ctaURL() { + return `${settings.siteUrl}/project` + }, +}) + +templates.groupSharedWorkspaceDisabledForMember = ctaTemplate({ + subject() { + return `Your group's shared workspace has been disabled` + }, + title() { + return `Here's what's changed for your group.` + }, + greeting(opts) { + return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,' + }, + message(opts) { + const groupName = _.escape(opts.groupName) + return [ + `Your group administrator has disabled the shared workspace for ${groupName}.`, + `Projects that were in the shared workspace have been returned to their owners. You’ll still have access to any projects their owners have shared with you directly — but projects you could only see through the shared workspace may no longer be visible.`, + `You don’t need to do anything. If you’re not sure whether you still have access to a project, check with the project owner or your group administrator.`, + ] + }, + ctaText() { + return 'Open your projects' + }, + ctaURL() { + return `${settings.siteUrl}/project` + }, +}) + +templates.groupSharedWorkspaceDisabledForAdmin = ctaTemplate({ + subject(opts) { + return `Shared workspace disabled for ${opts.groupName}` + }, + title() { + return `Here's a summary of what changed for your group.` + }, + greeting(opts) { + return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,' + }, + message(opts) { + const groupName = _.escape(opts.groupName) + return [ + `You’ve disabled the shared workspace for ${groupName}. Here's a summary of what happened.`, + '', + `No further action is needed on your part.`, + ] + }, + ctaText() { + return 'Open your projects' + }, + ctaURL() { + return `${settings.siteUrl}/project` + }, +}) + templates.groupDomainCapturedByGroupChanged = ctaTemplate({ subject(opts) { return opts.domainCapturedByGroup diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index 8c3631997c..ed83ab68ba 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -186,6 +186,7 @@ async function userSubscriptionPage(req, res) { 'group-discount-10' ) await SplitTestHandler.promises.getAssignment(req, res, 'ai-toggling') + await SplitTestHandler.promises.getAssignment(req, res, 'shared-workspace') await SplitTestHandler.promises.getAssignment( req, res, diff --git a/services/web/app/src/models/GroupPolicy.mjs b/services/web/app/src/models/GroupPolicy.mjs index 4e87d1fbb1..2d8a5c903c 100644 --- a/services/web/app/src/models/GroupPolicy.mjs +++ b/services/web/app/src/models/GroupPolicy.mjs @@ -30,6 +30,9 @@ export const GroupPolicySchema = new Schema( // User can't use the Dropbox feature userCannotUseDropbox: Boolean, + + // User can't use the shared workspace feature + userCannotUseSharedWorkspace: Boolean, }, { minimize: false } ) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 11478096eb..3df344bf53 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -499,9 +499,13 @@ "disable_equation_preview": "", "disable_equation_preview_confirm": "", "disable_equation_preview_enable_in_settings": "", + "disable_shared_workspace": "", + "disable_shared_workspace_modal_body": "", + "disable_shared_workspace_modal_title": "", "disable_single_sign_on": "", "disable_sso": "", "disable_stop_on_first_error": "", + "disable_workspace": "", "disabled": "", "disabling": "", "disagree": "", @@ -645,8 +649,12 @@ "enable": "", "enable_ai_features": "", "enable_managed_users": "", + "enable_shared_workspace": "", + "enable_shared_workspace_modal_body": "", + "enable_shared_workspace_modal_title": "", "enable_single_sign_on": "", "enable_sso": "", + "enable_workspace": "", "enabled": "", "enables_real_time_syntax_checking_in_the_editor": "", "enabling": "", @@ -1896,6 +1904,12 @@ "share_project": "", "share_project_name": "", "shared_with_you": "", + "shared_workspace": "", + "shared_workspace_failed_to_disable": "", + "shared_workspace_failed_to_enable": "", + "shared_workspace_is_enabled": "", + "shared_workspace_managed_by_overleaf_support": "", + "shared_workspace_section_subtext": "", "sharelatex_beta_program": "", "sharing_permissions": "", "shortcut_to_open_advanced_reference_search": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx index aefa2dda13..b226bcbcce 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx @@ -100,6 +100,7 @@ export default function ManagedGroupSubscriptions() { const isSharingPermissionsEnabled = useFeatureFlag( 'sharing-updates-sharing-permissions' ) + const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace') const aiTogglingSplitTestEnabled = useFeatureFlag('ai-toggling') if (!managedGroupSubscriptions) { @@ -115,12 +116,18 @@ export default function ManagedGroupSubscriptions() { {managedGroupSubscriptions.map(subscription => { const isAdmin = usersEmail === subscription.admin_id.email - // Feature controls are currently rendered only for managed groups, where the admin can - // toggle AI Feature on/off. For non-managed groups, the section only displays notifications - // for features that have been disabled by Overleaf Support. When the `ai-toggling` split - // test is enabled, it's always displayed for non-managed groups that have the feature on. - // This flag will be deleted once AI Features toggling is available to all groups. + // Shared Workspace is available to both managed and non-managed groups, so while the + // `shared-workspace` split test has the group opted in, the section is rendered whenever + // Overleaf Support hasn't disabled the feature for them. + // AI Features toggling is narrower: it's only interactive for managed groups, so for + // non-managed groups the section falls back to displaying notifications for features that + // have been disabled by Overleaf Support. When the `ai-toggling` split test is enabled, + // it's also displayed for non-managed groups that have the feature on. Both flags will be + // deleted once their features are available to all groups. const shouldDisplayFeatureControls = + (isSharedWorkspaceEnabled && + subscription.planLevelName === 'Pro' && + subscription.features?.sharedWorkspace !== false) || (subscription.features?.aiToggling && aiTogglingSplitTestEnabled) || subscription.managedUsersEnabled || subscription.groupPolicy?.userCannotUseAIFeatures || diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 89c9b8b301..7eb5840882 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -296,6 +296,7 @@ export interface Meta { 'ol-script-logs': ScriptLogType[] 'ol-settingsGroupSSO': { enabled: boolean } | undefined 'ol-settingsPlans': Plan[] + 'ol-sharedWorkspaceEnabled': boolean 'ol-sharingPermissions': SharingPermissions 'ol-shouldAllowEditingDetails': boolean 'ol-shouldLoadHotjar': boolean diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f13f34cf43..417f67506f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -642,9 +642,13 @@ "disable_equation_preview": "Disable equation preview", "disable_equation_preview_confirm": "This will disable equation preview for you in all projects.", "disable_equation_preview_enable_in_settings": "You can enable it again in Settings.", + "disable_shared_workspace": "Disable shared workspace", + "disable_shared_workspace_modal_body": "Projects in the workspace will be transferred to their owners and shared only with their active collaborators. Group members will no longer have access to the shared workspace.", + "disable_shared_workspace_modal_title": "Disable shared workspace?", "disable_single_sign_on": "Disable single sign-on", "disable_sso": "Disable SSO", "disable_stop_on_first_error": "Disable “Stop on first error”", + "disable_workspace": "Disable workspace", "disabled": "Disabled", "disabling": "Disabling", "disagree": "Disagree", @@ -817,8 +821,12 @@ "enable": "Enable", "enable_ai_features": "Enable AI features", "enable_managed_users": "Enable Managed Users", + "enable_shared_workspace": "Enable shared workspace", + "enable_shared_workspace_modal_body": "Projects added to the workspace will be visible to the whole group. Once enabled, shared workspace can’t be turned off without transferring projects back to their owners.", + "enable_shared_workspace_modal_title": "Enable shared workspace?", "enable_single_sign_on": "Enable single sign-on", "enable_sso": "Enable SSO", + "enable_workspace": "Enable workspace", "enabled": "Enabled", "enables_real_time_syntax_checking_in_the_editor": "Enables real-time syntax checking in the editor", "enabling": "Enabling", @@ -2429,6 +2437,12 @@ "share_project": "Share Project", "share_project_name": "Share __projectName__", "shared_with_you": "Shared with you", + "shared_workspace": "Shared workspace", + "shared_workspace_failed_to_disable": "Shared workspace couldn’t be disabled. Try again or contact __appName__ support if the problem continues.", + "shared_workspace_failed_to_enable": "Shared workspace couldn’t be enabled. Try again or contact __appName__ support if the problem continues.", + "shared_workspace_is_enabled": "Shared workspace is enabled", + "shared_workspace_managed_by_overleaf_support": "Shared workspace is managed by __appName__ support.", + "shared_workspace_section_subtext": "All group members can see and access projects in the shared workspace.", "sharelatex_beta_program": "__appName__ beta program", "sharing_permissions": "Sharing permissions", "shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ or __altSpace__)", diff --git a/services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx index e30accd1f4..85847c2c83 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx @@ -2,6 +2,7 @@ import { expect } from 'chai' import { screen } from '@testing-library/react' import ManagedGroupSubscriptions from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-group-subscriptions' import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { MetaTag } from '@/utils/meta' import { cleanUpContext, renderWithSubscriptionDashContext, @@ -531,5 +532,139 @@ describe('', function () { expect(screen.queryByText('Feature controls')).to.be.null }) }) + + describe('Shared Workspace', function () { + // metaAttributesCache isn't cleared between tests, so ol-splitTestVariants leaks from + // earlier tests. Every test below sets it explicitly to stay independent of run order. + function splitTestVariants(enabled: boolean): MetaTag { + return { + name: 'ol-splitTestVariants', + value: enabled ? { 'shared-workspace': 'enabled' } : {}, + } + } + + it('renders the feature controls row for a non-managed group when the feature is enabled', async function () { + renderWithSubscriptionDashContext(, { + metaTags: [ + { + name: 'ol-managedGroupSubscriptions', + value: makeSubscription({ + managedUsersEnabled: false, + features: { + groupSSO: false, + managedUsers: false, + sharedWorkspace: true, + }, + }), + }, + { name: 'ol-usersEmail', value: adminEmail }, + splitTestVariants(true), + ], + }) + await screen.findByText('Feature controls') + }) + + it('renders the feature controls row for a non-managed group when the feature is unset', async function () { + renderWithSubscriptionDashContext(, { + metaTags: [ + { + name: 'ol-managedGroupSubscriptions', + value: makeSubscription({ + managedUsersEnabled: false, + features: { groupSSO: false, managedUsers: false }, + }), + }, + { name: 'ol-usersEmail', value: adminEmail }, + splitTestVariants(true), + ], + }) + await screen.findByText('Feature controls') + }) + + it('does not render the feature controls row when the split test is not enabled', function () { + renderWithSubscriptionDashContext(, { + metaTags: [ + { + name: 'ol-managedGroupSubscriptions', + value: makeSubscription({ + managedUsersEnabled: false, + features: { + groupSSO: false, + managedUsers: false, + sharedWorkspace: true, + }, + }), + }, + { name: 'ol-usersEmail', value: adminEmail }, + splitTestVariants(false), + ], + }) + expect(screen.queryByText('Feature controls')).to.be.null + }) + + it('does not render the feature controls row when Overleaf Support has disabled the feature', function () { + renderWithSubscriptionDashContext(, { + metaTags: [ + { + name: 'ol-managedGroupSubscriptions', + value: makeSubscription({ + managedUsersEnabled: false, + features: { + groupSSO: false, + managedUsers: false, + sharedWorkspace: false, + }, + }), + }, + { name: 'ol-usersEmail', value: adminEmail }, + splitTestVariants(true), + ], + }) + expect(screen.queryByText('Feature controls')).to.be.null + }) + + it('does not render the feature controls row when the user is not the group admin', function () { + renderWithSubscriptionDashContext(, { + metaTags: [ + { + name: 'ol-managedGroupSubscriptions', + value: makeSubscription({ + managedUsersEnabled: false, + features: { + groupSSO: false, + managedUsers: false, + sharedWorkspace: true, + }, + }), + }, + { name: 'ol-usersEmail', value: 'other@example.com' }, + splitTestVariants(true), + ], + }) + expect(screen.queryByText('Feature controls')).to.be.null + }) + + it('does not render the feature controls row when the group is not on a Pro plan', function () { + renderWithSubscriptionDashContext(, { + metaTags: [ + { + name: 'ol-managedGroupSubscriptions', + value: makeSubscription({ + managedUsersEnabled: false, + planLevelName: 'Standard', + features: { + groupSSO: false, + managedUsers: false, + sharedWorkspace: true, + }, + }), + }, + { name: 'ol-usersEmail', value: adminEmail }, + splitTestVariants(true), + ], + }) + expect(screen.queryByText('Feature controls')).to.be.null + }) + }) }) }) diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index 050f01e870..66c97eea4a 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/services/web/test/unit/src/Email/EmailBuilder.test.mjs @@ -1122,6 +1122,183 @@ describe('EmailBuilder', function () { }) }) + describe('groupSharedWorkspaceDisabledForOwner', function () { + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + groupName: 'Example Group', + firstName: 'Joe', + } + ctx.email = ctx.EmailBuilder.buildEmail( + 'groupSharedWorkspaceDisabledForOwner', + ctx.opts + ) + ctx.expectedUrl = `${ctx.settings.siteUrl}/project` + }) + + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) + expect(ctx.email.subject).to.equal( + 'Your shared workspace has been disabled' + ) + expect(ctx.email.html).to.contain( + 'Your projects are back in your personal list.' + ) + expect(ctx.email.html).to.contain( + `Your group administrator has disabled the shared workspace for ${ctx.opts.groupName}.` + ) + const buttonLink = dom('a:contains("Open your projects")') + expect(buttonLink.length).to.equal(1) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) + expect(ctx.email.html).to.contain('copy and paste this link') + expect(ctx.email.html).to.contain(ctx.expectedUrl) + }) + }) + + describe('plain text email', function () { + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) + }) + }) + + it('escapes the group name in the HTML email', function (ctx) { + const email = ctx.EmailBuilder.buildEmail( + 'groupSharedWorkspaceDisabledForOwner', + { ...ctx.opts, groupName: '' } + ) + expect(email.html).to.not.contain('') + expect(email.html).to.contain('<script>alert(1)</script>') + }) + }) + + describe('groupSharedWorkspaceDisabledForMember', function () { + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + groupName: 'Example Group', + firstName: 'Joe', + } + ctx.email = ctx.EmailBuilder.buildEmail( + 'groupSharedWorkspaceDisabledForMember', + ctx.opts + ) + ctx.expectedUrl = `${ctx.settings.siteUrl}/project` + }) + + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) + expect(ctx.email.subject).to.equal( + "Your group's shared workspace has been disabled" + ) + expect(ctx.email.html).to.contain( + "Here's what's changed for your group." + ) + expect(ctx.email.html).to.contain( + `Your group administrator has disabled the shared workspace for ${ctx.opts.groupName}.` + ) + const buttonLink = dom('a:contains("Open your projects")') + expect(buttonLink.length).to.equal(1) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) + expect(ctx.email.html).to.contain('copy and paste this link') + expect(ctx.email.html).to.contain(ctx.expectedUrl) + }) + }) + + describe('plain text email', function () { + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) + }) + }) + + it('escapes the group name in the HTML email', function (ctx) { + const email = ctx.EmailBuilder.buildEmail( + 'groupSharedWorkspaceDisabledForMember', + { ...ctx.opts, groupName: '' } + ) + expect(email.html).to.not.contain('') + expect(email.html).to.contain('<script>alert(1)</script>') + }) + }) + + describe('groupSharedWorkspaceDisabledForAdmin', function () { + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + groupName: 'Example Group', + firstName: 'Joe', + } + ctx.email = ctx.EmailBuilder.buildEmail( + 'groupSharedWorkspaceDisabledForAdmin', + ctx.opts + ) + ctx.expectedUrl = `${ctx.settings.siteUrl}/project` + }) + + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) + expect(ctx.email.subject).to.equal( + `Shared workspace disabled for ${ctx.opts.groupName}` + ) + expect(ctx.email.html).to.contain( + "Here's a summary of what changed for your group." + ) + expect(ctx.email.html).to.contain( + `You’ve disabled the shared workspace for ${ctx.opts.groupName}. Here's a summary of what happened.` + ) + expect(ctx.email.html).to.contain( + 'Projects in the workspace have been returned to their owners and are no longer visible to the whole group.' + ) + expect(ctx.email.html).to.contain( + 'Each project is now shared only with the collaborators the owner had already added.' + ) + expect(ctx.email.html).to.contain( + 'Group members have been notified of the change.' + ) + const buttonLink = dom('a:contains("Open your projects")') + expect(buttonLink.length).to.equal(1) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) + expect(ctx.email.html).to.contain('copy and paste this link') + expect(ctx.email.html).to.contain(ctx.expectedUrl) + }) + }) + + describe('plain text email', function () { + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) + }) + }) + + it('escapes the group name in the HTML email', function (ctx) { + const email = ctx.EmailBuilder.buildEmail( + 'groupSharedWorkspaceDisabledForAdmin', + { ...ctx.opts, groupName: '' } + ) + expect(email.html).to.not.contain('') + expect(email.html).to.contain('<script>alert(1)</script>') + }) + }) + describe('groupDomainCapturedByGroupChanged', function () { it('should build active-domain-capture email', function (ctx) { const email = ctx.EmailBuilder.buildEmail( diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index b19cf08dba..1050106433 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -102,6 +102,7 @@ export type ManagedGroupSubscription = { groupSSO: boolean | null managedUsers: boolean | null aiToggling?: boolean | null + sharedWorkspace?: boolean | null } managedUsersEnabled: boolean groupPolicy?: GroupPolicy