Merge pull request #7792 from overleaf/ta-settings-fixes-4

[SettingsPage] Wording and Error Handling

GitOrigin-RevId: 1e2445a68e0d32cbec558832892f2ce5a051d729
This commit is contained in:
Timothée Alby 2022-05-16 10:02:17 +02:00 committed by Copybot
parent 6e704d2919
commit c043db0ed9
14 changed files with 143 additions and 24 deletions

View file

@ -18,6 +18,10 @@ async function settingsPage(req, res) {
if (ssoError) {
delete req.session.ssoError
}
const ssoErrorMessage = req.session.ssoErrorMessage
if (ssoErrorMessage) {
delete req.session.ssoErrorMessage
}
// Institution SSO
let institutionLinked = _.get(req.session, ['saml', 'linked'])
if (institutionLinked) {
@ -97,7 +101,7 @@ async function settingsPage(req, res) {
reconfirmedViaSAML,
reconfirmationRemoveEmail,
samlBeta: req.session.samlBeta,
ssoError: ssoError,
ssoErrorMessage,
thirdPartyIds: UserPagesController._restructureThirdPartyIds(user),
})
} else {

View file

@ -126,7 +126,7 @@ include ../../_mixins/reconfirm_affiliation
.notification-body
if user.features.dropbox
| !{translate("dropbox_unlinked_premium_feature", {}, ['strong'])}
| !{translate("can_now_relink_dropbox", {}, [{name: 'a', attrs: {href: '/user/settings#dropboxSettings' }}])}
| !{translate("can_now_relink_dropbox", {}, [{name: 'a', attrs: {href: '/user/settings#project-sync' }}])}
else
| !{translate("dropbox_unlinked_premium_feature", {}, ['strong'])}
| !{translate("confirm_affiliation_to_relink_dropbox")}

View file

@ -14,7 +14,7 @@ block append meta
meta(name="ol-reconfirmedViaSAML", content=reconfirmedViaSAML)
meta(name="ol-reconfirmationRemoveEmail", content=reconfirmationRemoveEmail)
meta(name="ol-samlBeta", content=samlBeta)
meta(name="ol-ssoError", content=ssoError)
meta(name="ol-ssoErrorMessage", content=ssoErrorMessage)
meta(name="ol-thirdPartyIds", data-type="json", content=thirdPartyIds || {})
meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {})
meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed())

View file

