Merge pull request #32396 from overleaf/jel-unlink-sso

[web] Add button so user can unlink Commons SSO

GitOrigin-RevId: 46e0607549341a98beca3873ea63bf091a883e85
This commit is contained in:
Jessica Lawshe
2026-04-23 09:04:41 -05:00
committed by Copybot
parent 08701ca5aa
commit 7ff114bbef
8 changed files with 339 additions and 38 deletions

View File

@@ -244,6 +244,7 @@ async function redundantSubscription(userId, providerId, providerName) {
}
async function linkAccounts(userId, samlData, auditLog) {
// Only for Commons SSO
const {
externalUserId,
institutionEmail,
@@ -303,19 +304,30 @@ async function unlinkAccounts(
}
async function _removeIdentifier(userId, providerId) {
// Called either when a user deletes a linked email
// or unlinks their institution SSO.
// The identifier is stored both in samlIdentifiers array
// and in the emails array as samlProviderId.
// When a user deletes a linked email, the email is removed
// before this function is called, so we do expect no match
// in that scenario
providerId = providerId.toString()
const query = {
_id: userId,
}
const update = {
$pull: {
samlIdentifiers: {
providerId,
await User.updateOne(
{ _id: userId },
{
$pull: {
samlIdentifiers: { providerId },
},
$unset: {
'emails.$[email].samlProviderId': 1,
},
},
}
await User.updateOne(query, update).exec()
{
arrayFilters: [{ 'email.samlProviderId': providerId }],
}
).exec()
}
async function updateEntitlement(

View File

@@ -2134,6 +2134,12 @@
"unlink": "",
"unlink_all_users": "",
"unlink_all_users_explanation": "",
"unlink_commons_sso_confirm": "",
"unlink_commons_sso_error": "",
"unlink_commons_sso_lose_licence": "",
"unlink_commons_sso_no_login": "",
"unlink_commons_sso_title": "",
"unlink_commons_sso_troubleshoot": "",
"unlink_dropbox_folder": "",
"unlink_dropbox_warning": "",
"unlink_from_sso": "",
@@ -2144,6 +2150,7 @@
"unlink_provider_account_title": "",
"unlink_provider_account_warning": "",
"unlink_reference": "",
"unlink_sso": "",
"unlink_the_project_from_the_current_github_repo": "",
"unlink_user_explanation": "",
"unlink_users": "",

View File

@@ -14,6 +14,7 @@ import { useLocation } from '../../../../shared/hooks/use-location'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import UnlinkCommonsSSOModal from './unlink-commons-sso-modal'
type EmailsRowProps = {
userEmailData: UserEmailData
@@ -65,6 +66,7 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
const { t } = useTranslation()
const { state } = useUserEmailsContext()
const location = useLocation()
const [showUnlinkSSOModal, setShowUnlinkSSOModal] = useState(false)
const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
useState(false)
@@ -89,22 +91,49 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
return (
<OLRow>
<OLCol lg={{ span: 8, offset: 4 }}>
<EmailCell>
<p>
<Trans
i18nKey="acct_linked_to_institution_acct_2"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
values={{
institutionName: userEmailData.affiliation?.institution.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
</EmailCell>
<div className="horizontal-divider" />
<OLRow>
<OLCol lg={9}>
<EmailCell>
<p>
<Trans
i18nKey="acct_linked_to_institution_acct_2"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
values={{
institutionName:
userEmailData.affiliation?.institution.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
</EmailCell>
</OLCol>
<OLCol lg={3} className="text-lg-end">
<EmailCell>
<OLButton
variant="secondary"
size="sm"
onClick={() => setShowUnlinkSSOModal(true)}
>
{t('unlink_sso')}
</OLButton>
<UnlinkCommonsSSOModal
show={showUnlinkSSOModal}
onClose={() => setShowUnlinkSSOModal(false)}
institutionName={
userEmailData.affiliation?.institution.name || ''
}
institutionEmail={userEmailData.email}
hasLicence={userEmailData.emailHasInstitutionLicence || false}
/>
</EmailCell>
</OLCol>
</OLRow>
</OLCol>
</OLRow>
)

View File

@@ -0,0 +1,98 @@
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLNotification from '@/shared/components/ol/ol-notification'
import OLButton from '@/shared/components/ol/ol-button'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { useLocation } from '@/shared/hooks/use-location'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import { useAsync } from '@/shared/hooks/use-async-with-cancel'
export default function UnlinkCommonsSSOModal({
show,
onClose,
institutionName,
institutionEmail,
hasLicence,
}: {
show: boolean
onClose: () => void
institutionName: string
institutionEmail: string
hasLicence: boolean
}) {
const { t } = useTranslation()
const location = useLocation()
const hasPassword = getMeta('ol-hasPassword')
const { isLoading, isError, runAsync, reset } = useAsync()
function handleClose() {
reset()
onClose()
}
function handleUnlinkSSO() {
runAsync(signal =>
postJSON('/saml/unlink-commons', {
body: { institutionEmail },
signal,
})
)
.then(() => {
handleClose()
location.reload()
})
.catch(error => {
debugConsole.error('Error unlinking SSO account', error)
})
}
return (
<OLModal
id={`unlink-${institutionEmail}-sso-modal`}
show={show}
onHide={handleClose}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('unlink_commons_sso_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('unlink_commons_sso_confirm', { institutionName })}</p>
<p>{t('unlink_commons_sso_troubleshoot')}</p>
{hasLicence && <p>{t('unlink_commons_sso_lose_licence')}</p>}
{!hasPassword && <p>{t('unlink_commons_sso_no_login')}</p>}
{isError && (
<OLNotification
type="error"
content={t('unlink_commons_sso_error')}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton
disabled={isLoading}
variant="secondary"
onClick={handleClose}
>
{t('cancel')}
</OLButton>
<OLButton
disabled={isLoading}
variant="danger"
onClick={handleUnlinkSSO}
isLoading={isLoading}
loadingLabel={t('unlinking')}
>
{t('unlink')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -2767,6 +2767,12 @@
"unlink": "Unlink",
"unlink_all_users": "Unlink all users",
"unlink_all_users_explanation": "Youre about to remove the SSO login option for all users in your group. If SSO is enabled, this will force users to reauthenticate their Overleaf accounts with your IdP. Theyll receive an email asking them to do this.",
"unlink_commons_sso_confirm": "Are you sure you want to remove your __institutionName__ login?",
"unlink_commons_sso_error": "Unlinking failed. Please try again.",
"unlink_commons_sso_lose_licence": "Your account will lose your institutional premium features. You can rejoin your institutional subscription if you are entitled by adding your institutional login option back to your account.",
"unlink_commons_sso_no_login": "If you do not add the institutional login back, you can still access your work by setting up an Overleaf-specific password using the password reset page.",
"unlink_commons_sso_title": "Unlink institutional login",
"unlink_commons_sso_troubleshoot": "Removing and re-adding your institutional login can often fix login problems.",
"unlink_dropbox_folder": "Unlink Dropbox Account",
"unlink_dropbox_warning": "Any projects that you have synced with Dropbox will be disconnected and no longer kept in sync with Dropbox. Are you sure you want to unlink your Dropbox account?",
"unlink_from_sso": "Unlink from SSO",
@@ -2777,6 +2783,7 @@
"unlink_provider_account_title": "Unlink __provider__ Account",
"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_sso": "Unlink SSO",
"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_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",

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { cloneDeep } from 'lodash'
@@ -116,6 +116,15 @@ describe('<EmailsRow/>', function () {
expect(screen.queryByRole('button', { name: 'Link accounts' })).to.be
.null
})
it('shows unlink button and opens unlink modal', function () {
renderEmailsRow(affiliatedEmail)
fireEvent.click(screen.getByRole('button', { name: 'Unlink SSO' }))
screen.getByRole('dialog')
screen.getByText('Unlink institutional login')
})
})
describe('and domain capture is also on for group and Commons SSO also enabled', function () {

View File

@@ -0,0 +1,137 @@
import { expect } from 'chai'
import sinon from 'sinon'
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import UnlinkCommonsSSOModal from '../../../../../../frontend/js/features/settings/components/emails/unlink-commons-sso-modal'
import { location } from '../../../../../../frontend/js/shared/components/location'
describe('<UnlinkCommonsSSOModal/>', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
this.locationSandbox = sinon.createSandbox()
this.locationStub = this.locationSandbox.stub(location)
window.metaAttributesCache.set('ol-hasPassword', false)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
this.locationSandbox.restore()
window.metaAttributesCache.delete('ol-hasPassword')
})
function renderModal(props = {}) {
const onClose = sinon.stub()
render(
<UnlinkCommonsSSOModal
show
onClose={onClose}
institutionName="Overleaf University"
institutionEmail="user@overleaf.com"
hasLicence
{...props}
/>
)
return { onClose }
}
it('renders modal content including licence warning', function () {
renderModal()
screen.getByRole('dialog')
screen.getByText('Unlink institutional login')
screen.getByText(/remove your Overleaf University login/i)
screen.getByText(/Removing and re-adding your institutional login/i)
screen.getByText(/will lose your institutional premium features/i)
screen.getByText(/can still access your work by setting up/i)
})
it('does not render licence warning when hasLicence is false', function () {
renderModal({ hasLicence: false })
expect(screen.queryByText(/will lose your institutional premium features/i))
.to.be.null
})
it('does not render password reset hint when user already has a password', function () {
window.metaAttributesCache.set('ol-hasPassword', true)
renderModal()
expect(screen.queryByText(/can still access your work by setting up/i)).to
.be.null
})
it('posts to unlink endpoint with correct body, closes modal, and reloads page', async function () {
const { onClose } = renderModal()
fetchMock.post('/saml/unlink-commons', 204)
const modal = screen.getByRole('dialog')
fireEvent.click(within(modal).getByRole('button', { name: 'Unlink' }))
await fetchMock.callHistory.flush(true)
expect(fetchMock.callHistory.called('/saml/unlink-commons')).to.be.true
const call = fetchMock.callHistory.calls('/saml/unlink-commons')[0]
expect(JSON.parse(call.options!.body as string)).to.deep.equal({
institutionEmail: 'user@overleaf.com',
})
await waitFor(() => {
expect(onClose).to.have.been.calledOnce
expect(this.locationStub.reload).to.have.been.calledOnce
})
})
it('disables buttons and shows loading label while request is in flight', async function () {
renderModal()
// Use a deferred promise so the request stays pending
let resolveRequest!: () => void
fetchMock.post(
'/saml/unlink-commons',
new Promise<void>(resolve => {
resolveRequest = resolve
}).then(() => 204)
)
const modal = screen.getByRole('dialog')
const unlinkButton = within(modal).getByRole('button', { name: 'Unlink' })
const cancelButton = within(modal).getByRole('button', { name: 'Cancel' })
fireEvent.click(unlinkButton)
await screen.findByText('Unlinking')
expect(unlinkButton).to.have.property('disabled', true)
expect(cancelButton).to.have.property('disabled', true)
resolveRequest()
await fetchMock.callHistory.flush(true)
})
it('shows an error notification when unlink fails', async function () {
renderModal()
fetchMock.post('/saml/unlink-commons', 500)
const modal = screen.getByRole('dialog')
fireEvent.click(within(modal).getByRole('button', { name: 'Unlink' }))
await screen.findByText('Unlinking failed. Please try again.')
})
it('clears error state when modal is closed via Cancel after a failure', async function () {
const { onClose } = renderModal()
fetchMock.post('/saml/unlink-commons', 500)
const modal = screen.getByRole('dialog')
fireEvent.click(within(modal).getByRole('button', { name: 'Unlink' }))
await screen.findByText('Unlinking failed. Please try again.')
// Cancel should call handleClose (which calls reset()) not just onClose
fireEvent.click(within(modal).getByRole('button', { name: 'Cancel' }))
expect(onClose).to.have.been.calledOnce
})
})

View File

@@ -566,7 +566,7 @@ describe('SAMLIdentityManager', function () {
}
)
})
it('should remove the identifier', async function (ctx) {
it('should remove the identifier with a single update', async function (ctx) {
await ctx.SAMLIdentityManager.unlinkAccounts(
ctx.user._id,
linkedEmail,
@@ -575,19 +575,21 @@ describe('SAMLIdentityManager', function () {
'Overleaf University',
ctx.auditLog
)
const query = {
_id: ctx.user._id,
}
const update = {
$pull: {
samlIdentifiers: {
providerId: '1',
expect(ctx.User.updateOne).to.have.been.calledOnce
expect(ctx.User.updateOne.getCall(0)).to.have.been.calledWithMatch(
{ _id: ctx.user._id },
{
$pull: {
samlIdentifiers: { providerId: '1' },
},
$unset: {
'emails.$[email].samlProviderId': 1,
},
},
}
expect(ctx.User.updateOne).to.have.been.calledOnce.and.calledWithMatch(
query,
update
{
arrayFilters: [{ 'email.samlProviderId': '1' }],
}
)
})
it('should send an email notification', async function (ctx) {