Przeglądaj źródła

Merge pull request #11694 from overleaf/ab-managed-institutions-react

[web] Migrate managed institutions to React dash

GitOrigin-RevId: 535dfafe42d88189bb20e7bb3beac233221ee6ba
Davinder Singh 3 lat temu
rodzic
commit
c524fee690

+ 23 - 0
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

+ 259 - 266
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)
-          }
+async function buildUsersSubscriptionViewModel(user) {
+  let {
+    personalSubscription,
+    memberGroupSubscriptions,
+    managedGroupSubscriptions,
+    currentInstitutionsWithLicence,
+    managedInstitutions,
+    managedPublishers,
+    v1SubscriptionStatus,
+    recurlySubscription,
+    recurlyCoupons,
+    plan,
+  } = await 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
         )
       },
-      managedInstitutions(cb) {
-        InstitutionsGetter.getManagedInstitutions(user._id, cb)
-      },
-      managedPublishers(cb) {
-        PublishersGetter.getManagedPublishers(user._id, cb)
+    ],
+    recurlyCoupons: [
+      'recurlySubscription',
+      ({ recurlySubscription }, cb) => {
+        if (!recurlySubscription) {
+          return cb(null, null)
+        }
+        const accountId = recurlySubscription.account.account_code
+        RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
       },
-      v1SubscriptionStatus(cb) {
-        V1SubscriptionManager.getSubscriptionStatusFromV1(
-          user._id,
-          (error, status, v1Id) => {
-            if (error) {
-              return cb(error)
-            }
-            cb(null, status)
-          }
+    ],
+    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)
     },
-    (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(cb) {
+      SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
+    },
+    currentInstitutionsWithLicence(cb) {
+      InstitutionsGetter.getCurrentInstitutionsWithLicence(
+        user._id,
+        (error, institutions) => {
+          if (error instanceof V1ConnectionError) {
+            return cb(null, false)
+          }
+          cb(null, institutions)
+        }
       )
-      managedGroupSubscriptions = managedGroupSubscriptions.map(
-        serializeMongooseObject
+    },
+    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)
+        }
       )
+    },
+  })
 
-      if (plan != null) {
-        personalSubscription.plan = plan
-      }
+  if (memberGroupSubscriptions == null) {
+    memberGroupSubscriptions = []
+  }
+  if (managedGroupSubscriptions == null) {
+    managedGroupSubscriptions = []
+  }
+  if (managedInstitutions == null) {
+    managedInstitutions = []
+  }
+  if (v1SubscriptionStatus == null) {
+    v1SubscriptionStatus = {}
+  }
+  if (recurlyCoupons == null) {
+    recurlyCoupons = []
+  }
 
-      // 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
-      }
+  personalSubscription = serializeMongooseObject(personalSubscription)
+  memberGroupSubscriptions = memberGroupSubscriptions.map(
+    serializeMongooseObject
+  )
+  managedGroupSubscriptions = managedGroupSubscriptions.map(
+    serializeMongooseObject
+  )
+  managedInstitutions = managedInstitutions.map(serializeMongooseObject)
+  await Promise.all(
+    managedInstitutions.map(InstitutionsManager.promises.fetchV1Data)
+  )
 
-      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) {
-            return callback(
-              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
-            )
+  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}'`
+        )
       }
-
-      for (const memberGroupSubscription of memberGroupSubscriptions) {
+      let pendingAdditionalLicenses = 0
+      let pendingAddOnTax = 0
+      let pendingAddOnPrice = 0
+      if (recurlySubscription.pending_subscription.subscription_add_ons) {
         if (
-          memberGroupSubscription.manager_ids?.some(
-            id => id.toString() === user._id.toString()
+          pendingPlan.membersLimitAddOn &&
+          Array.isArray(
+            recurlySubscription.pending_subscription.subscription_add_ons
           )
         ) {
-          memberGroupSubscription.userIsGroupManager = true
-        }
-        if (memberGroupSubscription.teamNotice) {
-          memberGroupSubscription.teamNotice = sanitizeHtml(
-            memberGroupSubscription.teamNotice
+          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
+              }
+            }
           )
         }
-        buildGroupSubscriptionForView(memberGroupSubscription)
+        // 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 managedGroupSubscription of managedGroupSubscriptions) {
-        if (
-          managedGroupSubscription.member_ids?.some(
-            id => id.toString() === user._id.toString()
-          )
-        ) {
-          managedGroupSubscription.userIsGroupMember = true
-        }
-        buildGroupSubscriptionForView(managedGroupSubscription)
-      }
+  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)
+  }
 
-      callback(null, {
-        personalSubscription,
-        managedGroupSubscriptions,
-        memberGroupSubscriptions,
-        currentInstitutionsWithLicence,
-        managedInstitutions,
-        managedPublishers,
-        v1SubscriptionStatus,
-      })
+  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,
   },

+ 5 - 0
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": "",

+ 31 - 0
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 => (
+        <ManagedInstitution
+          institution={institution}
+          key={`managed-institution-${institution.v1Id}`}
+        />
+      ))}
+    </>
+  )
+}

+ 94 - 0
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<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>
+  )
+}

+ 2 - 0
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() {
 
             <PersonalSubscription />
             <ManagedGroupSubscriptions />
+            <ManagedInstitutions />
             <InstitutionMemberships />
             {!hasDisplayedSubscription && <FreePlan />}
           </div>

+ 29 - 4
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<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,

+ 2 - 0
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",

+ 161 - 0
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('<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)
+  })
+})

+ 6 - 0
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,