@ -1,4 +1,5 @@
import { useState } from 'react'
import { Alert } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { useSSOContext, SSOSubscription } from '../context/sso-context'
@ -8,7 +9,7 @@ import getMeta from '../../../utils/meta'
function LinkingSection() {
const { t } = useTranslation()
const { subscriptions } = useSSOContext()
const ssoErrorMessage = getMeta('ol-ssoErrorMessage') as string
const [integrationLinkingWidgets] = useState(
() =>
getMeta('integrationLinkingWidgets') ||
@ -77,6 +78,11 @@ function LinkingSection() {
<h3 id="linked-accounts" className="text-capitalize">
{t('linked_accounts')}
</h3>
{ssoErrorMessage ? (
<Alert bsStyle="danger">
{t('sso_link_error')}: {ssoErrorMessage}
</Alert>
) : null}
<div className="settings-widgets-container">
{Object.values(subscriptions).map(
(subscription, subscriptionIndex) => (

View file

@ -16,6 +16,7 @@ type IntegrationLinkingWidgetProps = {
unlinkPath: string
unlinkConfirmationTitle: string
unlinkConfirmationText: string
disabled?: boolean
}
export function IntegrationLinkingWidget({
@ -30,6 +31,7 @@ export function IntegrationLinkingWidget({
unlinkPath,
unlinkConfirmationTitle,
unlinkConfirmationText,
disabled,
}: IntegrationLinkingWidgetProps) {
const { t } = useTranslation()
@ -59,7 +61,7 @@ export function IntegrationLinkingWidget({
{t('learn_more')}
</a>
</p>
{linked && statusIndicator}
{hasFeature && statusIndicator}
</div>
<div>
<ActionButton
@ -68,6 +70,7 @@ export function IntegrationLinkingWidget({
linked={linked}
handleUnlinkClick={handleUnlinkClick}
linkPath={linkPath}
disabled={disabled}
/>
</div>
<UnlinkConfirmationModal
@ -87,6 +90,7 @@ type ActionButtonProps = {
linked?: boolean
handleUnlinkClick: () => void
linkPath: string
disabled?: boolean
}
function ActionButton({
@ -95,6 +99,7 @@ function ActionButton({
linked,
handleUnlinkClick,
linkPath,
disabled,
}: ActionButtonProps) {
const { t } = useTranslation()
@ -106,13 +111,21 @@ function ActionButton({
)
} else if (linked) {
return (
<button className="btn btn-danger" onClick={handleUnlinkClick}>
<button
className="btn btn-danger"
onClick={handleUnlinkClick}
disabled={disabled}
>
{t('unlink')}
</button>
)
} else {
return (
<a href={linkPath} className="btn btn-primary text-capitalize">
<a
href={linkPath}
className="btn btn-primary text-capitalize"
disabled={disabled}
>
{t('link')}
</a>
)

View file

@ -1,10 +1,12 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Modal } from 'react-bootstrap'
import { FetchError } from '../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import IEEELogo from '../../../../shared/svgs/ieee-logo'
import GoogleLogo from '../../../../shared/svgs/google-logo'
import OrcidLogo from '../../../../shared/svgs/orcid-logo'
import LinkingStatus from './status'
const providerLogos = {
collabratec: <IEEELogo />,
@ -38,14 +40,15 @@ export function SSOLinkingWidget({
const handleUnlinkClick = useCallback(() => {
setShowModal(true)
setErrorMessage('')
}, [])
const handleUnlinkConfirmationClick = useCallback(() => {
setShowModal(false)
setUnlinkRequestInflight(true)
onUnlink()
.catch((error: Error) => {
setErrorMessage(error.message)
.catch((error: FetchError) => {
setErrorMessage(error.getUserFacingMessage())
})
.finally(() => {
setUnlinkRequestInflight(false)
@ -71,7 +74,9 @@ export function SSOLinkingWidget({
</a>
) : null}
</p>
{errorMessage && <div>{errorMessage} </div>}
{errorMessage ? (
<LinkingStatus status="error" description={errorMessage} />
) : null}
</div>
<div>
<ActionButton

View file

@ -0,0 +1,57 @@
import { ReactNode } from 'react'
import Icon from '../../../../shared/components/icon'
type Status = 'pending' | 'success' | 'error'
type LinkingStatusProps = {
status?: Status
description: string | ReactNode
}
export default function LinkingStatus({
status,
description,
}: LinkingStatusProps) {
return (
<span>
<StatusIcon status={status} />
<span className="small"> {description}</span>
</span>
)
}
type StatusIconProps = {
status: Status
}
function StatusIcon({ status }: StatusIconProps) {
switch (status) {
case 'success':
return (
<Icon
type="check-circle"
fw
className="settings-widget-status-icon status-success"
/>
)
case 'error':
return (
<Icon
type="times-circle"
fw
className="settings-widget-status-icon status-error"
/>
)
case 'pending':
return (
<Icon
type="circle"
fw
className="settings-widget-status-icon status-pending"
spin
/>
)
default:
return null
}
}

View file

@ -1,5 +1,4 @@
import { useEffect } from 'react'
import { Alert } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import EmailsSection from './emails-section'
@ -38,16 +37,10 @@ function SettingsPageRoot() {
function SettingsPageContent() {
const { t } = useTranslation()
const ssoError = getMeta('ol-ssoError') as string
const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings
return (
<UserProvider>
{ssoError ? (
<Alert bsStyle="danger">
{t('sso_link_error')}: {t(ssoError)}
</Alert>
) : null}
<div className="card">
<div className="page-header">
<h1>{t('account_settings')}</h1>

View file

@ -58,4 +58,5 @@ export function setDefaultMeta() {
})
window.metaAttributesCache.delete('integrationLinkingWidgets')
window.metaAttributesCache.delete('referenceLinkingWidgets')
window.metaAttributesCache.delete('ol-ssoErrorMessage')
}

View file

@ -4,6 +4,8 @@ import { setDefaultMeta, defaultSetupMocks } from './helpers/linking'
import { UserProvider } from '../../js/shared/context/user-context'
import { SSOProvider } from '../../js/features/settings/context/sso-context'
const MOCK_DELAY = 1000
export const Section = args => {
useFetchMock(defaultSetupMocks)
setDefaultMeta()
@ -40,6 +42,27 @@ export const SectionAllUnlinked = args => {
)
}
export const SectionSSOErrors = args => {
useFetchMock(fetchMock =>
fetchMock.post('/user/oauth-unlink', 500, { delay: MOCK_DELAY })
)
setDefaultMeta()
window.metaAttributesCache.set('integrationLinkingWidgets', [])
window.metaAttributesCache.set('referenceLinkingWidgets', [])
window.metaAttributesCache.set(
'ol-ssoErrorMessage',
'Account already linked to another Overleaf user'
)
return (
<UserProvider>
<SSOProvider>
<LinkingSection {...args} />
</SSOProvider>
</UserProvider>
)
}
export default {
title: 'Account Settings / Linking',
component: LinkingSection,

View file

@ -107,17 +107,21 @@ tbody > tr.affiliations-table-warning-row > td {
margin-bottom: (@line-height-computed / 2) - @table-cell-padding;
}
.settings-widget-status-icon,
.dropbox-sync-icon {
position: relative;
font-size: 1.3em;
line-height: 1.3em;
vertical-align: top;
&.status-error,
&.dropbox-sync-icon-error {
color: @alert-danger-bg;
}
&.status-success,
&.dropbox-sync-icon-success {
color: @alert-success-bg;
}
&.status-pending,
&.dropbox-sync-icon-updating {
color: @alert-info-bg;
&::after {

View file

@ -635,7 +635,7 @@
"tracked_change_deleted": "Deleted",
"show_all": "show all",
"show_less": "show less",
"dropbox_sync_error": "Dropbox Sync Error",
"dropbox_sync_error": "Sorry, there was an error talking to our Dropbox service. Please try again in a few moments.",
"send": "Send",
"sending": "Sending",
"invalid_password": "Invalid Password",
@ -762,7 +762,7 @@
"mendeley_reference_loading_error_forbidden": "Could not load references from Mendeley, please re-link your account and try again",
"zotero_reference_loading_error_forbidden": "Could not load references from Zotero, please re-link your account and try again",
"mendeley_integration": "Mendeley Integration",
"mendeley_sync_description": "With Mendeley integration you can import your references from Mendeley into your __appName__ projects.",
"mendeley_sync_description": "With the Mendeley integration you can import your references from Mendeley into your __appName__ projects.",
"mendeley_is_premium": "Mendeley integration is a premium feature",
"link_to_mendeley": "Link to Mendeley",
"unlink_to_mendeley": "Unlink Mendeley",
@ -772,7 +772,7 @@
"mendeley_groups_loading_error": "There was an error loading groups from Mendeley",
"mendeley_groups_relink": "There was an error accessing your Mendeley data. This was likely caused by lack of permissions. Please re-link your account and try again.",
"zotero_integration": "Zotero Integration",
"zotero_sync_description": "With Zotero integration you can import your references from Zotero into your __appName__ projects.",
"zotero_sync_description": "With the Zotero integration you can import your references from Zotero into your __appName__ projects.",
"zotero_is_premium": "Zotero integration is a premium feature",
"link_to_zotero": "Link to Zotero",
"unlink_to_zotero": "Unlink Zotero",
@ -1463,8 +1463,8 @@
"about_us": "About Us",
"loading_recent_github_commits": "Loading recent commits",
"no_new_commits_in_github": "No new commits in GitHub since last merge.",
"dropbox_sync_description": "Keep your __appName__ projects in sync with your Dropbox. Changes in __appName__ are automatically sent to your Dropbox, and the other way around.",
"github_sync_description": "With GitHub Sync you can link your __appName__ projects to GitHub repositories. Create new commits from __appName__, and merge with commits made offline or in GitHub.",
"dropbox_sync_description": "Keep your __appName__ projects in sync with your Dropbox account. Changes in __appName__ are automatically sent to your Dropbox account, and the other way around.",
"github_sync_description": "With GitHub Sync you can link your __appName__ projects to GitHub repositories, create new commits from __appName__, and merge commits from GitHub.",
"github_import_description": "With GitHub Sync you can import your GitHub Repositories into __appName__. Create new commits from __appName__, and merge with commits made offline or in GitHub.",
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
"link": "Link",

View file

@ -89,6 +89,12 @@ describe('<LinkingSection />', function () {
expect(screen.queryByText('Twitter')).to.not.exist
})
it('shows SSO error message', async function () {
window.metaAttributesCache.set('ol-ssoErrorMessage', 'You no SSO')
renderSectionWithProviders()
screen.getByText('Error linking SSO account: You no SSO')
})
it('does not show providers section when empty', async function () {
window.metaAttributesCache.delete('ol-oauthProviders')
renderSectionWithProviders()

View file

@ -1,6 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, fireEvent, render, waitFor } from '@testing-library/react'
import { FetchError } from '../../../../../../frontend/js/infrastructure/fetch-json'
import { SSOLinkingWidget } from '../../../../../../frontend/js/features/settings/components/linking/sso-widget'
describe('<SSOLinkingWidget />', function () {
@ -103,7 +104,9 @@ describe('<SSOLinkingWidget />', function () {
describe('when unlinking fails', function () {
beforeEach(function () {
const unlinkFunction = sinon.stub().rejects(new Error('unlinking failed'))
const unlinkFunction = sinon
.stub()
.rejects(new FetchError('unlinking failed', ''))
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
@ -116,7 +119,11 @@ describe('<SSOLinkingWidget />', function () {
})
it('should display an error message ', async function () {
await waitFor(() => screen.getByText('unlinking failed'))
await waitFor(() =>
screen.getByText(
'Something went wrong talking to the server :(. Please try again.'
)
)
})
it('should display the unlink button ', async function () {