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

[web] Add option to disable chat for subscription

GitOrigin-RevId: 0052d060c74c39400496f7f9f54c820398d60012
This commit is contained in:
Miguel Serrano 2025-01-30 14:24:53 +01:00 committed by Copybot
parent ba60f885a4
commit 8ff8e7a4bf
14 changed files with 138 additions and 5 deletions

View file

@ -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'))
}

View file

@ -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,

View file

@ -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'
),

View file

@ -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) {

View file

@ -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()
},

View file

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

View file

@ -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 }
)

View file

@ -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
)

View file

@ -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[]

View file

@ -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;

View file

@ -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,
}

View file

@ -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)
})

View file

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

View file

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