diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug
index 08fb6479db..3b21ea8584 100644
--- a/services/web/app/views/subscriptions/dashboard-react.pug
+++ b/services/web/app/views/subscriptions/dashboard-react.pug
@@ -8,6 +8,7 @@ block head-scripts
block append meta
meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions)
+ meta(name="ol-memberGroupSubscriptions", data-type="json" content=memberGroupSubscriptions)
meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions)
meta(name="ol-managedPublishers", data-type="json" content=managedPublishers)
meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd)
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index a073ec4273..f554ad0d40 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -410,6 +410,8 @@
"learn_more": "",
"learn_more_about_link_sharing": "",
"leave": "",
+ "leave_group": "",
+ "leave_now": "",
"leave_projects": "",
"let_us_know": "",
"license_for_educational_purposes": "",
@@ -759,6 +761,7 @@
"sure_you_want_to_cancel_plan_change": "",
"sure_you_want_to_change_plan": "",
"sure_you_want_to_delete": "",
+ "sure_you_want_to_leave_group": "",
"switch_to_editor": "",
"switch_to_pdf": "",
"symbol_palette": "",
@@ -909,6 +912,7 @@
"you_are_a_manager_of_publisher_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_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_can_now_log_in_sso": "",
"you_dont_have_any_repositories": "",
"you_have_added_x_of_group_size_y": "",
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx
new file mode 100644
index 0000000000..fc7b461a80
--- /dev/null
+++ b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx
@@ -0,0 +1,54 @@
+import { Button } from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
+import { LEAVE_GROUP_MODAL_ID } from './leave-group-modal'
+import PremiumFeaturesLink from './premium-features-link'
+
+type GroupSubscriptionMembershipProps = {
+ subscription: MemberGroupSubscription
+ isLast: boolean
+}
+
+export default function GroupSubscriptionMembership({
+ subscription,
+ isLast,
+}: GroupSubscriptionMembershipProps) {
+ const { t } = useTranslation()
+ const { handleOpenModal, setLeavingGroupId } =
+ useSubscriptionDashboardContext()
+
+ const leaveGroup = () => {
+ handleOpenModal(LEAVE_GROUP_MODAL_ID)
+ setLeavingGroupId(subscription._id)
+ }
+
+ return (
+
+
+ , ]} // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
+ values={{
+ planName: subscription.planLevelName,
+ groupName: subscription.teamName || '',
+ adminEmail: subscription.admin_id.email,
+ }}
+ />
+
+ {subscription.teamNotice && (
+
+ {/* Team notice is sanitized in SubscriptionViewModelBuilder */}
+ {subscription.teamNotice}
+
+ )}
+ {isLast &&
}
+
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx
new file mode 100644
index 0000000000..8b42636ddc
--- /dev/null
+++ b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx
@@ -0,0 +1,32 @@
+import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
+import GroupSubscriptionMembership from './group-subscription-membership'
+import LeaveGroupModal from './leave-group-modal'
+
+export default function GroupSubscriptionMemberships() {
+ const { memberGroupSubscriptions } = useSubscriptionDashboardContext()
+
+ if (!memberGroupSubscriptions) {
+ return null
+ }
+
+ const memberOnlyGroupSubscriptions = memberGroupSubscriptions.filter(
+ subscription => !subscription.userIsGroupManager
+ )
+
+ return (
+ <>
+ {memberOnlyGroupSubscriptions.map(
+ (subscription: MemberGroupSubscription, index: number) => (
+
+ )
+ )}
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
new file mode 100644
index 0000000000..b15b426434
--- /dev/null
+++ b/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
@@ -0,0 +1,71 @@
+import { useCallback, useState } from 'react'
+import { Button, Modal } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { deleteJSON } from '../../../../infrastructure/fetch-json'
+import AccessibleModal from '../../../../shared/components/accessible-modal'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
+
+export const LEAVE_GROUP_MODAL_ID = 'leave-group'
+
+export default function LeaveGroupModal() {
+ const { t } = useTranslation()
+ const { handleCloseModal, modalIdShown, leavingGroupId } =
+ useSubscriptionDashboardContext()
+ const [inflight, setInflight] = useState(false)
+
+ const handleConfirmLeaveGroup = useCallback(async () => {
+ if (!leavingGroupId) {
+ return
+ }
+ setInflight(true)
+ try {
+ const params = new URLSearchParams()
+ params.set('subscriptionId', leavingGroupId)
+ await deleteJSON(`/subscription/group/user?${params}`)
+ window.location.reload()
+ } catch (error) {
+ console.log('something went wrong', error)
+ setInflight(false)
+ }
+ }, [leavingGroupId])
+
+ if (modalIdShown !== LEAVE_GROUP_MODAL_ID || !leavingGroupId) {
+ return null
+ }
+
+ return (
+
+
+ {t('leave_group')}
+
+
+
+ {t('sure_you_want_to_leave_group')}
+
+
+
+
+
+
+
+ )
+}
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 bb6994731d..72c44388ff 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
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'
+import GroupSubscriptionMemberships from './group-subscription-memberships'
import InstitutionMemberships from './institution-memberships'
import FreePlan from './free-plan'
import ManagedPublishers from './managed-publishers'
@@ -24,6 +25,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 213bda972f..800bf4e1cd 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
@@ -9,6 +9,7 @@ import {
} from 'react'
import {
ManagedGroupSubscription,
+ MemberGroupSubscription,
Subscription,
} from '../../../../../types/subscription/dashboard/subscription'
import {
@@ -40,6 +41,7 @@ type SubscriptionDashboardContextValue = {
hasDisplayedSubscription: boolean
institutionMemberships?: Institution[]
managedGroupSubscriptions: ManagedGroupSubscription[]
+ memberGroupSubscriptions: MemberGroupSubscription[]
managedInstitutions: ManagedInstitution[]
managedPublishers: ManagedPublisher[]
updateManagedInstitution: (institution: ManagedInstitution) => void
@@ -64,6 +66,8 @@ type SubscriptionDashboardContextValue = {
setShowCancellation: React.Dispatch>
showChangePersonalPlan: boolean
setShowChangePersonalPlan: React.Dispatch>
+ leavingGroupId?: string
+ setLeavingGroupId: React.Dispatch>
}
export const SubscriptionDashboardContext = createContext<
@@ -100,23 +104,33 @@ export function SubscriptionDashboardProvider({
useState()
const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] =
useState(false)
+ const [leavingGroupId, setLeavingGroupId] = useState()
const plansWithoutDisplayPrice = getMeta('ol-plans')
- const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
- const personalSubscription = getMeta('ol-subscription')
- const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
+ const institutionMemberships: Institution[] = getMeta(
+ 'ol-currentInstitutionsWithLicence'
+ )
+ const personalSubscription: Subscription = getMeta('ol-subscription')
+ const managedGroupSubscriptions: ManagedGroupSubscription[] = getMeta(
+ 'ol-managedGroupSubscriptions'
+ )
+ const memberGroupSubscriptions: MemberGroupSubscription[] = getMeta(
+ 'ol-memberGroupSubscriptions'
+ )
const [managedInstitutions, setManagedInstitutions] = useState<
ManagedInstitution[]
>(getMeta('ol-managedInstitutions'))
const managedPublishers = getMeta('ol-managedPublishers')
const recurlyApiKey = getMeta('ol-recurlyApiKey')
- const hasDisplayedSubscription =
+ const hasDisplayedSubscription = Boolean(
institutionMemberships?.length > 0 ||
- personalSubscription ||
- managedGroupSubscriptions?.length > 0 ||
- managedInstitutions?.length > 0 ||
- managedPublishers?.length > 0
+ personalSubscription ||
+ memberGroupSubscriptions?.length > 0 ||
+ managedGroupSubscriptions?.length > 0 ||
+ managedInstitutions?.length > 0 ||
+ managedPublishers?.length > 0
+ )
useEffect(() => {
if (!isRecurlyLoaded()) {
@@ -227,6 +241,7 @@ export function SubscriptionDashboardProvider({
hasDisplayedSubscription,
institutionMemberships,
managedGroupSubscriptions,
+ memberGroupSubscriptions,
managedInstitutions,
managedPublishers,
updateManagedInstitution,
@@ -247,6 +262,8 @@ export function SubscriptionDashboardProvider({
setShowCancellation,
showChangePersonalPlan,
setShowChangePersonalPlan,
+ leavingGroupId,
+ setLeavingGroupId,
}),
[
groupPlanToChangeToCode,
@@ -259,6 +276,7 @@ export function SubscriptionDashboardProvider({
hasDisplayedSubscription,
institutionMemberships,
managedGroupSubscriptions,
+ memberGroupSubscriptions,
managedInstitutions,
managedPublishers,
updateManagedInstitution,
@@ -279,6 +297,8 @@ export function SubscriptionDashboardProvider({
setShowCancellation,
showChangePersonalPlan,
setShowChangePersonalPlan,
+ leavingGroupId,
+ setLeavingGroupId,
]
)
diff --git a/services/web/frontend/js/main/subscription-dashboard.js b/services/web/frontend/js/main/subscription-dashboard.js
index 921ed1f9e6..f3f7272c32 100644
--- a/services/web/frontend/js/main/subscription-dashboard.js
+++ b/services/web/frontend/js/main/subscription-dashboard.js
@@ -335,7 +335,7 @@ App.controller(
_csrf: window.csrfToken,
},
})
- .then(() => location.reload())
+ .then(() => window.location.reload())
.catch(() => console.log('something went wrong changing plan'))
}
diff --git a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
new file mode 100644
index 0000000000..b4370db780
--- /dev/null
+++ b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
@@ -0,0 +1,142 @@
+import { expect } from 'chai'
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react'
+import sinon from 'sinon'
+import GroupSubscriptionMemberships from '../../../../../../frontend/js/features/subscription/components/dashboard/group-subscription-memberships'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
+import fetchMock from 'fetch-mock'
+import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import {
+ groupActiveSubscription,
+ groupActiveSubscriptionWithPendingLicenseChange,
+} from '../../fixtures/subscriptions'
+
+const userId = 'fff999fff999'
+const memberGroupSubscriptions: MemberGroupSubscription[] = [
+ {
+ ...groupActiveSubscription,
+ userIsGroupManager: false,
+ planLevelName: 'Professional',
+ admin_id: {
+ id: 'abc123abc123',
+ email: 'you@example.com',
+ },
+ },
+ {
+ ...groupActiveSubscriptionWithPendingLicenseChange,
+ userIsGroupManager: true,
+ planLevelName: 'Collaborator',
+ admin_id: {
+ id: 'bcd456bcd456',
+ email: 'someone@example.com',
+ },
+ },
+]
+
+describe('', function () {
+ beforeEach(function () {
+ window.metaAttributesCache = new Map()
+ window.metaAttributesCache.set(
+ 'ol-memberGroupSubscriptions',
+ memberGroupSubscriptions
+ )
+ window.user_id = userId
+ })
+
+ afterEach(function () {
+ window.metaAttributesCache = new Map()
+ delete window.user_id
+ fetchMock.reset()
+ })
+
+ it('renders all group subscriptions not managed', function () {
+ render(
+
+
+
+ )
+
+ const elements = screen.getAllByText('You are on our', {
+ exact: false,
+ })
+ expect(elements.length).to.equal(1)
+ expect(elements[0].textContent).to.equal(
+ 'You are on our Professional plan as a member of the group subscription GAS administered by you@example.com'
+ )
+ })
+
+ describe('opens leave group modal when button is clicked', function () {
+ let reloadStub: () => void
+ const originalLocation = window.location
+
+ beforeEach(function () {
+ reloadStub = sinon.stub()
+ Object.defineProperty(window, 'location', {
+ value: { reload: reloadStub },
+ })
+
+ render(
+
+
+
+ )
+
+ const leaveGroupButton = screen.getByText('Leave group')
+ fireEvent.click(leaveGroupButton)
+
+ this.confirmModal = screen.getByRole('dialog')
+ within(this.confirmModal).getByText(
+ 'Are you sure you want to leave this group?'
+ )
+
+ this.cancelButton = within(this.confirmModal).getByText('Cancel')
+ this.leaveNowButton = within(this.confirmModal).getByText('Leave now')
+ })
+
+ afterEach(function () {
+ Object.defineProperty(window, 'location', {
+ value: originalLocation,
+ })
+ })
+
+ it('close the modal', function () {
+ fireEvent.click(this.cancelButton)
+ expect(screen.queryByRole('dialog')).to.not.exist
+ })
+
+ it('leave the group', async function () {
+ const leaveGroupApiMock = fetchMock.delete(
+ `/subscription/group/user?subscriptionId=bcd567`,
+ {
+ status: 204,
+ }
+ )
+
+ fireEvent.click(this.leaveNowButton)
+
+ expect(leaveGroupApiMock.called()).to.be.true
+ await waitFor(() => {
+ expect(reloadStub).to.have.been.called
+ })
+ })
+ })
+
+ it('renders nothing when there are no group subscriptions', function () {
+ window.metaAttributesCache.set('ol-memberGroupSubscriptions', undefined)
+
+ render(
+
+
+
+ )
+ const elements = screen.queryAllByText('You are on our', {
+ exact: false,
+ })
+ expect(elements.length).to.equal(0)
+ })
+})
diff --git a/services/web/types/subscription/dashboard/modal-ids.ts b/services/web/types/subscription/dashboard/modal-ids.ts
index cfe06b7ebd..0c49515501 100644
--- a/services/web/types/subscription/dashboard/modal-ids.ts
+++ b/services/web/types/subscription/dashboard/modal-ids.ts
@@ -2,3 +2,4 @@ export type SubscriptionDashModalIds =
| 'change-to-plan'
| 'change-to-group'
| 'keep-current-plan'
+ | 'leave-group'
diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts
index ea6e63c09e..d10ab0953d 100644
--- a/services/web/types/subscription/dashboard/subscription.ts
+++ b/services/web/types/subscription/dashboard/subscription.ts
@@ -1,3 +1,4 @@
+import { CurrencyCode } from '../../../frontend/js/features/subscription/data/currency'
import { Nullable } from '../../utils'
import { Plan } from '../plan'
import { User } from '../../../types/user'
@@ -24,7 +25,7 @@ export type Subscription = {
additionalLicenses: number
totalLicenses: number
nextPaymentDueAt: string
- currency: string
+ currency: CurrencyCode
state?: SubscriptionState
trialEndsAtFormatted: Nullable
trial_ends_at: Nullable
@@ -53,7 +54,8 @@ export type Subscription = {
}
export type GroupSubscription = Subscription & {
- teamName: string
+ teamName?: string
+ teamNotice?: string
}
export type ManagedGroupSubscription = Omit & {
@@ -61,3 +63,9 @@ export type ManagedGroupSubscription = Omit & {
planLevelName: string
admin_id: User
}
+
+export type MemberGroupSubscription = Omit & {
+ userIsGroupManager: boolean
+ planLevelName: string
+ admin_id: User
+}