From cf2dfc6bf1cd946a86b2f9876125b216c87e65f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Fri, 22 Apr 2022 15:49:26 +0200 Subject: [PATCH] Merge pull request #7593 from overleaf/ta-settings-migration [SettingsPage] Integration Branch GitOrigin-RevId: 5a3c26b2a02d716c4ae3981e3f08b811ae307725 --- .../src/Features/User/UserPagesController.js | 197 ++++++++------ .../app/src/infrastructure/ExpressLocals.js | 1 + .../web/app/views/user/settings-react.pug | 29 +++ services/web/config/settings.defaults.js | 1 + .../web/frontend/extracted-translations.json | 73 ++++++ .../components/account-info-section.tsx | 186 +++++++++++++ .../settings/components/emails-section.tsx | 47 +++- .../settings/components/emails/actions.tsx | 62 +++++ .../emails/actions/make-primary.tsx | 109 ++++++++ .../components/emails/actions/remove.tsx | 81 ++++++ .../settings/components/emails/cell.tsx | 11 +- .../components/emails/downshift-input.tsx | 101 ++++++++ .../settings/components/emails/header.tsx | 9 +- .../emails/institution-and-role.tsx | 117 +++++++-- .../resend-confirmation-email-button.tsx | 12 +- .../settings/components/emails/row.tsx | 11 +- .../integration-linking-section.tsx | 46 ++++ .../components/integration-linking/widget.tsx | 158 ++++++++++++ .../components/leave/modal-content.tsx | 5 +- .../components/leave/modal-form-error.tsx | 5 +- .../settings/components/leave/modal-form.tsx | 2 +- .../settings/components/password-section.tsx | 199 ++++++++++++++ .../js/features/settings/components/root.tsx | 61 +++++ .../components/sso-linking-section.tsx | 68 +++++ .../components/sso-linking/widget.tsx} | 68 +++-- .../context/sso-context.tsx | 35 ++- .../settings/context/user-email-context.tsx | 244 +++++++++++++++--- .../js/features/settings/departments.ts | 76 ++++++ .../frontend/js/features/settings/roles.ts | 13 + .../settings/utils/action-creators.ts | 52 ++++ .../js/features/settings/utils/selectors.ts | 22 ++ .../web/frontend/js/pages/user/settings.js | 13 + .../frontend/js/shared/components/tooltip.tsx | 37 +++ .../js/shared/context/user-context.js | 17 ++ .../web/frontend/js/shared/hooks/use-async.ts | 27 +- .../frontend/js/shared/svgs/dropbox-logo.js | 47 ++++ .../frontend/js/shared/svgs/github-logo.js | 39 +++ .../frontend/js/shared/svgs/google-logo.js | 39 +++ .../web/frontend/js/shared/svgs/ieee-logo.js | 23 ++ .../frontend/js/shared/svgs/mendeley-logo.js | 39 +++ .../web/frontend/js/shared/svgs/orcid-logo.js | 43 +++ .../frontend/js/shared/svgs/zotero-logo.js | 39 +++ .../web/frontend/stories/settings.stories.js | 85 ------ .../stories/settings/account-info.stories.js | 40 +++ .../stories/settings/emails.stories.js | 26 ++ .../stories/settings/helpers/account-info.js | 19 ++ .../stories/settings/helpers/emails.js | 59 +++++ .../settings/helpers/integration-linking.js | 21 ++ .../stories/settings/helpers/leave.js | 14 + .../stories/settings/helpers/password.js | 34 +++ .../stories/settings/helpers/sso-linking.js | 43 +++ .../settings/integration-linking.stories.js | 23 ++ .../stories/settings/leave.stories.js | 16 +- .../frontend/stories/settings/page.stories.js | 53 ++++ .../stories/settings/password.stories.js | 48 ++++ .../stories/settings/sso-linking.stories.js | 23 ++ .../frontend/stylesheets/_style_includes.less | 1 + .../stylesheets/app/account-settings.less | 53 ++++ .../stylesheets/components/spacing.less | 102 ++++++++ .../web/frontend/stylesheets/core/type.less | 5 + services/web/locales/en.json | 10 +- .../components/layout-dropdown-button.test.js | 21 +- .../components/account-info-section.test.tsx | 198 ++++++++++++++ .../emails/emails-section-actions.test.tsx | 120 +++++++++ ...ails-section-institution-and-role.test.tsx | 80 +++++- .../components/emails/emails-section.test.tsx | 79 ++++-- .../integration-linking/widget.test.tsx | 100 +++++++ .../components/leave-section.test.tsx | 11 + .../components/leave/modal-content.test.tsx | 2 +- .../components/leave/modal-form.test.tsx | 10 +- .../settings/components/leave/modal.test.tsx | 4 +- .../components/password-section.test.tsx | 197 ++++++++++++++ .../settings/components/root.test.tsx | 48 ++++ .../components/sso-linking/widget.test.tsx} | 23 +- .../context/sso-context.test.tsx | 55 ++-- .../frontend/shared/hooks/use-async.test.ts | 8 +- .../unit/src/User/UserPagesControllerTests.js | 30 ++- services/web/types/exposed-settings.ts | 33 +++ services/web/types/oauth-providers.ts | 13 + .../web/types/password-strength-options.ts | 6 + services/web/types/third-party-ids.ts | 3 + services/web/types/user-email.ts | 1 + services/web/types/window.ts | 13 +- 83 files changed, 3795 insertions(+), 399 deletions(-) create mode 100644 services/web/app/views/user/settings-react.pug create mode 100644 services/web/frontend/js/features/settings/components/account-info-section.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/actions.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/actions/make-primary.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/actions/remove.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/downshift-input.tsx create mode 100644 services/web/frontend/js/features/settings/components/integration-linking-section.tsx create mode 100644 services/web/frontend/js/features/settings/components/integration-linking/widget.tsx create mode 100644 services/web/frontend/js/features/settings/components/password-section.tsx create mode 100644 services/web/frontend/js/features/settings/components/root.tsx create mode 100644 services/web/frontend/js/features/settings/components/sso-linking-section.tsx rename services/web/frontend/js/features/{user-settings/components/sso-linking-widget.tsx => settings/components/sso-linking/widget.tsx} (69%) rename services/web/frontend/js/features/{user-settings => settings}/context/sso-context.tsx (67%) create mode 100644 services/web/frontend/js/features/settings/departments.ts create mode 100644 services/web/frontend/js/features/settings/roles.ts create mode 100644 services/web/frontend/js/features/settings/utils/action-creators.ts create mode 100644 services/web/frontend/js/features/settings/utils/selectors.ts create mode 100644 services/web/frontend/js/pages/user/settings.js create mode 100644 services/web/frontend/js/shared/components/tooltip.tsx create mode 100644 services/web/frontend/js/shared/svgs/dropbox-logo.js create mode 100644 services/web/frontend/js/shared/svgs/github-logo.js create mode 100644 services/web/frontend/js/shared/svgs/google-logo.js create mode 100644 services/web/frontend/js/shared/svgs/ieee-logo.js create mode 100644 services/web/frontend/js/shared/svgs/mendeley-logo.js create mode 100644 services/web/frontend/js/shared/svgs/orcid-logo.js create mode 100644 services/web/frontend/js/shared/svgs/zotero-logo.js delete mode 100644 services/web/frontend/stories/settings.stories.js create mode 100644 services/web/frontend/stories/settings/account-info.stories.js create mode 100644 services/web/frontend/stories/settings/emails.stories.js create mode 100644 services/web/frontend/stories/settings/helpers/account-info.js create mode 100644 services/web/frontend/stories/settings/helpers/emails.js create mode 100644 services/web/frontend/stories/settings/helpers/integration-linking.js create mode 100644 services/web/frontend/stories/settings/helpers/leave.js create mode 100644 services/web/frontend/stories/settings/helpers/password.js create mode 100644 services/web/frontend/stories/settings/helpers/sso-linking.js create mode 100644 services/web/frontend/stories/settings/integration-linking.stories.js create mode 100644 services/web/frontend/stories/settings/page.stories.js create mode 100644 services/web/frontend/stories/settings/password.stories.js create mode 100644 services/web/frontend/stories/settings/sso-linking.stories.js create mode 100644 services/web/frontend/stylesheets/components/spacing.less create mode 100644 services/web/test/frontend/features/settings/components/account-info-section.test.tsx create mode 100644 services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx create mode 100644 services/web/test/frontend/features/settings/components/integration-linking/widget.test.tsx create mode 100644 services/web/test/frontend/features/settings/components/password-section.test.tsx create mode 100644 services/web/test/frontend/features/settings/components/root.test.tsx rename services/web/test/frontend/features/{user-settings/components/sso-linking-widget.test.tsx => settings/components/sso-linking/widget.test.tsx} (84%) rename services/web/test/frontend/features/{user-settings => settings}/context/sso-context.test.tsx (63%) create mode 100644 services/web/types/exposed-settings.ts create mode 100644 services/web/types/oauth-providers.ts create mode 100644 services/web/types/password-strength-options.ts create mode 100644 services/web/types/third-party-ids.ts diff --git a/services/web/app/src/Features/User/UserPagesController.js b/services/web/app/src/Features/User/UserPagesController.js index 4e5465112b..899d91287a 100644 --- a/services/web/app/src/Features/User/UserPagesController.js +++ b/services/web/app/src/Features/User/UserPagesController.js @@ -6,6 +6,128 @@ const Settings = require('@overleaf/settings') const AuthenticationController = require('../Authentication/AuthenticationController') const SessionManager = require('../Authentication/SessionManager') const _ = require('lodash') +const { expressify } = require('../../util/promises') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') + +async function settingsPage(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const reconfirmationRemoveEmail = req.query.remove + // SSO + const ssoError = req.session.ssoError + if (ssoError) { + delete req.session.ssoError + } + // Institution SSO + let institutionLinked = _.get(req.session, ['saml', 'linked']) + if (institutionLinked) { + // copy object if exists because _.get does not + institutionLinked = Object.assign( + { + hasEntitlement: _.get(req.session, ['saml', 'hasEntitlement']), + }, + institutionLinked + ) + } + const samlError = _.get(req.session, ['saml', 'error']) + const institutionEmailNonCanonical = _.get(req.session, [ + 'saml', + 'emailNonCanonical', + ]) + const institutionRequestedEmail = _.get(req.session, [ + 'saml', + 'requestedEmail', + ]) + + const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) + delete req.session.saml + let shouldAllowEditingDetails = true + if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) { + shouldAllowEditingDetails = false + } + if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) { + shouldAllowEditingDetails = false + } + const oauthProviders = Settings.oauthProviders || {} + + const user = await UserGetter.promises.getUser(userId) + if (!user) { + // The user has just deleted their account. + return res.redirect('/logout') + } + const assignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'settings-page' + ) + if (assignment.variant === 'react') { + res.render('user/settings-react', { + title: 'account_settings', + user: { + id: user.id, + isAdmin: user.isAdmin, + email: user.email, + allowedFreeTrial: user.allowedFreeTrial, + first_name: user.first_name, + last_name: user.last_name, + features: { + dropbox: user.features.dropbox, + github: user.features.github, + mendeley: user.features.mendeley, + zotero: user.features.zotero, + references: user.features.references, + }, + refProviders: { + mendeley: user.refProviders?.mendeley, + zotero: user.refProviders?.zotero, + }, + }, + hasPassword: !!user.hashedPassword, + firstName: user.first_name, + lastName: user.last_name, + shouldAllowEditingDetails, + oauthProviders: UserPagesController._translateProviderDescriptions( + oauthProviders, + req + ), + institutionLinked, + samlError, + institutionEmailNonCanonical: + institutionEmailNonCanonical && institutionRequestedEmail + ? institutionEmailNonCanonical + : undefined, + reconfirmedViaSAML, + reconfirmationRemoveEmail, + samlBeta: req.session.samlBeta, + ssoError: ssoError, + thirdPartyIds: UserPagesController._restructureThirdPartyIds(user), + }) + } else { + res.render('user/settings', { + title: 'account_settings', + user, + hasPassword: !!user.hashedPassword, + shouldAllowEditingDetails, + languages: Settings.languages, + accountSettingsTabActive: true, + oauthProviders: UserPagesController._translateProviderDescriptions( + oauthProviders, + req + ), + oauthUseV2: Settings.oauthUseV2 || false, + institutionLinked, + samlError, + institutionEmailNonCanonical: + institutionEmailNonCanonical && institutionRequestedEmail + ? institutionEmailNonCanonical + : undefined, + reconfirmedViaSAML, + reconfirmationRemoveEmail, + samlBeta: req.session.samlBeta, + ssoError: ssoError, + thirdPartyIds: UserPagesController._restructureThirdPartyIds(user), + }) + } +} const UserPagesController = { registerPage(req, res) { @@ -63,80 +185,7 @@ const UserPagesController = { res.render('user/reconfirm', pageData) }, - settingsPage(req, res, next) { - const userId = SessionManager.getLoggedInUserId(req.session) - const reconfirmationRemoveEmail = req.query.remove - // SSO - const ssoError = req.session.ssoError - if (ssoError) { - delete req.session.ssoError - } - // Institution SSO - let institutionLinked = _.get(req.session, ['saml', 'linked']) - if (institutionLinked) { - // copy object if exists because _.get does not - institutionLinked = Object.assign( - { - hasEntitlement: _.get(req.session, ['saml', 'hasEntitlement']), - }, - institutionLinked - ) - } - const samlError = _.get(req.session, ['saml', 'error']) - const institutionEmailNonCanonical = _.get(req.session, [ - 'saml', - 'emailNonCanonical', - ]) - const institutionRequestedEmail = _.get(req.session, [ - 'saml', - 'requestedEmail', - ]) - - const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) - delete req.session.saml - let shouldAllowEditingDetails = true - if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) { - shouldAllowEditingDetails = false - } - if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) { - shouldAllowEditingDetails = false - } - const oauthProviders = Settings.oauthProviders || {} - - UserGetter.getUser(userId, (err, user) => { - if (err != null) { - return next(err) - } - if (!user) { - // The user has just deleted their account. - return res.redirect('/logout') - } - res.render('user/settings', { - title: 'account_settings', - user, - hasPassword: !!user.hashedPassword, - shouldAllowEditingDetails, - languages: Settings.languages, - accountSettingsTabActive: true, - oauthProviders: UserPagesController._translateProviderDescriptions( - oauthProviders, - req - ), - oauthUseV2: Settings.oauthUseV2 || false, - institutionLinked, - samlError, - institutionEmailNonCanonical: - institutionEmailNonCanonical && institutionRequestedEmail - ? institutionEmailNonCanonical - : undefined, - reconfirmedViaSAML, - reconfirmationRemoveEmail, - samlBeta: req.session.samlBeta, - ssoError: ssoError, - thirdPartyIds: UserPagesController._restructureThirdPartyIds(user), - }) - }) - }, + settingsPage: expressify(settingsPage), sessionsPage(req, res, next) { const user = SessionManager.getSessionUser(req.session) diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index df705ccac9..e3fdd2db11 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -365,6 +365,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { dropboxAppName: Settings.apis.thirdPartyDataStore?.dropboxAppName || 'Overleaf', hasSamlBeta: req.session.samlBeta, + hasAffiliationsFeature: Features.hasFeature('affiliations'), hasSamlFeature: Features.hasFeature('saml'), samlInitPath: _.get(Settings, ['saml', 'ukamf', 'initPath']), hasLinkUrlFeature: Features.hasFeature('link-url'), diff --git a/services/web/app/views/user/settings-react.pug b/services/web/app/views/user/settings-react.pug new file mode 100644 index 0000000000..9270a7ee7d --- /dev/null +++ b/services/web/app/views/user/settings-react.pug @@ -0,0 +1,29 @@ +extends ../layout + +block entrypointVar + - entrypoint = 'pages/user/settings' + +block append meta + meta(name="ol-hasPassword" data-type="boolean" content=hasPassword) + meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails) + meta(name="ol-oauthProviders", data-type="json", content=oauthProviders) + meta(name="ol-institutionLinked", data-type="json", content=institutionLinked) + meta(name="ol-samlError", data-type="json", content=samlError) + meta(name="ol-institutionEmailNonCanonical", content=institutionEmailNonCanonical) + + 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-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()) + meta(name="ol-firstName" content=firstName) + meta(name="ol-lastName" content=lastName) + meta(name="ol-user" data-type="json" content=user) + meta(name="ol-dropbox" data-type="json" content=dropbox) + meta(name="ol-github" data-type="json" content=github) + + +block content + main.content.content-alt#settings-page-root diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 3631740d7a..a7fd72673a 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -773,6 +773,7 @@ module.exports = { editorToolbarButtons: [], sourceEditorExtensions: [], sourceEditorComponents: [], + integrationLinkingWidgets: [], }, moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 78871e37b9..55749f3fa2 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -3,6 +3,7 @@ "account_not_linked_to_dropbox": "", "account_settings": "", "add_files": "", + "add_role_and_department": "", "also": "", "anyone_with_link_can_edit": "", "anyone_with_link_can_view": "", @@ -26,10 +27,13 @@ "category_misc": "", "category_operators": "", "category_relations": "", + "change": "", "change_or_cancel-cancel": "", "change_or_cancel-change": "", "change_or_cancel-or": "", "change_owner": "", + "change_password": "", + "change_primary_email_address_instructions": "<0>0><2>2>", "change_project_owner": "", "chat": "", "chat_error": "", @@ -52,6 +56,7 @@ "compile_mode": "", "compile_terminated_by_user": "", "compiling": "", + "confirm_new_password": "", "conflicting_paths_found": "", "connected_users": "", "contact_message_label": "", @@ -63,24 +68,44 @@ "create": "", "create_project_in_github": "", "creating": "", + "current_password": "", "delete": "", + "delete_account": "", + "delete_account_confirmation_label": "", + "delete_account_warning_message_3": "", + "delete_acct_no_existing_pw": "", + "delete_your_account": "", "deleting": "", "demonstrating_git_integration": "", + "department": "", "description": "", "dismiss": "", "dismiss_error_popup": "", + "doesnt_match": "", "done": "", "download": "", "download_pdf": "", "drag_here": "", + "dropbox_checking_sync_status": "", "dropbox_for_link_share_projs": "", "dropbox_sync": "", + "dropbox_sync_both": "", + "dropbox_sync_description": "", + "dropbox_sync_in": "", + "dropbox_sync_out": "", + "dropbox_sync_status_error": "", + "dropbox_synced": "", "duplicate_file": "", "easily_manage_your_project_files_everywhere": "", "editing": "", "editor_and_pdf": "", "editor_only_hide_pdf": "", + "email": "", + "email_or_password_wrong_try_again": "", + "emails_and_affiliations_explanation": "", + "emails_and_affiliations_title": "", "error": "", + "error_performing_request": "", "expand": "", "export_project_to_github": "", "fast": "", @@ -92,6 +117,7 @@ "files_cannot_include_invalid_characters": "", "find_out_more_about_the_file_outline": "", "find_the_symbols_you_need_with_premium": "", + "first_name": "", "fold_line": "", "following_paths_conflict": "", "free_accounts_have_timeout_upgrade_to_increase": "", @@ -116,6 +142,7 @@ "github_repository_diverged": "", "github_symlink_error": "", "github_sync": "", + "github_sync_description": "", "github_sync_error": "", "github_sync_repository_not_found_description": "", "github_timeout_error": "", @@ -165,22 +192,29 @@ "imported_from_the_output_of_another_project_at_date": "", "imported_from_zotero_at_date": "", "importing_and_merging_changes_in_github": "", + "institution_and_role": "", + "integrations": "", "invalid_email": "", "invalid_file_name": "", "invalid_filename": "", "invalid_request": "", "invite_not_accepted": "", + "last_name": "", "layout": "", "layout_processing": "", "learn_how_to_make_documents_compile_quickly": "", + "learn_more": "", "learn_more_about_link_sharing": "", "learn_more_about_the_symbol_palette": "", + "link": "", "link_sharing_is_off": "", "link_sharing_is_on": "", "link_to_github": "", "link_to_github_description": "", "link_to_mendeley": "", "link_to_zotero": "", + "linked_accounts": "", + "linked_accounts_explained": "", "linked_file": "", "loading": "", "loading_recent_github_commits": "", @@ -194,12 +228,15 @@ "logs_and_output_files": "", "logs_pane_info_message": "", "main_file_not_found": "", + "make_email_primary_description": "", + "make_primary": "", "make_private": "", "manage_files_from_your_dropbox_folder": "", "math_display": "", "math_inline": "", "maximum_files_uploaded_together": "", "mendeley_groups_loading_error": "", + "mendeley_integration": "", "mendeley_is_premium": "", "mendeley_reference_loading_error": "", "mendeley_reference_loading_error_expired": "", @@ -210,11 +247,14 @@ "n_items_plural": "", "navigate_log_source": "", "navigation": "", + "need_to_leave": "", "need_to_upgrade_for_more_collabs": "", "need_to_upgrade_for_more_collabs_variant": "", "new_file": "", "new_folder": "", "new_name": "", + "new_password": "", + "no_existing_password": "", "no_messages": "", "no_new_commits_in_github": "", "no_other_projects_found": "", @@ -239,6 +279,8 @@ "owner": "", "page_current": "", "pagination_navigation": "", + "password": "", + "password_managed_externally": "", "pdf_compile_in_progress_error": "", "pdf_compile_rate_limit_hit": "", "pdf_compile_try_again": "", @@ -247,15 +289,22 @@ "pdf_preview_error": "", "pdf_rendering_error": "", "pdf_viewer_error": "", + "please_change_primary_to_remove": "", + "please_check_your_inbox": "", "please_compile_pdf_before_download": "", + "please_confirm_your_email_before_making_it_default": "", + "please_link_before_making_primary": "", + "please_reconfirm_your_affiliation_before_making_this_primary": "", "please_refresh": "", "please_select_a_file": "", "please_select_a_project": "", "please_select_an_output_file": "", "please_set_main_file": "", "plus_upgraded_accounts_receive": "", + "premium_feature": "", "private": "", "processing": "", + "professional": "", "proj_timed_out_reason": "", "project_approaching_file_limit": "", "project_flagged_too_many_compiles": "", @@ -295,9 +344,15 @@ "rename": "", "repository_name": "", "resend": "", + "resend_confirmation_email": "", "review": "", "revoke": "", "revoke_invite": "", + "role": "", + "save_or_cancel-cancel": "", + "save_or_cancel-or": "", + "save_or_cancel-save": "", + "saving": "", "search": "", "search_bib_files": "", "search_match_case": "", @@ -315,6 +370,7 @@ "select_from_your_computer": "", "selected": "", "send_first_message": "", + "sending": "", "server_error": "", "session_error": "", "session_expired_redirecting_to_login": "", @@ -332,12 +388,14 @@ "something_went_wrong_server": "", "somthing_went_wrong_compiling": "", "split_screen": "", + "sso_link_error": "", "start_free_trial": "", "stop_compile": "", "stop_on_validation_error": "", "store_your_work": "", "subject": "", "submit_title": "", + "subscription_admins_cannot_be_deleted": "", "sure_you_want_to_delete": "", "sync_project_to_github_explanation": "", "sync_to_dropbox": "", @@ -347,6 +405,7 @@ "tags": "", "template_approved_by_publisher": "", "terminated": "", + "thanks_settings_updated": "", "this_project_is_public": "", "this_project_is_public_read_only": "", "this_project_will_appear_in_your_dropbox_folder_at": "", @@ -365,10 +424,21 @@ "try_refresh_page": "", "turn_off_link_sharing": "", "turn_on_link_sharing": "", + "unconfirmed": "", "unfold_line": "", "unlimited_projects": "", + "unlink": "", + "unlink_dropbox_folder": "", + "unlink_dropbox_warning": "", "unlink_github_repository": "", + "unlink_github_warning": "", + "unlink_provider_account_title": "", + "unlink_provider_account_warning": "", + "unlink_reference": "", + "unlink_warning_reference": "", "unlinking": "", + "update": "", + "update_account_info": "", "update_dropbox_settings": "", "upgrade": "", "upgrade_for_longer_compiles": "", @@ -376,6 +446,8 @@ "upload": "", "url_to_fetch_the_file_from": "", "use_your_own_machine": "", + "user_deletion_error": "", + "user_deletion_password_reset_tip": "", "validation_issue_entry_description": "", "view_logs": "", "view_pdf": "", @@ -386,6 +458,7 @@ "work_with_non_overleaf_users": "", "your_message": "", "zotero_groups_loading_error": "", + "zotero_integration": "", "zotero_is_premium": "", "zotero_reference_loading_error": "", "zotero_reference_loading_error_expired": "", diff --git a/services/web/frontend/js/features/settings/components/account-info-section.tsx b/services/web/frontend/js/features/settings/components/account-info-section.tsx new file mode 100644 index 0000000000..ab5113eb92 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/account-info-section.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react' +import { + Alert, + Button, + ControlLabel, + FormControl, + FormGroup, +} from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { postJSON } from '../../../infrastructure/fetch-json' +import getMeta from '../../../utils/meta' +import { ExposedSettings } from '../../../../../types/exposed-settings' +import useAsync from '../../../shared/hooks/use-async' + +function AccountInfoSection() { + const { t } = useTranslation() + const { hasAffiliationsFeature } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings + const isExternalAuthenticationSystemUsed = getMeta( + 'ol-isExternalAuthenticationSystemUsed' + ) as boolean + const shouldAllowEditingDetails = getMeta( + 'ol-shouldAllowEditingDetails' + ) as boolean + + const [email, setEmail] = useState(() => getMeta('ol-usersEmail') as string) + const [firstName, setFirstName] = useState( + () => getMeta('ol-firstName') as string + ) + const [lastName, setLastName] = useState( + () => getMeta('ol-lastName') as string + ) + const { isLoading, error, isSuccess, runAsync } = useAsync() + const [isFormValid, setIsFormValid] = useState(true) + + const handleEmailChange = event => { + setEmail(event.target.value) + setIsFormValid(event.target.validity.valid) + } + + const handleFirstNameChange = event => { + setFirstName(event.target.value) + } + + const handleLastNameChange = event => { + setLastName(event.target.value) + } + + const canUpdateEmail = + !hasAffiliationsFeature && !isExternalAuthenticationSystemUsed + const canUpdateNames = shouldAllowEditingDetails + + const handleSubmit = event => { + event.preventDefault() + if (!isFormValid) { + return + } + runAsync( + postJSON('/user/settings', { + body: { + email: canUpdateEmail ? email : undefined, + firstName: canUpdateNames ? firstName : undefined, + lastName: canUpdateNames ? lastName : undefined, + }, + }) + ).catch(() => {}) + } + + return ( + <> + {t('update_account_info')} + + {hasAffiliationsFeature ? null : ( + + )} + + + {isSuccess ? ( + + {t('thanks_settings_updated')} + + ) : null} + {error ? ( + + {error.getUserFacingMessage()} + + ) : null} + {canUpdateEmail || canUpdateNames ? ( + + {isLoading ? <>{t('saving')}…> : t('update')} + + ) : null} + + > + ) +} + +type ReadOrWriteFormGroupProps = { + id: string + type: string + label: string + value: string + handleChange: (event: any) => void + canEdit: boolean + required: boolean +} + +function ReadOrWriteFormGroup({ + id, + type, + label, + value, + handleChange, + canEdit, + required, +}: ReadOrWriteFormGroupProps) { + const [validationMessage, setValidationMessage] = useState('') + + const handleInvalid = event => { + event.preventDefault() + } + + const handleChangeAndValidity = event => { + handleChange(event) + setValidationMessage(event.target.validationMessage) + } + + if (!canEdit) { + return ( + + {label} + + + ) + } + + return ( + + {label} + + {validationMessage ? ( + {validationMessage} + ) : null} + + ) +} + +export default AccountInfoSection diff --git a/services/web/frontend/js/features/settings/components/emails-section.tsx b/services/web/frontend/js/features/settings/components/emails-section.tsx index 5dde2bf56f..b01c87fbb9 100644 --- a/services/web/frontend/js/features/settings/components/emails-section.tsx +++ b/services/web/frontend/js/features/settings/components/emails-section.tsx @@ -1,16 +1,23 @@ import { Fragment } from 'react' import { useTranslation, Trans } from 'react-i18next' +import getMeta from '../../../utils/meta' import { UserEmailsProvider, useUserEmailsContext, } from '../context/user-email-context' import EmailsHeader from './emails/header' import EmailsRow from './emails/row' +import Icon from '../../../shared/components/icon' +import { Alert } from 'react-bootstrap' +import { ExposedSettings } from '../../../../../types/exposed-settings' function EmailsSectionContent() { const { t } = useTranslation() const { state: { data: userEmailsData }, + isInitializing, + isInitializingSuccess, + isInitializingError, } = useUserEmailsContext() const userEmails = Object.values(userEmailsData.byId) @@ -25,20 +32,42 @@ function EmailsSectionContent() {
{t('linked_accounts_explained')}
+ {description}{' '} + + {t('learn_more')} + +
{content}
diff --git a/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx b/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx index 8c89eadc13..b28c9be75c 100644 --- a/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx +++ b/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx @@ -2,6 +2,7 @@ import { Alert } from 'react-bootstrap' import { useTranslation, Trans } from 'react-i18next' import getMeta from '../../../../utils/meta' import { FetchError } from '../../../../infrastructure/fetch-json' +import { ExposedSettings } from '../../../../../../types/exposed-settings' type LeaveModalFormErrorProps = { error: FetchError @@ -9,13 +10,13 @@ type LeaveModalFormErrorProps = { function LeaveModalFormError({ error }: LeaveModalFormErrorProps) { const { t } = useTranslation() - const isSaas = getMeta('ol-isSaas') as boolean + const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings let errorMessage let errorTip = null if (error.response?.status === 403) { errorMessage = t('email_or_password_wrong_try_again') - if (isSaas) { + if (isOverleaf) { errorTip = ( + {t('change_password')} + + > + ) +} + +function PasswordInnerSection() { + const { t } = useTranslation() + const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings + const isExternalAuthenticationSystemUsed = getMeta( + 'ol-isExternalAuthenticationSystemUsed' + ) as boolean + const hasPassword = getMeta('ol-hasPassword') as boolean + + if (isExternalAuthenticationSystemUsed && !isOverleaf) { + return {t('password_managed_externally')} + } + + if (!hasPassword) { + return ( + + + {t('no_existing_password')} + + + ) + } + + return +} + +function PasswordForm() { + const { t } = useTranslation() + const passwordStrengthOptions = getMeta( + 'ol-passwordStrengthOptions' + ) as PasswordStrengthOptions + + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword1, setNewPassword1] = useState('') + const [newPassword2, setNewPassword2] = useState('') + const { isLoading, error, isSuccess, data, runAsync } = useAsync() + const [isNewPasswordValid, setIsNewPasswordValid] = useState(false) + const [isFormValid, setIsFormValid] = useState(false) + + const handleCurrentPasswordChange = event => { + setCurrentPassword(event.target.value) + } + + const handleNewPassword1Change = event => { + setNewPassword1(event.target.value) + setIsNewPasswordValid(event.target.validity.valid) + } + + const handleNewPassword2Change = event => { + setNewPassword2(event.target.value) + } + + useEffect(() => { + setIsFormValid( + !!currentPassword && isNewPasswordValid && newPassword1 === newPassword2 + ) + }, [currentPassword, newPassword1, newPassword2, isNewPasswordValid]) + + const handleSubmit = event => { + event.preventDefault() + if (!isFormValid) { + return + } + runAsync( + postJSON('/user/password/update', { + body: { + currentPassword, + newPassword1, + newPassword2, + }, + }) + ).catch(() => {}) + } + + return ( + + + + + {isSuccess && data?.message?.text ? ( + + {data.message.text} + + ) : null} + {error ? ( + + {error.getUserFacingMessage()} + + ) : null} + + {isLoading ? <>{t('saving')}…> : t('change')} + + + ) +} + +type PasswordFormGroupProps = { + id: string + label: string + value: string + handleChange: (event: any) => void + minLength?: number + validationMessage?: string +} + +function PasswordFormGroup({ + id, + label, + value, + handleChange, + minLength, + validationMessage: parentValidationMessage, +}: PasswordFormGroupProps) { + const [validationMessage, setValidationMessage] = useState('') + const [hadInteraction, setHadInteraction] = useState(false) + + const handleInvalid = event => { + event.preventDefault() + } + + const handleChangeAndValidity = event => { + handleChange(event) + setHadInteraction(true) + setValidationMessage(event.target.validationMessage) + } + + return ( + + {label} + + {hadInteraction && (parentValidationMessage || validationMessage) ? ( + + {parentValidationMessage || validationMessage} + + ) : null} + + ) +} + +export default PasswordSection diff --git a/services/web/frontend/js/features/settings/components/root.tsx b/services/web/frontend/js/features/settings/components/root.tsx new file mode 100644 index 0000000000..8396b3ca63 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/root.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react' +import { Alert } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import getMeta from '../../../utils/meta' +import EmailsSection from './emails-section' +import AccountInfoSection from './account-info-section' +import PasswordSection from './password-section' +import IntegrationLinkingSection from './integration-linking-section' +import SSOLinkingSection from './sso-linking-section' +import LeaveSection from './leave-section' +import * as eventTracking from '../../../infrastructure/event-tracking' +import { UserProvider } from '../../../shared/context/user-context' + +function SettingsPageRoot() { + const { t } = useTranslation() + const ssoError = getMeta('ol-ssoError') as string + + useEffect(() => { + eventTracking.sendMB('settings-view') + }, []) + + return ( + + + + + {ssoError ? ( + + {t('sso_link_error')}: {t(ssoError)} + + ) : null} + + + {t('account_settings')} + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default SettingsPageRoot diff --git a/services/web/frontend/js/features/settings/components/sso-linking-section.tsx b/services/web/frontend/js/features/settings/components/sso-linking-section.tsx new file mode 100644 index 0000000000..b8a216f20f --- /dev/null +++ b/services/web/frontend/js/features/settings/components/sso-linking-section.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next' +import { + SSOProvider, + useSSOContext, + SSOSubscription, +} from '../context/sso-context' +import { SSOLinkingWidget } from './sso-linking/widget' + +function SSOLinkingSection() { + const { t } = useTranslation() + + return ( + + {t('linked_accounts')} + {t('linked_accounts_explained')} + + + ) +} + +function SSOLinkingWidgets() { + const { subscriptions } = useSSOContext() + + return ( + + {Object.values(subscriptions).map((subscription, subscriptionIndex) => ( + + ))} + + ) +} + +type SSOLinkingWidgetContainerProps = { + subscription: SSOSubscription + isLast: boolean +} + +function SSOLinkingWidgetContainer({ + subscription, + isLast, +}: SSOLinkingWidgetContainerProps) { + const { t } = useTranslation() + const { unlink } = useSSOContext() + + return ( + <> + unlink(subscription.providerId)} + /> + {isLast ? null : } + > + ) +} + +export default SSOLinkingSection diff --git a/services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx b/services/web/frontend/js/features/settings/components/sso-linking/widget.tsx similarity index 69% rename from services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx rename to services/web/frontend/js/features/settings/components/sso-linking/widget.tsx index 634bda7cca..7a656f97ec 100644 --- a/services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx +++ b/services/web/frontend/js/features/settings/components/sso-linking/widget.tsx @@ -1,29 +1,37 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button, Modal } from 'react-bootstrap' -import AccessibleModal from '../../../shared/components/accessible-modal' +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' + +const providerLogos = { + collabratec: , + google: , + orcid: , +} type SSOLinkingWidgetProps = { - logoSrc: string + providerId: string title: string description: string + helpPath?: string linked?: boolean linkPath: string onUnlink: () => Promise - unlinkConfirmationTitle: string - unlinkConfirmationText: string } export function SSOLinkingWidget({ - logoSrc, + providerId, title, description, + helpPath, linked, linkPath, onUnlink, - unlinkConfirmationTitle, - unlinkConfirmationText, }: SSOLinkingWidgetProps) { + const { t } = useTranslation() const [showModal, setShowModal] = useState(false) const [unlinkRequestInflight, setUnlinkRequestInflight] = useState(false) const [errorMessage, setErrorMessage] = useState('') @@ -49,16 +57,23 @@ export function SSOLinkingWidget({ }, []) return ( - - - - - - {title} - {description} + + {providerLogos[providerId]} + + + {title} + + + {description?.replace(/<[^>]+>/g, '')}{' '} + {helpPath ? ( + + {t('learn_more')} + + ) : null} + {errorMessage && {errorMessage} } - + @@ -92,15 +106,15 @@ function ActionButton({ const { t } = useTranslation() if (unlinkRequestInflight) { return ( - + {t('unlinking')} - + ) } else if (accountIsLinked) { return ( - + {t('unlink')} - + ) } else { return ( @@ -112,17 +126,15 @@ function ActionButton({ } type UnlinkConfirmModalProps = { - show: boolean title: string - content: string + show: boolean handleConfirmation: () => void handleHide: () => void } function UnlinkConfirmModal({ - show, title, - content, + show, handleConfirmation, handleHide, }: UnlinkConfirmModalProps) { @@ -131,11 +143,13 @@ function UnlinkConfirmModal({ return ( - {title} + + {t('unlink_provider_account_title', { provider: title })} + - {content} + {t('unlink_provider_account_warning', { provider: title })} diff --git a/services/web/frontend/js/features/user-settings/context/sso-context.tsx b/services/web/frontend/js/features/settings/context/sso-context.tsx similarity index 67% rename from services/web/frontend/js/features/user-settings/context/sso-context.tsx rename to services/web/frontend/js/features/settings/context/sso-context.tsx index 80f10a56e4..e9f3c1712a 100644 --- a/services/web/frontend/js/features/user-settings/context/sso-context.tsx +++ b/services/web/frontend/js/features/settings/context/sso-context.tsx @@ -9,12 +9,17 @@ import { import { postJSON } from '../../../infrastructure/fetch-json' import useIsMounted from '../../../shared/hooks/use-is-mounted' import { set, cloneDeep } from 'lodash' +import getMeta from '../../../utils/meta' +import type { + OAuthProviders, + OAuthProvider, +} from '../../../../../types/oauth-providers' +import type { ThirdPartyIds } from '../../../../../types/third-party-ids' -type SSOSubscription = { - name: string - descriptionKey: string - linked?: boolean - linkPath: string +export type SSOSubscription = { + providerId: string + provider: OAuthProvider + linked: boolean } type SSOContextValue = { @@ -30,15 +35,21 @@ type SSOProviderProps = { export function SSOProvider({ children }: SSOProviderProps) { const isMountedRef = useIsMounted() + const oauthProviders = getMeta('ol-oauthProviders') as OAuthProviders + const thirdPartyIds = getMeta('ol-thirdPartyIds') as ThirdPartyIds - const [subscriptions, setSubscriptions] = useState(() => { + const [subscriptions, setSubscriptions] = useState< + Record + >(() => { const initialSubscriptions: Record = {} - for (const [id, provider] of Object.entries(window.oauthProviders)) { - initialSubscriptions[id] = { - descriptionKey: provider.descriptionKey, - name: provider.name, - linkPath: provider.linkPath, - linked: !!window.thirdPartyIds[id], + for (const [id, provider] of Object.entries(oauthProviders)) { + const linked = !!thirdPartyIds[id] + if (!provider.hideWhenNotLinked || linked) { + initialSubscriptions[id] = { + providerId: id, + provider, + linked, + } } } return initialSubscriptions diff --git a/services/web/frontend/js/features/settings/context/user-email-context.tsx b/services/web/frontend/js/features/settings/context/user-email-context.tsx index f2fdfac0de..ffb918a490 100644 --- a/services/web/frontend/js/features/settings/context/user-email-context.tsx +++ b/services/web/frontend/js/features/settings/context/user-email-context.tsx @@ -1,55 +1,88 @@ -import { createContext, useContext, useReducer, useCallback } from 'react' -import getMeta from '../../../utils/meta' +import { + createContext, + useEffect, + useContext, + useReducer, + useCallback, +} from 'react' import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch' +import * as ActionCreators from '../utils/action-creators' import { UserEmailData } from '../../../../../types/user-email' +import { Nullable } from '../../../../../types/utils' +import { Affiliation } from '../../../../../types/affiliation' import { normalize, NormalizedObject } from '../../../utils/normalize' +import { getJSON } from '../../../infrastructure/fetch-json' +import useAsync from '../../../shared/hooks/use-async' // eslint-disable-next-line no-unused-vars -enum Actions { +export enum Actions { + SET_DATA = 'SET_DATA', // eslint-disable-line no-unused-vars SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars + MAKE_PRIMARY = 'MAKE_PRIMARY', // eslint-disable-line no-unused-vars + DELETE_EMAIL = 'DELETE_EMAIL', // eslint-disable-line no-unused-vars + SET_EMAIL_AFFILIATION_BEING_EDITED = 'SET_EMAIL_AFFILIATION_BEING_EDITED', // eslint-disable-line no-unused-vars + UPDATE_AFFILIATION = 'UPDATE_AFFILIATION', // eslint-disable-line no-unused-vars } -type ActionSetLoading = { +export type ActionSetData = { + type: Actions.SET_DATA + payload: UserEmailData[] +} + +export type ActionSetLoading = { type: Actions.SET_LOADING_STATE payload: boolean } -type State = { +export type ActionMakePrimary = { + type: Actions.MAKE_PRIMARY + payload: UserEmailData['email'] +} + +export type ActionDeleteEmail = { + type: Actions.DELETE_EMAIL + payload: UserEmailData['email'] +} + +export type ActionSetEmailAffiliationBeingEdited = { + type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED + payload: Nullable +} + +export type ActionUpdateAffiliation = { + type: Actions.UPDATE_AFFILIATION + payload: { + email: UserEmailData['email'] + role: Affiliation['role'] + department: Affiliation['department'] + } +} + +export type State = { isLoading: boolean data: { byId: NormalizedObject + linkedInstitutionIds: NonNullable[] + emailAffiliationBeingEdited: Nullable } } -type Action = ActionSetLoading +type Action = + | ActionSetData + | ActionSetLoading + | ActionMakePrimary + | ActionDeleteEmail + | ActionSetEmailAffiliationBeingEdited + | ActionUpdateAffiliation -const setLoadingAction = (state: State, action: ActionSetLoading) => ({ - ...state, - isLoading: action.payload, -}) - -const initialState: State = { - isLoading: false, - data: { - byId: {}, - }, -} - -const reducer = (state: State, action: Action) => { - switch (action.type) { - case Actions.SET_LOADING_STATE: - return setLoadingAction(state, action) - } -} - -const initializer = (initialState: State) => { - const normalized = normalize(getMeta('ol-userEmails'), { +const setData = (state: State, action: ActionSetData) => { + const normalized = normalize(action.payload, { idAttribute: 'email', }) const byId = normalized || {} return { - ...initialState, + ...state, data: { ...initialState.data, byId, @@ -57,23 +90,154 @@ const initializer = (initialState: State) => { } } -function useUserEmails() { - const [state, dispatch] = useReducer(reducer, initialState, initializer) - const safeDispatch = useSafeDispatch(dispatch) +const setLoadingAction = (state: State, action: ActionSetLoading) => ({ + ...state, + isLoading: action.payload, +}) - const setLoading = useCallback( - (flag: boolean) => { - safeDispatch({ - type: Actions.SET_LOADING_STATE, - payload: flag, - }) +const makePrimaryAction = (state: State, action: ActionMakePrimary) => { + const byId: State['data']['byId'] = {} + for (const id of Object.keys(state.data.byId)) { + byId[id] = { + ...state.data.byId[id], + default: state.data.byId[id].email === action.payload, + } + } + + return { + ...state, + data: { + ...state.data, + byId, }, - [safeDispatch] - ) + } +} + +const deleteEmailAction = (state: State, action: ActionDeleteEmail) => { + const { [action.payload]: _, ...byId } = state.data.byId + + return { + ...state, + data: { + ...state.data, + byId, + }, + } +} + +const setEmailAffiliationBeingEditedAction = ( + state: State, + action: ActionSetEmailAffiliationBeingEdited +) => ({ + ...state, + data: { + ...state.data, + emailAffiliationBeingEdited: action.payload, + }, +}) + +const updateAffiliationAction = ( + state: State, + action: ActionUpdateAffiliation +) => { + const { email, role, department } = action.payload + const affiliation = state.data.byId[email].affiliation + + return { + ...state, + data: { + ...state.data, + byId: { + ...state.data.byId, + [email]: { + ...state.data.byId[email], + ...(affiliation && { + affiliation: { + ...affiliation, + role, + department, + }, + }), + }, + }, + emailAffiliationBeingEdited: null, + }, + } +} + +const initialState: State = { + isLoading: false, + data: { + byId: {}, + linkedInstitutionIds: [], + emailAffiliationBeingEdited: null, + }, +} + +const reducer = (state: State, action: Action) => { + switch (action.type) { + case Actions.SET_DATA: + return setData(state, action) + case Actions.SET_LOADING_STATE: + return setLoadingAction(state, action) + case Actions.MAKE_PRIMARY: + return makePrimaryAction(state, action) + case Actions.DELETE_EMAIL: + return deleteEmailAction(state, action) + case Actions.SET_EMAIL_AFFILIATION_BEING_EDITED: + return setEmailAffiliationBeingEditedAction(state, action) + case Actions.UPDATE_AFFILIATION: + return updateAffiliationAction(state, action) + default: + return state + } +} + +function useUserEmails() { + const [state, unsafeDispatch] = useReducer(reducer, initialState) + const dispatch = useSafeDispatch(unsafeDispatch) + const { isLoading, isSuccess, isError, runAsync } = useAsync() + + useEffect(() => { + runAsync(getJSON('/user/emails?ensureAffiliation=true')) + .then(data => { + dispatch(ActionCreators.setData(data)) + }) + .catch(() => {}) + }, [runAsync, dispatch]) return { state, - setLoading, + isInitializing: isLoading, + isInitializingSuccess: isSuccess, + isInitializingError: isError, + setLoading: useCallback( + (flag: boolean) => dispatch(ActionCreators.setLoading(flag)), + [dispatch] + ), + makePrimary: useCallback( + (email: UserEmailData['email']) => + dispatch(ActionCreators.makePrimary(email)), + [dispatch] + ), + deleteEmail: useCallback( + (email: UserEmailData['email']) => + dispatch(ActionCreators.deleteEmail(email)), + [dispatch] + ), + setEmailAffiliationBeingEdited: useCallback( + (email: Nullable) => + dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)), + [dispatch] + ), + updateAffiliation: useCallback( + ( + email: UserEmailData['email'], + role: Affiliation['role'], + department: Affiliation['department'] + ) => dispatch(ActionCreators.updateAffiliation(email, role, department)), + [dispatch] + ), } } diff --git a/services/web/frontend/js/features/settings/departments.ts b/services/web/frontend/js/features/settings/departments.ts new file mode 100644 index 0000000000..0b514b3243 --- /dev/null +++ b/services/web/frontend/js/features/settings/departments.ts @@ -0,0 +1,76 @@ +export const defaults = [ + 'Aeronautics & Astronautics', + 'Anesthesia', + 'Anthropology', + 'Applied Physics', + 'Art & Art History', + 'Biochemistry', + 'Bioengineering', + 'Biology', + 'Business School Library', + 'Business, Graduate School of', + 'Cardiothoracic Surgery', + 'Chemical and Systems Biology', + 'Chemical Engineering', + 'Chemistry', + 'Civil & Environmental Engineering', + 'Classics', + 'Communication', + 'Comparative Literature', + 'Comparative Medicine', + 'Computer Science', + 'Dermatology', + 'Developmental Biology', + 'Earth System Science', + 'East Asian Languages and Cultures', + 'Economics', + 'Education, School of', + 'Electrical Engineering', + 'Energy Resources Engineering', + 'English', + 'French and Italian', + 'Genetics', + 'Geological Sciences', + 'Geophysics', + 'German Studies', + 'Health Research & Policy', + 'History', + 'Iberian & Latin American Cultures', + 'Law Library', + 'Law School', + 'Linguistics', + 'Management Science & Engineering', + 'Materials Science & Engineering', + 'Mathematics', + 'Mechanical Engineering', + 'Medical Library', + 'Medicine', + 'Microbiology & Immunology', + 'Molecular & Cellular Physiology', + 'Music', + 'Neurobiology', + 'Neurology & Neurological Sciences', + 'Neurosurgery', + 'Obstetrics and Gynecology', + 'Ophthalmology', + 'Orthopaedic Surgery', + 'Otolaryngology (Head and Neck Surgery)', + 'Pathology', + 'Pediatrics', + 'Philosophy', + 'Physics', + 'Political Science', + 'Psychiatry and Behavioral Sciences', + 'Psychology', + 'Radiation Oncology', + 'Radiology', + 'Religious Studies', + 'Slavic Languages and Literature', + 'Sociology', + 'University Libraries', + 'Statistics', + 'Structural Biology', + 'Surgery', + 'Theater and Performance Studies', + 'Urology', +] diff --git a/services/web/frontend/js/features/settings/roles.ts b/services/web/frontend/js/features/settings/roles.ts new file mode 100644 index 0000000000..7d8d81ee01 --- /dev/null +++ b/services/web/frontend/js/features/settings/roles.ts @@ -0,0 +1,13 @@ +export const defaults = [ + 'Undergraduate Student', + 'Masters Student (MSc, MA, ...)', + 'Doctoral Student (PhD, EngD, ...)', + 'Postdoc', + 'Lecturer', + 'Senior Lecturer', + 'Reader', + 'Associate Professor ', + 'Assistant Professor ', + 'Professor', + 'Emeritus Professor', +] diff --git a/services/web/frontend/js/features/settings/utils/action-creators.ts b/services/web/frontend/js/features/settings/utils/action-creators.ts new file mode 100644 index 0000000000..3b5420a5a9 --- /dev/null +++ b/services/web/frontend/js/features/settings/utils/action-creators.ts @@ -0,0 +1,52 @@ +import { + Actions, + ActionSetData, + ActionSetLoading, + ActionMakePrimary, + ActionDeleteEmail, + ActionSetEmailAffiliationBeingEdited, + ActionUpdateAffiliation, +} from '../context/user-email-context' +import { UserEmailData } from '../../../../../types/user-email' +import { Nullable } from '../../../../../types/utils' +import { Affiliation } from '../../../../../types/affiliation' + +export const setData = (data: UserEmailData[]): ActionSetData => ({ + type: Actions.SET_DATA, + payload: data, +}) + +export const setLoading = (flag: boolean): ActionSetLoading => ({ + type: Actions.SET_LOADING_STATE, + payload: flag, +}) + +export const makePrimary = ( + email: UserEmailData['email'] +): ActionMakePrimary => ({ + type: Actions.MAKE_PRIMARY, + payload: email, +}) + +export const deleteEmail = ( + email: UserEmailData['email'] +): ActionDeleteEmail => ({ + type: Actions.DELETE_EMAIL, + payload: email, +}) + +export const setEmailAffiliationBeingEdited = ( + email: Nullable +): ActionSetEmailAffiliationBeingEdited => ({ + type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED, + payload: email, +}) + +export const updateAffiliation = ( + email: UserEmailData['email'], + role: Affiliation['role'], + department: Affiliation['department'] +): ActionUpdateAffiliation => ({ + type: Actions.UPDATE_AFFILIATION, + payload: { email, role, department }, +}) diff --git a/services/web/frontend/js/features/settings/utils/selectors.ts b/services/web/frontend/js/features/settings/utils/selectors.ts new file mode 100644 index 0000000000..240cf4986f --- /dev/null +++ b/services/web/frontend/js/features/settings/utils/selectors.ts @@ -0,0 +1,22 @@ +import { State } from '../context/user-email-context' +import { UserEmailData } from '../../../../../types/user-email' + +export const inReconfirmNotificationPeriod = (userEmailData: UserEmailData) => { + return userEmailData.affiliation?.inReconfirmNotificationPeriod +} + +export const institutionAlreadyLinked = ( + state: State, + userEmailData: UserEmailData +) => { + const institutionId = userEmailData.affiliation?.institution.id?.toString() + + return institutionId !== undefined + ? state.data.linkedInstitutionIds.includes(institutionId) + : false +} + +export const isChangingAffiliation = ( + state: State, + email: UserEmailData['email'] +) => state.data.emailAffiliationBeingEdited === email diff --git a/services/web/frontend/js/pages/user/settings.js b/services/web/frontend/js/pages/user/settings.js new file mode 100644 index 0000000000..22fe3d9712 --- /dev/null +++ b/services/web/frontend/js/pages/user/settings.js @@ -0,0 +1,13 @@ +import '../../marketing' +import './../../utils/meta' +import './../../utils/webpack-public-path' +import './../../infrastructure/error-reporter' +import './../../i18n' +import '../../features/settings/components/root' +import ReactDOM from 'react-dom' +import SettingsPageRoot from '../../features/settings/components/root.tsx' + +const element = document.getElementById('settings-page-root') +if (element) { + ReactDOM.render(, element) +} diff --git a/services/web/frontend/js/shared/components/tooltip.tsx b/services/web/frontend/js/shared/components/tooltip.tsx new file mode 100644 index 0000000000..19c5996214 --- /dev/null +++ b/services/web/frontend/js/shared/components/tooltip.tsx @@ -0,0 +1,37 @@ +import { + OverlayTrigger, + OverlayTriggerProps, + Tooltip as BSTooltip, +} from 'react-bootstrap' + +type TooltipProps = { + children: React.ReactNode + description: string + id: string + overlayProps?: Omit + tooltipProps?: BSTooltip.TooltipProps +} + +function Tooltip({ + id, + description, + children, + tooltipProps, + overlayProps, +}: TooltipProps) { + return ( + + {description} + + } + {...overlayProps} + placement={overlayProps?.placement || 'top'} + > + {children} + + ) +} + +export default Tooltip diff --git a/services/web/frontend/js/shared/context/user-context.js b/services/web/frontend/js/shared/context/user-context.js index 0a9104b8e5..4b1c659071 100644 --- a/services/web/frontend/js/shared/context/user-context.js +++ b/services/web/frontend/js/shared/context/user-context.js @@ -13,6 +13,17 @@ UserContext.Provider.propTypes = { allowedFreeTrial: PropTypes.boolean, first_name: PropTypes.string, last_name: PropTypes.string, + features: PropTypes.shape({ + dropbox: PropTypes.boolean, + github: PropTypes.boolean, + mendeley: PropTypes.boolean, + zotero: PropTypes.boolean, + references: PropTypes.boolean, + }), + refProviders: PropTypes.shape({ + mendeley: PropTypes.any, + zotero: PropTypes.any, + }), }), }), } @@ -29,6 +40,12 @@ UserProvider.propTypes = { export function useUserContext(propTypes) { const data = useContext(UserContext) + if (!data) { + throw new Error( + 'useUserContext is only available inside UserContext, or `ol-user` meta is not defined' + ) + } + PropTypes.checkPropTypes(propTypes, data, 'data', 'UserContext.Provider') return data } diff --git a/services/web/frontend/js/shared/hooks/use-async.ts b/services/web/frontend/js/shared/hooks/use-async.ts index 707a4290f3..a4a541551d 100644 --- a/services/web/frontend/js/shared/hooks/use-async.ts +++ b/services/web/frontend/js/shared/hooks/use-async.ts @@ -10,13 +10,15 @@ type State = { type Action = Partial const defaultInitialState: State = { status: 'idle', data: null, error: null } -const initializer = (initialState: State) => ({ ...initialState }) function useAsync(initialState?: Partial) { + const initialStateRef = React.useRef({ + ...defaultInitialState, + ...initialState, + }) const [{ status, data, error }, setState] = React.useReducer( (state: State, action: Action) => ({ ...state, ...action }), - { ...defaultInitialState, ...initialState }, - initializer + initialStateRef.current ) const safeSetState = useSafeDispatch(setState) @@ -31,11 +33,25 @@ function useAsync(initialState?: Partial) { [safeSetState] ) + const reset = React.useCallback( + () => safeSetState(initialStateRef.current), + [safeSetState] + ) + const runAsync = React.useCallback( - (promise: Promise>) => { + (promise: Promise) => { safeSetState({ status: 'pending' }) - return promise.then(setData, setError) + return promise.then( + data => { + setData(data) + return data + }, + error => { + setError(error) + return Promise.reject(error) + } + ) }, [safeSetState, setData, setError] ) @@ -51,6 +67,7 @@ function useAsync(initialState?: Partial) { status, data, runAsync, + reset, } } diff --git a/services/web/frontend/js/shared/svgs/dropbox-logo.js b/services/web/frontend/js/shared/svgs/dropbox-logo.js new file mode 100644 index 0000000000..bcd13b6ec1 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/dropbox-logo.js @@ -0,0 +1,47 @@ +function DropboxLogo() { + return ( + + + + + + + + + + + + + + + + ) +} + +export default DropboxLogo diff --git a/services/web/frontend/js/shared/svgs/github-logo.js b/services/web/frontend/js/shared/svgs/github-logo.js new file mode 100644 index 0000000000..71c1c3651a --- /dev/null +++ b/services/web/frontend/js/shared/svgs/github-logo.js @@ -0,0 +1,39 @@ +function GithubLogo() { + return ( + + + + + + + + + + + ) +} + +export default GithubLogo diff --git a/services/web/frontend/js/shared/svgs/google-logo.js b/services/web/frontend/js/shared/svgs/google-logo.js new file mode 100644 index 0000000000..3c0c085642 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/google-logo.js @@ -0,0 +1,39 @@ +function GoogleLogo() { + return ( + + + + + + + + ) +} + +export default GoogleLogo diff --git a/services/web/frontend/js/shared/svgs/ieee-logo.js b/services/web/frontend/js/shared/svgs/ieee-logo.js new file mode 100644 index 0000000000..07bfcb2746 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/ieee-logo.js @@ -0,0 +1,23 @@ +function IEEELogo() { + return ( + + + + + + ) +} + +export default IEEELogo diff --git a/services/web/frontend/js/shared/svgs/mendeley-logo.js b/services/web/frontend/js/shared/svgs/mendeley-logo.js new file mode 100644 index 0000000000..8d16656115 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/mendeley-logo.js @@ -0,0 +1,39 @@ +function MendeleyLogo() { + return ( + + + + + + + + + + + ) +} + +export default MendeleyLogo diff --git a/services/web/frontend/js/shared/svgs/orcid-logo.js b/services/web/frontend/js/shared/svgs/orcid-logo.js new file mode 100644 index 0000000000..00c8e6c4aa --- /dev/null +++ b/services/web/frontend/js/shared/svgs/orcid-logo.js @@ -0,0 +1,43 @@ +function OrcidLogo() { + return ( + + + + + + + + + + + + + + + ) +} + +export default OrcidLogo diff --git a/services/web/frontend/js/shared/svgs/zotero-logo.js b/services/web/frontend/js/shared/svgs/zotero-logo.js new file mode 100644 index 0000000000..a41bc5c1a4 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/zotero-logo.js @@ -0,0 +1,39 @@ +function ZoteroLogo() { + return ( + + + + + + + + + + + ) +} + +export default ZoteroLogo diff --git a/services/web/frontend/stories/settings.stories.js b/services/web/frontend/stories/settings.stories.js deleted file mode 100644 index ede3090288..0000000000 --- a/services/web/frontend/stories/settings.stories.js +++ /dev/null @@ -1,85 +0,0 @@ -import EmailsSection from '../js/features/settings/components/emails-section' -import useFetchMock from './hooks/use-fetch-mock' - -const MOCK_DELAY = 1000 -window.metaAttributesCache = window.metaAttributesCache || new Map() - -function defaultSetupMocks(fetchMock) { - fetchMock.post( - /\/user\/emails\/resend_confirmation/, - (path, req) => { - return 200 - }, - { - delay: MOCK_DELAY, - } - ) -} - -const fakeUsersData = [ - { - affiliation: { - institution: { - confirmed: true, - name: 'Overleaf', - }, - licence: 'pro_plus', - }, - confirmedAt: '2022-03-09T10:59:44.139Z', - email: 'foo@overleaf.com', - default: true, - }, - { - confirmedAt: '2022-03-10T10:59:44.139Z', - email: 'bar@overleaf.com', - default: false, - }, - { - affiliation: { - institution: { - confirmed: true, - name: 'Overleaf', - }, - licence: 'pro_plus', - department: 'Art & Art History', - role: 'Reader', - }, - email: 'baz@overleaf.com', - default: false, - }, - { - email: 'qux@overleaf.com', - default: false, - }, -] - -export const EmailsList = args => { - useFetchMock(defaultSetupMocks) - window.metaAttributesCache.set('ol-userEmails', fakeUsersData) - - return -} - -export const NetworkErrors = args => { - useFetchMock(defaultSetupMocks) - window.metaAttributesCache.set('ol-userEmails', fakeUsersData) - - useFetchMock(fetchMock => { - fetchMock.post( - /\/user\/emails\/resend_confirmation/, - () => { - return 503 - }, - { - delay: MOCK_DELAY, - } - ) - }) - - return -} - -export default { - title: 'Emails and Affiliations', - component: EmailsSection, -} diff --git a/services/web/frontend/stories/settings/account-info.stories.js b/services/web/frontend/stories/settings/account-info.stories.js new file mode 100644 index 0000000000..a2c8f22362 --- /dev/null +++ b/services/web/frontend/stories/settings/account-info.stories.js @@ -0,0 +1,40 @@ +import useFetchMock from '../hooks/use-fetch-mock' +import AccountInfoSection from '../../js/features/settings/components/account-info-section' +import { setDefaultMeta, defaultSetupMocks } from './helpers/account-info' + +export const Success = args => { + setDefaultMeta() + useFetchMock(defaultSetupMocks) + + return +} + +export const ReadOnly = args => { + setDefaultMeta() + window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true) + window.metaAttributesCache.set('ol-shouldAllowEditingDetails', false) + + return +} + +export const NoEmailInput = args => { + setDefaultMeta() + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) + useFetchMock(defaultSetupMocks) + + return +} + +export const Error = args => { + setDefaultMeta() + useFetchMock(fetchMock => fetchMock.post(/\/user\/settings/, 500)) + + return +} + +export default { + title: 'Account Settings / Account Info', + component: AccountInfoSection, +} diff --git a/services/web/frontend/stories/settings/emails.stories.js b/services/web/frontend/stories/settings/emails.stories.js new file mode 100644 index 0000000000..6f0fdb0968 --- /dev/null +++ b/services/web/frontend/stories/settings/emails.stories.js @@ -0,0 +1,26 @@ +import EmailsSection from '../../js/features/settings/components/emails-section' +import useFetchMock from './../hooks/use-fetch-mock' +import { + setDefaultMeta, + defaultSetupMocks, + errorsMocks, +} from './helpers/emails' + +export const EmailsList = args => { + useFetchMock(defaultSetupMocks) + setDefaultMeta() + + return +} + +export const NetworkErrors = args => { + useFetchMock(errorsMocks) + setDefaultMeta() + + return +} + +export default { + title: 'Account Settings / Emails and Affiliations', + component: EmailsSection, +} diff --git a/services/web/frontend/stories/settings/helpers/account-info.js b/services/web/frontend/stories/settings/helpers/account-info.js new file mode 100644 index 0000000000..3774bb7b47 --- /dev/null +++ b/services/web/frontend/stories/settings/helpers/account-info.js @@ -0,0 +1,19 @@ +const MOCK_DELAY = 1000 + +export function defaultSetupMocks(fetchMock) { + fetchMock.post(/\/user\/settings/, 200, { + delay: MOCK_DELAY, + }) +} + +export function setDefaultMeta() { + window.metaAttributesCache = window.metaAttributesCache || new Map() + window.metaAttributesCache.set('ol-usersEmail', 'sherlock@holmes.co.uk') + window.metaAttributesCache.set('ol-firstName', 'Sherlock') + window.metaAttributesCache.set('ol-lastName', 'Holmes') + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: false, + }) + window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', false) + window.metaAttributesCache.set('ol-shouldAllowEditingDetails', true) +} diff --git a/services/web/frontend/stories/settings/helpers/emails.js b/services/web/frontend/stories/settings/helpers/emails.js new file mode 100644 index 0000000000..0bd84895fc --- /dev/null +++ b/services/web/frontend/stories/settings/helpers/emails.js @@ -0,0 +1,59 @@ +const MOCK_DELAY = 1000 + +const fakeUsersData = [ + { + affiliation: { + institution: { + confirmed: true, + name: 'Overleaf', + }, + licence: 'pro_plus', + }, + confirmedAt: '2022-03-09T10:59:44.139Z', + email: 'foo@overleaf.com', + default: true, + }, + { + confirmedAt: '2022-03-10T10:59:44.139Z', + email: 'bar@overleaf.com', + default: false, + }, + { + affiliation: { + institution: { + confirmed: true, + name: 'Overleaf', + }, + licence: 'pro_plus', + department: 'Art & Art History', + role: 'Postdoc', + }, + email: 'baz@overleaf.com', + default: false, + }, + { + email: 'qux@overleaf.com', + default: false, + }, +] + +export function defaultSetupMocks(fetchMock) { + fetchMock + .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) + .post(/\/user\/emails\/*/, 200, { + delay: MOCK_DELAY, + }) +} + +export function errorsMocks(fetchMock) { + fetchMock + .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) + .post(/\/user\/emails\/*/, 500) +} + +export function setDefaultMeta() { + window.metaAttributesCache = window.metaAttributesCache || new Map() + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) +} diff --git a/services/web/frontend/stories/settings/helpers/integration-linking.js b/services/web/frontend/stories/settings/helpers/integration-linking.js new file mode 100644 index 0000000000..cf7df7cc7f --- /dev/null +++ b/services/web/frontend/stories/settings/helpers/integration-linking.js @@ -0,0 +1,21 @@ +const MOCK_DELAY = 1000 + +export function defaultSetupMocks(fetchMock) { + fetchMock.get( + 'express:/user/tpds/queues', + { tpdsToWeb: 0, webToTpds: 0 }, + { delay: MOCK_DELAY } + ) +} + +export function setDefaultMeta() { + window.metaAttributesCache.set('ol-user', { + features: { github: true, dropbox: true, mendeley: false, zotero: false }, + refProviders: { + mendeley: true, + zotero: true, + }, + }) + window.metaAttributesCache.set('ol-github', { enabled: false }) + window.metaAttributesCache.set('ol-dropbox', { registered: true }) +} diff --git a/services/web/frontend/stories/settings/helpers/leave.js b/services/web/frontend/stories/settings/helpers/leave.js new file mode 100644 index 0000000000..7e5003afc1 --- /dev/null +++ b/services/web/frontend/stories/settings/helpers/leave.js @@ -0,0 +1,14 @@ +const MOCK_DELAY = 1000 + +export function defaultSetupMocks(fetchMock) { + fetchMock.post(/\/user\/delete/, 200, { + delay: MOCK_DELAY, + }) +} + +export function setDefaultMeta() { + window.metaAttributesCache = window.metaAttributesCache || new Map() + window.metaAttributesCache.set('ol-usersEmail', 'user@primary.com') + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) + window.metaAttributesCache.set('ol-hasPassword', true) +} diff --git a/services/web/frontend/stories/settings/helpers/password.js b/services/web/frontend/stories/settings/helpers/password.js new file mode 100644 index 0000000000..d7ede1d1bb --- /dev/null +++ b/services/web/frontend/stories/settings/helpers/password.js @@ -0,0 +1,34 @@ +const MOCK_DELAY = 1000 + +export function defaultSetupMocks(fetchMock) { + fetchMock.post( + /\/user\/password\/update/, + { + status: 200, + body: { + message: { + type: 'success', + email: 'tim.alby@overleaf.com', + text: 'Password changed', + }, + }, + }, + { + delay: MOCK_DELAY, + } + ) +} + +export function setDefaultMeta() { + window.metaAttributesCache = window.metaAttributesCache || new Map() + window.metaAttributesCache.set('ol-ExposedSettings', { + isOverleaf: true, + }) + window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', false) + window.metaAttributesCache.set('ol-hasPassword', true) + window.metaAttributesCache.set('ol-passwordStrengthOptions', { + length: { + min: 2, + }, + }) +} diff --git a/services/web/frontend/stories/settings/helpers/sso-linking.js b/services/web/frontend/stories/settings/helpers/sso-linking.js new file mode 100644 index 0000000000..3cdc6cf6dc --- /dev/null +++ b/services/web/frontend/stories/settings/helpers/sso-linking.js @@ -0,0 +1,43 @@ +const MOCK_DELAY = 1000 + +export function defaultSetupMocks(fetchMock) { + fetchMock.post('/user/oauth-unlink', 200, { delay: MOCK_DELAY }) +} + +export function setDefaultMeta() { + window.metaAttributesCache.set('ol-thirdPartyIds', { + collabratec: 'collabratec-id', + google: 'google-id', + twitter: 'twitter-id', + }) + + window.metaAttributesCache.set('ol-oauthProviders', { + collabratec: { + descriptionKey: 'linked_collabratec_description', + descriptionOptions: { appName: 'Overleaf' }, + name: 'IEEE Collabratec®', + hideWhenNotLinked: true, + linkPath: '/collabratec/auth/link', + }, + google: { + descriptionKey: 'login_with_service', + descriptionOptions: { service: 'Google' }, + name: 'Google', + linkPath: '/auth/google', + }, + orcid: { + descriptionKey: 'oauth_orcid_description', + descriptionOptions: { + link: '/blog/434', + appName: 'Overleaf', + }, + name: 'Orcid', + linkPath: '/auth/orcid', + }, + twitter: { + hideWhenNotLinked: true, + name: 'Twitter', + linkPath: '/auth/twitter', + }, + }) +} diff --git a/services/web/frontend/stories/settings/integration-linking.stories.js b/services/web/frontend/stories/settings/integration-linking.stories.js new file mode 100644 index 0000000000..774e9d03f2 --- /dev/null +++ b/services/web/frontend/stories/settings/integration-linking.stories.js @@ -0,0 +1,23 @@ +import useFetchMock from '../hooks/use-fetch-mock' +import IntegrationLinkingSection from '../../js/features/settings/components/integration-linking-section' +import { + setDefaultMeta, + defaultSetupMocks, +} from './helpers/integration-linking' +import { UserProvider } from '../../js/shared/context/user-context' + +export const Section = args => { + useFetchMock(defaultSetupMocks) + setDefaultMeta() + + return ( + + + + ) +} + +export default { + title: 'Account Settings / Integration Linking / Section', + component: IntegrationLinkingSection, +} diff --git a/services/web/frontend/stories/settings/leave.stories.js b/services/web/frontend/stories/settings/leave.stories.js index 31c028d0e3..b7ca02836c 100644 --- a/services/web/frontend/stories/settings/leave.stories.js +++ b/services/web/frontend/stories/settings/leave.stories.js @@ -1,21 +1,7 @@ import useFetchMock from '../hooks/use-fetch-mock' import LeaveModal from '../../js/features/settings/components/leave/modal' import LeaveSection from '../../js/features/settings/components/leave-section' - -const MOCK_DELAY = 1000 -window.metaAttributesCache = window.metaAttributesCache || new Map() - -function defaultSetupMocks(fetchMock) { - fetchMock.post(/\/user\/delete/, 200, { - delay: MOCK_DELAY, - }) -} - -function setDefaultMeta() { - window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com') - window.metaAttributesCache.set('ol-isSaas', true) - window.metaAttributesCache.set('ol-hasPassword', true) -} +import { setDefaultMeta, defaultSetupMocks } from './helpers/leave' export const Section = args => { useFetchMock(defaultSetupMocks) diff --git a/services/web/frontend/stories/settings/page.stories.js b/services/web/frontend/stories/settings/page.stories.js new file mode 100644 index 0000000000..9a28ac1dd9 --- /dev/null +++ b/services/web/frontend/stories/settings/page.stories.js @@ -0,0 +1,53 @@ +import useFetchMock from '../hooks/use-fetch-mock' +import SettingsPageRoot from '../../js/features/settings/components/root' +import { + setDefaultMeta as setDefaultLeaveMeta, + defaultSetupMocks as defaultSetupLeaveMocks, +} from './helpers/leave' +import { + setDefaultMeta as setDefaultAccountInfoMeta, + defaultSetupMocks as defaultSetupAccountInfoMocks, +} from './helpers/account-info' +import { + setDefaultMeta as setDefaultPasswordMeta, + defaultSetupMocks as defaultSetupPasswordMocks, +} from './helpers/password' +import { + setDefaultMeta as setDefaultEmailsMeta, + defaultSetupMocks as defaultSetupEmailsMocks, +} from './helpers/emails' +import { + setDefaultMeta as setDefaultIntegrationLinkingMeta, + defaultSetupMocks as defaultSetupIntegrationLinkingMocks, +} from './helpers/integration-linking' +import { + setDefaultMeta as setDefaultSSOMeta, + defaultSetupMocks as defaultSetupSSOMocks, +} from './helpers/sso-linking' +import { UserProvider } from '../../js/shared/context/user-context' + +export const Root = args => { + setDefaultLeaveMeta() + setDefaultAccountInfoMeta() + setDefaultPasswordMeta() + setDefaultEmailsMeta() + setDefaultIntegrationLinkingMeta() + setDefaultSSOMeta() + useFetchMock(defaultSetupLeaveMocks) + useFetchMock(defaultSetupAccountInfoMocks) + useFetchMock(defaultSetupPasswordMocks) + useFetchMock(defaultSetupEmailsMocks) + useFetchMock(defaultSetupIntegrationLinkingMocks) + useFetchMock(defaultSetupSSOMocks) + + return ( + + + + ) +} + +export default { + title: 'Account Settings / Full Page', + component: SettingsPageRoot, +} diff --git a/services/web/frontend/stories/settings/password.stories.js b/services/web/frontend/stories/settings/password.stories.js new file mode 100644 index 0000000000..78bad98425 --- /dev/null +++ b/services/web/frontend/stories/settings/password.stories.js @@ -0,0 +1,48 @@ +import useFetchMock from '../hooks/use-fetch-mock' +import PasswordSection from '../../js/features/settings/components/password-section' +import { setDefaultMeta, defaultSetupMocks } from './helpers/password' + +export const Success = args => { + setDefaultMeta() + useFetchMock(defaultSetupMocks) + + return +} + +export const ManagedExternally = args => { + setDefaultMeta() + window.metaAttributesCache.set('ol-ExposedSettings', { + isOverleaf: false, + }) + window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true) + useFetchMock(defaultSetupMocks) + + return +} + +export const NoExistingPassword = args => { + setDefaultMeta() + window.metaAttributesCache.set('ol-hasPassword', false) + useFetchMock(defaultSetupMocks) + + return +} + +export const Error = args => { + setDefaultMeta() + useFetchMock(fetchMock => + fetchMock.post(/\/user\/password\/update/, { + status: 400, + body: { + message: 'Your old password is wrong', + }, + }) + ) + + return +} + +export default { + title: 'Account Settings / Password', + component: PasswordSection, +} diff --git a/services/web/frontend/stories/settings/sso-linking.stories.js b/services/web/frontend/stories/settings/sso-linking.stories.js new file mode 100644 index 0000000000..84ffa29553 --- /dev/null +++ b/services/web/frontend/stories/settings/sso-linking.stories.js @@ -0,0 +1,23 @@ +import useFetchMock from '../hooks/use-fetch-mock' +import SSOLinkingSection from '../../js/features/settings/components/sso-linking-section' +import { setDefaultMeta, defaultSetupMocks } from './helpers/sso-linking' + +export const Section = args => { + useFetchMock(defaultSetupMocks) + setDefaultMeta() + + return +} + +export const SectionAllUnlinked = args => { + useFetchMock(defaultSetupMocks) + setDefaultMeta() + window.metaAttributesCache.set('ol-thirdPartyIds', {}) + + return +} + +export default { + title: 'Account Settings / SSO Linking / Section', + component: SSOLinkingSection, +} diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less index 4a25b163e9..38ca1bab96 100644 --- a/services/web/frontend/stylesheets/_style_includes.less +++ b/services/web/frontend/stylesheets/_style_includes.less @@ -59,6 +59,7 @@ @import 'components/expand-collapse.less'; @import 'components/beta-badges.less'; @import 'components/divider.less'; +@import 'components/spacing.less'; // Components w/ JavaScript @import 'components/modals.less'; diff --git a/services/web/frontend/stylesheets/app/account-settings.less b/services/web/frontend/stylesheets/app/account-settings.less index b8b53d4567..edb5f9f80a 100644 --- a/services/web/frontend/stylesheets/app/account-settings.less +++ b/services/web/frontend/stylesheets/app/account-settings.less @@ -129,3 +129,56 @@ tbody > tr.affiliations-table-warning-row > td { } } } + +.settings-widgets-container { + border: 1px solid @gray-lighter; + + hr { + margin: 15px 10px; + } +} + +.settings-widget-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + + > div { + display: flex; + flex-direction: column; + padding-right: 20px; + + &:last-child { + padding-right: 0px; + } + } + + img { + width: 40px; + height: 40px; + } + + .description-container { + flex-grow: 1; + } + + .title-row { + display: flex; + align-items: center; + margin-bottom: 10px; + + > h4 { + margin: 0; + margin-right: 10px; + } + } + + p { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/services/web/frontend/stylesheets/components/spacing.less b/services/web/frontend/stylesheets/components/spacing.less new file mode 100644 index 0000000000..c4011545ab --- /dev/null +++ b/services/web/frontend/stylesheets/components/spacing.less @@ -0,0 +1,102 @@ +// Not part of BS3 but adding those with the intention to move to BS5 +// https://digital-science.slack.com/archives/C014X5MJB8S/p1650008783200769 +@spacers: { + 0: 0; + 1: @margin-xs; + 2: @margin-sm; + 3: @margin-md; + 4: @margin-lg; + 5: @margin-xl; + 6: @margin-xxl; +}; + +each(@spacers, { + // Margins + .m-@{key} { + margin: @value !important; + } + .ms-@{key} { + margin-left: @value !important; + } + .me-@{key} { + margin-right: @value !important; + } + .mt-@{key} { + margin-top: @value !important; + } + .mb-@{key} { + margin-bottom: @value !important; + } + .mx-@{key} { + margin-left: @value !important; + margin-right: @value !important; + } + .my-@{key} { + margin-top: @value !important; + margin-bottom: @value !important; + } + + // Negative margins + .m-n@{key} { + margin: -@value !important; + } + .ms-n@{key} { + margin-left: -@value !important; + } + .me-n@{key} { + margin-right: -@value !important; + } + .mt-n@{key} { + margin-top: -@value !important; + } + .mb-n@{key} { + margin-bottom: -@value !important; + } + .mx-n@{key} { + margin-left: -@value !important; + margin-right: -@value !important; + } + .my-n@{key} { + margin-top: -@value !important; + margin-bottom: -@value !important; + } + + // Paddings + .p-@{key} { + padding: @value !important; + } + .ps-@{key} { + padding-left: @value !important; + } + .pe-@{key} { + padding-right: @value !important; + } + .pt-@{key} { + padding-top: @value !important; + } + .pb-@{key} { + padding-bottom: @value !important; + } + .px-@{key} { + padding-left: @value !important; + padding-right: @value !important; + } + .py-@{key} { + padding-top: @value !important; + padding-bottom: @value !important; + } +}); + +.m-auto { + margin: auto !important; +} + +.mx-auto { + margin-left: auto !important; + margin-right: auto !important; +} + +.my-auto { + margin-top: auto !important; + margin-bottom: auto !important; +} diff --git a/services/web/frontend/stylesheets/core/type.less b/services/web/frontend/stylesheets/core/type.less index 224b075a59..60b6fd77c9 100755 --- a/services/web/frontend/stylesheets/core/type.less +++ b/services/web/frontend/stylesheets/core/type.less @@ -142,6 +142,11 @@ cite { .text-justify { text-align: justify; } +@media (min-width: @screen-md-min) { + .text-md-right { + text-align: right; + } +} // Contextual colors .text-muted { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 2f74693ddd..d7cf30ed02 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -55,6 +55,7 @@ "increased_compile_timeout": "Increased compile timeout", "compile_timeout": "Compile timeout (minutes)", "collabs_per_proj_single": "__collabcount__ collaborator per project", + "premium_feature": "Premium feature", "premium_features": "Premium features", "special_price_student": "Special Price Student Plans", "hide_outline": "Hide File outline", @@ -98,6 +99,7 @@ "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.", "template_gallery": "Template Gallery", "template_not_found_description": "This way of creating projects from templates has been removed. Please visit our template gallery to find more templates.", + "integrations": "Integrations", "dropbox_checking_sync_status": "Checking Dropbox Integration status", "dropbox_sync_in": "Updating Overleaf", "dropbox_sync_out": "Updating Dropbox", @@ -258,6 +260,7 @@ "linked_accounts_explained": "You can link your __appName__ account with other services to enable the features described below", "oauth_orcid_description": " Securely establish your identity by linking your ORCID iD to your __appName__ account. Submissions to participating publishers will automatically include your ORCID iD for improved workflow and visibility. ", "no_existing_password": "Please use the password reset form to set your password", + "password_managed_externally": "Password settings are managed externally", " to_reactivate_your_subscription_go_to": "To reactivate your subscription go to", "subscription_canceled": "Subscription Canceled", "coupons_not_included": "This does not include your current discounts, which will be applied automatically before your next payment", @@ -752,6 +755,8 @@ "reference_import_button": "Import References to", "unlink_reference": "Unlink References Provider", "unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.", + "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.", "mendeley": "Mendeley", "zotero": "Zotero", "from_provider": "From __provider__", @@ -1420,6 +1425,7 @@ "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.", "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", "unlink": "Unlink", "unlink_github_warning": "Any projects that you have synced with GitHub will be disconnected and no longer kept in sync with GitHub. Are you sure you want to unlink your GitHub account?", "github_account_successfully_linked": "GitHub Account Successfully Linked!", @@ -1613,5 +1619,7 @@ "learn_more_about_emails": "<0>Learn more0> about managing your __appName__ emails.", "thank_you_email_checked": "Thank you, we’re now taking you back to the projects page", "change_primary_email_address_instructions": "To change your primary email, please add your new primary email address first (by clicking <0>Add another email0>) and confirm it. Then click the <0>Make Primary0> button. <1>Learn more1> about managing your __appName__ emails.", - "help_improve_overleaf_fill_out_this_survey": "If you would like to help us improve Overleaf, please take a moment to fill out <0>this survey0>." + "help_improve_overleaf_fill_out_this_survey": "If you would like to help us improve Overleaf, please take a moment to fill out <0>this survey0>.", + "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?" } diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js index 951f1e5cf9..cdd56ea93a 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js @@ -6,10 +6,9 @@ import LayoutDropdownButton from '../../../../../frontend/js/features/editor-nav import { renderWithEditorContext } from '../../../helpers/render-with-context' import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' -const eventTrackingSpy = sinon.spy(eventTracking) - describe('', function () { let openStub + let sendMBSpy const defaultUi = { pdfLayout: 'flat', view: 'pdf', @@ -17,11 +16,13 @@ describe('', function () { beforeEach(function () { openStub = sinon.stub(window, 'open') + sendMBSpy = sinon.spy(eventTracking, 'sendMB') window.metaAttributesCache = new Map() }) afterEach(function () { openStub.restore() + sendMBSpy.restore() window.metaAttributesCache = new Map() fetchMock.restore() }) @@ -101,7 +102,7 @@ describe('', function () { }) it('should record event', function () { - sinon.assert.calledWith(eventTrackingSpy.sendMB, 'project-layout-detach') + sinon.assert.calledWith(sendMBSpy, 'project-layout-detach') }) }) @@ -124,15 +125,11 @@ describe('', function () { }) it('should record events', function () { - sinon.assert.calledWith( - eventTrackingSpy.sendMB, - 'project-layout-reattach' - ) - sinon.assert.calledWith( - eventTrackingSpy.sendMB, - 'project-layout-change', - { layout: 'flat', view: 'editor' } - ) + sinon.assert.calledWith(sendMBSpy, 'project-layout-reattach') + sinon.assert.calledWith(sendMBSpy, 'project-layout-change', { + layout: 'flat', + view: 'editor', + }) }) it('should select new menu item', function () { diff --git a/services/web/test/frontend/features/settings/components/account-info-section.test.tsx b/services/web/test/frontend/features/settings/components/account-info-section.test.tsx new file mode 100644 index 0000000000..439410d37a --- /dev/null +++ b/services/web/test/frontend/features/settings/components/account-info-section.test.tsx @@ -0,0 +1,198 @@ +import { expect } from 'chai' +import { fireEvent, screen, render } from '@testing-library/react' +import fetchMock from 'fetch-mock' +import AccountInfoSection from '../../../../../frontend/js/features/settings/components/account-info-section' + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache = window.metaAttributesCache || new Map() + window.metaAttributesCache.set('ol-usersEmail', 'sherlock@holmes.co.uk') + window.metaAttributesCache.set('ol-firstName', 'Sherlock') + window.metaAttributesCache.set('ol-lastName', 'Holmes') + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: false, + }) + window.metaAttributesCache.set( + 'ol-isExternalAuthenticationSystemUsed', + false + ) + window.metaAttributesCache.set('ol-shouldAllowEditingDetails', true) + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('submits all inputs', async function () { + const updateMock = fetchMock.post('/user/settings', 200) + render() + + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'john@watson.co.uk' }, + }) + fireEvent.change(screen.getByLabelText('First Name'), { + target: { value: 'John' }, + }) + fireEvent.change(screen.getByLabelText('Last Name'), { + target: { value: 'Watson' }, + }) + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + expect(updateMock.called()).to.be.true + expect(JSON.parse(updateMock.lastCall()[1].body)).to.deep.equal({ + email: 'john@watson.co.uk', + firstName: 'John', + lastName: 'Watson', + }) + }) + + it('disables button on invalid email', async function () { + const updateMock = fetchMock.post('/user/settings', 200) + render() + + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'john' }, + }) + const button = screen.getByRole('button', { + name: 'Update', + }) + + expect(button.disabled).to.be.true + fireEvent.click(button) + + expect(updateMock.called()).to.be.false + }) + + it('shows inflight state and success message', async function () { + let finishUpdateCall + fetchMock.post( + '/user/settings', + new Promise(resolve => (finishUpdateCall = resolve)) + ) + render() + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + await screen.findByText('Saving…') + + finishUpdateCall(200) + await screen.findByRole('button', { + name: 'Update', + }) + screen.getByText('Thanks, your settings have been updated.') + }) + + it('shows server error', async function () { + fetchMock.post('/user/settings', 500) + render() + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + await screen.findByText( + 'Something went wrong talking to the server :(. Please try again.' + ) + }) + + it('shows invalid error', async function () { + fetchMock.post('/user/settings', 400) + render() + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + await screen.findByText( + 'Invalid Request. Please correct the data and try again.' + ) + }) + + it('shows conflict error', async function () { + fetchMock.post('/user/settings', { + status: 409, + body: { + message: 'This email is already registered', + }, + }) + render() + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + await screen.findByText('This email is already registered') + }) + + it('hides email input', async function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) + const updateMock = fetchMock.post('/user/settings', 200) + + render() + expect(screen.queryByLabelText('Email')).to.not.exist + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + expect(JSON.parse(updateMock.lastCall()[1].body)).to.deep.equal({ + firstName: 'Sherlock', + lastName: 'Holmes', + }) + }) + + it('disables email input', async function () { + window.metaAttributesCache.set( + 'ol-isExternalAuthenticationSystemUsed', + true + ) + const updateMock = fetchMock.post('/user/settings', 200) + + render() + expect(screen.getByLabelText('Email').readOnly).to.be.true + expect(screen.getByLabelText('First Name').readOnly).to.be.false + expect(screen.getByLabelText('Last Name').readOnly).to.be.false + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + expect(JSON.parse(updateMock.lastCall()[1].body)).to.deep.equal({ + firstName: 'Sherlock', + lastName: 'Holmes', + }) + }) + + it('disables names input', async function () { + window.metaAttributesCache.set('ol-shouldAllowEditingDetails', false) + const updateMock = fetchMock.post('/user/settings', 200) + + render() + expect(screen.getByLabelText('Email').readOnly).to.be.false + expect(screen.getByLabelText('First Name').readOnly).to.be.true + expect(screen.getByLabelText('Last Name').readOnly).to.be.true + + fireEvent.click( + screen.getByRole('button', { + name: 'Update', + }) + ) + expect(JSON.parse(updateMock.lastCall()[1].body)).to.deep.equal({ + email: 'sherlock@holmes.co.uk', + }) + }) +}) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx new file mode 100644 index 0000000000..b7c4e5c79b --- /dev/null +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx @@ -0,0 +1,120 @@ +import { + render, + screen, + waitForElementToBeRemoved, + fireEvent, +} from '@testing-library/react' +import { expect } from 'chai' +import { UserEmailData } from '../../../../../../types/user-email' +import fetchMock from 'fetch-mock' +import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section' + +const userEmailData: UserEmailData = { + confirmedAt: '2022-03-10T10:59:44.139Z', + email: 'bar@overleaf.com', + default: false, +} + +describe('email actions - make primary', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) + fetchMock.reset() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('shows loader when making email primary and removes button', async function () { + fetchMock + .get('/user/emails?ensureAffiliation=true', [userEmailData]) + .post('/user/emails/default', 200) + const userEmailDataCopy = { ...userEmailData } + render() + + const button = await screen.findByRole('button', { name: /make primary/i }) + fireEvent.click(button) + + expect(screen.queryByRole('button', { name: /make primary/i })).to.be.null + + userEmailDataCopy.default = true + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: /sending/i }) + ) + + expect( + screen.queryByText(/an error has occurred while performing your request/i) + ).to.be.null + expect(screen.queryByRole('button', { name: /make primary/i })).to.be.null + }) + + it('shows error when making email primary', async function () { + fetchMock + .get('/user/emails?ensureAffiliation=true', [userEmailData]) + .post('/user/emails/default', 503) + render() + + const button = await screen.findByRole('button', { name: /make primary/i }) + fireEvent.click(button) + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: /sending/i }) + ) + + screen.getByText(/an error has occurred while performing your request/i) + screen.getByRole('button', { name: /make primary/i }) + }) +}) + +describe('email actions - delete', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) + fetchMock.reset() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('shows loader when deleting and removes the row', async function () { + fetchMock + .get('/user/emails?ensureAffiliation=true', [userEmailData]) + .post('/user/emails/delete', 200) + render() + + const button = await screen.findByRole('button', { name: /remove/i }) + fireEvent.click(button) + + expect(screen.queryByRole('button', { name: /remove/i })).to.be.null + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: /deleting/i }) + ) + + expect(screen.queryByText(userEmailData.email)).to.be.null + }) + + it('shows error when making email primary', async function () { + fetchMock + .get('/user/emails?ensureAffiliation=true', [userEmailData]) + .post('/user/emails/delete', 503) + render() + + const button = await screen.findByRole('button', { name: /remove/i }) + fireEvent.click(button) + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: /deleting/i }) + ) + + screen.getByText(/an error has occurred while performing your request/i) + screen.getByRole('button', { name: /remove/i }) + }) +}) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx index ecfbe25652..06e3719b5b 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx @@ -1,7 +1,15 @@ -import { render, screen } from '@testing-library/react' +import { + render, + screen, + fireEvent, + waitForElementToBeRemoved, +} from '@testing-library/react' import { expect } from 'chai' import { UserEmailData } from '../../../../../../types/user-email' +import fetchMock from 'fetch-mock' import InstitutionAndRole from '../../../../../../frontend/js/features/settings/components/emails/institution-and-role' +import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context' +import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section' const userData1: UserEmailData = { affiliation: { @@ -57,9 +65,26 @@ const userData2: UserEmailData = { } describe('user role and institution', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) + fetchMock.reset() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + it('renders affiliation name with add role/department button', function () { const userEmailData = userData1 - render() + render( + + + + ) screen.getByText(userEmailData.affiliation.institution.name, { exact: false, @@ -70,7 +95,11 @@ describe('user role and institution', function () { it('renders affiliation name, role and department with change button', function () { const userEmailData = userData2 - render() + render( + + + + ) screen.getByText(userEmailData.affiliation.institution.name, { exact: false, @@ -81,4 +110,49 @@ describe('user role and institution', function () { expect(screen.queryByRole('button', { name: /add role and department/i })) .to.not.exist }) + + it('adds new role and department', async function () { + fetchMock + .get('/user/emails?ensureAffiliation=true', [userData1]) + .post('/user/emails/endorse', 200) + render() + + const addBtn = await screen.findByRole('button', { + name: /add role and department/i, + }) + fireEvent.click(addBtn) + + const submitBtn = screen.getByRole('button', { + name: /save/i, + }) as HTMLButtonElement + expect(submitBtn.disabled).to.be.true + + const roleValue = 'Dummy role' + const departmentValue = 'Dummy department' + + const roleInput = screen.getByPlaceholderText(/role/i) + fireEvent.change(roleInput, { + target: { value: roleValue }, + }) + + expect(submitBtn.disabled).to.be.true + + const departmentInput = screen.getByPlaceholderText(/department/i) + fireEvent.change(departmentInput, { + target: { value: departmentValue }, + }) + + expect(submitBtn.disabled).to.be.false + + fireEvent.click(submitBtn) + + expect(submitBtn.disabled).to.be.true + + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: /saving/i }) + ) + + screen.getByText(roleValue, { exact: false }) + screen.getByText(departmentValue, { exact: false }) + }) }) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx index c96828797e..b0dcbc01fb 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -3,6 +3,7 @@ import { screen, within, fireEvent, + waitFor, waitForElementToBeRemoved, } from '@testing-library/react' import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section' @@ -55,77 +56,104 @@ const fakeUsersData = [ ] describe('', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: true, + }) + fetchMock.reset() + }) + afterEach(function () { window.metaAttributesCache = new Map() fetchMock.reset() }) it('renders translated heading', function () { - window.metaAttributesCache.set('ol-userEmails', fakeUsersData) render() screen.getByRole('heading', { name: /emails and affiliations/i }) }) it('renders translated description', function () { - window.metaAttributesCache.set('ol-userEmails', fakeUsersData) render() screen.getByText(/add additional email addresses/i) screen.getByText(/to change your primary email/i) }) - it('renders user emails', function () { - window.metaAttributesCache.set('ol-userEmails', fakeUsersData) + it('renders a loading message when loading', async function () { render() - fakeUsersData.forEach(userData => { - screen.getByText(new RegExp(userData.email, 'i')) + await screen.findByText(/loading/i) + }) + + it('renders an error message and hides loading message on error', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', 500) + render() + + await screen.findByText( + /an error has occurred while performing your request/i + ) + expect(screen.queryByText(/loading/i)).to.be.null + }) + + it('renders user emails', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', fakeUsersData) + render() + + await waitFor(() => { + fakeUsersData.forEach(userData => { + screen.getByText(new RegExp(userData.email, 'i')) + }) }) }) - it('renders primary status', function () { - window.metaAttributesCache.set('ol-userEmails', [professionalUserData]) + it('renders primary status', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', [professionalUserData]) render() - screen.getByText(`${professionalUserData.email} (primary)`) + await screen.findByText(`${professionalUserData.email} (primary)`) }) - it('shows confirmation status for unconfirmed users', function () { - window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData]) + it('shows confirmation status for unconfirmed users', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) render() - screen.getByText(/please check your inbox/i) + await screen.findByText(/please check your inbox/i) }) - it('hides confirmation status for confirmed users', function () { - window.metaAttributesCache.set('ol-userEmails', [confirmedUserData]) + it('hides confirmation status for confirmed users', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', [confirmedUserData]) render() + await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) expect(screen.queryByText(/please check your inbox/i)).to.be.null }) - it('renders resend link', function () { - window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData]) + it('renders resend link', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) render() - screen.getByRole('button', { name: /resend confirmation email/i }) + await screen.findByRole('button', { name: /resend confirmation email/i }) }) - it('renders professional label', function () { - window.metaAttributesCache.set('ol-userEmails', [professionalUserData]) + it('renders professional label', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', [professionalUserData]) render() - const node = screen.getByText(professionalUserData.email, { + const node = await screen.findByText(professionalUserData.email, { exact: false, }) expect(within(node).getByText(/professional/i)).to.exist }) it('shows loader when resending email', async function () { - fetchMock.post('/user/emails/resend_confirmation', 200) - window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData]) + fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) + render() + await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) + + fetchMock.post('/user/emails/resend_confirmation', 200) const button = screen.getByRole('button', { name: /resend confirmation email/i, @@ -150,9 +178,12 @@ describe('', function () { }) it('shows error when resending email fails', async function () { - fetchMock.post('/user/emails/resend_confirmation', 503) - window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData]) + fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) + render() + await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) + + fetchMock.post('/user/emails/resend_confirmation', 503) const button = screen.getByRole('button', { name: /resend confirmation email/i, diff --git a/services/web/test/frontend/features/settings/components/integration-linking/widget.test.tsx b/services/web/test/frontend/features/settings/components/integration-linking/widget.test.tsx new file mode 100644 index 0000000000..1c9bdc06e2 --- /dev/null +++ b/services/web/test/frontend/features/settings/components/integration-linking/widget.test.tsx @@ -0,0 +1,100 @@ +import { expect } from 'chai' +import { screen, fireEvent, render, waitFor } from '@testing-library/react' +import { IntegrationLinkingWidget } from '../../../../../../frontend/js/features/settings/components/integration-linking/widget' + +describe('', function () { + const defaultProps = { + logoSrc: '/logo', + title: 'Integration', + description: ['paragraph1', 'paragraph2'], + linkPath: '/link', + unlinkPath: '/unlink', + unlinkConfirmationTitle: 'confirm unlink', + unlinkConfirmationText: 'you will be unlinked', + } + + describe('when the feature is not available', function () { + beforeEach(function () { + render() + }) + + it("should render 'Premium feature' label", function () { + screen.getByText('Premium feature') + }) + + it('should render a link to upgrade the account', function () { + expect( + screen.getByRole('link', { name: 'Upgrade' }).getAttribute('href') + ).to.equal('user/subscription/plans') + }) + }) + + describe('when the integration is not linked', function () { + beforeEach(function () { + render( + + ) + }) + + it('should render a link to initiate integration linking', function () { + expect( + screen.getByRole('link', { name: 'Link' }).getAttribute('href') + ).to.equal('/link') + }) + + it("should not render 'premium feature' labels", function () { + expect(screen.queryByText('premium_feature')).to.not.exist + expect(screen.queryByText('integration_is_a_premium_feature')).to.not + .exist + }) + }) + + describe('when the integration is linked', function () { + beforeEach(function () { + render( + status indicator} + /> + ) + }) + + it('should render a status indicator', function () { + screen.getByText('status indicator') + }) + + it("should not render 'premium feature' labels", function () { + expect(screen.queryByText('premium_feature')).to.not.exist + expect(screen.queryByText('integration_is_a_premium_feature')).to.not + .exist + }) + + it('should display an `unlink` button', function () { + screen.getByRole('button', { name: 'Unlink' }) + }) + + it('should open a modal with a link to confirm integration unlinking', function () { + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + screen.getByText('confirm unlink') + screen.getByText('you will be unlinked') + expect( + screen.getByRole('link', { name: 'Unlink' }).getAttribute('href') + ).to.equal('/unlink') + }) + + it('should cancel unlinking when clicking "cancel" in the confirmation modal', async function () { + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + screen.getByText('confirm unlink') + const cancelBtn = screen.getByRole('button', { + name: 'Cancel', + hidden: false, + }) + fireEvent.click(cancelBtn) + await waitFor(() => + screen.getByRole('button', { name: 'Cancel', hidden: true }) + ) + }) + }) +}) diff --git a/services/web/test/frontend/features/settings/components/leave-section.test.tsx b/services/web/test/frontend/features/settings/components/leave-section.test.tsx index 673a395e99..d8bf7ff54c 100644 --- a/services/web/test/frontend/features/settings/components/leave-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/leave-section.test.tsx @@ -8,6 +8,17 @@ import { import LeaveSection from '../../../../../frontend/js/features/settings/components/leave-section' describe('', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com') + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) + window.metaAttributesCache.set('ol-hasPassword', true) + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + it('opens modal', async function () { render() diff --git a/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx index 786c03926f..d9c3ffe2f9 100644 --- a/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx +++ b/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx @@ -7,7 +7,7 @@ import LeaveModalContent from '../../../../../../frontend/js/features/settings/c describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() - window.metaAttributesCache.set('ol-isSaas', false) + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) window.metaAttributesCache.set('ol-hasPassword', true) }) diff --git a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx index 139268e7ba..caca95b9b5 100644 --- a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx +++ b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx @@ -8,7 +8,8 @@ import LeaveModalForm from '../../../../../../frontend/js/features/settings/comp describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() - window.metaAttributesCache.set('ol-userDefaultEmail', 'foo@bar.com') + window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com') + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) }) afterEach(function () { @@ -63,6 +64,7 @@ describe('', function () { assign: locationStub, }, }) + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) }) afterEach(function () { @@ -110,7 +112,8 @@ describe('', function () { }) }) - it('handles credentials error with Saas tip', async function () { + it('handles credentials error without Saas tip', async function () { + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: false }) fetchMock.post('/user/delete', 403) render( ', function () { .exist }) - it('handles credentials error without Saas tip', async function () { + it('handles credentials error with Saas tip', async function () { fetchMock.post('/user/delete', 403) - window.metaAttributesCache.set('ol-isSaas', true) render( {}} diff --git a/services/web/test/frontend/features/settings/components/leave/modal.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal.test.tsx index 00e6856f97..2a3425c3c8 100644 --- a/services/web/test/frontend/features/settings/components/leave/modal.test.tsx +++ b/services/web/test/frontend/features/settings/components/leave/modal.test.tsx @@ -7,7 +7,9 @@ import LeaveModal from '../../../../../../frontend/js/features/settings/componen describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() - window.metaAttributesCache.set('ol-userDefaultEmail', 'foo@bar.com') + window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com') + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) + window.metaAttributesCache.set('ol-hasPassword', true) }) afterEach(function () { diff --git a/services/web/test/frontend/features/settings/components/password-section.test.tsx b/services/web/test/frontend/features/settings/components/password-section.test.tsx new file mode 100644 index 0000000000..a193d4f3b0 --- /dev/null +++ b/services/web/test/frontend/features/settings/components/password-section.test.tsx @@ -0,0 +1,197 @@ +import { expect } from 'chai' +import { fireEvent, screen, render } from '@testing-library/react' +import fetchMock from 'fetch-mock' +import PasswordSection from '../../../../../frontend/js/features/settings/components/password-section' + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache = window.metaAttributesCache || new Map() + window.metaAttributesCache.set('ol-ExposedSettings', { + isOverleaf: true, + }) + window.metaAttributesCache.set( + 'ol-isExternalAuthenticationSystemUsed', + false + ) + window.metaAttributesCache.set('ol-hasPassword', true) + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('shows password managed externally message', async function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + isOverleaf: false, + }) + window.metaAttributesCache.set( + 'ol-isExternalAuthenticationSystemUsed', + true + ) + render() + + screen.getByText('Password settings are managed externally') + }) + + it('shows no existing password message', async function () { + window.metaAttributesCache.set('ol-hasPassword', false) + render() + + screen.getByText('Please use the password reset form to set your password') + }) + + it('submits all inputs', async function () { + const updateMock = fetchMock.post('/user/password/update', 200) + render() + submitValidForm() + + expect(updateMock.called()).to.be.true + expect(JSON.parse(updateMock.lastCall()[1].body)).to.deep.equal({ + currentPassword: 'foobar', + newPassword1: 'barbaz', + newPassword2: 'barbaz', + }) + }) + + it('disables button on invalid form', async function () { + const updateMock = fetchMock.post('/user/password/update', 200) + render() + + fireEvent.click( + screen.getByRole('button', { + name: 'Change', + }) + ) + expect(updateMock.called()).to.be.false + }) + + it('validates inputs', async function () { + render() + + const button = screen.getByRole('button', { + name: 'Change', + }) + expect(button.disabled).to.be.true + + fireEvent.change(screen.getByLabelText('Current Password'), { + target: { value: 'foobar' }, + }) + expect(button.disabled).to.be.true + + fireEvent.change(screen.getByLabelText('New Password'), { + target: { value: 'barbaz' }, + }) + expect(button.disabled).to.be.true + + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'bar' }, + }) + screen.getByText('Doesn’t match') + expect(button.disabled).to.be.true + + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'barbaz' }, + }) + expect(button.disabled).to.be.false + }) + + it('sets browser validation attributes', async function () { + window.metaAttributesCache.set('ol-passwordStrengthOptions', { + length: { + min: 3, + }, + }) + render() + + const currentPasswordInput = screen.getByLabelText('Current Password') + const newPassword1Input = screen.getByLabelText('New Password') + const newPassword2Input = screen.getByLabelText('Confirm New Password') + + expect(newPassword1Input.minLength).to.equal(3) + + // not required before changes + expect(currentPasswordInput.required).to.be.false + expect(newPassword1Input.required).to.be.false + expect(newPassword2Input.required).to.be.false + + fireEvent.change(currentPasswordInput, { + target: { value: 'foobar' }, + }) + fireEvent.change(newPassword1Input, { + target: { value: 'barbaz' }, + }) + fireEvent.change(newPassword2Input, { + target: { value: 'barbaz' }, + }) + expect(currentPasswordInput.required).to.be.true + expect(newPassword1Input.required).to.be.true + expect(newPassword2Input.required).to.be.true + }) + + it('shows inflight state and success message', async function () { + let finishUpdateCall + fetchMock.post( + '/user/password/update', + new Promise(resolve => (finishUpdateCall = resolve)) + ) + render() + submitValidForm() + + await screen.findByText('Saving…') + + finishUpdateCall({ + status: 200, + body: { + message: { + type: 'success', + email: 'tim.alby@overleaf.com', + text: 'Password changed', + }, + }, + }) + await screen.findByRole('button', { + name: 'Change', + }) + screen.getByText('Password changed') + }) + + it('shows server error', async function () { + fetchMock.post('/user/password/update', 500) + render() + submitValidForm() + await screen.findByText( + 'Something went wrong talking to the server :(. Please try again.' + ) + }) + + it('shows server error message', async function () { + fetchMock.post('/user/password/update', { + status: 400, + body: { + message: 'Your old password is wrong', + }, + }) + render() + submitValidForm() + + await screen.findByText('Your old password is wrong') + }) +}) + +function submitValidForm() { + fireEvent.change(screen.getByLabelText('Current Password'), { + target: { value: 'foobar' }, + }) + fireEvent.change(screen.getByLabelText('New Password'), { + target: { value: 'barbaz' }, + }) + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'barbaz' }, + }) + fireEvent.click( + screen.getByRole('button', { + name: 'Change', + }) + ) +} diff --git a/services/web/test/frontend/features/settings/components/root.test.tsx b/services/web/test/frontend/features/settings/components/root.test.tsx new file mode 100644 index 0000000000..6cc5a2b8c0 --- /dev/null +++ b/services/web/test/frontend/features/settings/components/root.test.tsx @@ -0,0 +1,48 @@ +import sinon from 'sinon' +import { screen, render } from '@testing-library/react' +import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' +import SettingsPageRoot from '../../../../../frontend/js/features/settings/components/root' + +describe('', function () { + let sendMBSpy + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com') + window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true }) + window.metaAttributesCache.set('ol-hasPassword', true) + window.metaAttributesCache.set('ol-ExposedSettings', { + hasAffiliationsFeature: false, + }) + window.metaAttributesCache.set('ol-user', { + features: { github: true, dropbox: true, mendeley: true, zotero: true }, + refProviders: { + mendeley: true, + zotero: true, + }, + }) + window.metaAttributesCache.set('ol-github', { enabled: true }) + window.metaAttributesCache.set('ol-dropbox', { registered: true }) + window.metaAttributesCache.set('ol-oauthProviders', {}) + sendMBSpy = sinon.spy(eventTracking, 'sendMB') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + sendMBSpy.restore() + }) + + it('displays page', async function () { + render() + + screen.getByRole('button', { + name: 'Delete your account', + }) + }) + + it('sends tracking event on load', async function () { + render() + + sinon.assert.calledOnce(sendMBSpy) + sinon.assert.calledWith(sendMBSpy, 'settings-view') + }) +}) diff --git a/services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx b/services/web/test/frontend/features/settings/components/sso-linking/widget.test.tsx similarity index 84% rename from services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx rename to services/web/test/frontend/features/settings/components/sso-linking/widget.test.tsx index a89801140c..e8dd9d6478 100644 --- a/services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx +++ b/services/web/test/frontend/features/settings/components/sso-linking/widget.test.tsx @@ -1,16 +1,15 @@ import { expect } from 'chai' import sinon from 'sinon' import { screen, fireEvent, render, waitFor } from '@testing-library/react' -import { SSOLinkingWidget } from '../../../../../frontend/js/features/user-settings/components/sso-linking-widget' +import { SSOLinkingWidget } from '../../../../../../frontend/js/features/settings/components/sso-linking/widget' describe('', function () { const defaultProps = { - logoSrc: 'logo.png', + providerId: 'integration_id', title: 'integration', description: 'integration description', + helpPath: '/help/integration', linkPath: 'integration/link', - unlinkConfirmationTitle: 'confirm unlink', - unlinkConfirmationText: 'you will be unlinked', onUnlink: () => Promise.resolve(), } @@ -18,13 +17,16 @@ describe('', function () { render() screen.getByText('integration') screen.getByText('integration description') + expect( + screen.getByRole('link', { name: 'Learn more' }).getAttribute('href') + ).to.equal('/help/integration') }) describe('when unlinked', function () { it('should render a link to `linkPath`', function () { render() expect( - screen.getByRole('link', { name: 'link' }).getAttribute('href') + screen.getByRole('link', { name: 'Link' }).getAttribute('href') ).to.equal('integration/link') }) }) @@ -45,13 +47,14 @@ describe('', function () { it('should open a modal to confirm integration unlinking', function () { fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) - screen.getByText('confirm unlink') - screen.getByText('you will be unlinked') + screen.getByText('Unlink integration Account') + screen.getByText( + 'Warning: When you unlink your account from integration you will not be able to sign in using integration anymore.' + ) }) it('should cancel unlinking when clicking cancel in the confirmation modal', async function () { fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) - screen.getByText('confirm unlink') const cancelBtn = screen.getByRole('button', { name: 'Cancel', hidden: false, @@ -117,9 +120,7 @@ describe('', function () { }) it('should display the unlink button ', async function () { - await waitFor(() => - expect(screen.getByRole('button', { name: 'Unlink' })) - ) + await waitFor(() => screen.getByRole('button', { name: 'Unlink' })) }) }) }) diff --git a/services/web/test/frontend/features/user-settings/context/sso-context.test.tsx b/services/web/test/frontend/features/settings/context/sso-context.test.tsx similarity index 63% rename from services/web/test/frontend/features/user-settings/context/sso-context.test.tsx rename to services/web/test/frontend/features/settings/context/sso-context.test.tsx index 37c9863262..b9b43e3dcf 100644 --- a/services/web/test/frontend/features/user-settings/context/sso-context.test.tsx +++ b/services/web/test/frontend/features/settings/context/sso-context.test.tsx @@ -3,9 +3,32 @@ import { renderHook } from '@testing-library/react-hooks' import { SSOProvider, useSSOContext, -} from '../../../../../frontend/js/features/user-settings/context/sso-context' +} from '../../../../../frontend/js/features/settings/context/sso-context' import fetchMock from 'fetch-mock' +const mockOauthProviders = { + google: { + descriptionKey: 'login_with_service', + descriptionOptions: { service: 'Google' }, + name: 'Google', + linkPath: '/auth/google', + }, + orcid: { + descriptionKey: 'oauth_orcid_description', + descriptionOptions: { + link: '/blog/434', + appName: 'Overleaf', + }, + name: 'Orcid', + linkPath: '/auth/orcid', + }, + twitter: { + hideWhenNotLinked: true, + name: 'Twitter', + linkPath: '/auth/twitter', + }, +} + describe('SSOContext', function () { const renderSSOContext = () => renderHook(() => useSSOContext(), { @@ -13,21 +36,11 @@ describe('SSOContext', function () { }) beforeEach(function () { - window.oauthProviders = { - google: { - descriptionKey: 'login_google', - name: 'Google', - linkPath: '/auth/google', - }, - orcid: { - descriptionKey: 'login_orcid', - name: 'Google', - linkPath: '/auth/google', - }, - } - window.thirdPartyIds = { - google: 'googleId', - } + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-thirdPartyIds', { + google: 'google-id', + }) + window.metaAttributesCache.set('ol-oauthProviders', mockOauthProviders) fetchMock.reset() }) @@ -35,16 +48,14 @@ describe('SSOContext', function () { const { result } = renderSSOContext() expect(result.current.subscriptions).to.deep.equal({ google: { - descriptionKey: 'login_google', - linkPath: '/auth/google', + providerId: 'google', + provider: mockOauthProviders.google, linked: true, - name: 'Google', }, orcid: { - descriptionKey: 'login_orcid', - linkPath: '/auth/google', + providerId: 'orcid', + provider: mockOauthProviders.orcid, linked: false, - name: 'Google', }, }) }) diff --git a/services/web/test/frontend/shared/hooks/use-async.test.ts b/services/web/test/frontend/shared/hooks/use-async.test.ts index c812df26b1..f9114f4793 100644 --- a/services/web/test/frontend/shared/hooks/use-async.test.ts +++ b/services/web/test/frontend/shared/hooks/use-async.test.ts @@ -91,6 +91,12 @@ describe('useAsync', function () { ...resolvedState, data: resolvedValue, }) + + act(() => { + result.current.reset() + }) + + expect(result.current).to.include(defaultState) }) it('calling `runAsync` with a promise which rejects', async function () { @@ -101,7 +107,7 @@ describe('useAsync', function () { let p: Promise act(() => { - p = result.current.runAsync(promise) + p = result.current.runAsync(promise).catch(() => {}) }) expect(result.current).to.include(pendingState) diff --git a/services/web/test/unit/src/User/UserPagesControllerTests.js b/services/web/test/unit/src/User/UserPagesControllerTests.js index deef5bc2ca..099696f6cd 100644 --- a/services/web/test/unit/src/User/UserPagesControllerTests.js +++ b/services/web/test/unit/src/User/UserPagesControllerTests.js @@ -43,9 +43,16 @@ describe('UserPagesController', function () { externalUserId: 'testId', }, ], + refProviders: { + mendeley: true, + zotero: true, + }, } - this.UserGetter = { getUser: sinon.stub() } + this.UserGetter = { + getUser: sinon.stub(), + promises: { getUser: sinon.stub() }, + } this.UserSessionsManager = { getAllUserSessions: sinon.stub() } this.dropboxStatus = {} this.ErrorController = { notFound: sinon.stub() } @@ -57,6 +64,11 @@ describe('UserPagesController', function () { _getRedirectFromSession: sinon.stub(), setRedirectInSession: sinon.stub(), } + this.SplitTestHandler = { + promises: { + getAssignment: sinon.stub().resolves({ variant: 'default' }), + }, + } this.UserPagesController = SandboxedModule.require(modulePath, { requires: { '@overleaf/settings': this.settings, @@ -67,6 +79,7 @@ describe('UserPagesController', function () { this.AuthenticationController, '../Authentication/SessionManager': this.SessionManager, request: (this.request = sinon.stub()), + '../SplitTests/SplitTestHandler': this.SplitTestHandler, }, }) this.req = { @@ -217,9 +230,7 @@ describe('UserPagesController', function () { this.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 200 }, { has_password: true }) - return (this.UserGetter.getUser = sinon - .stub() - .callsArgWith(1, null, this.user)) + this.UserGetter.promises.getUser = sinon.stub().resolves(this.user) }) it('should render user/settings', function (done) { @@ -230,6 +241,17 @@ describe('UserPagesController', function () { return this.UserPagesController.settingsPage(this.req, this.res) }) + it('should render user/settings-react', function (done) { + this.SplitTestHandler.promises.getAssignment = sinon + .stub() + .resolves({ variant: 'react' }) + this.res.render = function (page) { + page.should.equal('user/settings-react') + return done() + } + return this.UserPagesController.settingsPage(this.req, this.res) + }) + it('should send user', function (done) { this.res.render = (page, opts) => { opts.user.should.equal(this.user) diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts new file mode 100644 index 0000000000..fbebe06d9f --- /dev/null +++ b/services/web/types/exposed-settings.ts @@ -0,0 +1,33 @@ +export type ExposedSettings = { + appName: string + cookieDomain: string + dropboxAppName: string + emailConfirmationDisabled: boolean + enableSubscriptions: boolean + gaToken?: string + gaTokenV4?: string + hasAffiliationsFeature: boolean + hasLinkUrlFeature: boolean + hasLinkedProjectFileFeature: boolean + hasLinkedProjectOutputFileFeature: boolean + hasSamlBeta?: boolean + hasSamlFeature: boolean + isOverleaf: boolean + maxEntitiesPerProject: number + maxUploadSize: number + recaptchaDisabled: { + invite: boolean + login: boolean + passwordReset: boolean + register: boolean + } + recaptchaSiteKeyV3?: string + samlInitPath?: string + sentryAllowedOriginRegex: string + sentryDsn?: string + sentryEnvironment?: string + sentryRelease?: string + siteUrl: string + textExtensions: string[] + validRootDocExtensions: string[] +} diff --git a/services/web/types/oauth-providers.ts b/services/web/types/oauth-providers.ts new file mode 100644 index 0000000000..f453ba9fd2 --- /dev/null +++ b/services/web/types/oauth-providers.ts @@ -0,0 +1,13 @@ +export type OAuthProvider = { + name: string + descriptionKey: string + descriptionOptions: { + appName: string + link?: string + service?: string + } + hideWhenNotLinked?: boolean + linkPath: string +} + +export type OAuthProviders = Record diff --git a/services/web/types/password-strength-options.ts b/services/web/types/password-strength-options.ts new file mode 100644 index 0000000000..dd8dd257c9 --- /dev/null +++ b/services/web/types/password-strength-options.ts @@ -0,0 +1,6 @@ +export type PasswordStrengthOptions = { + length?: { + min?: number + max?: number + } +} diff --git a/services/web/types/third-party-ids.ts b/services/web/types/third-party-ids.ts new file mode 100644 index 0000000000..0ff3fa9870 --- /dev/null +++ b/services/web/types/third-party-ids.ts @@ -0,0 +1,3 @@ +export type ThirdPartyId = string + +export type ThirdPartyIds = Record diff --git a/services/web/types/user-email.ts b/services/web/types/user-email.ts index 2e95af337e..f1978fda17 100644 --- a/services/web/types/user-email.ts +++ b/services/web/types/user-email.ts @@ -5,5 +5,6 @@ export type UserEmailData = { confirmedAt?: string email: string default: boolean + samlProviderId?: string ssoAvailable?: boolean } diff --git a/services/web/types/window.ts b/services/web/types/window.ts index c427822230..de5401b495 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -1,8 +1,5 @@ -export type OAuthProvider = { - name: string - descriptionKey: string - linkPath: string -} +import { ExposedSettings } from './exposed-settings' +import { OAuthProviders } from './oauth-providers' declare global { // eslint-disable-next-line no-unused-vars @@ -11,12 +8,12 @@ declare global { user: { id: string } - oauthProviders: Record + oauthProviders: OAuthProviders thirdPartyIds: Record - metaAttributesCache: Map + metaAttributesCache: Map i18n: { currentLangCode: string } - ExposedSettings: Record + ExposedSettings: ExposedSettings } }
{t('password_managed_externally')}
+ + {t('no_existing_password')} + +
{description}
+ {description?.replace(/<[^>]+>/g, '')}{' '} + {helpPath ? ( + + {t('learn_more')} + + ) : null} +
{t('unlink_provider_account_warning', { provider: title })}