Merge pull request #25151 from overleaf/dk-use-user-features

UserFeaturesContext with cross-tab syncing via BroadcastChannel

GitOrigin-RevId: 4262719f5018f5717211851ce28b3255af65461a
This commit is contained in:
Jimmy Domagala-Tang
2025-05-12 08:15:33 -04:00
committed by Copybot
parent 2f3166aa54
commit 82e5b2c5d7
16 changed files with 336 additions and 105 deletions
@@ -15,7 +15,6 @@ const metrics = require('@overleaf/metrics')
const { User } = require('../../models/User')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
const LimitationsManager = require('../Subscription/LimitationsManager')
const FeaturesHelper = require('../Subscription/FeaturesHelper')
const Settings = require('@overleaf/settings')
const AuthorizationManager = require('../Authorization/AuthorizationManager')
const InactiveProjectManager = require('../InactiveData/InactiveProjectManager')
@@ -753,16 +752,7 @@ const _ProjectController = {
let fullFeatureSet = user?.features
if (!anonymous) {
// generate users feature set including features added, or overriden via modules
const moduleFeatures =
(await Modules.promises.hooks.fire(
'getModuleProvidedFeatures',
userId
)) || []
fullFeatureSet = FeaturesHelper.computeFeatureSet([
user.features,
...moduleFeatures,
])
fullFeatureSet = await UserGetter.promises.getUserFeatures(userId)
}
const isPaywallChangeCompileTimeoutEnabled =
@@ -25,6 +25,7 @@ const RecurlyClient = require('./RecurlyClient')
const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities')
const PlansLocator = require('./PlansLocator')
const PaymentProviderEntities = require('./PaymentProviderEntities')
const { User } = require('../../models/User')
/**
* @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
@@ -186,7 +187,9 @@ async function userSubscriptionPage(req, res) {
async function successfulSubscription(req, res) {
const user = SessionManager.getSessionUser(req.session)
if (!user) {
throw new Error('User is not logged in')
}
const { personalSubscription } =
await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
user,
@@ -198,11 +201,23 @@ async function successfulSubscription(req, res) {
if (!personalSubscription) {
res.redirect('/user/subscription/plans')
} else {
const userInDb = await User.findById(user._id, {
_id: 1,
features: 1,
})
if (!userInDb) {
throw new Error('User not found')
}
res.render('subscriptions/successful-subscription-react', {
title: 'thank_you',
personalSubscription,
postCheckoutRedirect,
user,
user: {
_id: user._id,
features: userInDb.features,
},
})
}
}
@@ -387,7 +402,6 @@ async function purchaseAddon(req, res, next) {
addOnCode,
quantity
)
return res.sendStatus(200)
} catch (err) {
if (err instanceof DuplicateAddOnError) {
HttpErrorHandler.badRequest(
@@ -406,6 +420,14 @@ async function purchaseAddon(req, res, next) {
return next(err)
}
}
try {
await FeaturesUpdater.promises.refreshFeatures(user._id, 'add-on-purchase')
} catch (err) {
logger.error({ err }, 'Failed to refresh features after add-on purchase')
}
return res.sendStatus(200)
}
async function removeAddon(req, res, next) {
@@ -11,6 +11,8 @@ const Errors = require('../Errors/Errors')
const Features = require('../../infrastructure/Features')
const { User } = require('../../models/User')
const { normalizeQuery, normalizeMultiQuery } = require('../Helpers/Mongo')
const Modules = require('../../infrastructure/Modules')
const FeaturesHelper = require('../Subscription/FeaturesHelper')
function _lastDayToReconfirm(emailData, institutionData) {
const globalReconfirmPeriod = settings.reconfirmNotificationDays
@@ -95,6 +97,21 @@ async function getUserFullEmails(userId) {
)
}
async function getUserFeatures(userId) {
const user = await UserGetter.promises.getUser(userId, {
features: 1,
})
if (!user) {
throw new Error('User not Found')
}
const moduleFeatures =
(await Modules.promises.hooks.fire('getModuleProvidedFeatures', userId)) ||
[]
return FeaturesHelper.computeFeatureSet([user.features, ...moduleFeatures])
}
async function getUserConfirmedEmails(userId) {
const user = await UserGetter.promises.getUser(userId, {
emails: 1,
@@ -136,13 +153,7 @@ const UserGetter = {
}
},
getUserFeatures(userId, callback) {
this.getUser(userId, { features: 1 }, (error, user) => {
if (error) return callback(error)
if (!user) return callback(new Errors.NotFoundError('user not found'))
callback(null, user.features)
})
},
getUserFeatures: callbackify(getUserFeatures),
getUserEmail(userId, callback) {
this.getUser(userId, { email: 1 }, (error, user) =>
@@ -335,9 +346,10 @@ const decorateFullEmails = (
}
UserGetter.promises = promisifyAll(UserGetter, {
without: ['getSsoUsersAtInstitution', 'getUserFullEmails'],
without: ['getSsoUsersAtInstitution', 'getUserFullEmails', 'getUserFeatures'],
})
UserGetter.promises.getUserFullEmails = getUserFullEmails
UserGetter.promises.getSsoUsersAtInstitution = getSsoUsersAtInstitution
UserGetter.promises.getUserFeatures = getUserFeatures
module.exports = UserGetter
@@ -1,6 +1,7 @@
const UserGetter = require('./UserGetter')
const SessionManager = require('../Authentication/SessionManager')
const { ObjectId } = require('mongodb-legacy')
const { expressify } = require('@overleaf/promise-utils')
function getLoggedInUsersPersonalInfo(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -78,9 +79,19 @@ function formatPersonalInfo(user) {
return formattedUser
}
async function getUserFeatures(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!userId) {
throw new Error('User is not logged in')
}
const features = await UserGetter.promises.getUserFeatures(userId)
return res.json(features)
}
module.exports = {
getLoggedInUsersPersonalInfo,
getPersonalInfo,
sendFormattedPersonalInfo,
formatPersonalInfo,
getUserFeatures: expressify(getUserFeatures),
}
+5
View File
@@ -484,6 +484,11 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthenticationController.requirePrivateApiAuth(),
UserInfoController.getPersonalInfo
)
webRouter.get(
'/user/features',
AuthenticationController.requireLogin(),
UserInfoController.getUserFeatures
)
webRouter.get(
'/user/reconfirm',
@@ -1 +1,4 @@
module.exports = {}
module.exports = {
addEventListener: () => {},
removeEventListener: () => {},
}
@@ -23,6 +23,7 @@ import { ReferencesProvider } from '@/features/ide-react/context/references-cont
import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { UserProvider } from '@/shared/context/user-context'
import { UserFeaturesProvider } from '@/shared/context/user-features-context'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { IdeRedesignSwitcherProvider } from './ide-redesign-switcher-context'
import { CommandRegistryProvider } from './command-registry-context'
@@ -60,6 +61,7 @@ export const ReactContextRoot: FC<
UserSettingsProvider,
IdeRedesignSwitcherProvider,
CommandRegistryProvider,
UserFeaturesProvider,
...providers,
}
@@ -77,35 +79,37 @@ export const ReactContextRoot: FC<
<Providers.ReferencesProvider>
<Providers.DetachProvider>
<Providers.EditorProvider>
<Providers.PermissionsProvider>
<Providers.RailProvider>
<Providers.LayoutProvider>
<Providers.ProjectSettingsProvider>
<Providers.EditorManagerProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.IdeRedesignSwitcherProvider>
<Providers.CommandRegistryProvider>
{children}
</Providers.CommandRegistryProvider>
</Providers.IdeRedesignSwitcherProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.EditorManagerProvider>
</Providers.ProjectSettingsProvider>
</Providers.LayoutProvider>
</Providers.RailProvider>
</Providers.PermissionsProvider>
<Providers.UserFeaturesProvider>
<Providers.PermissionsProvider>
<Providers.RailProvider>
<Providers.LayoutProvider>
<Providers.ProjectSettingsProvider>
<Providers.EditorManagerProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.IdeRedesignSwitcherProvider>
<Providers.CommandRegistryProvider>
{children}
</Providers.CommandRegistryProvider>
</Providers.IdeRedesignSwitcherProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.EditorManagerProvider>
</Providers.ProjectSettingsProvider>
</Providers.LayoutProvider>
</Providers.RailProvider>
</Providers.PermissionsProvider>
</Providers.UserFeaturesProvider>
</Providers.EditorProvider>
</Providers.DetachProvider>
</Providers.ReferencesProvider>
@@ -1,3 +1,4 @@
import { UserProvider } from '@/shared/context/user-context'
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
import SuccessfulSubscription from './successful-subscription'
@@ -13,7 +14,9 @@ function Root() {
return (
<SplitTestProvider>
<SubscriptionDashboardProvider>
<SuccessfulSubscription />
<UserProvider>
<SuccessfulSubscription />
</UserProvider>
</SubscriptionDashboardProvider>
</SplitTestProvider>
)
@@ -13,6 +13,7 @@ import {
isStandaloneAiPlanCode,
} from '../../data/add-on-codes'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
function SuccessfulSubscription() {
const { t } = useTranslation()
@@ -20,6 +21,7 @@ function SuccessfulSubscription() {
useSubscriptionDashboardContext()
const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect')
const { appName, adminEmail } = getMeta('ol-ExposedSettings')
useBroadcastUser()
if (!subscription || !('payment' in subscription)) return null
@@ -1,6 +1,7 @@
export interface WritefullEvents {
'writefull-login-complete': {
method: 'email-password' | 'login-with-overleaf'
isPremium: boolean
}
'writefull-received-suggestions': { numberOfSuggestions: number }
'writefull-register-as-auto-account': { email: string }
@@ -0,0 +1,69 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import { User } from '../../../../types/user'
import { useUserContext } from './user-context'
import { useReceiveUser } from '../hooks/user-channel/use-receive-user'
import { getJSON } from '@/infrastructure/fetch-json'
import { useEditorContext } from './editor-context'
export const UserFeaturesContext = createContext<User['features']>(undefined)
export const UserFeaturesProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const user = useUserContext()
const { writefullInstance } = useEditorContext()
const [features, setFeatures] = useState(user.features)
useReceiveUser(
useCallback(data => {
if (data?.features) {
setFeatures(data.features)
}
}, [])
)
useEffect(() => {
const listener = async ({ isPremium }: { isPremium: boolean }) => {
if (features?.aiErrorAssistant === isPremium) {
// the user is premium on writefull and has the AI assist, no need to refresh the features
return
}
const newFeatures = await getJSON('/user/features')
setFeatures(newFeatures)
}
writefullInstance?.addEventListener('writefull-login-complete', listener)
return () => {
writefullInstance?.removeEventListener(
'writefull-login-complete',
listener
)
}
}, [features?.aiErrorAssistant, writefullInstance])
return (
<UserFeaturesContext.Provider value={features}>
{children}
</UserFeaturesContext.Provider>
)
}
export function useUserFeaturesContext() {
const context = useContext(UserFeaturesContext)
if (!context) {
throw new Error(
'useUserFeaturesContext is only available inside UserFeaturesContext, or `ol-user` meta is not defined'
)
}
return context
}
@@ -4,21 +4,28 @@ import SuccessfulSubscription from '../../../../../../frontend/js/features/subsc
import { renderWithSubscriptionDashContext } from '../../helpers/render-with-subscription-dash-context'
import { annualActiveSubscription } from '../../fixtures/subscriptions'
import { ExposedSettings } from '../../../../../../types/exposed-settings'
import { UserProvider } from '@/shared/context/user-context'
describe('successful subscription page', function () {
it('renders the invoices link', function () {
const adminEmail = 'foo@example.com'
renderWithSubscriptionDashContext(<SuccessfulSubscription />, {
metaTags: [
{
name: 'ol-ExposedSettings',
value: {
adminEmail,
} as ExposedSettings,
},
{ name: 'ol-subscription', value: annualActiveSubscription },
],
})
renderWithSubscriptionDashContext(
<UserProvider>
<SuccessfulSubscription />
</UserProvider>,
{
metaTags: [
{
name: 'ol-ExposedSettings',
value: {
adminEmail,
} as ExposedSettings,
},
{ name: 'ol-subscription', value: annualActiveSubscription },
],
}
)
screen.getByRole('heading', { name: /thanks for subscribing/i })
const alert = screen.getByRole('alert')
@@ -1121,44 +1121,6 @@ describe('ProjectController', function () {
this.ProjectController.loadEditor(this.req, this.res)
})
})
describe('when fetching the users featureSet', function () {
beforeEach(function () {
this.Modules.promises.hooks.fire = sinon.stub().resolves()
this.user.features = {}
})
it('should take into account features overrides from modules', function (done) {
// this case occurs when the user has bought the ai bundle on WF, which should include our error assistant
const bundleFeatures = { aiErrorAssistant: true }
this.user.features = { aiErrorAssistant: false }
this.Modules.promises.hooks.fire = sinon
.stub()
.resolves([bundleFeatures])
this.res.render = (pageName, opts) => {
expect(opts.user.features).to.deep.equal(bundleFeatures)
this.Modules.promises.hooks.fire.should.have.been.calledWith(
'getModuleProvidedFeatures',
this.user._id
)
done()
}
this.ProjectController.loadEditor(this.req, this.res)
})
it('should handle modules not returning any features', function (done) {
this.Modules.promises.hooks.fire = sinon.stub().resolves([])
this.res.render = (pageName, opts) => {
expect(opts.user.features).to.deep.equal({})
this.Modules.promises.hooks.fire.should.have.been.calledWith(
'getModuleProvidedFeatures',
this.user._id
)
done()
}
this.ProjectController.loadEditor(this.req, this.res)
})
})
})
describe('userProjectsJson', function () {
@@ -158,6 +158,7 @@ describe('SubscriptionController', function () {
'./FeaturesUpdater': (this.FeaturesUpdater = {
promises: {
hasFeaturesViaWritefull: sinon.stub().resolves(false),
refreshFeatures: sinon.stub().resolves({ features: {} }),
},
}),
'./GroupPlansData': (this.GroupPlansData = {}),
@@ -186,6 +187,11 @@ describe('SubscriptionController', function () {
'../../util/currency': (this.currency = {
formatCurrency: sinon.stub(),
}),
'../../models/User': {
User: {
findById: sinon.stub().resolves(this.user),
},
},
},
})
@@ -221,7 +227,10 @@ describe('SubscriptionController', function () {
title: 'thank_you',
personalSubscription: 'foo',
postCheckoutRedirect: undefined,
user: this.user,
user: {
_id: this.user._id,
features: this.user.features,
},
})
done()
}
@@ -19,7 +19,7 @@ describe('UserGetter', function () {
beforeEach(function () {
const confirmedAt = new Date()
this.fakeUser = {
_id: '12390i',
_id: new ObjectId(),
email: 'email2@foo.bar',
emails: [
{
@@ -45,6 +45,10 @@ describe('UserGetter', function () {
}
this.getUserAffiliations = sinon.stub().resolves([])
this.Modules = {
promises: { hooks: { fire: sinon.stub().resolves() } },
}
this.UserGetter = SandboxedModule.require(modulePath, {
requires: {
'../Helpers/Mongo': { normalizeQuery, normalizeMultiQuery },
@@ -63,6 +67,7 @@ describe('UserGetter', function () {
'../../models/User': {
User: (this.User = {}),
},
'../../infrastructure/Modules': this.Modules,
},
})
})
@@ -1259,4 +1264,56 @@ describe('UserGetter', function () {
})
})
})
describe('getUserFeatures', function () {
beforeEach(function () {
this.Modules.promises.hooks.fire = sinon.stub().resolves()
this.fakeUser.features = {}
})
it('should return user features', function (done) {
this.fakeUser.features = { feature1: true, feature2: false }
this.UserGetter.getUserFeatures(new ObjectId(), (error, features) => {
expect(error).to.not.exist
expect(features).to.deep.equal(this.fakeUser.features)
done()
})
})
it('should return user features when using promises', async function () {
this.fakeUser.features = { feature1: true, feature2: false }
const features = await this.UserGetter.promises.getUserFeatures(
this.fakeUser._id
)
expect(features).to.deep.equal(this.fakeUser.features)
})
it('should take into account features overrides from modules', async function () {
// this case occurs when the user has bought the ai bundle on WF, which should include our error assistant
const bundleFeatures = { aiErrorAssistant: true }
this.fakeUser.features = { aiErrorAssistant: false }
this.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures])
const features = await this.UserGetter.promises.getUserFeatures(
this.fakeUser._id
)
expect(features).to.deep.equal(bundleFeatures)
this.Modules.promises.hooks.fire.should.have.been.calledWith(
'getModuleProvidedFeatures',
this.fakeUser._id
)
})
it('should handle modules not returning any features', async function () {
this.Modules.promises.hooks.fire = sinon.stub().resolves([])
this.fakeUser.features = { test: true }
const features = await this.UserGetter.promises.getUserFeatures(
this.fakeUser._id
)
expect(features).to.deep.equal({ test: true })
this.Modules.promises.hooks.fire.should.have.been.calledWith(
'getModuleProvidedFeatures',
this.fakeUser._id
)
})
})
})
@@ -10,7 +10,11 @@ describe('UserInfoController', function () {
beforeEach(function () {
this.UserDeleter = { deleteUser: sinon.stub().callsArgWith(1) }
this.UserUpdater = { updatePersonalInfo: sinon.stub() }
this.UserGetter = {}
this.UserGetter = {
promises: {
getUserFeatures: sinon.stub(),
},
}
this.UserInfoController = SandboxedModule.require(modulePath, {
requires: {
@@ -148,4 +152,74 @@ describe('UserInfoController', function () {
})
})
})
describe('getUserFeatures', function () {
describe('when the user is logged in', function () {
beforeEach(async function () {
this.user_id = new ObjectId().toString()
this.features = {
collaborators: 10,
trackChanges: true,
references: true,
}
this.SessionManager.getLoggedInUserId.returns(this.user_id)
this.UserGetter.promises.getUserFeatures.resolves(this.features)
await this.UserInfoController.getUserFeatures(
this.req,
this.res,
this.next
)
})
it('should fetch the user features', function () {
expect(this.UserGetter.promises.getUserFeatures.callCount).to.equal(1)
expect(
this.UserGetter.promises.getUserFeatures.calledWith(this.user_id)
).to.equal(true)
})
it('should return the features as JSON', function () {
expect(this.res.json.callCount).to.equal(1)
expect(this.res.json.calledWith(this.features)).to.equal(true)
})
})
describe('when the user is not logged in', function () {
beforeEach(async function () {
this.SessionManager.getLoggedInUserId.returns(null)
await this.UserInfoController.getUserFeatures(
this.req,
this.res,
this.next
)
})
it('should call next with an error', function () {
expect(this.next.callCount).to.equal(1)
expect(this.next.firstCall.args[0]).to.be.an.instanceof(Error)
expect(this.next.firstCall.args[0].message).to.equal(
'User is not logged in'
)
})
})
describe('when fetching features fails', function () {
beforeEach(async function () {
this.user_id = new ObjectId().toString()
this.error = new Error('something went wrong')
this.SessionManager.getLoggedInUserId.returns(this.user_id)
this.UserGetter.promises.getUserFeatures.rejects(this.error)
await this.UserInfoController.getUserFeatures(
this.req,
this.res,
this.next
)
})
it('should call next with the error', function () {
expect(this.next.callCount).to.equal(1)
expect(this.next.firstCall.args[0]).to.equal(this.error)
})
})
})
})