first commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user