mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 11:03:36 -05:00
Merge pull request #11694 from overleaf/ab-managed-institutions-react
[web] Migrate managed institutions to React dash GitOrigin-RevId: 535dfafe42d88189bb20e7bb3beac233221ee6ba
This commit is contained in:
parent
ba2d5db50c
commit
c524fee690
10 changed files with 620 additions and 278 deletions
|
@ -2,6 +2,8 @@ const async = require('async')
|
|||
const { callbackify, promisify } = require('util')
|
||||
const { ObjectId } = require('mongodb')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
const fetch = require('node-fetch')
|
||||
const {
|
||||
getInstitutionAffiliations,
|
||||
getConfirmedInstitutionAffiliations,
|
||||
|
@ -341,11 +343,32 @@ const notifyUser = (
|
|||
callback
|
||||
)
|
||||
|
||||
async function fetchV1Data(institution) {
|
||||
const url = `${Settings.apis.v1.url}/universities/list/${institution.v1Id}`
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(Settings.apis.v1.timeout),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
institution.name = data?.name
|
||||
institution.countryCode = data?.country_code
|
||||
institution.departments = data?.departments
|
||||
institution.portalSlug = data?.portal_slug
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ model: 'Institution', v1Id: institution.v1Id, error },
|
||||
'[fetchV1DataError]'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
InstitutionsManager.promises = {
|
||||
checkInstitutionUsers,
|
||||
clearInstitutionNotifications: promisify(
|
||||
InstitutionsManager.clearInstitutionNotifications
|
||||
),
|
||||
fetchV1Data,
|
||||
}
|
||||
|
||||
module.exports = InstitutionsManager
|
||||
|
|
|
@ -6,12 +6,13 @@ const SubscriptionLocator = require('./SubscriptionLocator')
|
|||
const SubscriptionUpdater = require('./SubscriptionUpdater')
|
||||
const V1SubscriptionManager = require('./V1SubscriptionManager')
|
||||
const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
|
||||
const InstitutionsManager = require('../Institutions/InstitutionsManager')
|
||||
const PublishersGetter = require('../Publishers/PublishersGetter')
|
||||
const sanitizeHtml = require('sanitize-html')
|
||||
const _ = require('underscore')
|
||||
const async = require('async')
|
||||
const SubscriptionHelper = require('./SubscriptionHelper')
|
||||
const { callbackify, promisify } = require('../../util/promises')
|
||||
const { callbackify } = require('../../util/promises')
|
||||
const {
|
||||
InvalidError,
|
||||
NotFoundError,
|
||||
|
@ -65,291 +66,283 @@ async function getRedirectToHostedPage(userId, pageType) {
|
|||
].join('')
|
||||
}
|
||||
|
||||
function buildUsersSubscriptionViewModel(user, callback) {
|
||||
async.auto(
|
||||
{
|
||||
personalSubscription(cb) {
|
||||
SubscriptionLocator.getUsersSubscription(user, cb)
|
||||
},
|
||||
recurlySubscription: [
|
||||
'personalSubscription',
|
||||
({ personalSubscription }, cb) => {
|
||||
if (
|
||||
personalSubscription == null ||
|
||||
personalSubscription.recurlySubscription_id == null ||
|
||||
personalSubscription.recurlySubscription_id === ''
|
||||
) {
|
||||
return cb(null, null)
|
||||
}
|
||||
RecurlyWrapper.getSubscription(
|
||||
personalSubscription.recurlySubscription_id,
|
||||
{ includeAccount: true },
|
||||
cb
|
||||
)
|
||||
},
|
||||
],
|
||||
recurlyCoupons: [
|
||||
'recurlySubscription',
|
||||
({ recurlySubscription }, cb) => {
|
||||
if (!recurlySubscription) {
|
||||
return cb(null, null)
|
||||
}
|
||||
const accountId = recurlySubscription.account.account_code
|
||||
RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
|
||||
},
|
||||
],
|
||||
plan: [
|
||||
'personalSubscription',
|
||||
({ personalSubscription }, cb) => {
|
||||
if (personalSubscription == null) {
|
||||
return cb()
|
||||
}
|
||||
const plan = PlansLocator.findLocalPlanInSettings(
|
||||
personalSubscription.planCode
|
||||
)
|
||||
if (plan == null) {
|
||||
return cb(
|
||||
new Error(
|
||||
`No plan found for planCode '${personalSubscription.planCode}'`
|
||||
)
|
||||
)
|
||||
}
|
||||
cb(null, plan)
|
||||
},
|
||||
],
|
||||
memberGroupSubscriptions(cb) {
|
||||
SubscriptionLocator.getMemberSubscriptions(user, cb)
|
||||
},
|
||||
managedGroupSubscriptions(cb) {
|
||||
SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
|
||||
},
|
||||
currentInstitutionsWithLicence(cb) {
|
||||
InstitutionsGetter.getCurrentInstitutionsWithLicence(
|
||||
user._id,
|
||||
(error, institutions) => {
|
||||
if (error instanceof V1ConnectionError) {
|
||||
return cb(null, false)
|
||||
}
|
||||
cb(null, institutions)
|
||||
}
|
||||
)
|
||||
},
|
||||
managedInstitutions(cb) {
|
||||
InstitutionsGetter.getManagedInstitutions(user._id, cb)
|
||||
},
|
||||
managedPublishers(cb) {
|
||||
PublishersGetter.getManagedPublishers(user._id, cb)
|
||||
},
|
||||
v1SubscriptionStatus(cb) {
|
||||
V1SubscriptionManager.getSubscriptionStatusFromV1(
|
||||
user._id,
|
||||
(error, status, v1Id) => {
|
||||
if (error) {
|
||||
return cb(error)
|
||||
}
|
||||
cb(null, status)
|
||||
}
|
||||
)
|
||||
},
|
||||
async function buildUsersSubscriptionViewModel(user) {
|
||||
let {
|
||||
personalSubscription,
|
||||
memberGroupSubscriptions,
|
||||
managedGroupSubscriptions,
|
||||
currentInstitutionsWithLicence,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
v1SubscriptionStatus,
|
||||
recurlySubscription,
|
||||
recurlyCoupons,
|
||||
plan,
|
||||
} = await async.auto({
|
||||
personalSubscription(cb) {
|
||||
SubscriptionLocator.getUsersSubscription(user, cb)
|
||||
},
|
||||
(err, results) => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
let {
|
||||
personalSubscription,
|
||||
memberGroupSubscriptions,
|
||||
managedGroupSubscriptions,
|
||||
currentInstitutionsWithLicence,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
v1SubscriptionStatus,
|
||||
recurlySubscription,
|
||||
recurlyCoupons,
|
||||
plan,
|
||||
} = results
|
||||
|
||||
if (memberGroupSubscriptions == null) {
|
||||
memberGroupSubscriptions = []
|
||||
}
|
||||
if (managedGroupSubscriptions == null) {
|
||||
managedGroupSubscriptions = []
|
||||
}
|
||||
if (managedInstitutions == null) {
|
||||
managedInstitutions = []
|
||||
}
|
||||
if (v1SubscriptionStatus == null) {
|
||||
v1SubscriptionStatus = {}
|
||||
}
|
||||
if (recurlyCoupons == null) {
|
||||
recurlyCoupons = []
|
||||
}
|
||||
|
||||
personalSubscription = serializeMongooseObject(personalSubscription)
|
||||
memberGroupSubscriptions = memberGroupSubscriptions.map(
|
||||
serializeMongooseObject
|
||||
)
|
||||
managedGroupSubscriptions = managedGroupSubscriptions.map(
|
||||
serializeMongooseObject
|
||||
)
|
||||
|
||||
if (plan != null) {
|
||||
personalSubscription.plan = plan
|
||||
}
|
||||
|
||||
// Subscription DB object contains a recurly property, used to cache trial info
|
||||
// on the project-list. However, this can cause the wrong template to render,
|
||||
// if we do not have any subscription data from Recurly (recurlySubscription)
|
||||
// TODO: Delete this workaround once recurly cache property name migration rolled out.
|
||||
if (personalSubscription) {
|
||||
delete personalSubscription.recurly
|
||||
}
|
||||
|
||||
if (personalSubscription && recurlySubscription) {
|
||||
const tax = recurlySubscription.tax_in_cents || 0
|
||||
// Some plans allow adding more seats than the base plan provides.
|
||||
// This is recorded as a subscription add on.
|
||||
// Note: tax_in_cents already includes the tax for any addon.
|
||||
let addOnPrice = 0
|
||||
let additionalLicenses = 0
|
||||
recurlySubscription: [
|
||||
'personalSubscription',
|
||||
({ personalSubscription }, cb) => {
|
||||
if (
|
||||
plan.membersLimitAddOn &&
|
||||
Array.isArray(recurlySubscription.subscription_add_ons)
|
||||
personalSubscription == null ||
|
||||
personalSubscription.recurlySubscription_id == null ||
|
||||
personalSubscription.recurlySubscription_id === ''
|
||||
) {
|
||||
recurlySubscription.subscription_add_ons.forEach(addOn => {
|
||||
if (addOn.add_on_code === plan.membersLimitAddOn) {
|
||||
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
|
||||
additionalLicenses += addOn.quantity
|
||||
}
|
||||
})
|
||||
return cb(null, null)
|
||||
}
|
||||
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
|
||||
personalSubscription.recurly = {
|
||||
tax,
|
||||
taxRate: recurlySubscription.tax_rate
|
||||
? parseFloat(recurlySubscription.tax_rate._)
|
||||
: 0,
|
||||
billingDetailsLink: buildHostedLink('billing-details'),
|
||||
accountManagementLink: buildHostedLink('account-management'),
|
||||
additionalLicenses,
|
||||
totalLicenses,
|
||||
nextPaymentDueAt: SubscriptionFormatters.formatDate(
|
||||
recurlySubscription.current_period_ends_at
|
||||
),
|
||||
currency: recurlySubscription.currency,
|
||||
state: recurlySubscription.state,
|
||||
trialEndsAtFormatted: SubscriptionFormatters.formatDate(
|
||||
recurlySubscription.trial_ends_at
|
||||
),
|
||||
trial_ends_at: recurlySubscription.trial_ends_at,
|
||||
activeCoupons: recurlyCoupons,
|
||||
account: recurlySubscription.account,
|
||||
RecurlyWrapper.getSubscription(
|
||||
personalSubscription.recurlySubscription_id,
|
||||
{ includeAccount: true },
|
||||
cb
|
||||
)
|
||||
},
|
||||
],
|
||||
recurlyCoupons: [
|
||||
'recurlySubscription',
|
||||
({ recurlySubscription }, cb) => {
|
||||
if (!recurlySubscription) {
|
||||
return cb(null, null)
|
||||
}
|
||||
if (recurlySubscription.pending_subscription) {
|
||||
const pendingPlan = PlansLocator.findLocalPlanInSettings(
|
||||
recurlySubscription.pending_subscription.plan.plan_code
|
||||
)
|
||||
if (pendingPlan == null) {
|
||||
return callback(
|
||||
new Error(
|
||||
`No plan found for planCode '${personalSubscription.planCode}'`
|
||||
)
|
||||
const accountId = recurlySubscription.account.account_code
|
||||
RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
|
||||
},
|
||||
],
|
||||
plan: [
|
||||
'personalSubscription',
|
||||
({ personalSubscription }, cb) => {
|
||||
if (personalSubscription == null) {
|
||||
return cb()
|
||||
}
|
||||
const plan = PlansLocator.findLocalPlanInSettings(
|
||||
personalSubscription.planCode
|
||||
)
|
||||
if (plan == null) {
|
||||
return cb(
|
||||
new Error(
|
||||
`No plan found for planCode '${personalSubscription.planCode}'`
|
||||
)
|
||||
)
|
||||
}
|
||||
cb(null, plan)
|
||||
},
|
||||
],
|
||||
memberGroupSubscriptions(cb) {
|
||||
SubscriptionLocator.getMemberSubscriptions(user, cb)
|
||||
},
|
||||
managedGroupSubscriptions(cb) {
|
||||
SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
|
||||
},
|
||||
currentInstitutionsWithLicence(cb) {
|
||||
InstitutionsGetter.getCurrentInstitutionsWithLicence(
|
||||
user._id,
|
||||
(error, institutions) => {
|
||||
if (error instanceof V1ConnectionError) {
|
||||
return cb(null, false)
|
||||
}
|
||||
let pendingAdditionalLicenses = 0
|
||||
let pendingAddOnTax = 0
|
||||
let pendingAddOnPrice = 0
|
||||
if (recurlySubscription.pending_subscription.subscription_add_ons) {
|
||||
if (
|
||||
pendingPlan.membersLimitAddOn &&
|
||||
Array.isArray(
|
||||
recurlySubscription.pending_subscription.subscription_add_ons
|
||||
)
|
||||
) {
|
||||
recurlySubscription.pending_subscription.subscription_add_ons.forEach(
|
||||
addOn => {
|
||||
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
|
||||
pendingAddOnPrice +=
|
||||
addOn.quantity * addOn.unit_amount_in_cents
|
||||
pendingAdditionalLicenses += addOn.quantity
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Need to calculate tax ourselves as we don't get tax amounts for pending subs
|
||||
pendingAddOnTax =
|
||||
personalSubscription.recurly.taxRate * pendingAddOnPrice
|
||||
cb(null, institutions)
|
||||
}
|
||||
)
|
||||
},
|
||||
managedInstitutions(cb) {
|
||||
InstitutionsGetter.getManagedInstitutions(user._id, cb)
|
||||
},
|
||||
managedPublishers(cb) {
|
||||
PublishersGetter.getManagedPublishers(user._id, cb)
|
||||
},
|
||||
v1SubscriptionStatus(cb) {
|
||||
V1SubscriptionManager.getSubscriptionStatusFromV1(
|
||||
user._id,
|
||||
(error, status, v1Id) => {
|
||||
if (error) {
|
||||
return cb(error)
|
||||
}
|
||||
const pendingSubscriptionTax =
|
||||
personalSubscription.recurly.taxRate *
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents +
|
||||
pendingAddOnPrice +
|
||||
pendingAddOnTax +
|
||||
pendingSubscriptionTax,
|
||||
recurlySubscription.currency
|
||||
)
|
||||
personalSubscription.recurly.currentPlanDisplayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency
|
||||
)
|
||||
const pendingTotalLicenses =
|
||||
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
|
||||
personalSubscription.recurly.pendingAdditionalLicenses =
|
||||
pendingAdditionalLicenses
|
||||
personalSubscription.recurly.pendingTotalLicenses =
|
||||
pendingTotalLicenses
|
||||
personalSubscription.pendingPlan = pendingPlan
|
||||
} else {
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency
|
||||
)
|
||||
cb(null, status)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
for (const memberGroupSubscription of memberGroupSubscriptions) {
|
||||
if (
|
||||
memberGroupSubscription.manager_ids?.some(
|
||||
id => id.toString() === user._id.toString()
|
||||
)
|
||||
) {
|
||||
memberGroupSubscription.userIsGroupManager = true
|
||||
}
|
||||
if (memberGroupSubscription.teamNotice) {
|
||||
memberGroupSubscription.teamNotice = sanitizeHtml(
|
||||
memberGroupSubscription.teamNotice
|
||||
)
|
||||
}
|
||||
buildGroupSubscriptionForView(memberGroupSubscription)
|
||||
}
|
||||
if (memberGroupSubscriptions == null) {
|
||||
memberGroupSubscriptions = []
|
||||
}
|
||||
if (managedGroupSubscriptions == null) {
|
||||
managedGroupSubscriptions = []
|
||||
}
|
||||
if (managedInstitutions == null) {
|
||||
managedInstitutions = []
|
||||
}
|
||||
if (v1SubscriptionStatus == null) {
|
||||
v1SubscriptionStatus = {}
|
||||
}
|
||||
if (recurlyCoupons == null) {
|
||||
recurlyCoupons = []
|
||||
}
|
||||
|
||||
for (const managedGroupSubscription of managedGroupSubscriptions) {
|
||||
if (
|
||||
managedGroupSubscription.member_ids?.some(
|
||||
id => id.toString() === user._id.toString()
|
||||
)
|
||||
) {
|
||||
managedGroupSubscription.userIsGroupMember = true
|
||||
}
|
||||
buildGroupSubscriptionForView(managedGroupSubscription)
|
||||
}
|
||||
personalSubscription = serializeMongooseObject(personalSubscription)
|
||||
memberGroupSubscriptions = memberGroupSubscriptions.map(
|
||||
serializeMongooseObject
|
||||
)
|
||||
managedGroupSubscriptions = managedGroupSubscriptions.map(
|
||||
serializeMongooseObject
|
||||
)
|
||||
managedInstitutions = managedInstitutions.map(serializeMongooseObject)
|
||||
await Promise.all(
|
||||
managedInstitutions.map(InstitutionsManager.promises.fetchV1Data)
|
||||
)
|
||||
|
||||
callback(null, {
|
||||
personalSubscription,
|
||||
managedGroupSubscriptions,
|
||||
memberGroupSubscriptions,
|
||||
currentInstitutionsWithLicence,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
v1SubscriptionStatus,
|
||||
if (plan != null) {
|
||||
personalSubscription.plan = plan
|
||||
}
|
||||
|
||||
// Subscription DB object contains a recurly property, used to cache trial info
|
||||
// on the project-list. However, this can cause the wrong template to render,
|
||||
// if we do not have any subscription data from Recurly (recurlySubscription)
|
||||
// TODO: Delete this workaround once recurly cache property name migration rolled out.
|
||||
if (personalSubscription) {
|
||||
delete personalSubscription.recurly
|
||||
}
|
||||
|
||||
if (personalSubscription && recurlySubscription) {
|
||||
const tax = recurlySubscription.tax_in_cents || 0
|
||||
// Some plans allow adding more seats than the base plan provides.
|
||||
// This is recorded as a subscription add on.
|
||||
// Note: tax_in_cents already includes the tax for any addon.
|
||||
let addOnPrice = 0
|
||||
let additionalLicenses = 0
|
||||
if (
|
||||
plan.membersLimitAddOn &&
|
||||
Array.isArray(recurlySubscription.subscription_add_ons)
|
||||
) {
|
||||
recurlySubscription.subscription_add_ons.forEach(addOn => {
|
||||
if (addOn.add_on_code === plan.membersLimitAddOn) {
|
||||
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
|
||||
additionalLicenses += addOn.quantity
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
|
||||
personalSubscription.recurly = {
|
||||
tax,
|
||||
taxRate: recurlySubscription.tax_rate
|
||||
? parseFloat(recurlySubscription.tax_rate._)
|
||||
: 0,
|
||||
billingDetailsLink: buildHostedLink('billing-details'),
|
||||
accountManagementLink: buildHostedLink('account-management'),
|
||||
additionalLicenses,
|
||||
totalLicenses,
|
||||
nextPaymentDueAt: SubscriptionFormatters.formatDate(
|
||||
recurlySubscription.current_period_ends_at
|
||||
),
|
||||
currency: recurlySubscription.currency,
|
||||
state: recurlySubscription.state,
|
||||
trialEndsAtFormatted: SubscriptionFormatters.formatDate(
|
||||
recurlySubscription.trial_ends_at
|
||||
),
|
||||
trial_ends_at: recurlySubscription.trial_ends_at,
|
||||
activeCoupons: recurlyCoupons,
|
||||
account: recurlySubscription.account,
|
||||
}
|
||||
if (recurlySubscription.pending_subscription) {
|
||||
const pendingPlan = PlansLocator.findLocalPlanInSettings(
|
||||
recurlySubscription.pending_subscription.plan.plan_code
|
||||
)
|
||||
if (pendingPlan == null) {
|
||||
throw new Error(
|
||||
`No plan found for planCode '${personalSubscription.planCode}'`
|
||||
)
|
||||
}
|
||||
let pendingAdditionalLicenses = 0
|
||||
let pendingAddOnTax = 0
|
||||
let pendingAddOnPrice = 0
|
||||
if (recurlySubscription.pending_subscription.subscription_add_ons) {
|
||||
if (
|
||||
pendingPlan.membersLimitAddOn &&
|
||||
Array.isArray(
|
||||
recurlySubscription.pending_subscription.subscription_add_ons
|
||||
)
|
||||
) {
|
||||
recurlySubscription.pending_subscription.subscription_add_ons.forEach(
|
||||
addOn => {
|
||||
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
|
||||
pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
|
||||
pendingAdditionalLicenses += addOn.quantity
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Need to calculate tax ourselves as we don't get tax amounts for pending subs
|
||||
pendingAddOnTax =
|
||||
personalSubscription.recurly.taxRate * pendingAddOnPrice
|
||||
}
|
||||
const pendingSubscriptionTax =
|
||||
personalSubscription.recurly.taxRate *
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents +
|
||||
pendingAddOnPrice +
|
||||
pendingAddOnTax +
|
||||
pendingSubscriptionTax,
|
||||
recurlySubscription.currency
|
||||
)
|
||||
personalSubscription.recurly.currentPlanDisplayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency
|
||||
)
|
||||
const pendingTotalLicenses =
|
||||
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
|
||||
personalSubscription.recurly.pendingAdditionalLicenses =
|
||||
pendingAdditionalLicenses
|
||||
personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
|
||||
personalSubscription.pendingPlan = pendingPlan
|
||||
} else {
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const memberGroupSubscription of memberGroupSubscriptions) {
|
||||
if (
|
||||
memberGroupSubscription.manager_ids?.some(
|
||||
id => id.toString() === user._id.toString()
|
||||
)
|
||||
) {
|
||||
memberGroupSubscription.userIsGroupManager = true
|
||||
}
|
||||
if (memberGroupSubscription.teamNotice) {
|
||||
memberGroupSubscription.teamNotice = sanitizeHtml(
|
||||
memberGroupSubscription.teamNotice
|
||||
)
|
||||
}
|
||||
buildGroupSubscriptionForView(memberGroupSubscription)
|
||||
}
|
||||
|
||||
for (const managedGroupSubscription of managedGroupSubscriptions) {
|
||||
if (
|
||||
managedGroupSubscription.member_ids?.some(
|
||||
id => id.toString() === user._id.toString()
|
||||
)
|
||||
) {
|
||||
managedGroupSubscription.userIsGroupMember = true
|
||||
}
|
||||
buildGroupSubscriptionForView(managedGroupSubscription)
|
||||
}
|
||||
|
||||
return {
|
||||
personalSubscription,
|
||||
managedGroupSubscriptions,
|
||||
memberGroupSubscriptions,
|
||||
currentInstitutionsWithLicence,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
v1SubscriptionStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -558,12 +551,12 @@ function buildPlansListForSubscriptionDash(currentPlan) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
buildUsersSubscriptionViewModel,
|
||||
buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
|
||||
buildPlansList,
|
||||
buildPlansListForSubscriptionDash,
|
||||
getBestSubscription: callbackify(getBestSubscription),
|
||||
promises: {
|
||||
buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),
|
||||
buildUsersSubscriptionViewModel,
|
||||
getRedirectToHostedPage,
|
||||
getBestSubscription,
|
||||
},
|
||||
|
|
|
@ -444,6 +444,7 @@
|
|||
"manage_beta_program_membership": "",
|
||||
"manage_files_from_your_dropbox_folder": "",
|
||||
"manage_group_managers": "",
|
||||
"manage_institution_managers": "",
|
||||
"manage_labs_program_membership": "",
|
||||
"manage_members": "",
|
||||
"manage_newsletter": "",
|
||||
|
@ -727,6 +728,7 @@
|
|||
"subject": "",
|
||||
"subject_to_additional_vat": "",
|
||||
"submit_title": "",
|
||||
"subscribe": "",
|
||||
"subscription_admins_cannot_be_deleted": "",
|
||||
"subscription_canceled_and_terminate_on_x": "",
|
||||
"subscription_will_remain_active_until_end_of_billing_period_x": "",
|
||||
|
@ -814,6 +816,7 @@
|
|||
"unlink_reference": "",
|
||||
"unlink_warning_reference": "",
|
||||
"unlinking": "",
|
||||
"unsubscribe": "",
|
||||
"untrash": "",
|
||||
"update": "",
|
||||
"update_account_info": "",
|
||||
|
@ -837,6 +840,7 @@
|
|||
"vat": "",
|
||||
"vat_number": "",
|
||||
"view_all": "",
|
||||
"view_hub": "",
|
||||
"view_logs": "",
|
||||
"view_metrics": "",
|
||||
"view_pdf": "",
|
||||
|
@ -856,6 +860,7 @@
|
|||
"x_price_for_y_months": "",
|
||||
"year": "",
|
||||
"you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
|
||||
"you_are_a_manager_of_commons_at_institution_x": "",
|
||||
"you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
|
||||
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
|
||||
"you_can_now_log_in_sso": "",
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
|
||||
import ManagedInstitution from './managed_institution'
|
||||
|
||||
export type Institution = {
|
||||
v1Id: number
|
||||
managerIds: string[]
|
||||
metricsEmail: {
|
||||
optedOutUserIds: string[]
|
||||
lastSent: Date
|
||||
}
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function ManagedInstitutions() {
|
||||
const { managedInstitutions } = useSubscriptionDashboardContext()
|
||||
|
||||
if (!managedInstitutions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{managedInstitutions.map(institution => (
|
||||
<ManagedInstitution
|
||||
institution={institution}
|
||||
key={`managed-institution-${institution.v1Id}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
|
||||
import { Institution } from './managed-institutions'
|
||||
|
||||
type ManagedInstitutionProps = {
|
||||
institution: Institution
|
||||
}
|
||||
|
||||
export default function ManagedInstitution({
|
||||
institution,
|
||||
}: ManagedInstitutionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [subscriptionChanging, setSubscriptionChanging] = useState(false)
|
||||
const { updateManagedInstitution } = useSubscriptionDashboardContext()
|
||||
|
||||
const changeInstitutionalEmailSubscription = useCallback(
|
||||
(e, institutionId: Institution['v1Id']) => {
|
||||
const updateSubscription = async (institutionId: Institution['v1Id']) => {
|
||||
setSubscriptionChanging(true)
|
||||
try {
|
||||
const data = await postJSON<string[]>(
|
||||
`/institutions/${institutionId}/emailSubscription`
|
||||
)
|
||||
institution.metricsEmail.optedOutUserIds = data
|
||||
updateManagedInstitution(institution)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
setSubscriptionChanging(false)
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
updateSubscription(institutionId)
|
||||
},
|
||||
[institution, updateManagedInstitution]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="you_are_a_manager_of_commons_at_institution_x"
|
||||
components={[<strong />]} // eslint-disable-line react/jsx-key
|
||||
values={{
|
||||
institutionName: institution.name || '',
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`/metrics/institutions/${institution.v1Id}`}
|
||||
>
|
||||
<i className="fa fa-fw fa-line-chart" /> {t('view_metrics')}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href={`/institutions/${institution.v1Id}/hub`}>
|
||||
<i className="fa fa-fw fa-user-circle" /> {t('view_hub')}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href={`/manage/institutions/${institution.v1Id}/managers`}>
|
||||
<i className="fa fa-fw fa-users" /> {t('manage_institution_managers')}
|
||||
</a>
|
||||
</p>
|
||||
<div>
|
||||
<p>
|
||||
<span>Monthly metrics emails: </span>
|
||||
{subscriptionChanging ? (
|
||||
<i className="fa fa-spin fa-refresh" />
|
||||
) : (
|
||||
<button
|
||||
className="btn-inline-link"
|
||||
style={{ border: 0 }}
|
||||
onClick={e =>
|
||||
changeInstitutionalEmailSubscription(e, institution.v1Id)
|
||||
}
|
||||
>
|
||||
{institution.metricsEmail.optedOutUserIds.includes(
|
||||
window.user_id!
|
||||
)
|
||||
? t('subscribe')
|
||||
: t('unsubscribe')}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -3,6 +3,7 @@ import InstitutionMemberships from './institution-memberships'
|
|||
import FreePlan from './free-plan'
|
||||
import PersonalSubscription from './personal-subscription'
|
||||
import ManagedGroupSubscriptions from './managed-group-subscriptions'
|
||||
import ManagedInstitutions from './managed-institutions'
|
||||
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
|
||||
|
||||
function SubscriptionDashboard() {
|
||||
|
@ -20,6 +21,7 @@ function SubscriptionDashboard() {
|
|||
|
||||
<PersonalSubscription />
|
||||
<ManagedGroupSubscriptions />
|
||||
<ManagedInstitutions />
|
||||
<InstitutionMemberships />
|
||||
{!hasDisplayedSubscription && <FreePlan />}
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
@ -11,6 +12,7 @@ import {
|
|||
Subscription,
|
||||
} from '../../../../../types/subscription/dashboard/subscription'
|
||||
import { Plan } from '../../../../../types/subscription/plan'
|
||||
import { Institution as ManagedInstitution } from '../components/dashboard/managed-institutions'
|
||||
import { Institution } from '../../../../../types/institution'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
|
||||
|
@ -18,10 +20,12 @@ import { isRecurlyLoaded } from '../util/is-recurly-loaded'
|
|||
|
||||
type SubscriptionDashboardContextValue = {
|
||||
hasDisplayedSubscription: boolean
|
||||
institutionMemberships?: Array<Institution>
|
||||
managedGroupSubscriptions: Array<ManagedGroupSubscription>
|
||||
institutionMemberships?: Institution[]
|
||||
managedGroupSubscriptions: ManagedGroupSubscription[]
|
||||
managedInstitutions: ManagedInstitution[]
|
||||
updateManagedInstitution: (institution: ManagedInstitution) => void
|
||||
personalSubscription?: Subscription
|
||||
plans: Array<Plan>
|
||||
plans: Plan[]
|
||||
queryingIndividualPlansData: boolean
|
||||
recurlyLoadError: boolean
|
||||
setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
|
||||
|
@ -51,12 +55,16 @@ export function SubscriptionDashboardProvider({
|
|||
const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
|
||||
const personalSubscription = getMeta('ol-subscription')
|
||||
const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
|
||||
const [managedInstitutions, setManagedInstitutions] = useState<
|
||||
ManagedInstitution[]
|
||||
>(getMeta('ol-managedInstitutions'))
|
||||
const recurlyApiKey = getMeta('ol-recurlyApiKey')
|
||||
|
||||
const hasDisplayedSubscription =
|
||||
institutionMemberships?.length > 0 ||
|
||||
personalSubscription ||
|
||||
managedGroupSubscriptions?.length > 0
|
||||
managedGroupSubscriptions?.length > 0 ||
|
||||
managedInstitutions?.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecurlyLoaded()) {
|
||||
|
@ -91,11 +99,26 @@ export function SubscriptionDashboardProvider({
|
|||
}
|
||||
}, [personalSubscription, plansWithoutDisplayPrice])
|
||||
|
||||
const updateManagedInstitution = useCallback(
|
||||
(institution: ManagedInstitution) => {
|
||||
setManagedInstitutions(institutions => {
|
||||
return [
|
||||
...(institutions || []).map(i =>
|
||||
i.v1Id === institution.v1Id ? institution : i
|
||||
),
|
||||
]
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const value = useMemo<SubscriptionDashboardContextValue>(
|
||||
() => ({
|
||||
hasDisplayedSubscription,
|
||||
institutionMemberships,
|
||||
managedGroupSubscriptions,
|
||||
managedInstitutions,
|
||||
updateManagedInstitution,
|
||||
personalSubscription,
|
||||
plans,
|
||||
queryingIndividualPlansData,
|
||||
|
@ -110,6 +133,8 @@ export function SubscriptionDashboardProvider({
|
|||
hasDisplayedSubscription,
|
||||
institutionMemberships,
|
||||
managedGroupSubscriptions,
|
||||
managedInstitutions,
|
||||
updateManagedInstitution,
|
||||
personalSubscription,
|
||||
plans,
|
||||
queryingIndividualPlansData,
|
||||
|
|
|
@ -881,6 +881,7 @@
|
|||
"manage_beta_program_membership": "Manage Beta Program Membership",
|
||||
"manage_files_from_your_dropbox_folder": "Manage files from your Dropbox folder",
|
||||
"manage_group_managers": "Manage group managers",
|
||||
"manage_institution_managers": "Manage institution managers",
|
||||
"manage_labs_program_membership": "Manage Labs Program Membership",
|
||||
"manage_members": "Manage members",
|
||||
"manage_newsletter": "Manage Your Newsletter Preferences",
|
||||
|
@ -1603,6 +1604,7 @@
|
|||
"vat_number": "VAT Number",
|
||||
"view_all": "View All",
|
||||
"view_collab_edits": "View collaborator edits ",
|
||||
"view_hub": "View hub",
|
||||
"view_in_template_gallery": "View it in the template gallery",
|
||||
"view_logs": "View logs",
|
||||
"view_metrics": "View metrics",
|
||||
|
|
|
@ -0,0 +1,161 @@
|
|||
import { expect } from 'chai'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ManagedInstitutions, {
|
||||
Institution,
|
||||
} from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-institutions'
|
||||
import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
const userId = 'fff999fff999'
|
||||
const institution1 = {
|
||||
v1Id: 123,
|
||||
managerIds: [],
|
||||
metricsEmail: {
|
||||
optedOutUserIds: [],
|
||||
lastSent: new Date(),
|
||||
},
|
||||
name: 'Inst 1',
|
||||
}
|
||||
const institution2 = {
|
||||
v1Id: 456,
|
||||
managerIds: [],
|
||||
metricsEmail: {
|
||||
optedOutUserIds: [userId],
|
||||
lastSent: new Date(),
|
||||
},
|
||||
name: 'Inst 2',
|
||||
}
|
||||
const managedInstitutions: Institution[] = [institution1, institution2]
|
||||
|
||||
describe('<ManagedInstitutions />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache = new Map()
|
||||
window.metaAttributesCache.set(
|
||||
'ol-managedInstitutions',
|
||||
managedInstitutions
|
||||
)
|
||||
window.user_id = userId
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.metaAttributesCache = new Map()
|
||||
delete window.user_id
|
||||
fetchMock.reset()
|
||||
})
|
||||
|
||||
it('renders all managed institutions', function () {
|
||||
render(
|
||||
<SubscriptionDashboardProvider>
|
||||
<ManagedInstitutions />
|
||||
</SubscriptionDashboardProvider>
|
||||
)
|
||||
|
||||
const elements = screen.getAllByText('You are a', {
|
||||
exact: false,
|
||||
})
|
||||
expect(elements.length).to.equal(2)
|
||||
expect(elements[0].textContent).to.equal(
|
||||
'You are a manager of the Overleaf Commons subscription at Inst 1'
|
||||
)
|
||||
expect(elements[1].textContent).to.equal(
|
||||
'You are a manager of the Overleaf Commons subscription at Inst 2'
|
||||
)
|
||||
|
||||
const viewMetricsLinks = screen.getAllByText('View metrics')
|
||||
expect(viewMetricsLinks.length).to.equal(2)
|
||||
expect(viewMetricsLinks[0].getAttribute('href')).to.equal(
|
||||
'/metrics/institutions/123'
|
||||
)
|
||||
expect(viewMetricsLinks[1].getAttribute('href')).to.equal(
|
||||
'/metrics/institutions/456'
|
||||
)
|
||||
|
||||
const viewHubLinks = screen.getAllByText('View hub')
|
||||
expect(viewHubLinks.length).to.equal(2)
|
||||
expect(viewHubLinks[0].getAttribute('href')).to.equal(
|
||||
'/institutions/123/hub'
|
||||
)
|
||||
expect(viewHubLinks[1].getAttribute('href')).to.equal(
|
||||
'/institutions/456/hub'
|
||||
)
|
||||
|
||||
const manageGroupManagersLinks = screen.getAllByText(
|
||||
'Manage institution managers'
|
||||
)
|
||||
expect(manageGroupManagersLinks.length).to.equal(2)
|
||||
expect(manageGroupManagersLinks[0].getAttribute('href')).to.equal(
|
||||
'/manage/institutions/123/managers'
|
||||
)
|
||||
expect(manageGroupManagersLinks[1].getAttribute('href')).to.equal(
|
||||
'/manage/institutions/456/managers'
|
||||
)
|
||||
|
||||
const subscribeLinks = screen.getAllByText('Subscribe')
|
||||
expect(subscribeLinks.length).to.equal(1)
|
||||
|
||||
const unsubscribeLinks = screen.getAllByText('Unsubscribe')
|
||||
expect(unsubscribeLinks.length).to.equal(1)
|
||||
})
|
||||
|
||||
it('clicking unsubscribe should unsubscribe from metrics emails', async function () {
|
||||
window.metaAttributesCache.set('ol-managedInstitutions', [institution1])
|
||||
const unsubscribeUrl = '/institutions/123/emailSubscription'
|
||||
|
||||
fetchMock.post(unsubscribeUrl, {
|
||||
status: 204,
|
||||
body: [userId],
|
||||
})
|
||||
|
||||
render(
|
||||
<SubscriptionDashboardProvider>
|
||||
<ManagedInstitutions />
|
||||
</SubscriptionDashboardProvider>
|
||||
)
|
||||
|
||||
const unsubscribeLink = screen.getByText('Unsubscribe')
|
||||
await fireEvent.click(unsubscribeLink)
|
||||
await waitFor(() => expect(fetchMock.called(unsubscribeUrl)).to.be.true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Subscribe')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking subscribe should subscribe to metrics emails', async function () {
|
||||
window.metaAttributesCache.set('ol-managedInstitutions', [institution2])
|
||||
const subscribeUrl = '/institutions/456/emailSubscription'
|
||||
|
||||
fetchMock.post(subscribeUrl, {
|
||||
status: 204,
|
||||
body: [],
|
||||
})
|
||||
|
||||
render(
|
||||
<SubscriptionDashboardProvider>
|
||||
<ManagedInstitutions />
|
||||
</SubscriptionDashboardProvider>
|
||||
)
|
||||
|
||||
const subscribeLink = screen.getByText('Subscribe')
|
||||
await fireEvent.click(subscribeLink)
|
||||
await waitFor(() => expect(fetchMock.called(subscribeUrl)).to.be.true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unsubscribe')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
it('renders nothing when there are no institutions', function () {
|
||||
window.metaAttributesCache.set('ol-managedInstitutions', undefined)
|
||||
|
||||
render(
|
||||
<SubscriptionDashboardProvider>
|
||||
<ManagedInstitutions />
|
||||
</SubscriptionDashboardProvider>
|
||||
)
|
||||
const elements = screen.queryAllByText('You are a', {
|
||||
exact: false,
|
||||
})
|
||||
expect(elements.length).to.equal(0)
|
||||
})
|
||||
})
|
|
@ -82,6 +82,11 @@ describe('SubscriptionViewModelBuilder', function () {
|
|||
getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.InstitutionsManager = {
|
||||
promises: {
|
||||
fetchV1Data: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.RecurlyWrapper = {
|
||||
promises: {
|
||||
getSubscription: sinon.stub().resolves(),
|
||||
|
@ -100,6 +105,7 @@ describe('SubscriptionViewModelBuilder', function () {
|
|||
'@overleaf/settings': this.Settings,
|
||||
'./SubscriptionLocator': this.SubscriptionLocator,
|
||||
'../Institutions/InstitutionsGetter': this.InstitutionsGetter,
|
||||
'../Institutions/InstitutionsManager': this.InstitutionsManager,
|
||||
'./RecurlyWrapper': this.RecurlyWrapper,
|
||||
'./SubscriptionUpdater': this.SubscriptionUpdater,
|
||||
'./PlansLocator': this.PlansLocator,
|
||||
|
|
Loading…
Reference in a new issue