From c981330cc124b45e010cefe6aa7d41f82502ff23 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Wed, 10 Jun 2026 10:46:57 +0200 Subject: [PATCH] [web] Disabled AI state for project collaborators (#34256) * [web] Disabled AI state for project collaborators Displays a disabled state, along with an informative tooltip in Editor AI controls. GitOrigin-RevId: a749cd25d54f6ef3d7bf4276343400894bc193a4 --- .../Features/Project/ProjectController.mjs | 41 +-- .../web/app/views/project/editor/_meta.pug | 1 + .../web/frontend/extracted-translations.json | 2 + .../components/toolbar/math-dropdown.tsx | 12 +- .../components/toolbar/table-dropdown.tsx | 8 +- .../components/toolbar/toolbar-items.tsx | 8 +- .../components/ol/ol-list-group-item.tsx | 30 ++- services/web/frontend/js/utils/meta.ts | 1 + services/web/locales/en.json | 2 + .../src/Project/ProjectController.test.mjs | 237 ++++++++++++++++++ 10 files changed, 318 insertions(+), 24 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 0bf04a0ad9..c954aebfb2 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -56,7 +56,9 @@ import { formatCurrency } from '../../util/currency.js' import UserSettingsHelper from './UserSettingsHelper.mjs' import AiFeatureUsageRateLimiter from '../../infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs' import WorkbenchRateLimiter from '../../infrastructure/rate-limiters/WorkbenchRateLimiter.mjs' +import PermissionsManager from '../Authorization/PermissionsManager.mjs' +const { checkUserPermissions } = PermissionsManager.promises const { isPaidSubscription } = SubscriptionHelper const { hasAdminAccess } = AdminAuthorizationHelper const { ObjectId } = mongodb @@ -491,6 +493,7 @@ const _ProjectController = { 'focus-mode', 'editor-upgrade-button-relocation', 'markdown-visual', + 'ai-disabled-collaborators', ].filter(Boolean) const getUserValues = async userId => @@ -786,32 +789,28 @@ const _ProjectController = { !userHasPremiumSub && !userInNonIndividualSub - let aiFeaturesAllowed = false + let aiFeaturesAllowedForUser = false + let aiFeaturesAllowedForProject = false if (userId && Features.hasFeature('saas')) { try { - // exit early if the user couldnt use ai anyways, since permissions checks are expensive + aiFeaturesAllowedForUser = await checkUserPermissions(user, [ + 'use-ai', + ]) + const canUserWriteOrReviewProjectContent = privilegeLevel === PrivilegeLevels.READ_AND_WRITE || privilegeLevel === PrivilegeLevels.OWNER || privilegeLevel === PrivilegeLevels.REVIEW - if (canUserWriteOrReviewProjectContent) { - // check permissions for user and project owner, to see if they allow AI on the project - const permissionsResults = await Modules.promises.hooks.fire( - 'projectAllowsCapability', - project, - userId, + aiFeaturesAllowedForProject = await checkUserPermissions( + project.owner_ref, ['use-ai'] ) - const aiAllowed = permissionsResults.every( - result => result === true - ) - - aiFeaturesAllowed = aiAllowed } } catch (err) { // still allow users to access project if we cant get their permissions, but disable AI feature - aiFeaturesAllowed = false + aiFeaturesAllowedForUser = false + aiFeaturesAllowedForProject = false } } @@ -828,7 +827,7 @@ const _ProjectController = { user, userValues, userId, - aiFeaturesAllowed, + aiFeaturesAllowedForUser && aiFeaturesAllowedForProject, userIsMemberOfGroupSubscription ) @@ -867,7 +866,16 @@ const _ProjectController = { const hasPaidSubscription = isPaidSubscription(subscription) const aiFeaturesDisabled = user.aiFeatures?.enabled === false - const showAiFeatures = aiFeaturesAllowed && !aiFeaturesDisabled + let showAiFeatures = aiFeaturesAllowedForUser && !aiFeaturesDisabled + let showAiFeaturesDisabled = + showAiFeatures && !aiFeaturesAllowedForProject + if ( + splitTestAssignments['ai-disabled-collaborators']?.variant !== 'enabled' + ) { + showAiFeatures = showAiFeatures && !showAiFeaturesDisabled + showAiFeaturesDisabled = false + } + // only add-on is ai based, so we only need its pricing info if ai features are usable const addonPrices = showAiFeatures && (await ProjectController._getAddonPrices(req, res)) @@ -985,6 +993,7 @@ const _ProjectController = { symbolPaletteAvailable: Features.hasFeature('symbol-palette'), userRestrictions: Array.from(req.userRestrictions || []), showAiFeatures, + showAiFeaturesDisabled, // default to free tier if they dont have a quota hasAiFreeTier: fullFeatureSet?.aiUsageQuota === Settings.aiFeatures?.freeQuota || diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 3131779382..fe48efabe9 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -24,6 +24,7 @@ meta(name="ol-debugPdfDetach" data-type="boolean" content=debugPdfDetach) meta(name="ol-showSymbolPalette" data-type="boolean" content=showSymbolPalette) meta(name="ol-symbolPaletteAvailable" data-type="boolean" content=symbolPaletteAvailable) meta(name="ol-showAiFeatures" data-type="boolean" content=showAiFeatures) +meta(name="ol-showAiFeaturesDisabled" data-type="boolean" content=showAiFeaturesDisabled) meta(name="ol-hasUnlimitedAi" data-type="boolean" content=hasUnlimitedAi) meta(name="ol-hasAiFreeTier" data-type="boolean" content=hasAiFreeTier) meta(name="ol-detachRole" data-type="string" content=detachRole) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 9830089d88..4c280eba15 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -124,8 +124,10 @@ "ai_assist_unavailable": "", "ai_assistance": "", "ai_assistant": "", + "ai_assistant_disabled": "", "ai_can_make_mistakes": "", "ai_features": "", + "ai_features_unavailable_on_this_project": "", "ai_feedback_please_provide_more_detail": "", "ai_feedback_tell_us_what_was_wrong_so_we_can_improve": "", "ai_feedback_the_answer_was_too_long": "", diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx index bd9d65fbed..9bf170006c 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx @@ -13,11 +13,15 @@ import { memo } from 'react' import OLListGroupItem from '@/shared/components/ol/ol-list-group-item' import sparkleWhite from '@/shared/svgs/sparkle-small-white.svg' import sparkle from '@/shared/svgs/ai-sparkle-text.svg' +import getMeta from '@/utils/meta' export const MathDropdown = memo(function MathDropdown() { const { t } = useTranslation() const view = useCodeMirrorViewContext() const { writefullInstance } = useEditorContext() + const showAiFeaturesDisabled = getMeta('ol-showAiFeaturesDisabled') + + const renderAIButton = writefullInstance || showAiFeaturesDisabled return ( } > - {writefullInstance && ( + {renderAIButton && ( <> {t('toolbar_insert_math_lowercase')} { writefullInstance?.openEquationGenerator() }} @@ -48,7 +54,9 @@ export const MathDropdown = memo(function MathDropdown() { src={sparkleWhite} aria-hidden="true" /> - {t('generate_from_text_or_image')} + + {t('generate_from_text_or_image')} + )} diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx index 7a5dbe79ec..459195de47 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx @@ -14,10 +14,12 @@ import useDropdown from '../../../../shared/hooks/use-dropdown' import * as commands from '../../extensions/toolbar/commands' import { useCodeMirrorViewContext } from '../codemirror-context' import { emitToolbarEvent } from '../../extensions/toolbar/utils/analytics' +import getMeta from '@/utils/meta' export const TableDropdown = memo(function TableDropdown() { const { t } = useTranslation() const { writefullInstance } = useEditorContext() + const showAiFeaturesDisabled = getMeta('ol-showAiFeaturesDisabled') const selectSizeDropdown = useDropdown() const target = useRef(null) const view = useCodeMirrorViewContext() @@ -46,6 +48,8 @@ export const TableDropdown = memo(function TableDropdown() { { writefullInstance?.openTableGenerator() }} @@ -62,7 +66,9 @@ export const TableDropdown = memo(function TableDropdown() { src={sparkleWhite} aria-hidden="true" /> - {t('generate_from_text_or_image')} + + {t('generate_from_text_or_image')} +
!overflowed || overflowed.has(group) return ( @@ -156,7 +158,11 @@ export const ToolbarItems: FC<{ icon="book_5" /> - {writefullInstance ? : } + {writefullInstance || showAiFeaturesDisabled ? ( + + ) : ( + + )}
)} {showGroup('group-list') && ( diff --git a/services/web/frontend/js/shared/components/ol/ol-list-group-item.tsx b/services/web/frontend/js/shared/components/ol/ol-list-group-item.tsx index 35c279bbc8..963347bcdf 100644 --- a/services/web/frontend/js/shared/components/ol/ol-list-group-item.tsx +++ b/services/web/frontend/js/shared/components/ol/ol-list-group-item.tsx @@ -1,11 +1,19 @@ -import { forwardRef } from 'react' +import { forwardRef, useId } from 'react' import { ListGroupItem, ListGroupItemProps } from 'react-bootstrap' +import OLTooltip from '@/shared/components/ol/ol-tooltip' -const OLListGroupItem = forwardRef( - function OLListGroupItem(props, ref) { +type OLListGroupItemProps = ListGroupItemProps & { + disabledReason?: string +} + +const OLListGroupItem = forwardRef( + function OLListGroupItem({ disabledReason, ...props }, ref) { const as = props.as ?? 'button' + const tooltipId = useId() - return ( + const showDisabledTooltip = Boolean(props.disabled && disabledReason) + + const item = ( ( type={as === 'button' ? 'button' : undefined} /> ) + + if (showDisabledTooltip) { + return ( + + {item} + + ) + } + + return item } ) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index ea71ed2358..39901d1db1 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -286,6 +286,7 @@ export interface Meta { 'ol-shouldAllowEditingDetails': boolean 'ol-shouldLoadHotjar': boolean 'ol-showAiFeatures': boolean + 'ol-showAiFeaturesDisabled': boolean 'ol-showCouponField': boolean 'ol-showGroupDiscount': boolean 'ol-showGroupsAndEnterpriseBanner': boolean diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e3e3e4cc16..12559eb00b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -157,9 +157,11 @@ "ai_assist_unavailable": "AI features are now included in every paid plan. See the <0>pricing page for what each plan includes.", "ai_assistance": "AI assistance", "ai_assistant": "AI Assistant", + "ai_assistant_disabled": "AI Assistant disabled", "ai_assistant_explanation": "A LaTeX-fluent AI Assistant built into your editor.", "ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.", "ai_features": "AI features", + "ai_features_unavailable_on_this_project": "AI features have been turned off for this project", "ai_feedback_please_provide_more_detail": "Please provide more detail about what was wrong (optional)", "ai_feedback_tell_us_what_was_wrong_so_we_can_improve": "Tell us what was wrong so we can improve.", "ai_feedback_the_answer_was_too_long": "The answer was too long", diff --git a/services/web/test/unit/src/Project/ProjectController.test.mjs b/services/web/test/unit/src/Project/ProjectController.test.mjs index af78d06920..18a2286455 100644 --- a/services/web/test/unit/src/Project/ProjectController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectController.test.mjs @@ -250,6 +250,12 @@ describe('ProjectController', function () { }), } + ctx.PermissionsManager = { + promises: { + checkUserPermissions: sinon.stub().resolves(false), + }, + } + vi.doMock('mongodb-legacy', () => ({ default: { ObjectId }, })) @@ -515,6 +521,13 @@ describe('ProjectController', function () { }) ) + vi.doMock( + '../../../../app/src/Features/Authorization/PermissionsManager', + () => ({ + default: ctx.PermissionsManager, + }) + ) + ctx.ProjectController = (await import(MODULE_PATH)).default ctx.projectName = '£12321jkj9ujkljds' @@ -1510,6 +1523,230 @@ describe('ProjectController', function () { }) }) }) + + describe('AI features availability', function () { + beforeEach(function (ctx) { + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([[true]]) + ctx.settings.localizedAddOnsPricing = { + USD: { + assistant: { + annual: 60, + monthly: 5, + annualDividedByTwelve: 5, + }, + }, + } + }) + + it('should set showAiFeatures to true when the user has the use-ai permission', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(true) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(true) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false when the user lacks the use-ai permission', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(false) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false when the user has disabled ai features', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(true) + ctx.user.aiFeatures = { enabled: false } + ctx.UserModel.findById.returns({ + exec: sinon.stub().resolves(ctx.user), + }) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false when the permission check throws', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.rejects( + new Error('permission check failed') + ) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false when the project owner permission check throws', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(true) + ctx.PermissionsManager.promises.checkUserPermissions + .onSecondCall() + .rejects(new Error('permission check failed')) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false when the user can use ai but the project disallows it', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.callsFake( + subject => Promise.resolve(typeof subject !== 'string') + ) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeaturesDisabled to false when the user can use ai and the project allows it', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(true) + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([true]) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeaturesDisabled to false when the user lacks the use-ai permission', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(false) + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([[false]]) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeaturesDisabled to false when the user has disabled ai features', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(true) + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([[false]]) + ctx.user.aiFeatures = { enabled: false } + ctx.UserModel.findById.returns({ + exec: sinon.stub().resolves(ctx.user), + }) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + describe("when the 'ai-disabled-collaborators' variant is enabled", function () { + beforeEach(function (ctx) { + ctx.SplitTestHandler.promises.getAssignment + .withArgs(ctx.req, ctx.res, 'ai-disabled-collaborators') + .resolves({ variant: 'enabled' }) + }) + + it('should set showAiFeatures to true and showAiFeaturesDisabled to true when the user can use ai but the project disallows it', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.callsFake( + subject => Promise.resolve(typeof subject !== 'string') + ) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(true) + expect(opts.showAiFeaturesDisabled).to.equal(true) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to true and showAiFeaturesDisabled to false when the user can use ai and the project allows it', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(true) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(true) + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false and showAiFeaturesDisabled to false when the user lacks the use-ai permission', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.resolves(false) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + + it('should set showAiFeatures to false and showAiFeaturesDisabled to false when the user has disabled ai features', async function (ctx) { + ctx.PermissionsManager.promises.checkUserPermissions.callsFake( + subject => Promise.resolve(typeof subject !== 'string') + ) + ctx.user.aiFeatures = { enabled: false } + ctx.UserModel.findById.returns({ + exec: sinon.stub().resolves(ctx.user), + }) + await new Promise((resolve, reject) => { + ctx.res.render = (pageName, opts) => { + expect(opts.showAiFeatures).to.equal(false) + expect(opts.showAiFeaturesDisabled).to.equal(false) + resolve() + } + ctx.ProjectController.loadEditor(ctx.req, ctx.res, err => { + if (err) reject(err) + }) + }) + }) + }) + }) }) describe('userProjectsJson', function () {