diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index fa1e6f6b79..5d10739ba3 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index a5a3c50bd8..dcd766d643 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -22,6 +22,7 @@ type resendInviteResponse = { type ManagedUserDropdownButtonProps = { user: User openOffboardingModalForUser: (user: User) => void + openUnlinkUserModal: (user: User) => void groupId: string setGroupUserAlert: Dispatch> } @@ -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({ ) } + if (groupSSOActive && isGroupSSOLinked) { + buttons.push( + + {t('unlink_user')} + + ) + } if (groupSSOActive && !isGroupSSOLinked && !userPending) { buttons.push( + case 'unlinkedSSO': + return ( + ]} // eslint-disable-line react/jsx-key + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> + } + id="sso-user-unlinked" + ariaLive="polite" + isDismissible + onDismiss={onDismiss} + /> + ) } } diff --git a/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx b/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx index 22812ab0ca..0235b539df 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/member-row.tsx @@ -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> } @@ -21,6 +22,7 @@ type ManagedUserRowProps = { export default function MemberRow({ user, openOffboardingModalForUser, + openUnlinkUserModal, setGroupUserAlert, groupId, }: ManagedUserRowProps) { @@ -95,6 +97,7 @@ export default function MemberRow({ diff --git a/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx b/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx index f3225c4958..cd58db2046 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx @@ -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(undefined) + const [userToUnlink, setUserToUnlink] = useState(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 && ( + setUserToUnlink(undefined)} + setGroupUserAlert={setGroupUserAlert} + /> + )} ) } diff --git a/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx b/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx new file mode 100644 index 0000000000..13462e8947 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx @@ -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> +} + +export default function UnlinkUserModal({ + onClose, + user, + setGroupUserAlert, +}: UnlinkUserModalProps) { + const { t } = useTranslation() + const groupId = getMeta('ol-groupId') + const [hasError, setHasError] = useState() + 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 ( + + + {t('unlink_user')} + + + {hasError && ( +
+ +
+ )} +

+ ]} // eslint-disable-line react/jsx-key + values={{ email: user.email }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> +

+
+ + + + +
+ ) +} diff --git a/services/web/frontend/js/features/group-management/context/group-members-context.tsx b/services/web/frontend/js/features/group-management/context/group-members-context.tsx index 2c443b7f41..0098774bc2 100644 --- a/services/web/frontend/js/features/group-management/context/group-members-context.tsx +++ b/services/web/frontend/js/features/group-management/context/group-members-context.tsx @@ -28,6 +28,7 @@ export type GroupMembersContextValue = { removeMember: (user: User) => Promise 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( () => ({ 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, diff --git a/services/web/frontend/js/features/group-management/utils/types.ts b/services/web/frontend/js/features/group-management/utils/types.ts index e3f2f06c7e..13b7fd9d38 100644 --- a/services/web/frontend/js/features/group-management/utils/types.ts +++ b/services/web/frontend/js/features/group-management/utils/types.ts @@ -6,6 +6,7 @@ export type GroupUserAlertVariant = | 'resendInviteTooManyRequests' | 'resendSSOLinkInviteSuccess' | 'resendSSOLinkInviteFailed' + | 'unlinkedSSO' export type GroupUserAlert = | { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 568a6d4655..00a0925156 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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__", "sso_test_interstitial_info_1": "<0>Before starting this test, please ensure you’ve <1>configured Overleaf as a Service Provider in your IdP, and authorized access to the Overleaf service.", "sso_test_interstitial_info_2": "Clicking <0>Test configuration will redirect you to your IdP’s login screen. <1>Read our documentation for full details of what happens during the test. And check our <2>SSO troubleshooting advice if you get stuck.", "sso_test_interstitial_title": "Let’s 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": "You’re about to remove the SSO login option for <0>__email__. This will force them to reauthenticate their Overleaf account with your IdP. They’ll 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", diff --git a/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx index 882cabaf16..2e37fca39b 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx @@ -23,6 +23,7 @@ function mountDropDownComponent(user: User, subscriptionId: string) { @@ -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') diff --git a/services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx index 09b788e28c..3d5a833ee9 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx @@ -36,6 +36,7 @@ describe('MemberRow', function () { @@ -87,6 +88,7 @@ describe('MemberRow', function () { @@ -122,6 +124,7 @@ describe('MemberRow', function () { @@ -157,6 +160,7 @@ describe('MemberRow', function () { @@ -205,6 +209,7 @@ describe('MemberRow', function () { @@ -255,6 +260,7 @@ describe('MemberRow', function () { @@ -290,6 +296,7 @@ describe('MemberRow', function () { @@ -325,6 +332,7 @@ describe('MemberRow', function () { @@ -373,6 +381,7 @@ describe('MemberRow', function () { @@ -425,6 +434,7 @@ describe('MemberRow', function () { @@ -460,6 +470,7 @@ describe('MemberRow', function () { @@ -495,6 +506,7 @@ describe('MemberRow', function () { @@ -544,6 +556,7 @@ describe('MemberRow', function () { @@ -596,6 +609,7 @@ describe('MemberRow', function () { @@ -631,6 +645,7 @@ describe('MemberRow', function () { @@ -666,6 +681,7 @@ describe('MemberRow', function () { diff --git a/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx b/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx new file mode 100644 index 0000000000..d02fedcd35 --- /dev/null +++ b/services/web/test/frontend/features/group-management/components/members-table/unlink-user-modal.test.tsx @@ -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 + }) => {children} + + return render(component, { wrapper: GroupMembersProviderWrapper }) +} + +describe('', 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() + await screen.findByRole('heading', { + name: 'Unlink user', + }) + }) +})