Jelajahi Sumber

[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
Miguel Serrano 2 bulan lalu
induk
melakukan
c981330cc1

+ 25 - 16
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 ||

+ 1 - 0
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)

+ 2 - 0
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": "",

+ 10 - 2
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 (
     <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>
         </>
       )}

+ 7 - 1
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<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

+ 7 - 1
services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx

@@ -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') && (

+ 26 - 4
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<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()
+
+    const showDisabledTooltip = Boolean(props.disabled && disabledReason)
 
-    return (
+    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 - 0
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

+ 2 - 0
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</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",

+ 237 - 0
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 () {