[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
This commit is contained in:
Miguel Serrano
2026-06-10 11:58:01 +00:00
committed by Copybot
parent a4d682719b
commit c981330cc1
10 changed files with 318 additions and 24 deletions
@@ -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 ||
@@ -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)
@@ -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": "",
@@ -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 (
<ToolbarButtonMenu
@@ -25,13 +29,15 @@ export const MathDropdown = memo(function MathDropdown() {
label={t('toolbar_insert_math')}
icon={<MaterialIcon type="calculate" />}
>
{writefullInstance && (
{renderAIButton && (
<>
<DropdownHeader className="ol-cm-toolbar-header mx-2">
{t('toolbar_insert_math_lowercase')}
</DropdownHeader>
<OLListGroupItem
aria-label={t('toolbar_generate_math')}
disabled={showAiFeaturesDisabled}
disabledReason={t('ai_features_unavailable_on_this_project')}
onClick={() => {
writefullInstance?.openEquationGenerator()
}}
@@ -48,7 +54,9 @@ export const MathDropdown = memo(function MathDropdown() {
src={sparkleWhite}
aria-hidden="true"
/>
<span>{t('generate_from_text_or_image')}</span>
<span className={showAiFeaturesDisabled ? 'opacity-50' : ''}>
{t('generate_from_text_or_image')}
</span>
</OLListGroupItem>
</>
)}
@@ -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<any>(null)
const view = useCodeMirrorViewContext()
@@ -46,6 +48,8 @@ export const TableDropdown = memo(function TableDropdown() {
</DropdownHeader>
<OLListGroupItem
aria-label={t('toolbar_generate_table')}
disabled={showAiFeaturesDisabled}
disabledReason={t('ai_features_unavailable_on_this_project')}
onClick={() => {
writefullInstance?.openTableGenerator()
}}
@@ -62,7 +66,9 @@ export const TableDropdown = memo(function TableDropdown() {
src={sparkleWhite}
aria-hidden="true"
/>
<span>{t('generate_from_text_or_image')}</span>
<span className={showAiFeaturesDisabled ? 'opacity-50' : ''}>
{t('generate_from_text_or_image')}
</span>
</OLListGroupItem>
<div className="ol-cm-toolbar-dropdown-divider mx-2 my-0" />
<OLListGroupItem
@@ -43,6 +43,8 @@ export const ToolbarItems: FC<{
const isActive = withinFormattingCommand(state)
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showAiFeaturesDisabled = getMeta('ol-showAiFeaturesDisabled')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
return (
@@ -156,7 +158,11 @@ export const ToolbarItems: FC<{
icon="book_5"
/>
<InsertFigureDropdown />
{writefullInstance ? <TableDropdown /> : <LegacyTableDropdown />}
{writefullInstance || showAiFeaturesDisabled ? (
<TableDropdown />
) : (
<LegacyTableDropdown />
)}
</div>
)}
{showGroup('group-list') && (
@@ -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<HTMLElement, ListGroupItemProps>(
function OLListGroupItem(props, ref) {
type OLListGroupItemProps = ListGroupItemProps & {
disabledReason?: string
}
const OLListGroupItem = forwardRef<HTMLElement, OLListGroupItemProps>(
function OLListGroupItem({ disabledReason, ...props }, ref) {
const as = props.as ?? 'button'
const tooltipId = useId()
return (
const showDisabledTooltip = Boolean(props.disabled && disabledReason)
const item = (
<ListGroupItem
{...props}
ref={ref}
@@ -13,6 +21,20 @@ const OLListGroupItem = forwardRef<HTMLElement, ListGroupItemProps>(
type={as === 'button' ? 'button' : undefined}
/>
)
if (showDisabledTooltip) {
return (
<OLTooltip
id={tooltipId}
description={disabledReason}
overlayProps={{ placement: 'right' }}
>
<span className="d-block">{item}</span>
</OLTooltip>
)
}
return item
}
)
+1
View File
@@ -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
+2
View File
@@ -157,9 +157,11 @@
"ai_assist_unavailable": "AI features are now included in every paid plan. See the <0>pricing page</0> 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",
@@ -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 () {