Merge pull request #17683 from overleaf/jel-unlink

[web] Unlink group SSO member

GitOrigin-RevId: ac3498fc72538fd0eef639c783dc7675ac593b94
This commit is contained in:
Jessica Lawshe 2024-04-03 09:12:09 -05:00 committed by Copybot
parent 9d78fd0945
commit d8f870555c
12 changed files with 257 additions and 2 deletions

View file

@ -1229,6 +1229,7 @@
"sso_link_invite_has_been_sent_to_email": "",
"sso_logs": "",
"sso_not_active": "",
"sso_reauth_request": "",
"sso_test_interstitial_info_1": "",
"sso_test_interstitial_info_2": "",
"sso_test_interstitial_title": "",
@ -1446,6 +1447,8 @@
"unlink_provider_account_warning": "",
"unlink_reference": "",
"unlink_the_project_from_the_current_github_repo": "",
"unlink_user": "",
"unlink_user_explanation": "",
"unlink_users": "",
"unlink_warning_reference": "",
"unlinking": "",

View file

@ -22,6 +22,7 @@ type resendInviteResponse = {
type ManagedUserDropdownButtonProps = {
user: User
openOffboardingModalForUser: (user: User) => void
openUnlinkUserModal: (user: User) => void
groupId: string
setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
}
@ -29,6 +30,7 @@ type ManagedUserDropdownButtonProps = {
export default function DropdownButton({
user,
openOffboardingModalForUser,
openUnlinkUserModal,
groupId,
setGroupUserAlert,
}: ManagedUserDropdownButtonProps) {
@ -178,6 +180,10 @@ export default function DropdownButton({
removeMember(user)
}
const onUnlinkUserClick = () => {
openUnlinkUserModal(user)
}
const buttons = []
if (userPending) {
@ -208,6 +214,17 @@ export default function DropdownButton({
</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

View file

@ -2,6 +2,7 @@ import { type PropsWithChildren, useState } from 'react'
import { Alert, type AlertProps } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import type { GroupUserAlertVariant } from '../../utils/types'
import NotificationScrolledTo from '@/shared/components/notification-scrolled-to'
type GroupUsersListAlertProps = {
variant: GroupUserAlertVariant
@ -53,6 +54,25 @@ export default function ListAlert({
)
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}
/>
)
}
}

View file

@ -14,6 +14,7 @@ import getMeta from '@/utils/meta'
type ManagedUserRowProps = {
user: User
openOffboardingModalForUser: (user: User) => void
openUnlinkUserModal: (user: User) => void
groupId: string
setGroupUserAlert: Dispatch<SetStateAction<GroupUserAlert>>
}
@ -21,6 +22,7 @@ type ManagedUserRowProps = {
export default function MemberRow({
user,
openOffboardingModalForUser,
openUnlinkUserModal,
setGroupUserAlert,
groupId,
}: ManagedUserRowProps) {
@ -95,6 +97,7 @@ export default function MemberRow({
<DropdownButton
user={user}
openOffboardingModalForUser={openOffboardingModalForUser}
openUnlinkUserModal={openUnlinkUserModal}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
/>

View file

@ -11,6 +11,7 @@ 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'
type ManagedUsersListProps = {
groupId: string
@ -23,6 +24,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
)
const [groupUserAlert, setGroupUserAlert] =
useState<GroupUserAlert>(undefined)
const [userToUnlink, setUserToUnlink] = useState<User | undefined>(undefined)
const { users } = useGroupMembersContext()
const managedUsersActive: any = getMeta('ol-managedUsersActive')
const groupSSOActive = getMeta('ol-groupSSOActive')
@ -100,6 +102,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
key={user.email}
user={user}
openOffboardingModalForUser={setUserToOffboard}
openUnlinkUserModal={setUserToUnlink}
setGroupUserAlert={setGroupUserAlert}
groupId={groupId}
/>
@ -118,6 +121,13 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
onClose={() => setUserToOffboard(undefined)}
/>
)}
{userToUnlink && (
<UnlinkUserModal
user={userToUnlink}
onClose={() => setUserToUnlink(undefined)}
setGroupUserAlert={setGroupUserAlert}
/>
)}
</div>
)
}

View file

@ -0,0 +1,118 @@
import { Modal } from 'react-bootstrap'
import AccessibleModal from '@/shared/components/accessible-modal'
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'
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 updatedUser = Object.assign({}, user, {
enrollment: {
sso: user.enrollment.sso.filter(sso => sso.groupId !== groupId),
},
})
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 (
<AccessibleModal show onHide={onClose}>
<Modal.Header>
<Modal.Title>{t('unlink_user')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{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>
</Modal.Body>
<Modal.Footer>
<button className="btn btn-secondary" disabled={unlinkInFlight}>
{t('cancel')}
</button>
<button
className="btn btn-danger"
onClick={e => handleUnlink(e)}
disabled={unlinkInFlight}
>
{t('unlink_user')}
</button>
</Modal.Footer>
</AccessibleModal>
)
}

View file

@ -28,6 +28,7 @@ export type GroupMembersContextValue = {
removeMember: (user: User) => Promise<void>
removeMemberLoading: boolean
removeMemberError?: APIError
updateMemberView: (userId: string, updatedUser: User) => void
inviteMemberLoading: boolean
inviteError?: APIError
paths: { [key: string]: string }
@ -140,6 +141,21 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
[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,
@ -149,6 +165,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
selectAllNonManagedUsers,
selectUser,
unselectUser,
updateMemberView,
addMembers,
removeMembers,
removeMember,
@ -166,6 +183,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
selectAllNonManagedUsers,
selectUser,
unselectUser,
updateMemberView,
addMembers,
removeMembers,
removeMember,

View file

@ -6,6 +6,7 @@ export type GroupUserAlertVariant =
| 'resendInviteTooManyRequests'
| 'resendSSOLinkInviteSuccess'
| 'resendSSOLinkInviteFailed'
| 'unlinkedSSO'
export type GroupUserAlert =
| {

View file

@ -1780,6 +1780,7 @@
"sso_logs": "SSO Logs",
"sso_not_active": "SSO not active",
"sso_not_linked": "You have not linked your account to __provider__. Please log in to your account another way and link your __provider__ account via your account settings.",
"sso_reauth_request": "SSO reauthentication request has been sent to <0>__email__</0>",
"sso_test_interstitial_info_1": "<0>Before starting this test</0>, please ensure youve <1>configured Overleaf as a Service Provider in your IdP</1>, and authorized access to the Overleaf service.",
"sso_test_interstitial_info_2": "Clicking <0>Test configuration</0> will redirect you to your IdPs login screen. <1>Read our documentation</1> for full details of what happens during the test. And check our <2>SSO troubleshooting advice</2> if you get stuck.",
"sso_test_interstitial_title": "Lets test your SSO configuration",
@ -2060,6 +2061,8 @@
"unlink_provider_account_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.",
"unlink_reference": "Unlink References Provider",
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own. (You need an active __appName__ subscription to set up a GitHub Sync).",
"unlink_user": "Unlink user",
"unlink_user_explanation": "Youre about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. Theyll receive an email asking them to do this.",
"unlink_users": "Unlink users",
"unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.",
"unlinking": "Unlinking",

View file

@ -23,6 +23,7 @@ function mountDropDownComponent(user: User, subscriptionId: string) {
<DropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -314,6 +315,7 @@ describe('DropdownButton', function () {
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('unlink-user-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
@ -422,6 +424,7 @@ describe('DropdownButton', function () {
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('unlink-user-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
@ -468,6 +471,7 @@ describe('DropdownButton', function () {
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('unlink-user-action').should('be.visible')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
})
@ -512,6 +516,7 @@ describe('DropdownButton', function () {
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
cy.findByTestId('no-actions-available').should('not.exist')
cy.findByTestId('unlink-user-action').should('not.exist')
})
})
@ -557,6 +562,7 @@ describe('DropdownButton', function () {
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('unlink-user-action').should('be.visible')
cy.findByTestId('delete-user-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
@ -779,11 +785,12 @@ describe('DropdownButton', function () {
cy.get(`.action-btn`).should('exist')
})
it('should show no actions when dropdown button is clicked', function () {
it('should show no actions except to unlink when dropdown button is clicked', function () {
cy.get('.action-btn').click()
cy.findByTestId('no-actions-available').should('exist')
cy.findByTestId('unlink-user-action').should('exist')
cy.findByTestId('no-actions-available').should('not.exist')
cy.findByTestId('delete-user-action').should('not.exist')
cy.findByTestId('remove-user-action').should('not.exist')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')

View file

@ -36,6 +36,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -87,6 +88,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -122,6 +124,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -157,6 +160,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -205,6 +209,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -255,6 +260,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -290,6 +296,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -325,6 +332,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -373,6 +381,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -425,6 +434,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -460,6 +470,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -495,6 +506,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -544,6 +556,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -596,6 +609,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -631,6 +645,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
@ -666,6 +681,7 @@ describe('MemberRow', function () {
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>

View file

@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react'
import { ReactElement } from 'react'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import UnlinkUserModal from '@/features/group-management/components/members-table/unlink-user-modal'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
export function renderWithContext(component: ReactElement, props = {}) {
const GroupMembersProviderWrapper = ({
children,
}: {
children: ReactElement
}) => <GroupMembersProvider {...props}>{children}</GroupMembersProvider>
return render(component, { wrapper: GroupMembersProviderWrapper })
}
describe('<UnlinkUserModal />', function () {
let defaultProps: any
beforeEach(function () {
defaultProps = {
onClose: sinon.stub(),
user: {},
setGroupUserAlert: sinon.stub(),
}
})
afterEach(function () {
fetchMock.reset()
})
it('displays the modal', async function () {
renderWithContext(<UnlinkUserModal {...defaultProps} />)
await screen.findByRole('heading', {
name: 'Unlink user',
})
})
})