first commit
This commit is contained in:
@@ -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)
|
@@ -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')} ·{' '}
|
||||
{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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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')} />
|
||||
)
|
||||
}
|
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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')}…
|
||||
</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>
|
||||
)
|
||||
}
|
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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')}…
|
||||
</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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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')} />
|
||||
|
||||
{t('managed')}
|
||||
</span>
|
||||
)
|
||||
|
||||
const managedUserAccepted = (
|
||||
<span className="security-state-managed">
|
||||
<MaterialIcon type="check" accessibilityLabel={t('managed')} />
|
||||
|
||||
{t('managed')}
|
||||
</span>
|
||||
)
|
||||
const managedUserNotAccepted = (
|
||||
<span className="security-state-not-managed">
|
||||
<MaterialIcon type="close" accessibilityLabel={t('not_managed')} />
|
||||
|
||||
{t('managed')}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (user.isEntityAdmin) {
|
||||
return <span className="security-state-group-admin" />
|
||||
}
|
||||
if (user.invite) {
|
||||
return managedUserInvite
|
||||
}
|
||||
return user.enrollment?.managedBy
|
||||
? managedUserAccepted
|
||||
: managedUserNotAccepted
|
||||
}
|
@@ -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 && (
|
||||
<>
|
||||
|
||||
<OLTooltip
|
||||
id={`pending-invite-symbol-${user.email}`}
|
||||
description={t('pending_invite')}
|
||||
>
|
||||
<OLTag data-testid="badge-pending-invite">
|
||||
{t('pending_invite')}
|
||||
</OLTag>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)}
|
||||
{user.isEntityAdmin && (
|
||||
<>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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')} />
|
||||
{t('sso')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SSOLinked() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className="security-state-managed">
|
||||
<MaterialIcon type="check" accessibilityLabel={t('sso_active')} />
|
||||
{t('sso')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SSOUnlinked() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className="security-state-not-managed">
|
||||
<MaterialIcon type="close" accessibilityLabel={t('sso_not_active')} />
|
||||
{t('sso')}
|
||||
</span>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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,
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
export type GroupUserAlertVariant =
|
||||
| 'resendManagedUserInviteSuccess'
|
||||
| 'resendManagedUserInviteFailed'
|
||||
| 'resendGroupInviteSuccess'
|
||||
| 'resendGroupInviteFailed'
|
||||
| 'resendInviteTooManyRequests'
|
||||
| 'resendSSOLinkInviteSuccess'
|
||||
| 'resendSSOLinkInviteFailed'
|
||||
| 'unlinkedSSO'
|
||||
|
||||
export type GroupUserAlert =
|
||||
| {
|
||||
variant: GroupUserAlertVariant
|
||||
email?: string
|
||||
}
|
||||
| undefined
|
Reference in New Issue
Block a user