Merge pull request #35936 from overleaf/ii-workspaces-subscription-admin-settings

Shared Workspace settings for subscription admins

GitOrigin-RevId: 61cc45a48aaf1709905e98b3cccf09b50e2afa84
This commit is contained in:
ilkin-overleaf
2026-09-17 11:57:14 +00:00
committed by Copybot
parent 51ae580da9
commit 0113c8ee9f
10 changed files with 440 additions and 5 deletions
@@ -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.`,
'<ul>' +
'<li>Projects in the workspace have been returned to their owners and are no longer visible to the whole group.</li>' +
'<li>Each project is now shared only with the collaborators the owner had already added.</li>' +
'<li>Group members have been notified of the change.</li>' +
'</ul>',
`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
@@ -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,
@@ -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 }
)
@@ -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": "",
@@ -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 ||
+1
View File
@@ -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
+14
View File
@@ -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 <b>Settings</b>.",
"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": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
@@ -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('<ManagedGroupSubscriptions />', 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(<ManagedGroupSubscriptions />, {
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(<ManagedGroupSubscriptions />, {
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(<ManagedGroupSubscriptions />, {
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(<ManagedGroupSubscriptions />, {
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(<ManagedGroupSubscriptions />, {
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(<ManagedGroupSubscriptions />, {
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
})
})
})
})
@@ -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: '<script>alert(1)</script>' }
)
expect(email.html).to.not.contain('<script>alert(1)</script>')
expect(email.html).to.contain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
})
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: '<script>alert(1)</script>' }
)
expect(email.html).to.not.contain('<script>alert(1)</script>')
expect(email.html).to.contain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
})
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: '<script>alert(1)</script>' }
)
expect(email.html).to.not.contain('<script>alert(1)</script>')
expect(email.html).to.contain('&lt;script&gt;alert(1)&lt;/script&gt;')
})
})
describe('groupDomainCapturedByGroupChanged', function () {
it('should build active-domain-capture email', function (ctx) {
const email = ctx.EmailBuilder.buildEmail(
@@ -102,6 +102,7 @@ export type ManagedGroupSubscription = {
groupSSO: boolean | null
managedUsers: boolean | null
aiToggling?: boolean | null
sharedWorkspace?: boolean | null
}
managedUsersEnabled: boolean
groupPolicy?: GroupPolicy