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 page0> 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 () {