first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,454 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { debounce } from 'lodash'
import { Trans, useTranslation } from 'react-i18next'
import withErrorBoundary from '@/infrastructure/error-boundary'
import useAbortController from '@/shared/hooks/use-abort-controller'
import LoadingSpinner from '@/shared/components/loading-spinner'
import Notification from '@/shared/components/notification'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import {
Card,
Row,
Col,
FormGroup,
FormLabel,
FormControl,
} from 'react-bootstrap-5'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
import Button from '@/features/ui/components/bootstrap-5/button'
import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
import RequestStatus from '@/features/group-management/components/request-status'
import useAsync from '@/shared/hooks/use-async'
import getMeta from '@/utils/meta'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import * as yup from 'yup'
import {
AddOnUpdate,
SubscriptionChangePreview,
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride, Nullable } from '../../../../../../types/utils'
import { sendMB } from '../../../../infrastructure/event-tracking'
export const MAX_NUMBER_OF_USERS = 20
type CostSummaryData = MergeAndOverride<
SubscriptionChangePreview,
{ change: AddOnUpdate }
>
function AddSeats() {
const { t } = useTranslation()
const groupName = getMeta('ol-groupName')
const subscriptionId = getMeta('ol-subscriptionId')
const totalLicenses = Number(getMeta('ol-totalLicenses'))
const isProfessional = getMeta('ol-isProfessional')
const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
const [shouldContactSales, setShouldContactSales] = useState(false)
const controller = useAbortController()
const { signal: addSeatsSignal } = useAbortController()
const { signal: contactSalesSignal } = useAbortController()
const {
isLoading: isLoadingCostSummary,
isError: isErrorCostSummary,
runAsync: runAsyncCostSummary,
data: costSummaryData,
reset: resetCostSummaryData,
error: errorCostSummary,
} = useAsync<CostSummaryData, FetchError>()
const {
isLoading: isAddingSeats,
isError: isErrorAddingSeats,
isSuccess: isSuccessAddingSeats,
runAsync: runAsyncAddSeats,
data: addedSeatsData,
} = useAsync<{ adding: number }>()
const {
isLoading: isSendingMailToSales,
isError: isErrorSendingMailToSales,
isSuccess: isSuccessSendingMailToSales,
runAsync: runAsyncSendMailToSales,
} = useAsync()
const addSeatsValidationSchema = useMemo(() => {
return yup
.number()
.typeError(t('value_must_be_a_number'))
.integer(t('value_must_be_a_whole_number'))
.min(1, t('value_must_be_at_least_x', { value: 1 }))
.required(t('this_field_is_required'))
}, [t])
const debouncedCostSummaryRequest = useMemo(
() =>
debounce((value: number, signal: AbortSignal) => {
const post = postJSON('/user/subscription/group/add-users/preview', {
signal,
body: { adding: value },
})
runAsyncCostSummary(post).catch(debugConsole.error)
}, 500),
[runAsyncCostSummary]
)
const debouncedTrackUserEnterSeatNumberEvent = useMemo(
() =>
debounce((value: number) => {
sendMB('flex-add-users-form', {
action: 'enter-seat-number',
seatNumber: value,
})
}, 500),
[]
)
const validateSeats = async (value: string | undefined) => {
try {
await addSeatsValidationSchema.validate(value)
setAddSeatsInputError(undefined)
return true
} catch (error) {
if (error instanceof yup.ValidationError) {
setAddSeatsInputError(error.errors[0])
} else {
debugConsole.error(error)
}
return false
}
}
const handleSeatsChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? undefined : e.target.value
const isValidSeatsNumber = await validateSeats(value)
let shouldContactSales = false
if (isValidSeatsNumber) {
const seats = Number(value)
debouncedTrackUserEnterSeatNumberEvent(seats)
if (seats > MAX_NUMBER_OF_USERS) {
debouncedCostSummaryRequest.cancel()
shouldContactSales = true
} else {
debouncedCostSummaryRequest(seats, controller.signal)
}
} else {
debouncedTrackUserEnterSeatNumberEvent.cancel()
debouncedCostSummaryRequest.cancel()
}
resetCostSummaryData()
setShouldContactSales(shouldContactSales)
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const rawSeats =
formData.get('seats') === ''
? undefined
: (formData.get('seats') as string)
if (!(await validateSeats(rawSeats))) {
return
}
if (shouldContactSales) {
sendMB('flex-add-users-form', {
action: 'click-send-request-button',
})
const post = postJSON(
'/user/subscription/group/add-users/sales-contact-form',
{
signal: contactSalesSignal,
body: {
adding: rawSeats,
},
}
)
runAsyncSendMailToSales(post).catch(debugConsole.error)
} else {
sendMB('flex-add-users-form', {
action: 'click-add-user-button',
})
const post = postJSON('/user/subscription/group/add-users/create', {
signal: addSeatsSignal,
body: { adding: Number(rawSeats) },
})
runAsyncAddSeats(post)
.then(() => {
sendMB('flex-add-users-success')
})
.catch(() => {
debugConsole.error()
sendMB('flex-add-users-error')
})
}
}
useEffect(() => {
return () => {
debouncedCostSummaryRequest.cancel()
}
}, [debouncedCostSummaryRequest])
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
const handleUnload = () => formRef.current?.reset()
window.addEventListener('beforeunload', handleUnload)
return () => window.removeEventListener('beforeunload', handleUnload)
}, [])
if (isErrorAddingSeats || isErrorSendingMailToSales) {
return (
<RequestStatus
variant="danger"
icon="error"
title={t('something_went_wrong')}
content={
<Trans
i18nKey="it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch"
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
components={[<a href="/contact" rel="noreferrer noopener" />]}
/>
}
/>
)
}
if (isSuccessAddingSeats) {
return (
<RequestStatus
variant="primary"
icon="check_circle"
title={t('youve_added_more_licenses')}
content={
<Trans
i18nKey="youve_added_x_more_licenses_to_your_subscription_invite_people"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href={`/manage/groups/${subscriptionId}/members`}
rel="noreferrer noopener"
/>,
]}
values={{ users: addedSeatsData?.adding }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
/>
)
}
if (isSuccessSendingMailToSales) {
return (
<RequestStatus
icon="email"
title={t('we_got_your_request')}
content={t('our_team_will_get_back_to_you_shortly')}
/>
)
}
return (
<div className="container">
<Row>
<Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
href="/user/subscription"
size="lg"
icon="arrow_back"
accessibilityLabel={t('back_to_subscription')}
/>
<h2>{groupName || t('group_subscription')}</h2>
</div>
<Card className="card-description-secondary">
<Card.Body>
<form
noValidate
className="d-grid gap-4"
onSubmit={handleSubmit}
ref={formRef}
data-testid="add-more-users-group-form"
>
<div className="d-grid gap-1">
<h4 className="fw-bold m-0 card-description-secondary">
{t('buy_more_licenses')}
</h4>
<div>
{t('your_current_plan_supports_up_to_x_licenses', {
users: totalLicenses,
})}
</div>
<div>
<Trans
i18nKey="if_you_want_to_reduce_the_number_of_licenses_please_contact_support"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/contact"
onClick={() => {
sendMB('flex-add-users-form', {
action: 'click-contact-customer-support-link',
})
}}
/>,
]}
/>
</div>
</div>
<div>
<FormGroup controlId="number-of-users-input">
<FormLabel>
{t('how_many_licenses_do_you_want_to_buy')}
</FormLabel>
<FormControl
type="text"
required
className="w-25"
name="seats"
disabled={isLoadingCostSummary}
onChange={handleSeatsChange}
isInvalid={Boolean(addSeatsInputError)}
/>
{Boolean(addSeatsInputError) && (
<FormText type="error">{addSeatsInputError}</FormText>
)}
</FormGroup>
</div>
<CostSummarySection
isLoadingCostSummary={isLoadingCostSummary}
isErrorCostSummary={isErrorCostSummary}
errorCostSummary={errorCostSummary}
shouldContactSales={shouldContactSales}
costSummaryData={costSummaryData}
totalLicenses={totalLicenses}
/>
<div className="d-flex align-items-center justify-content-end gap-2">
{!isProfessional && (
<a
href="/user/subscription/group/upgrade-subscription"
rel="noreferrer noopener"
className="me-auto"
onClick={() => {
sendMB('flex-upgrade')
}}
>
{t('upgrade_my_plan')}
</a>
)}
<Button
variant="secondary"
href="/user/subscription"
onClick={() =>
sendMB('flex-add-users-form', {
action: 'click-cancel-button',
})
}
>
{t('cancel')}
</Button>
<Button
variant="primary"
type="submit"
disabled={
isAddingSeats ||
isLoadingCostSummary ||
isSendingMailToSales
}
isLoading={isAddingSeats || isSendingMailToSales}
>
{shouldContactSales ? t('send_request') : t('buy_licenses')}
</Button>
</div>
</form>
</Card.Body>
</Card>
</Col>
</Row>
</div>
)
}
type CostSummarySectionProps = {
isLoadingCostSummary: boolean
isErrorCostSummary: boolean
errorCostSummary: Nullable<FetchError>
shouldContactSales: boolean
costSummaryData: Nullable<CostSummaryData>
totalLicenses: number
}
function CostSummarySection({
isLoadingCostSummary,
isErrorCostSummary,
errorCostSummary,
shouldContactSales,
costSummaryData,
totalLicenses,
}: CostSummarySectionProps) {
const { t } = useTranslation()
if (isLoadingCostSummary) {
return <LoadingSpinner className="ms-auto me-auto" />
}
if (shouldContactSales) {
return (
<Notification
content={
<Trans
i18nKey="if_you_want_more_than_x_licenses_on_your_plan_we_need_to_add_them_for_you"
// eslint-disable-next-line react/jsx-key
components={[<b />]}
values={{ count: MAX_NUMBER_OF_USERS }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
type="info"
/>
)
}
if (isErrorCostSummary) {
if (errorCostSummary?.data?.code === 'subtotal_limit_exceeded') {
return (
<Notification
type="error"
content={
<Trans
i18nKey="sorry_there_was_an_issue_adding_x_users_to_your_subscription"
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
components={[<a href="/contact" rel="noreferrer noopener" />]}
values={{ count: errorCostSummary?.data?.adding }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
/>
)
}
return (
<Notification type="error" content={t('generic_something_went_wrong')} />
)
}
return (
<CostSummary
subscriptionChange={costSummaryData}
totalLicenses={totalLicenses}
/>
)
}
export default withErrorBoundary(AddSeats)

View File

@@ -0,0 +1,160 @@
import { Trans, useTranslation } from 'react-i18next'
import { Card, ListGroup } from 'react-bootstrap-5'
import { formatCurrency } from '@/shared/utils/currency'
import { formatTime } from '@/features/utils/format-date'
import {
AddOnUpdate,
SubscriptionChangePreview,
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride } from '../../../../../../types/utils'
type CostSummaryProps = {
subscriptionChange: MergeAndOverride<
SubscriptionChangePreview,
{ change: AddOnUpdate }
> | null
totalLicenses: number
}
function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
const { t } = useTranslation()
const factor = 100
return (
<Card
className="card-gray card-description-secondary"
data-testid="cost-summary"
>
<Card.Body className="d-grid gap-2 p-3">
<div>
<div className="fw-bold">{t('cost_summary')}</div>
{subscriptionChange ? (
<Trans
i18nKey="youre_adding_x_licenses_to_your_plan_giving_you_a_total_of_y_licenses"
components={[
<b />, // eslint-disable-line react/jsx-key
<b />, // eslint-disable-line react/jsx-key
]}
values={{
adding:
subscriptionChange.change.addOn.quantity -
subscriptionChange.change.addOn.prevQuantity,
total:
totalLicenses +
subscriptionChange.change.addOn.quantity -
subscriptionChange.change.addOn.prevQuantity,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
) : (
t(
'enter_the_number_of_licenses_youd_like_to_add_to_see_the_cost_breakdown'
)
)}
</div>
{subscriptionChange && (
<>
<div>
<ListGroup>
<ListGroup.Item
className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
data-testid="plan"
>
<span className="me-auto">
{subscriptionChange.nextInvoice.plan.name} x{' '}
{subscriptionChange.change.addOn.quantity -
subscriptionChange.change.addOn.prevQuantity}{' '}
{t('licenses')}
</span>
<span data-testid="price">
{formatCurrency(
subscriptionChange.immediateCharge.subtotal,
subscriptionChange.currency
)}
</span>
</ListGroup.Item>
{subscriptionChange.immediateCharge.discount !== 0 && (
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<span className="me-auto">{t('discount')}</span>
<span data-testid="discount">
(
{formatCurrency(
subscriptionChange.immediateCharge.discount,
subscriptionChange.currency
)}
)
</span>
</ListGroup.Item>
)}
<ListGroup.Item
className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
data-testid="tax"
>
<span className="me-auto">
{t('vat')} &middot;{' '}
{Math.round(
subscriptionChange.nextInvoice.tax.rate * 100 * factor
) / factor}
%
</span>
<span data-testid="price">
{formatCurrency(
subscriptionChange.immediateCharge.tax,
subscriptionChange.currency
)}
</span>
</ListGroup.Item>
<ListGroup.Item
className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
data-testid="total"
>
<strong className="me-auto">{t('total_due_today')}</strong>
<strong data-testid="price">
{formatCurrency(
subscriptionChange.immediateCharge.total,
subscriptionChange.currency
)}
</strong>
</ListGroup.Item>
</ListGroup>
<hr className="m-0" />
</div>
<div>
{t(
'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months'
)}
</div>
<div>
{t(
'after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel',
{
totalAmount: formatCurrency(
subscriptionChange.nextInvoice.total,
subscriptionChange.currency
),
subtotalAmount: formatCurrency(
subscriptionChange.nextInvoice.subtotal,
subscriptionChange.currency
),
taxAmount: formatCurrency(
subscriptionChange.nextInvoice.tax.amount,
subscriptionChange.currency
),
date: formatTime(
subscriptionChange.nextInvoice.date,
'MMMM D'
),
}
)}
{subscriptionChange.immediateCharge.discount !== 0 &&
` ${t('coupons_not_included')}.`}
</div>
</>
)}
</Card.Body>
</Card>
)
}
export default CostSummary

View File

@@ -0,0 +1,14 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import AddSeats from '@/features/group-management/components/add-seats/add-seats'
function Root() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return <AddSeats />
}
export default Root

View File

@@ -0,0 +1,20 @@
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
type BackButtonProps = {
href: string
accessibilityLabel: string
}
function BackButton({ href, accessibilityLabel }: BackButtonProps) {
return (
<IconButton
variant="ghost"
href={href}
size="lg"
icon="arrow_back"
accessibilityLabel={accessibilityLabel}
/>
)
}
export default BackButton

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import { Card as BSCard, CardBody, Col, Row } from 'react-bootstrap-5'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
type CardProps = {
children: React.ReactNode
}
function Card({ children }: CardProps) {
const { t } = useTranslation()
const groupName = getMeta('ol-groupName')
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<div className="container">
<Row>
<Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
href="/user/subscription"
size="lg"
icon="arrow_back"
accessibilityLabel={t('back_to_subscription')}
/>
<h2>{groupName || t('group_subscription')}</h2>
</div>
<BSCard>
<CardBody>{children}</CardBody>
</BSCard>
</Col>
</Row>
</div>
)
}
export default Card

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export type APIError = {
message?: string
}
type ErrorAlertProps = {
error?: APIError
}
export default function ErrorAlert({ error }: ErrorAlertProps) {
const { t } = useTranslation()
if (!error) {
return null
}
if (error.message) {
return (
<OLNotification
type="error"
content={`${t('error')}: ${error.message}`}
/>
)
}
return (
<OLNotification type="error" content={t('generic_something_went_wrong')} />
)
}

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import getMeta from '../../../utils/meta'
import { ManagersTable } from './managers-table'
export default function GroupManagers() {
const { isReady } = useWaitForI18n()
const { t } = useTranslation()
const groupId = getMeta('ol-groupId')
const groupName = getMeta('ol-groupName')
const paths = useMemo(
() => ({
addMember: `/manage/groups/${groupId}/managers`,
removeMember: `/manage/groups/${groupId}/managers`,
}),
[groupId]
)
if (!isReady) {
return null
}
return (
<ManagersTable
groupName={groupName}
translations={{
title: t('group_subscription'),
subtitle: t('managers_management'),
remove: t('remove_manager'),
}}
paths={paths}
/>
)
}

View File

@@ -0,0 +1,194 @@
import React, { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import getMeta from '../../../utils/meta'
import { useGroupMembersContext } from '../context/group-members-context'
import ErrorAlert from './error-alert'
import MembersList from './members-table/members-list'
import { sendMB } from '../../../infrastructure/event-tracking'
import BackButton from '@/features/group-management/components/back-button'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLCard from '@/features/ui/components/ol/ol-card'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormText from '@/features/ui/components/ol/ol-form-text'
export default function GroupMembers() {
const { isReady } = useWaitForI18n()
const { t } = useTranslation()
const {
users,
selectedUsers,
addMembers,
removeMembers,
removeMemberLoading,
removeMemberError,
inviteMemberLoading,
inviteError,
paths,
} = useGroupMembersContext()
const [emailString, setEmailString] = useState<string>('')
const groupId = getMeta('ol-groupId')
const groupName = getMeta('ol-groupName')
const groupSize = getMeta('ol-groupSize')
const canUseFlexibleLicensing = getMeta('ol-canUseFlexibleLicensing')
const canUseAddSeatsFeature = getMeta('ol-canUseAddSeatsFeature')
const handleEmailsChange = useCallback(
e => {
setEmailString(e.target.value)
},
[setEmailString]
)
if (!isReady) {
return null
}
const onAddMembersSubmit = (e: React.FormEvent) => {
e.preventDefault()
addMembers(emailString)
}
const groupSizeDetails = () => {
if (canUseFlexibleLicensing) {
return (
<small data-testid="group-size-details">
<strong>
{users.length === 1
? t('you_have_1_license_and_your_plan_supports_up_to_y', {
groupSize,
})
: t('you_have_x_licenses_and_your_plan_supports_up_to_y', {
addedUsersSize: users.length,
groupSize,
})}
</strong>
{canUseAddSeatsFeature && (
<>
{' '}
<a
href="/user/subscription/group/add-users"
rel="noreferrer noopener"
onClick={() => sendMB('flex-add-users')}
>
{t('buy_more_licenses')}.
</a>
</>
)}
</small>
)
}
return (
<small>
<Trans
i18nKey="you_have_added_x_of_group_size_y"
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
values={{ addedUsersSize: users.length, groupSize }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</small>
)
}
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 10, offset: 1 }}>
<div className="group-heading" data-testid="group-heading">
<BackButton
href="/user/subscription"
accessibilityLabel={t('back_to_subscription')}
/>
<h1 className="heading">{groupName || t('group_subscription')}</h1>
</div>
<OLCard>
<div
className="page-header mb-4"
data-testid="page-header-members-details"
>
<div className="pull-right">
{selectedUsers.length === 0 && groupSizeDetails()}
{removeMemberLoading ? (
<OLButton variant="danger" disabled>
{t('removing')}&hellip;
</OLButton>
) : (
<>
{selectedUsers.length > 0 && (
<OLButton variant="danger" onClick={removeMembers}>
{t('remove_from_group')}
</OLButton>
)}
</>
)}
</div>
<h2 className="h3 mt-0">{t('members_management')}</h2>
</div>
<div className="row-spaced-small">
<ErrorAlert error={removeMemberError} />
<MembersList groupId={groupId} />
</div>
<hr />
{users.length < groupSize && (
<div
className="add-more-members-form"
data-testid="add-more-members-form"
>
<p className="small">{t('invite_more_members')}</p>
<ErrorAlert error={inviteError} />
<form onSubmit={onAddMembersSubmit}>
<OLRow>
<OLCol xs={6}>
<OLFormControl
type="input"
placeholder="jane@example.com, joe@example.com"
aria-describedby="add-members-description"
value={emailString}
onChange={handleEmailsChange}
/>
</OLCol>
<OLCol xs={4}>
<OLButton
variant="primary"
onClick={onAddMembersSubmit}
isLoading={inviteMemberLoading}
loadingLabel={t('inviting')}
>
{t('invite')}
</OLButton>
</OLCol>
<OLCol xs={2}>
<a href={paths.exportMembers}>{t('export_csv')}</a>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={8}>
<OLFormText>
{t('add_comma_separated_emails_help')}
</OLFormText>
</OLCol>
</OLRow>
</form>
</div>
)}
{users.length >= groupSize && users.length > 0 && (
<>
<ErrorAlert error={inviteError} />
<OLRow>
<OLCol xs={{ span: 2, offset: 10 }}>
<a href={paths.exportMembers}>{t('export_csv')}</a>
</OLCol>
</OLRow>
</>
)}
</OLCard>
</OLCol>
</OLRow>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import getMeta from '../../../utils/meta'
import { ManagersTable } from './managers-table'
export default function InstitutionManagers() {
const { isReady } = useWaitForI18n()
const { t } = useTranslation()
const groupId = getMeta('ol-groupId')
const groupName = getMeta('ol-groupName')
const paths = useMemo(
() => ({
addMember: `/manage/institutions/${groupId}/managers`,
removeMember: `/manage/institutions/${groupId}/managers`,
}),
[groupId]
)
if (!isReady) {
return null
}
return (
<ManagersTable
groupName={groupName}
translations={{
title: t('institution_account'),
subtitle: t('managers_management'),
remove: t('remove_manager'),
}}
paths={paths}
/>
)
}

View File

@@ -0,0 +1,272 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { deleteJSON, FetchError, postJSON } from '@/infrastructure/fetch-json'
import getMeta from '../../../utils/meta'
import { parseEmails } from '../utils/emails'
import ErrorAlert, { APIError } from './error-alert'
import UserRow from './user-row'
import useUserSelection from '../hooks/use-user-selection'
import { User } from '../../../../../types/group-management/user'
import { debugConsole } from '@/utils/debugging'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import BackButton from '@/features/group-management/components/back-button'
import OLCard from '@/features/ui/components/ol/ol-card'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormText from '@/features/ui/components/ol/ol-form-text'
import OLTable from '@/features/ui/components/ol/ol-table'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
type ManagersPaths = {
addMember: string
removeMember: string
}
type UsersTableProps = {
groupName: string
paths: ManagersPaths
translations: {
title: string
subtitle: string
remove: string
}
}
export function ManagersTable({
groupName,
translations,
paths,
}: UsersTableProps) {
const { t } = useTranslation()
const {
users,
setUsers,
selectedUsers,
selectAllUsers,
unselectAllUsers,
selectUser,
unselectUser,
} = useUserSelection(getMeta('ol-users') || [])
const [emailString, setEmailString] = useState<string>('')
const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
const [inviteError, setInviteError] = useState<APIError>()
const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
const [removeMemberError, setRemoveMemberError] = useState<APIError>()
const addManagers = useCallback(
e => {
e.preventDefault()
setInviteError(undefined)
const emails = parseEmails(emailString)
;(async () => {
for (const email of emails) {
setInviteUserInflightCount(count => count + 1)
try {
const data = await postJSON<{ user: User }>(paths.addMember, {
body: {
email,
},
})
if (data.user) {
const alreadyListed = users.find(
user => user.email === data.user.email
)
if (!alreadyListed) {
setUsers(users => [...users, data.user])
}
}
setEmailString('')
} catch (error: unknown) {
debugConsole.error(error)
setInviteError((error as FetchError)?.data?.error || {})
}
setInviteUserInflightCount(count => count - 1)
}
})()
},
[emailString, paths.addMember, users, setUsers]
)
const removeManagers = useCallback(
e => {
e.preventDefault()
setRemoveMemberError(undefined)
;(async () => {
for (const user of selectedUsers) {
let url
if (paths.removeMember && user._id) {
url = `${paths.removeMember}/${user._id}`
} else {
return
}
setRemoveMemberInflightCount(count => count + 1)
try {
await deleteJSON(url, {})
setUsers(users => users.filter(u => u !== user))
unselectUser(user)
} catch (error: unknown) {
debugConsole.error(error)
setRemoveMemberError((error as FetchError)?.data?.error || {})
}
setRemoveMemberInflightCount(count => count - 1)
}
})()
},
[selectedUsers, unselectUser, setUsers, paths.removeMember]
)
const handleSelectAllClick = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
selectAllUsers()
} else {
unselectAllUsers()
}
},
[selectAllUsers, unselectAllUsers]
)
const handleEmailsChange = useCallback(
e => {
setEmailString(e.target.value)
},
[setEmailString]
)
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 10, offset: 1 }}>
<div className="group-heading" data-testid="group-heading">
<BackButton
href="/user/subscription"
accessibilityLabel={t('back_to_subscription')}
/>
<h1 className="heading">{groupName || translations.title}</h1>
</div>
<OLCard>
<div
className="page-header mb-4"
data-testid="page-header-members-details"
>
<div className="pull-right">
{removeMemberInflightCount > 0 ? (
<OLButton variant="danger" disabled>
{t('removing')}&hellip;
</OLButton>
) : (
<>
{selectedUsers.length > 0 && (
<OLButton variant="danger" onClick={removeManagers}>
{translations.remove}
</OLButton>
)}
</>
)}
</div>
<h2 className="h3 mt-0">{translations.subtitle}</h2>
</div>
<div className="row-spaced-small">
<ErrorAlert error={removeMemberError} />
<OLTable
className="managed-entities-table managed-entities-list structured-list"
container={false}
hover
data-testid="managed-entities-table"
>
<thead>
<tr>
<th className="cell-checkbox">
<OLFormCheckbox
autoComplete="off"
onChange={handleSelectAllClick}
checked={selectedUsers.length === users.length}
aria-label={t('select_all')}
data-testid="select-all-checkbox"
/>
</th>
<th>{t('email')}</th>
<th className="cell-name">{t('name')}</th>
<th className="cell-last-active">
<OLTooltip
id="last-active-tooltip"
description={t('last_active_description')}
overlayProps={{
placement: 'left',
}}
>
<span>
{t('last_active')}
<sup>(?)</sup>
</span>
</OLTooltip>
</th>
<th className="cell-accepted-invite">
{t('accepted_invite')}
</th>
</tr>
</thead>
<tbody>
{users.length === 0 && (
<tr>
<td className="text-center" colSpan={5}>
<small>{t('no_members')}</small>
</td>
</tr>
)}
{users.map(user => (
<UserRow
key={user.email}
user={user}
selectUser={selectUser}
unselectUser={unselectUser}
selected={selectedUsers.includes(user)}
/>
))}
</tbody>
</OLTable>
</div>
<hr />
<div>
<p className="small">{t('add_more_managers')}</p>
<ErrorAlert error={inviteError} />
<form onSubmit={addManagers} data-testid="add-members-form">
<OLRow>
<OLCol xs={6}>
<OLFormControl
type="input"
placeholder="jane@example.com, joe@example.com"
aria-describedby="add-members-description"
value={emailString}
onChange={handleEmailsChange}
/>
</OLCol>
<OLCol xs={4}>
<OLButton
variant="primary"
onClick={addManagers}
isLoading={inviteUserInflightCount > 0}
>
{t('add')}
</OLButton>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={8}>
<OLFormText>
{t('add_comma_separated_emails_help')}
</OLFormText>
</OLCol>
</OLRow>
</form>
</div>
</OLCard>
</OLCol>
</OLRow>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Trans, useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import Card from '@/features/group-management/components/card'
function ManuallyCollectedSubscription() {
const { t } = useTranslation()
return (
<Card>
<OLNotification
type="error"
title={t('account_billed_manually')}
content={
<Trans
i18nKey="it_looks_like_your_account_is_billed_manually"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</Card>
)
}
export default ManuallyCollectedSubscription

View File

@@ -0,0 +1,322 @@
import {
type ComponentProps,
useCallback,
type Dispatch,
type SetStateAction,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { User } from '../../../../../../types/group-management/user'
import useAsync from '@/shared/hooks/use-async'
import { type FetchError, postJSON } from '@/infrastructure/fetch-json'
import { GroupUserAlert } from '../../utils/types'
import { useGroupMembersContext } from '../../context/group-members-context'
import getMeta from '@/utils/meta'
import MaterialIcon from '@/shared/components/material-icon'
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
import { Spinner } from 'react-bootstrap-5'
type resendInviteResponse = {
success: boolean
}
type ManagedUserDropdownButtonProps = {
user: User
openOffboardingModalForUser: (user: User) => void
openUnlinkUserModal: (user: User) => void
groupId: string
setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
}
export default function DropdownButton({
user,
openOffboardingModalForUser,
openUnlinkUserModal,
groupId,
setGroupUserAlert,
}: ManagedUserDropdownButtonProps) {
const { t } = useTranslation()
const { removeMember } = useGroupMembersContext()
const {
runAsync: runResendManagedUserInviteAsync,
isLoading: isResendingManagedUserInvite,
} = useAsync<resendInviteResponse>()
const {
runAsync: runResendLinkSSOInviteAsync,
isLoading: isResendingSSOLinkInvite,
} = useAsync<resendInviteResponse>()
const {
runAsync: runResendGroupInviteAsync,
isLoading: isResendingGroupInvite,
} = useAsync<resendInviteResponse>()
const managedUsersActive = getMeta('ol-managedUsersActive')
const groupSSOActive = getMeta('ol-groupSSOActive')
const userPending = user.invite
const isGroupSSOLinked =
!userPending && user.enrollment?.sso?.some(sso => sso.groupId === groupId)
const isUserManaged = !userPending && user.enrollment?.managedBy === groupId
const handleResendManagedUserInvite = useCallback(
async user => {
try {
const result = await runResendManagedUserInviteAsync(
postJSON(
`/manage/groups/${groupId}/resendManagedUserInvite/${user._id}`
)
)
if (result.success) {
setGroupUserAlert({
variant: 'resendManagedUserInviteSuccess',
email: user.email,
})
}
} catch (err) {
if ((err as FetchError)?.response?.status === 429) {
setGroupUserAlert({
variant: 'resendInviteTooManyRequests',
email: user.email,
})
} else {
setGroupUserAlert({
variant: 'resendManagedUserInviteFailed',
email: user.email,
})
}
}
},
[setGroupUserAlert, groupId, runResendManagedUserInviteAsync]
)
const handleResendLinkSSOInviteAsync = useCallback(
async user => {
try {
const result = await runResendLinkSSOInviteAsync(
postJSON(`/manage/groups/${groupId}/resendSSOLinkInvite/${user._id}`)
)
if (result.success) {
setGroupUserAlert({
variant: 'resendSSOLinkInviteSuccess',
email: user.email,
})
}
} catch (err) {
if ((err as FetchError)?.response?.status === 429) {
setGroupUserAlert({
variant: 'resendInviteTooManyRequests',
email: user.email,
})
} else {
setGroupUserAlert({
variant: 'resendSSOLinkInviteFailed',
email: user.email,
})
}
}
},
[setGroupUserAlert, groupId, runResendLinkSSOInviteAsync]
)
const handleResendGroupInvite = useCallback(
async user => {
try {
await runResendGroupInviteAsync(
postJSON(`/manage/groups/${groupId}/resendInvite/`, {
body: {
email: user.email,
},
})
)
setGroupUserAlert({
variant: 'resendGroupInviteSuccess',
email: user.email,
})
} catch (err) {
if ((err as FetchError)?.response?.status === 429) {
setGroupUserAlert({
variant: 'resendInviteTooManyRequests',
email: user.email,
})
} else {
setGroupUserAlert({
variant: 'resendGroupInviteFailed',
email: user.email,
})
}
}
},
[setGroupUserAlert, groupId, runResendGroupInviteAsync]
)
const onResendManagedUserInviteClick = () => {
handleResendManagedUserInvite(user)
}
const onResendSSOLinkInviteClick = () => {
handleResendLinkSSOInviteAsync(user)
}
const onResendGroupInviteClick = () => {
handleResendGroupInvite(user)
}
const onDeleteUserClick = () => {
openOffboardingModalForUser(user)
}
const onRemoveFromGroup = () => {
removeMember(user)
}
const onUnlinkUserClick = () => {
openUnlinkUserModal(user)
}
const buttons = []
if (userPending) {
buttons.push(
<MenuItemButton
onClick={onResendGroupInviteClick}
key="resend-group-invite-action"
isLoading={isResendingGroupInvite}
data-testid="resend-group-invite-action"
>
{t('resend_group_invite')}
</MenuItemButton>
)
}
if (managedUsersActive && !isUserManaged && !userPending) {
buttons.push(
<MenuItemButton
onClick={onResendManagedUserInviteClick}
key="resend-managed-user-invite-action"
isLoading={isResendingManagedUserInvite}
data-testid="resend-managed-user-invite-action"
>
{t('resend_managed_user_invite')}
</MenuItemButton>
)
}
if (groupSSOActive && isGroupSSOLinked) {
buttons.push(
<MenuItemButton
onClick={onUnlinkUserClick}
key="unlink-user-action"
data-testid="unlink-user-action"
>
{t('unlink_user')}
</MenuItemButton>
)
}
if (groupSSOActive && !isGroupSSOLinked && !userPending) {
buttons.push(
<MenuItemButton
onClick={onResendSSOLinkInviteClick}
key="resend-sso-link-invite-action"
isLoading={isResendingSSOLinkInvite}
data-testid="resend-sso-link-invite-action"
>
{t('resend_link_sso')}
</MenuItemButton>
)
}
if (isUserManaged && !user.isEntityAdmin) {
buttons.push(
<MenuItemButton
className="delete-user-action"
key="delete-user-action"
data-testid="delete-user-action"
onClick={onDeleteUserClick}
>
{t('delete_user')}
</MenuItemButton>
)
} else if (!isUserManaged) {
buttons.push(
<MenuItemButton
key="remove-user-action"
data-testid="remove-user-action"
onClick={onRemoveFromGroup}
className="delete-user-action"
variant="danger"
>
{t('remove_from_group')}
</MenuItemButton>
)
}
if (buttons.length === 0) {
buttons.push(
<DropdownListItem>
<DropdownItem
as="button"
tabIndex={-1}
data-testid="no-actions-available"
disabled
>
{t('no_actions')}
</DropdownItem>
</DropdownListItem>
)
}
return (
<Dropdown align="end">
<DropdownToggle
id={`managed-user-dropdown-${user.email}`}
bsPrefix="dropdown-table-button-toggle"
>
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
</DropdownToggle>
<DropdownMenu flip={false}>{buttons}</DropdownMenu>
</Dropdown>
)
}
type MenuItemButtonProps = {
isLoading?: boolean
'data-testid'?: string
} & Pick<ComponentProps<'button'>, 'children' | 'onClick' | 'className'> &
Pick<ComponentProps<typeof DropdownItem>, 'variant'>
function MenuItemButton({
children,
onClick,
className,
isLoading,
variant,
'data-testid': dataTestId,
}: MenuItemButtonProps) {
return (
<DropdownListItem>
<DropdownItem
as="button"
tabIndex={-1}
onClick={onClick}
leadingIcon={
isLoading ? (
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
) : null
}
data-testid={dataTestId}
variant={variant}
>
{children}
</DropdownItem>
</DropdownListItem>
)
}

View File

@@ -0,0 +1,269 @@
import { Trans } from 'react-i18next'
import type { GroupUserAlertVariant } from '../../utils/types'
import NotificationScrolledTo from '@/shared/components/notification-scrolled-to'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type GroupUsersListAlertProps = {
variant: GroupUserAlertVariant
userEmail?: string
onDismiss: () => void
}
export default function ListAlert({
variant,
userEmail,
onDismiss,
}: GroupUsersListAlertProps) {
switch (variant) {
case 'resendManagedUserInviteSuccess':
return (
<ResendManagedUserInviteSuccess
onDismiss={onDismiss}
userEmail={userEmail}
/>
)
case 'resendSSOLinkInviteSuccess':
return (
<ResendSSOLinkInviteSuccess
onDismiss={onDismiss}
userEmail={userEmail}
/>
)
case 'resendManagedUserInviteFailed':
return (
<FailedToResendManagedInvite
onDismiss={onDismiss}
userEmail={userEmail}
/>
)
case 'resendSSOLinkInviteFailed':
return (
<FailedToResendSSOLink onDismiss={onDismiss} userEmail={userEmail} />
)
case 'resendGroupInviteSuccess':
return (
<ResendGroupInviteSuccess onDismiss={onDismiss} userEmail={userEmail} />
)
case 'resendGroupInviteFailed':
return (
<FailedToResendGroupInvite
onDismiss={onDismiss}
userEmail={userEmail}
/>
)
case 'resendInviteTooManyRequests':
return <TooManyRequests onDismiss={onDismiss} userEmail={userEmail} />
case 'unlinkedSSO':
return (
<NotificationScrolledTo
type="success"
content={
<Trans
i18nKey="sso_reauth_request"
values={{ email: userEmail }}
components={[<strong />]} // eslint-disable-line react/jsx-key
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
id="sso-user-unlinked"
ariaLive="polite"
isDismissible
onDismiss={onDismiss}
/>
)
}
}
type GroupUsersListAlertComponentProps = {
onDismiss: () => void
userEmail?: string
}
function ResendManagedUserInviteSuccess({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="success"
content={
<Trans
i18nKey="managed_user_invite_has_been_sent_to_email"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}
function ResendSSOLinkInviteSuccess({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="success"
content={
<Trans
i18nKey="sso_link_invite_has_been_sent_to_email"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}
function FailedToResendManagedInvite({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="error"
content={
<Trans
i18nKey="failed_to_send_managed_user_invite_to_email"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}
function FailedToResendSSOLink({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="error"
content={
<Trans
i18nKey="failed_to_send_sso_link_invite_to_email"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}
function ResendGroupInviteSuccess({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="success"
content={
<Trans
i18nKey="group_invite_has_been_sent_to_email"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}
function FailedToResendGroupInvite({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="error"
content={
<Trans
i18nKey="failed_to_send_group_invite_to_email"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}
function TooManyRequests({
onDismiss,
userEmail,
}: GroupUsersListAlertComponentProps) {
return (
<OLNotification
type="error"
content={
<Trans
i18nKey="an_email_has_already_been_sent_to"
values={{
email: userEmail,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
isDismissible
onDismiss={onDismiss}
/>
)
}

View File

@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import MaterialIcon from '@/shared/components/material-icon'
type ManagedUserStatusProps = {
user: User
}
export default function ManagedUserStatus({ user }: ManagedUserStatusProps) {
const { t } = useTranslation()
const managedUserInvite = (
<span className="security-state-invite-pending">
<MaterialIcon type="schedule" accessibilityLabel={t('pending_invite')} />
&nbsp;
{t('managed')}
</span>
)
const managedUserAccepted = (
<span className="security-state-managed">
<MaterialIcon type="check" accessibilityLabel={t('managed')} />
&nbsp;
{t('managed')}
</span>
)
const managedUserNotAccepted = (
<span className="security-state-not-managed">
<MaterialIcon type="close" accessibilityLabel={t('not_managed')} />
&nbsp;
{t('managed')}
</span>
)
if (user.isEntityAdmin) {
return <span className="security-state-group-admin" />
}
if (user.invite) {
return managedUserInvite
}
return user.enrollment?.managedBy
? managedUserAccepted
: managedUserNotAccepted
}

View File

@@ -0,0 +1,122 @@
import moment from 'moment'
import { type Dispatch, type SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import type { GroupUserAlert } from '../../utils/types'
import ManagedUserStatus from './managed-user-status'
import SSOStatus from './sso-status'
import DropdownButton from './dropdown-button'
import SelectUserCheckbox from './select-user-checkbox'
import getMeta from '@/utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLTag from '@/features/ui/components/ol/ol-tag'
import MaterialIcon from '@/shared/components/material-icon'
import classnames from 'classnames'
type ManagedUserRowProps = {
user: User
openOffboardingModalForUser: (user: User) => void
openUnlinkUserModal: (user: User) => void
groupId: string
setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
}
export default function MemberRow({
user,
openOffboardingModalForUser,
openUnlinkUserModal,
setGroupUserAlert,
groupId,
}: ManagedUserRowProps) {
const { t } = useTranslation()
const managedUsersActive = getMeta('ol-managedUsersActive')
const groupSSOActive = getMeta('ol-groupSSOActive')
return (
<tr className="managed-entity-row">
<SelectUserCheckbox user={user} />
<td
className={classnames('cell-email', {
'text-muted': user.invite,
})}
>
<span>
{user.email}
{user.invite && (
<>
&nbsp;
<OLTooltip
id={`pending-invite-symbol-${user.email}`}
description={t('pending_invite')}
>
<OLTag data-testid="badge-pending-invite">
{t('pending_invite')}
</OLTag>
</OLTooltip>
</>
)}
{user.isEntityAdmin && (
<>
&nbsp;
<OLTooltip
id={`group-admin-symbol-${user.email}`}
description={t('group_admin')}
>
<span data-testid="group-admin-symbol">
<MaterialIcon
type="account_circle"
accessibilityLabel={t('group_admin')}
className="align-middle"
/>
</span>
</OLTooltip>
</>
)}
</span>
</td>
<td
className={classnames('cell-name', {
'text-muted': user.invite,
})}
>
{user.first_name} {user.last_name}
</td>
<td
className={classnames('cell-last-active', {
'text-muted': user.invite,
})}
>
{user.last_active_at
? moment(user.last_active_at).format('Do MMM YYYY')
: 'N/A'}
</td>
{groupSSOActive && (
<td
className={classnames('cell-security', {
'text-muted': user.invite,
})}
>
<div className="managed-user-security">
<SSOStatus user={user} />
</div>
</td>
)}
{managedUsersActive && (
<td className="cell-managed">
<div className="managed-user-security">
<ManagedUserStatus user={user} />
</div>
</td>
)}
<td className="cell-dropdown">
<DropdownButton
user={user}
openOffboardingModalForUser={openOffboardingModalForUser}
openUnlinkUserModal={openUnlinkUserModal}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
/>
</td>
</tr>
)
}

View File

@@ -0,0 +1,128 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import { useGroupMembersContext } from '../../context/group-members-context'
import type { GroupUserAlert } from '../../utils/types'
import MemberRow from './member-row'
import OffboardManagedUserModal from './offboard-managed-user-modal'
import ListAlert from './list-alert'
import SelectAllCheckbox from './select-all-checkbox'
import classNames from 'classnames'
import getMeta from '@/utils/meta'
import UnlinkUserModal from './unlink-user-modal'
import OLTable from '@/features/ui/components/ol/ol-table'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type ManagedUsersListProps = {
groupId: string
}
export default function MembersList({ groupId }: ManagedUsersListProps) {
const { t } = useTranslation()
const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
undefined
)
const [groupUserAlert, setGroupUserAlert] =
useState<GroupUserAlert>(undefined)
const [userToUnlink, setUserToUnlink] = useState<User | undefined>(undefined)
const { users } = useGroupMembersContext()
const managedUsersActive = getMeta('ol-managedUsersActive')
const groupSSOActive = getMeta('ol-groupSSOActive')
const tHeadRowRef = useRef<HTMLTableRowElement>(null)
const [colSpan, setColSpan] = useState(0)
useEffect(() => {
if (tHeadRowRef.current) {
setColSpan(tHeadRowRef.current.querySelectorAll('th').length)
}
}, [])
return (
<div>
{groupUserAlert && (
<ListAlert
variant={groupUserAlert.variant}
userEmail={groupUserAlert.email}
onDismiss={() => setGroupUserAlert(undefined)}
/>
)}
<OLTable
className={classNames(
'managed-entities-table',
'structured-list',
'managed-entities-list',
{
'managed-users-active': managedUsersActive,
'group-sso-active': groupSSOActive,
}
)}
container={false}
hover
data-testid="managed-entities-table"
>
<thead>
<tr ref={tHeadRowRef}>
<SelectAllCheckbox />
<th className="cell-email">{t('email')}</th>
<th className="cell-name">{t('name')}</th>
<th className="cell-last-active">
<OLTooltip
id="last-active-tooltip"
description={t('last_active_description')}
overlayProps={{
placement: 'left',
}}
>
<span>
{t('last_active')}
<sup>(?)</sup>
</span>
</OLTooltip>
</th>
{groupSSOActive && (
<th className="cell-security">{t('security')}</th>
)}
{managedUsersActive && (
<th className="cell-managed">{t('managed')}</th>
)}
<th />
</tr>
</thead>
<tbody>
{users.length === 0 && (
<tr>
<td className="text-center" colSpan={colSpan}>
<small>{t('no_members')}</small>
</td>
</tr>
)}
{users.map(user => (
<MemberRow
key={user.email}
user={user}
openOffboardingModalForUser={setUserToOffboard}
openUnlinkUserModal={setUserToUnlink}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
/>
))}
</tbody>
</OLTable>
{userToOffboard && (
<OffboardManagedUserModal
user={userToOffboard}
groupId={groupId}
allMembers={users}
onClose={() => setUserToOffboard(undefined)}
/>
)}
{userToUnlink && (
<UnlinkUserModal
user={userToUnlink}
onClose={() => setUserToUnlink(undefined)}
setGroupUserAlert={setGroupUserAlert}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { User } from '../../../../../../types/group-management/user'
import { useState } from 'react'
import useAsync from '@/shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
import { useLocation } from '@/shared/hooks/use-location'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
type OffboardManagedUserModalProps = {
user: User
allMembers: User[]
groupId: string
onClose: () => void
}
export default function OffboardManagedUserModal({
user,
allMembers,
groupId,
onClose,
}: OffboardManagedUserModalProps) {
const { t } = useTranslation()
const location = useLocation()
const [selectedRecipientId, setSelectedRecipientId] = useState<string>()
const [suppliedEmail, setSuppliedEmail] = useState<string>()
const [error, setError] = useState<string>()
const { isLoading, isSuccess, runAsync } = useAsync()
const otherMembers = allMembers.filter(u => u._id !== user._id && !u.invite)
const userFullName = user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`
: user.first_name
const shouldEnableDeleteUserButton =
suppliedEmail === user.email && !!selectedRecipientId
const handleDeleteUserSubmit = (event: any) => {
event.preventDefault()
runAsync(
postJSON(`/manage/groups/${groupId}/offboardManagedUser/${user._id}`, {
body: {
verificationEmail: suppliedEmail,
transferProjectsToUserId: selectedRecipientId,
},
})
.then(() => {
location.reload()
})
.catch(err => {
debugConsole.error(err)
setError(
err instanceof FetchError ? err.getUserFacingMessage() : err.message
)
})
)
}
return (
<OLModal id={`delete-user-modal-${user._id}`} show onHide={onClose}>
<form id="delete-user-form" onSubmit={handleDeleteUserSubmit}>
<OLModalHeader>
<OLModalTitle>{t('delete_user')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('about_to_delete_user_preamble', {
userName: userFullName,
userEmail: user.email,
})}
</p>
<ul>
<li>{t('they_lose_access_to_account')}</li>
<li>{t('their_projects_will_be_transferred_to_another_user')}</li>
<li>{t('you_will_be_able_to_reassign_subscription')}</li>
</ul>
<p>
<span>{t('this_action_cannot_be_reversed')}</span>
&nbsp;
<a href="/learn/how-to/User_Management_in_Overleaf" target="_blank">
{t('learn_more_about_managed_users')}
</a>
</p>
<strong>{t('transfer_this_users_projects')}</strong>
<p>{t('transfer_this_users_projects_description')}</p>
<OLFormGroup controlId="recipient-select-input">
<OLFormLabel>{t('select_a_new_owner_for_projects')}</OLFormLabel>
<OLFormSelect
aria-label={t('select_user')}
required
placeholder={t('choose_from_group_members')}
value={selectedRecipientId || ''}
onChange={e => setSelectedRecipientId(e.target.value)}
>
<option hidden disabled value="">
{t('choose_from_group_members')}
</option>
{otherMembers.map(member => (
<option value={member._id} key={member.email}>
{member.email}
</option>
))}
</OLFormSelect>
</OLFormGroup>
<p>
<span>{t('all_projects_will_be_transferred_immediately')}</span>
</p>
<OLFormGroup controlId="supplied-email-input">
<OLFormLabel>
{t('confirm_delete_user_type_email_address', {
userName: userFullName,
})}
</OLFormLabel>
<OLFormControl
type="email"
aria-label={t('email')}
onChange={e => setSuppliedEmail(e.target.value)}
/>
</OLFormGroup>
{error && (
<OLNotification type="error" content={error} className="mb-0" />
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onClose}>
{t('cancel')}
</OLButton>
<OLButton
type="submit"
variant="danger"
disabled={isLoading || isSuccess || !shouldEnableDeleteUserButton}
loadingLabel={t('deleting')}
isLoading={isLoading}
>
{t('delete_user')}
</OLButton>
</OLModalFooter>
</form>
</OLModal>
)
}

View File

@@ -0,0 +1,42 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useGroupMembersContext } from '../../context/group-members-context'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
export default function SelectAllCheckbox() {
const { t } = useTranslation()
const { selectedUsers, users, selectAllNonManagedUsers, unselectAllUsers } =
useGroupMembersContext()
const handleSelectAllNonManagedClick = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
selectAllNonManagedUsers()
} else {
unselectAllUsers()
}
},
[selectAllNonManagedUsers, unselectAllUsers]
)
// Pending: user.enrollment will be `undefined`
// Not managed: user.enrollment will be an empty object
const nonManagedUsers = users.filter(user => !user.enrollment?.managedBy)
if (nonManagedUsers.length === 0) {
return null
}
return (
<th className="cell-checkbox">
<OLFormCheckbox
autoComplete="off"
onChange={handleSelectAllNonManagedClick}
checked={selectedUsers.length === nonManagedUsers.length}
aria-label={t('select_all')}
data-testid="select-all-checkbox"
/>
</th>
)
}

View File

@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next'
import type { User } from '../../../../../../types/group-management/user'
import { useGroupMembersContext } from '../../context/group-members-context'
import { useCallback } from 'react'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
type ManagedUsersSelectUserCheckboxProps = {
user: User
}
export default function SelectUserCheckbox({
user,
}: ManagedUsersSelectUserCheckboxProps) {
const { t } = useTranslation()
const { users, selectedUsers, selectUser, unselectUser } =
useGroupMembersContext()
const handleSelectUser = useCallback(
(event, user) => {
if (event.target.checked) {
selectUser(user)
} else {
unselectUser(user)
}
},
[selectUser, unselectUser]
)
// Pending: user.enrollment will be `undefined`
// Non managed: user.enrollment will be an empty object
const nonManagedUsers = users.filter(user => !user.enrollment?.managedBy)
// Hide the entire `td` (entire column) if no more users available to be click
// because all users are currently managed
if (nonManagedUsers.length === 0) {
return null
}
const selected = selectedUsers.includes(user)
return (
<td className="cell-checkbox">
{/* the next check will hide the `checkbox` but still show the `th` */}
{user.enrollment?.managedBy ? null : (
<OLFormCheckbox
autoComplete="off"
checked={selected}
onChange={e => handleSelectUser(e, user)}
aria-label={t('select_user')}
data-testid="select-single-checkbox"
/>
)}
</td>
)
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { User } from '../../../../../../types/group-management/user'
import MaterialIcon from '@/shared/components/material-icon'
type SSOStatusProps = {
user: User
}
export default function SSOStatus({ user }: SSOStatusProps) {
const groupId = getMeta('ol-groupId')
if (user.invite) {
return <PendingInvite />
}
const linkedSSO = user.enrollment?.sso?.some(sso => sso.groupId === groupId)
return linkedSSO ? <SSOLinked /> : <SSOUnlinked />
}
function PendingInvite() {
const { t } = useTranslation()
return (
<span className="security-state-invite-pending">
<MaterialIcon type="schedule" accessibilityLabel={t('pending_invite')} />
&nbsp; {t('sso')}
</span>
)
}
function SSOLinked() {
const { t } = useTranslation()
return (
<span className="security-state-managed">
<MaterialIcon type="check" accessibilityLabel={t('sso_active')} />
&nbsp; {t('sso')}
</span>
)
}
function SSOUnlinked() {
const { t } = useTranslation()
return (
<span className="security-state-not-managed">
<MaterialIcon type="close" accessibilityLabel={t('sso_not_active')} />
&nbsp; {t('sso')}
</span>
)
}

View File

@@ -0,0 +1,124 @@
import { useTranslation, Trans } from 'react-i18next'
import { User } from '../../../../../../types/group-management/user'
import getMeta from '@/utils/meta'
import { SetStateAction, useCallback, useState, type Dispatch } from 'react'
import useAsync from '@/shared/hooks/use-async'
import { postJSON } from '@/infrastructure/fetch-json'
import NotificationScrolledTo from '@/shared/components/notification-scrolled-to'
import { debugConsole } from '@/utils/debugging'
import { GroupUserAlert } from '../../utils/types'
import { useGroupMembersContext } from '../../context/group-members-context'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export type UnlinkUserModalProps = {
onClose: () => void
user: User
setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
}
export default function UnlinkUserModal({
onClose,
user,
setGroupUserAlert,
}: UnlinkUserModalProps) {
const { t } = useTranslation()
const groupId = getMeta('ol-groupId')
const [hasError, setHasError] = useState<string | undefined>()
const { isLoading: unlinkInFlight, runAsync, reset } = useAsync()
const { updateMemberView } = useGroupMembersContext()
const setUserAsUnlinked = useCallback(() => {
if (!user.enrollment?.sso) {
return
}
const enrollment = Object.assign({}, user.enrollment, {
sso: user.enrollment.sso.filter(sso => sso.groupId !== groupId),
})
const updatedUser = Object.assign({}, user, {
enrollment,
})
updateMemberView(user._id, updatedUser)
}, [groupId, updateMemberView, user])
const handleUnlink = useCallback(
event => {
event.preventDefault()
setHasError(undefined)
if (!user) {
setHasError(t('generic_something_went_wrong'))
return
}
runAsync(postJSON(`/manage/groups/${groupId}/unlink-user/${user._id}`))
.then(() => {
setUserAsUnlinked()
setGroupUserAlert({
variant: 'unlinkedSSO',
email: user.email,
})
onClose()
reset()
})
.catch(e => {
debugConsole.error(e)
setHasError(t('generic_something_went_wrong'))
})
},
[
groupId,
onClose,
reset,
runAsync,
setGroupUserAlert,
setUserAsUnlinked,
t,
user,
]
)
return (
<OLModal show onHide={onClose}>
<OLModalHeader>
<OLModalTitle>{t('unlink_user')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{hasError && (
<div className="mb-3">
<NotificationScrolledTo
type="error"
content={hasError}
id="alert-unlink-user-error"
ariaLive="polite"
/>
</div>
)}
<p>
<Trans
i18nKey="unlink_user_explanation"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ email: user.email }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" disabled={unlinkInFlight}>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
onClick={e => handleUnlink(e)}
disabled={unlinkInFlight}
>
{t('unlink_user')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,33 @@
import { Trans, useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import Card from '@/features/group-management/components/card'
function MissingBillingInformation() {
const { t } = useTranslation()
return (
<Card>
<OLNotification
type="error"
title={t('missing_payment_details')}
content={
<Trans
i18nKey="it_looks_like_your_payment_details_are_missing_please_update_your_billing_information"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/user/subscription/recurly/billing-details"
rel="noreferrer noopener"
/>,
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</Card>
)
}
export default MissingBillingInformation

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import getMeta from '../../../utils/meta'
import { ManagersTable } from './managers-table'
export default function PublisherManagers() {
const { isReady } = useWaitForI18n()
const { t } = useTranslation()
const groupId = getMeta('ol-groupId')
const groupName = getMeta('ol-groupName')
const paths = useMemo(
() => ({
addMember: `/manage/publishers/${groupId}/managers`,
removeMember: `/manage/publishers/${groupId}/managers`,
}),
[groupId]
)
if (!isReady) {
return null
}
return (
<ManagersTable
groupName={groupName}
translations={{
title: t('publisher_account'),
subtitle: t('managers_management'),
remove: t('remove_manager'),
}}
paths={paths}
/>
)
}

View File

@@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next'
import { Card, CardBody, Row, Col } from 'react-bootstrap-5'
import Button from '@/features/ui/components/bootstrap-5/button'
import MaterialIcon from '@/shared/components/material-icon'
import getMeta from '@/utils/meta'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import classnames from 'classnames'
type RequestStatusProps = {
icon: string
title: string
content?: React.ReactNode
variant?: 'primary' | 'danger'
}
function RequestStatus({ icon, title, content, variant }: RequestStatusProps) {
const { t } = useTranslation()
const groupName = getMeta('ol-groupName')
return (
<div className="container">
<Row>
<Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
href="/user/subscription"
size="lg"
icon="arrow_back"
accessibilityLabel={t('back_to_subscription')}
/>
<h2>{groupName || t('group_subscription')}</h2>
</div>
<Card>
<CardBody className="d-grid gap-3">
<div
className={classnames('card-icon', {
[`text-${variant}`]: variant,
})}
>
<MaterialIcon type={icon} />
</div>
<div className="d-grid gap-2 text-center">
<h3 className="mb-0 fw-bold" data-testid="title">
{title}
</h3>
{content && (
<div className="card-description-secondary">{content}</div>
)}
</div>
<div className="text-center">
<Button variant="secondary" href="/user/subscription">
{t('go_to_subscriptions')}
</Button>
</div>
</CardBody>
</Card>
</Col>
</Row>
</div>
)
}
export default RequestStatus

View File

@@ -0,0 +1,25 @@
import { Trans } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import Card from '@/features/group-management/components/card'
function SubtotalLimitExceeded() {
return (
<Card>
<OLNotification
type="error"
content={
<Trans
i18nKey="sorry_there_was_an_issue_upgrading_your_subscription"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</Card>
)
}
export default SubtotalLimitExceeded

View File

@@ -0,0 +1,14 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription'
function Root() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return <UpgradeSubscription />
}
export default Root

View File

@@ -0,0 +1,75 @@
import getMeta from '@/utils/meta'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Card, Row, Col } from 'react-bootstrap-5'
import MaterialIcon from '@/shared/components/material-icon'
import { formatCurrency } from '@/shared/utils/currency'
export const LICENSE_ADD_ON = 'additional-license'
function UpgradeSubscriptionPlanDetails() {
const { t } = useTranslation()
const preview = getMeta('ol-subscriptionChangePreview')
const totalLicenses = getMeta('ol-totalLicenses')
const licenseUnitPrice = useMemo(() => {
const additionalLicenseAddOn = preview.nextInvoice.addOns.filter(
addOn => addOn.code === LICENSE_ADD_ON
)
// Legacy plans might not have additional-license add-on.
// Hence we need to compute unit price
return additionalLicenseAddOn.length > 0
? additionalLicenseAddOn[0].unitAmount
: preview.nextInvoice.plan.amount / totalLicenses
}, [
preview.nextInvoice.addOns,
preview.nextInvoice.plan.amount,
totalLicenses,
])
return (
<Card
className="group-subscription-upgrade-features card-description-secondary border-1"
border="light"
>
<Card.Body className="d-grid gap-3 p-3">
<b>{preview.nextInvoice.plan.name}</b>
<Row xs="auto" className="gx-2">
<Col>
<span className="per-user-price" data-testid="per-user-price">
<b>
{formatCurrency(
licenseUnitPrice,
preview.currency,
getMeta('ol-i18n')?.currentLangCode ?? 'en',
true
)}
</b>
</span>
</Col>
<Col className="d-flex flex-column justify-content-center">
<div className="per-user-price-text">{t('per_license')}</div>
<div className="per-user-price-text">{t('billed_yearly')}</div>
</Col>
</Row>
<div className="feature-list-item">
<b>{t('all_features_in_group_standard_plus')}</b>
</div>
<div className="ps-2 feature-list-item">
<MaterialIcon type="check" className="me-1" />
{t('unlimited_collaborators_per_project')}
</div>
<div className="ps-2 feature-list-item">
<MaterialIcon type="check" className="me-1" />
{t('sso')}
</div>
<div className="ps-2 feature-list-item">
<MaterialIcon type="check" className="me-1" />
{t('managed_user_accounts')}
</div>
</Card.Body>
</Card>
)
}
export default UpgradeSubscriptionPlanDetails

View File

@@ -0,0 +1,114 @@
import { useTranslation } from 'react-i18next'
import { Card, ListGroup } from 'react-bootstrap-5'
import { formatCurrency } from '@/shared/utils/currency'
import { formatTime } from '@/features/utils/format-date'
import {
GroupPlanUpgrade,
SubscriptionChangePreview,
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride } from '../../../../../../types/utils'
import getMeta from '@/utils/meta'
export type SubscriptionChange = MergeAndOverride<
SubscriptionChangePreview,
{ change: GroupPlanUpgrade }
>
type UpgradeSummaryProps = {
subscriptionChange: SubscriptionChange
}
function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) {
const { t } = useTranslation()
const totalLicenses = getMeta('ol-totalLicenses')
return (
<Card className="card-gray card-description-secondary">
<Card.Body className="d-grid gap-2 p-3">
<div>
<div className="fw-bold">{t('upgrade_summary')}</div>
{t('you_have_x_licenses_on_your_subscription', {
groupSize: totalLicenses,
})}
</div>
<div>
<ListGroup>
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<span className="me-auto">
{subscriptionChange.nextInvoice.plan.name} x {totalLicenses}{' '}
{t('licenses')}
</span>
<span data-testid="subtotal">
{formatCurrency(
subscriptionChange.immediateCharge.subtotal,
subscriptionChange.currency
)}
</span>
</ListGroup.Item>
{subscriptionChange.immediateCharge.discount !== 0 && (
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<span className="me-auto">{t('discount')}</span>
<span data-testid="discount">
(
{formatCurrency(
subscriptionChange.immediateCharge.discount,
subscriptionChange.currency
)}
)
</span>
</ListGroup.Item>
)}
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<span className="me-auto">{t('vat')}</span>
<span data-testid="tax">
{formatCurrency(
subscriptionChange.immediateCharge.tax,
subscriptionChange.currency
)}
</span>
</ListGroup.Item>
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<strong className="me-auto">{t('total_due_today')}</strong>
<strong data-testid="total">
{formatCurrency(
subscriptionChange.immediateCharge.total,
subscriptionChange.currency
)}
</strong>
</ListGroup.Item>
</ListGroup>
<hr className="m-0" />
</div>
<div>
{t(
'we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription'
)}
</div>
<div>
{t(
'after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel',
{
totalAmount: formatCurrency(
subscriptionChange.nextInvoice.total,
subscriptionChange.currency
),
subtotalAmount: formatCurrency(
subscriptionChange.nextInvoice.subtotal,
subscriptionChange.currency
),
taxAmount: formatCurrency(
subscriptionChange.nextInvoice.tax.amount,
subscriptionChange.currency
),
date: formatTime(subscriptionChange.nextInvoice.date, 'MMMM D'),
}
)}
{subscriptionChange.immediateCharge.discount !== 0 &&
` ${t('coupons_not_included')}.`}
</div>
</Card.Body>
</Card>
)
}
export default UpgradeSummary

View File

@@ -0,0 +1,149 @@
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
import { useTranslation, Trans } from 'react-i18next'
import { Card, Row, Col } from 'react-bootstrap-5'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import Button from '@/features/ui/components/bootstrap-5/button'
import UpgradeSubscriptionPlanDetails from './upgrade-subscription-plan-details'
import useAsync from '@/shared/hooks/use-async'
import RequestStatus from '../request-status'
import UpgradeSummary, {
SubscriptionChange,
} from './upgrade-subscription-upgrade-summary'
import { debugConsole } from '@/utils/debugging'
import { sendMB } from '../../../../infrastructure/event-tracking'
function UpgradeSubscription() {
const { t } = useTranslation()
const groupName = getMeta('ol-groupName')
const preview = getMeta('ol-subscriptionChangePreview') as SubscriptionChange
const { isError, runAsync, isSuccess, isLoading } = useAsync()
const onSubmit = () => {
sendMB('flex-upgrade-form', {
action: 'click-upgrade-button',
})
runAsync(postJSON('/user/subscription/group/upgrade-subscription'))
.then(() => {
sendMB('flex-upgrade-success')
})
.catch(() => {
debugConsole.error()
sendMB('flex-upgrade-error')
})
}
if (isSuccess) {
return (
<RequestStatus
variant="primary"
icon="check_circle"
title={t('youve_upgraded_your_plan')}
/>
)
}
if (isError) {
return (
<RequestStatus
variant="danger"
icon="error"
title={t('something_went_wrong')}
content={
<Trans
i18nKey="it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/contact"
onClick={() => {
sendMB('flex-upgrade-form', {
action: 'click-get-in-touch-link',
})
}}
/>,
]}
/>
}
/>
)
}
return (
<div className="container">
<Row>
<Col xl={{ span: 8, offset: 2 }}>
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
href="/user/subscription"
size="lg"
icon="arrow_back"
accessibilityLabel={t('back_to_subscription')}
/>
<h2>{groupName || t('group_subscription')}</h2>
</div>
<Card className="card-description-secondary group-subscription-upgrade-card">
<Card.Body className="d-grid gap-2">
<b className="title">{t('upgrade_your_subscription')}</b>
<p>
<Trans
i18nKey="group_plan_upgrade_description"
values={{
currentPlan: preview.change.prevPlan.name,
nextPlan: preview.nextInvoice.plan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a href="/contact" />,
]}
/>
</p>
<Row className="mb-2">
<Col md={{ span: 6 }} className="mb-2">
<UpgradeSubscriptionPlanDetails />
</Col>
<Col md={{ span: 6 }}>
<UpgradeSummary subscriptionChange={preview} />
</Col>
</Row>
<div className="d-flex align-items-center justify-content-end gap-2">
<a
href="/user/subscription/group/add-users"
className="me-auto"
onClick={() => sendMB('flex-add-users')}
>
{t('add_more_licenses_to_my_plan')}
</a>
<Button
href="/user/subscription"
variant="secondary"
disabled={isLoading}
onClick={() => {
sendMB('flex-upgrade-form', {
action: 'click-cancel-button',
})
}}
>
{t('cancel')}
</Button>
<Button
variant="primary"
onClick={onSubmit}
isLoading={isLoading}
>
{t('upgrade')}
</Button>
</div>
</Card.Body>
</Card>
</Col>
</Row>
</div>
)
}
export default UpgradeSubscription

View File

@@ -0,0 +1,70 @@
import moment from 'moment'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { User } from '../../../../../types/group-management/user'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import MaterialIcon from '@/shared/components/material-icon'
type GroupMemberRowProps = {
user: User
selectUser: (user: User) => void
unselectUser: (user: User) => void
selected: boolean
}
export default function UserRow({
user,
selectUser,
unselectUser,
selected,
}: GroupMemberRowProps) {
const { t } = useTranslation()
const handleSelectUser = useCallback(
(event, user) => {
if (event.target.checked) {
selectUser(user)
} else {
unselectUser(user)
}
},
[selectUser, unselectUser]
)
return (
<tr key={`user-${user.email}`} className="managed-entity-row">
<td className="cell-checkbox">
<OLFormCheckbox
autoComplete="off"
checked={selected}
onChange={e => handleSelectUser(e, user)}
aria-label={t('select_user')}
data-testid="select-single-checkbox"
/>
</td>
<td>{user.email}</td>
<td className="cell-name">
{user.first_name} {user.last_name}
</td>
<td className="cell-last-active">
{user.last_active_at
? moment(user.last_active_at).format('Do MMM YYYY')
: 'N/A'}
</td>
<td className="cell-accepted-invite">
{user.invite ? (
<MaterialIcon
type="clear"
accessibilityLabel={t('invite_not_accepted')}
/>
) : (
<MaterialIcon
type="check"
className="text-success"
accessibilityLabel={t('accepted_invite')}
/>
)}
</td>
</tr>
)
}

View File

@@ -0,0 +1,213 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import { User } from '../../../../../types/group-management/user'
import { deleteJSON, FetchError, postJSON } from '@/infrastructure/fetch-json'
import { mapSeries } from '@/infrastructure/promise'
import getMeta from '@/utils/meta'
import { APIError } from '../components/error-alert'
import useUserSelection from '../hooks/use-user-selection'
import { parseEmails } from '../utils/emails'
import { debugConsole } from '@/utils/debugging'
export type GroupMembersContextValue = {
users: User[]
selectedUsers: User[]
selectUser: (user: User) => void
selectAllUsers: () => void
unselectAllUsers: () => void
selectAllNonManagedUsers: () => void
unselectUser: (user: User) => void
addMembers: (emailString: string) => void
removeMembers: (e: any) => void
removeMember: (user: User) => Promise<void>
removeMemberLoading: boolean
removeMemberError?: APIError
updateMemberView: (userId: string, updatedUser: User) => void
inviteMemberLoading: boolean
inviteError?: APIError
paths: { [key: string]: string }
}
export const GroupMembersContext = createContext<
GroupMembersContextValue | undefined
>(undefined)
type GroupMembersProviderProps = {
children: ReactNode
}
export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
const {
users,
setUsers,
selectedUsers,
selectAllUsers,
unselectAllUsers,
selectAllNonManagedUsers,
selectUser,
unselectUser,
} = useUserSelection(getMeta('ol-users') || [])
const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
const [inviteError, setInviteError] = useState<APIError>()
const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
const [removeMemberError, setRemoveMemberError] = useState<APIError>()
const groupId = getMeta('ol-groupId')
const paths = useMemo(
() => ({
addMember: `/manage/groups/${groupId}/invites`,
removeMember: `/manage/groups/${groupId}/user`,
removeInvite: `/manage/groups/${groupId}/invites`,
exportMembers: `/manage/groups/${groupId}/members/export`,
}),
[groupId]
)
const addMembers = useCallback(
emailString => {
setInviteError(undefined)
const emails = parseEmails(emailString)
mapSeries(emails, async email => {
setInviteUserInflightCount(count => count + 1)
try {
const data = await postJSON<{ user: User }>(paths.addMember, {
body: {
email,
},
})
if (data.user) {
const alreadyListed = users.find(
user => user.email === data.user.email
)
if (!alreadyListed) {
setUsers(users => [...users, data.user])
}
}
} catch (error: unknown) {
debugConsole.error(error)
setInviteError((error as FetchError)?.data?.error || {})
}
setInviteUserInflightCount(count => count - 1)
})
},
[paths.addMember, users, setUsers]
)
const removeMember = useCallback(
async user => {
let url
if (paths.removeInvite && user.invite && user._id == null) {
url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
} else if (paths.removeMember && user._id) {
url = `${paths.removeMember}/${user._id}`
} else {
return
}
setRemoveMemberInflightCount(count => count + 1)
try {
await deleteJSON(url, {})
setUsers(users => users.filter(u => u !== user))
unselectUser(user)
} catch (error: unknown) {
debugConsole.error(error)
setRemoveMemberError((error as FetchError)?.data?.error || {})
}
setRemoveMemberInflightCount(count => count - 1)
},
[unselectUser, setUsers, paths.removeInvite, paths.removeMember]
)
const removeMembers = useCallback(
e => {
e.preventDefault()
setRemoveMemberError(undefined)
;(async () => {
for (const user of selectedUsers) {
if (user?.enrollment?.managedBy) {
continue
}
await removeMember(user)
}
})()
},
[selectedUsers, removeMember]
)
const updateMemberView = useCallback(
(userId, updatedUser) => {
setUsers(
users.map(u => {
if (u._id === userId) {
return updatedUser
} else {
return u
}
})
)
},
[setUsers, users]
)
const value = useMemo<GroupMembersContextValue>(
() => ({
users,
selectedUsers,
selectAllUsers,
unselectAllUsers,
selectAllNonManagedUsers,
selectUser,
unselectUser,
updateMemberView,
addMembers,
removeMembers,
removeMember,
removeMemberLoading: removeMemberInflightCount > 0,
removeMemberError,
inviteMemberLoading: inviteUserInflightCount > 0,
inviteError,
paths,
}),
[
users,
selectedUsers,
selectAllUsers,
unselectAllUsers,
selectAllNonManagedUsers,
selectUser,
unselectUser,
updateMemberView,
addMembers,
removeMembers,
removeMember,
removeMemberInflightCount,
removeMemberError,
inviteUserInflightCount,
inviteError,
paths,
]
)
return (
<GroupMembersContext.Provider value={value}>
{children}
</GroupMembersContext.Provider>
)
}
export function useGroupMembersContext() {
const context = useContext(GroupMembersContext)
if (!context) {
throw new Error(
'GroupMembersContext is only available inside GroupMembersProvider'
)
}
return context
}

View File

@@ -0,0 +1,37 @@
import { useCallback, useState } from 'react'
import { User } from '../../../../../types/group-management/user'
export default function useUserSelection(initialUsers: User[]) {
const [users, setUsers] = useState<User[]>(initialUsers)
const [selectedUsers, setSelectedUsers] = useState<User[]>([])
const selectAllUsers = () => setSelectedUsers(users)
const unselectAllUsers = () => setSelectedUsers([])
const selectAllNonManagedUsers = useCallback(() => {
// Pending: user.enrollment will be `undefined`
// Not managed: user.enrollment will be an empty object
const nonManagedUsers = users.filter(user => !user.enrollment?.managedBy)
setSelectedUsers(nonManagedUsers)
}, [users])
const selectUser = useCallback((user: User) => {
setSelectedUsers(users => [...users, user])
}, [])
const unselectUser = useCallback((user: User) => {
setSelectedUsers(users => users.filter(u => u.email !== user.email))
}, [])
return {
users,
setUsers,
selectedUsers,
selectUser,
unselectUser,
selectAllUsers,
unselectAllUsers,
selectAllNonManagedUsers,
}
}

View File

@@ -0,0 +1,9 @@
import _ from 'lodash'
export function parseEmails(emailsString: string) {
const regexBySpaceOrComma = /[\s,]+/
let emails = emailsString.split(regexBySpaceOrComma)
emails = _.map(emails, email => email.trim())
emails = _.filter(emails, email => email.indexOf('@') !== -1)
return emails
}

View File

@@ -0,0 +1,16 @@
export type GroupUserAlertVariant =
| 'resendManagedUserInviteSuccess'
| 'resendManagedUserInviteFailed'
| 'resendGroupInviteSuccess'
| 'resendGroupInviteFailed'
| 'resendInviteTooManyRequests'
| 'resendSSOLinkInviteSuccess'
| 'resendSSOLinkInviteFailed'
| 'unlinkedSSO'
export type GroupUserAlert =
| {
variant: GroupUserAlertVariant
email?: string
}
| undefined