diff --git a/services/web/app/src/Features/Institutions/InstitutionsManager.js b/services/web/app/src/Features/Institutions/InstitutionsManager.js index 943fb7e296..b79dad2cad 100644 --- a/services/web/app/src/Features/Institutions/InstitutionsManager.js +++ b/services/web/app/src/Features/Institutions/InstitutionsManager.js @@ -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 diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 00aad3516c..325bb25fc7 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -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, }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index d673fd4c4b..8cc4e5b046 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed-institutions.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed-institutions.tsx new file mode 100644 index 0000000000..3ff725fc93 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/managed-institutions.tsx @@ -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 => ( + + ))} + + ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed_institution.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed_institution.tsx new file mode 100644 index 0000000000..af4f7a9117 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/managed_institution.tsx @@ -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( + `/institutions/${institutionId}/emailSubscription` + ) + institution.metricsEmail.optedOutUserIds = data + updateManagedInstitution(institution) + } catch (error) { + console.error(error) + } + setSubscriptionChanging(false) + } + + e.preventDefault() + updateSubscription(institutionId) + }, + [institution, updateManagedInstitution] + ) + + return ( +
+

+ ]} // eslint-disable-line react/jsx-key + values={{ + institutionName: institution.name || '', + }} + /> +

+

+ + {t('view_metrics')} + +

+

+ + {t('view_hub')} + +

+

+ + {t('manage_institution_managers')} + +

+
+

+ Monthly metrics emails: + {subscriptionChanging ? ( + + ) : ( + + )} +

+
+
+
+ ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx b/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx index 039612bb87..c3f8b562d1 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx @@ -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() { + {!hasDisplayedSubscription && } diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx index a74fccd625..d562d6fff2 100644 --- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx +++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx @@ -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 - managedGroupSubscriptions: Array + institutionMemberships?: Institution[] + managedGroupSubscriptions: ManagedGroupSubscription[] + managedInstitutions: ManagedInstitution[] + updateManagedInstitution: (institution: ManagedInstitution) => void personalSubscription?: Subscription - plans: Array + plans: Plan[] queryingIndividualPlansData: boolean recurlyLoadError: boolean setRecurlyLoadError: React.Dispatch> @@ -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( () => ({ hasDisplayedSubscription, institutionMemberships, managedGroupSubscriptions, + managedInstitutions, + updateManagedInstitution, personalSubscription, plans, queryingIndividualPlansData, @@ -110,6 +133,8 @@ export function SubscriptionDashboardProvider({ hasDisplayedSubscription, institutionMemberships, managedGroupSubscriptions, + managedInstitutions, + updateManagedInstitution, personalSubscription, plans, queryingIndividualPlansData, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 7f4cf0dc9d..cccabcc031 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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", diff --git a/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx new file mode 100644 index 0000000000..00ecd67074 --- /dev/null +++ b/services/web/test/frontend/features/subscription/components/dashboard/managed-institutions.test.tsx @@ -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('', 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + const elements = screen.queryAllByText('You are a', { + exact: false, + }) + expect(elements.length).to.equal(0) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index 65f019d61c..730eb3c850 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -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,