first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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