Parcourir la source

Merge pull request #23006 from overleaf/msm-chat-capabilities-poc-2

[web] Add option to disable chat for subscription

GitOrigin-RevId: 0052d060c74c39400496f7f9f54c820398d60012
Miguel Serrano il y a 1 an
Parent
commit
8ff8e7a4bf

+ 4 - 0
services/web/app/src/Features/Authorization/PermissionsController.js

@@ -8,6 +8,7 @@ const {
 const { assertUserPermissions } = require('./PermissionsManager').promises
 const Modules = require('../../infrastructure/Modules')
 const { expressify } = require('@overleaf/promise-utils')
+const Features = require('../../infrastructure/Features')
 
 /**
  * Function that returns middleware to add an `assertPermission` function to the request object to check if the user has a specific capability.
@@ -80,6 +81,9 @@ function requirePermission(...requiredCapabilities) {
     throw new Error('invalid required capabilities')
   }
   const doRequest = async function (req, res, next) {
+    if (!Features.hasFeature('saas')) {
+      return next()
+    }
     if (!req.user) {
       return next(new Error('no user'))
     }

+ 20 - 0
services/web/app/src/Features/Authorization/PermissionsManager.js

@@ -63,6 +63,25 @@ function ensureCapabilityExists(capability) {
   }
 }
 
+/**
+ * Validates an group policy object
+ *
+ * @param {Object} policies - An object containing policy names and booleans
+ *   as key-value entries.
+ * @throws {Error} if the `policies` object contains a policy that is not
+ *   registered, or the policy value is not a boolean
+ */
+function validatePolicies(policies) {
+  for (const [policy, value] of Object.entries(policies)) {
+    if (!POLICY_TO_CAPABILITY_MAP.has(policy)) {
+      throw new Error(`unknown policy: ${policy}`)
+    }
+    if (typeof value !== 'boolean') {
+      throw new Error(`policy value must be a boolean: ${policy} = ${value}`)
+    }
+  }
+}
+
 /**
  * Registers a new capability with the given name and options.
  *
@@ -439,6 +458,7 @@ async function checkUserListPermissions(userList, capabilities) {
 }
 
 module.exports = {
+  validatePolicies,
   registerCapability,
   registerPolicy,
   registerAllowedProperty,

+ 2 - 1
services/web/app/src/Features/Project/ProjectController.js

@@ -801,7 +801,8 @@ const _ProjectController = {
           isTokenMember,
           isInvitedMember
         ),
-        chatEnabled: Features.hasFeature('chat'),
+        chatEnabled:
+          Features.hasFeature('chat') && req.capabilitySet.has('chat'),
         projectHistoryBlobsEnabled: Features.hasFeature(
           'project-history-blobs'
         ),

+ 2 - 1
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -149,7 +149,8 @@ async function projectListPage(req, res, next) {
       // TODO use helper function
       if (!user.enrollment?.managedBy) {
         groupSubscriptionsPendingEnrollment = subscriptions.filter(
-          subscription => subscription.groupPlan && subscription.groupPolicy
+          subscription =>
+            subscription.groupPlan && subscription.managedUsersEnabled
         )
       }
     } catch (error) {

+ 3 - 1
services/web/app/src/Features/Subscription/SubscriptionLocator.js

@@ -31,10 +31,12 @@ const SubscriptionLocator = {
       .exec()
   },
 
-  async getMemberSubscriptions(userOrId) {
+  async getMemberSubscriptions(userOrId, populate = []) {
     const userId = SubscriptionLocator._getUserId(userOrId)
+    // eslint-disable-next-line no-restricted-syntax
     return await Subscription.find({ member_ids: userId })
       .populate('admin_id', 'email')
+      .populate(populate)
       .exec()
   },
 

+ 1 - 1
services/web/app/src/Features/Subscription/TeamInvitesController.mjs

@@ -96,7 +96,7 @@ async function viewInvite(req, res, next) {
       personalSubscription.recurlySubscription_id &&
       personalSubscription.recurlySubscription_id !== ''
 
-    if (subscription?.groupPolicy) {
+    if (subscription?.managedUsersEnabled) {
       if (!subscription.populated('groupPolicy')) {
         // eslint-disable-next-line no-restricted-syntax
         await subscription.populate('groupPolicy')

+ 3 - 0
services/web/app/src/models/GroupPolicy.js

@@ -24,6 +24,9 @@ const GroupPolicySchema = new Schema(
 
     // User can't use any of our AI features, such as the compile-assistant
     userCannotUseAIFeatures: Boolean,
+
+    // User can't use the chat feature
+    userCannotUseChat: Boolean,
   },
   { minimize: false }
 )

+ 2 - 0
services/web/app/src/router.mjs

@@ -1059,12 +1059,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
       '/project/:project_id/messages',
       AuthorizationMiddleware.blockRestrictedUserFromProject,
       AuthorizationMiddleware.ensureUserCanReadProject,
+      PermissionsController.requirePermission('chat'),
       ChatController.getMessages
     )
     webRouter.post(
       '/project/:project_id/messages',
       AuthorizationMiddleware.blockRestrictedUserFromProject,
       AuthorizationMiddleware.ensureUserCanReadProject,
+      PermissionsController.requirePermission('chat'),
       RateLimiterMiddleware.rateLimit(rateLimiters.sendChatMessage),
       ChatController.sendMessage
     )

+ 2 - 0
services/web/frontend/js/utils/meta.ts

@@ -27,6 +27,7 @@ import { GetProjectsResponseBody } from '../../../types/project/dashboard/api'
 import { Tag } from '../../../app/src/Features/Tags/types'
 import { Institution } from '../../../types/institution'
 import {
+  GroupPolicy,
   ManagedGroupSubscription,
   MemberGroupSubscription,
 } from '../../../types/subscription/dashboard/subscription'
@@ -99,6 +100,7 @@ export interface Meta {
   'ol-groupId': string
   'ol-groupName': string
   'ol-groupPlans': GroupPlans
+  'ol-groupPolicy': GroupPolicy
   'ol-groupSSOActive': boolean
   'ol-groupSSOTestResult': GroupSSOTestResult
   'ol-groupSettingsEnabledFor': string[]

+ 1 - 1
services/web/frontend/stylesheets/modules/group-settings.less

@@ -112,7 +112,7 @@ h3.group-settings-title {
   }
 }
 
-.below-managed-users {
+.below-settings-section {
   border-top: 1px solid @gray-lighter;
   padding-top: 25px;
   margin-top: 25px;

+ 17 - 0
services/web/migrations/20250121114712_add_chat_policy_to_group_policy.mjs

@@ -0,0 +1,17 @@
+const tags = ['saas']
+
+const migrate = async client => {
+  const { db } = client
+  await db.grouppolicies.updateMany({}, { $set: { userCannotUseChat: false } })
+}
+
+const rollback = async client => {
+  const { db } = client
+  await db.grouppolicies.updateMany({}, { $unset: { userCannotUseChat: '' } })
+}
+
+export default {
+  tags,
+  migrate,
+  rollback,
+}

+ 5 - 0
services/web/test/acceptance/src/AuthorizationTests.mjs

@@ -678,6 +678,11 @@ describe('Authorization', function () {
     })
 
     it('should allow an anonymous user chat messages access', function (done) {
+      // chat access for anonymous users is a CE/SP-only feature, although currently broken
+      // https://github.com/overleaf/internal/issues/10944
+      if (Features.hasFeature('saas')) {
+        this.skip()
+      }
       expectChatAccess(this.anon, this.projectId, done)
     })
 

+ 42 - 0
services/web/test/unit/src/Authorization/PermissionsManagerTests.js

@@ -64,6 +64,48 @@ describe('PermissionsManager', function () {
     ]
   })
 
+  describe('validatePolicies', function () {
+    it('accepts empty object', function () {
+      expect(() => this.PermissionsManager.validatePolicies({})).not.to.throw
+    })
+
+    it('accepts object with registered policies', function () {
+      expect(() =>
+        this.PermissionsManager.validatePolicies({
+          openPolicy: true,
+          restrictivePolicy: false,
+        })
+      ).not.to.throw
+    })
+
+    it('accepts object with policies containing non-boolean values', function () {
+      expect(() =>
+        this.PermissionsManager.validatePolicies({
+          openPolicy: 1,
+        })
+      ).to.throw('policy value must be a boolean: openPolicy = 1')
+      expect(() =>
+        this.PermissionsManager.validatePolicies({
+          openPolicy: undefined,
+        })
+      ).to.throw('policy value must be a boolean: openPolicy = undefined')
+      expect(() =>
+        this.PermissionsManager.validatePolicies({
+          openPolicy: null,
+        })
+      ).to.throw('policy value must be a boolean: openPolicy = null')
+    })
+
+    it('throws error on object with policies that are not registered', function () {
+      expect(() =>
+        this.PermissionsManager.validatePolicies({
+          openPolicy: true,
+          unregisteredPolicy: false,
+        })
+      ).to.throw('unknown policy: unregisteredPolicy')
+    })
+  })
+
   describe('hasPermission', function () {
     describe('when no policies apply to the user', function () {
       it('should return true if default permission is true', function () {

+ 34 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -1132,6 +1132,40 @@ describe('ProjectController', function () {
         })
       })
     })
+
+    describe('chatEnabled flag', function () {
+      it('should be set to false when the feature is disabled', function (done) {
+        this.Features.hasFeature = sinon.stub().withArgs('chat').returns(false)
+
+        this.res.render = (pageName, opts) => {
+          expect(opts.chatEnabled).to.be.false
+          done()
+        }
+        this.ProjectController.loadEditor(this.req, this.res)
+      })
+
+      it('should be set to false when the feature is enabled but the capability is not available', function (done) {
+        this.Features.hasFeature = sinon.stub().withArgs('chat').returns(false)
+        this.req.capabilitySet = new Set()
+
+        this.res.render = (pageName, opts) => {
+          expect(opts.chatEnabled).to.be.false
+          done()
+        }
+        this.ProjectController.loadEditor(this.req, this.res)
+      })
+
+      it('should be set to true when the feature is enabled and the capability is available', function (done) {
+        this.Features.hasFeature = sinon.stub().withArgs('chat').returns(true)
+        this.req.capabilitySet = new Set(['chat'])
+
+        this.res.render = (pageName, opts) => {
+          expect(opts.chatEnabled).to.be.true
+          done()
+        }
+        this.ProjectController.loadEditor(this.req, this.res)
+      })
+    })
   })
 
   describe('userProjectsJson', function () {