Просмотр исходного кода

Merge pull request #17683 from overleaf/jel-unlink

[web] Unlink group SSO member

GitOrigin-RevId: ac3498fc72538fd0eef639c783dc7675ac593b94
Jessica Lawshe 2 лет назад
Родитель
Сommit
d8f870555c

+ 3 - 0
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": "",

+ 17 - 0
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<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

+ 20 - 0
services/web/frontend/js/features/group-management/components/members-table/list-alert.tsx

@@ -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}
+        />
+      )
   }
 }
 

+ 3 - 0
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<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}
         />

+ 10 - 0
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<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>
   )
 }

+ 118 - 0
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<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>
+  )
+}

+ 18 - 0
services/web/frontend/js/features/group-management/context/group-members-context.tsx

@@ -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,

+ 1 - 0
services/web/frontend/js/features/group-management/utils/types.ts

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

+ 3 - 0
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__</0>",
   "sso_test_interstitial_info_1": "<0>Before starting this test</0>, please ensure you’ve <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 IdP’s 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": "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__</0>. 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",

+ 9 - 2
services/web/test/frontend/features/group-management/components/members-table/dropdown-button.spec.tsx

@@ -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')

+ 16 - 0
services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx

@@ -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()}
             />

+ 39 - 0
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
+  }) => <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',
+    })
+  })
+})