mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-03 14:19:01 +02:00
Merge pull request #7593 from overleaf/ta-settings-migration
[SettingsPage] Integration Branch GitOrigin-RevId: 5a3c26b2a02d716c4ae3981e3f08b811ae307725
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
@@ -773,6 +773,7 @@ module.exports = {
|
||||
editorToolbarButtons: [],
|
||||
sourceEditorExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
integrationLinkingWidgets: [],
|
||||
},
|
||||
|
||||
moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'],
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<h3>{t('update_account_info')}</h3>
|
||||
<form id="account-info-form" onSubmit={handleSubmit}>
|
||||
{hasAffiliationsFeature ? null : (
|
||||
<ReadOrWriteFormGroup
|
||||
id="email-input"
|
||||
type="email"
|
||||
label={t('email')}
|
||||
value={email}
|
||||
handleChange={handleEmailChange}
|
||||
canEdit={canUpdateEmail}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<ReadOrWriteFormGroup
|
||||
id="first-name-input"
|
||||
type="text"
|
||||
label={t('first_name')}
|
||||
value={firstName}
|
||||
handleChange={handleFirstNameChange}
|
||||
canEdit={canUpdateNames}
|
||||
required={false}
|
||||
/>
|
||||
<ReadOrWriteFormGroup
|
||||
id="last-name-input"
|
||||
type="text"
|
||||
label={t('last_name')}
|
||||
value={lastName}
|
||||
handleChange={handleLastNameChange}
|
||||
canEdit={canUpdateNames}
|
||||
required={false}
|
||||
/>
|
||||
{isSuccess ? (
|
||||
<FormGroup>
|
||||
<Alert bsStyle="success">{t('thanks_settings_updated')}</Alert>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
{error ? (
|
||||
<FormGroup>
|
||||
<Alert bsStyle="danger">{error.getUserFacingMessage()}</Alert>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
{canUpdateEmail || canUpdateNames ? (
|
||||
<Button
|
||||
form="account-info-form"
|
||||
type="submit"
|
||||
bsStyle="primary"
|
||||
disabled={isLoading || !isFormValid}
|
||||
>
|
||||
{isLoading ? <>{t('saving')}…</> : t('update')}
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<FormGroup>
|
||||
<ControlLabel htmlFor={id}>{label}</ControlLabel>
|
||||
<FormControl id={id} type="text" readOnly value={value} />
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
<ControlLabel htmlFor={id}>{label}</ControlLabel>
|
||||
<FormControl
|
||||
id={id}
|
||||
type={type}
|
||||
required={required}
|
||||
value={value}
|
||||
data-ol-dirty={!!validationMessage}
|
||||
onChange={handleChangeAndValidity}
|
||||
onInvalid={handleInvalid}
|
||||
/>
|
||||
{validationMessage ? (
|
||||
<span className="small text-danger">{validationMessage}</span>
|
||||
) : null}
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountInfoSection
|
||||
@@ -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() {
|
||||
<a href="/learn/how-to/Keeping_your_account_secure" />
|
||||
</Trans>
|
||||
</p>
|
||||
<EmailsHeader />
|
||||
{userEmails?.map((userEmail, i) => (
|
||||
<Fragment key={userEmail.email}>
|
||||
<EmailsRow userEmailData={userEmail} />
|
||||
{i + 1 !== userEmails.length && (
|
||||
<div className="horizontal-divider" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isInitializing && (
|
||||
<div className="text-center">
|
||||
<Icon type="refresh" fw spin /> {t('loading')}...
|
||||
</div>
|
||||
)}
|
||||
{isInitializingSuccess && (
|
||||
<>
|
||||
<EmailsHeader />
|
||||
{userEmails?.map((userEmail, i) => (
|
||||
<Fragment key={userEmail.email}>
|
||||
<EmailsRow userEmailData={userEmail} />
|
||||
{i + 1 !== userEmails.length && (
|
||||
<div className="horizontal-divider" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{isInitializingError && (
|
||||
<Alert bsStyle="danger" className="text-center">
|
||||
<Icon type="exclamation-triangle" fw />{' '}
|
||||
{t('error_performing_request')}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailsSection() {
|
||||
const { hasAffiliationsFeature } = getMeta(
|
||||
'ol-ExposedSettings'
|
||||
) as ExposedSettings
|
||||
if (!hasAffiliationsFeature) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<UserEmailsProvider>
|
||||
<EmailsSectionContent />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MakePrimary from './actions/make-primary'
|
||||
import Remove from './actions/remove'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { useUserEmailsContext } from '../../context/user-email-context'
|
||||
import { UserEmailData } from '../../../../../../types/user-email'
|
||||
|
||||
type ActionsProps = {
|
||||
userEmailData: UserEmailData
|
||||
}
|
||||
|
||||
function Actions({ userEmailData }: ActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setLoading: setUserEmailsContextLoading } = useUserEmailsContext()
|
||||
const makePrimaryAsync = useAsync()
|
||||
const deleteEmailAsync = useAsync()
|
||||
|
||||
useEffect(() => {
|
||||
setUserEmailsContextLoading(
|
||||
makePrimaryAsync.isLoading || deleteEmailAsync.isLoading
|
||||
)
|
||||
}, [
|
||||
setUserEmailsContextLoading,
|
||||
makePrimaryAsync.isLoading,
|
||||
deleteEmailAsync.isLoading,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (makePrimaryAsync.isLoading && !deleteEmailAsync.isIdle) {
|
||||
deleteEmailAsync.reset()
|
||||
}
|
||||
}, [makePrimaryAsync.isLoading, deleteEmailAsync])
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteEmailAsync.isLoading && !makePrimaryAsync.isIdle) {
|
||||
makePrimaryAsync.reset()
|
||||
}
|
||||
}, [deleteEmailAsync.isLoading, makePrimaryAsync])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MakePrimary
|
||||
userEmailData={userEmailData}
|
||||
makePrimaryAsync={makePrimaryAsync}
|
||||
/>{' '}
|
||||
<Remove
|
||||
userEmailData={userEmailData}
|
||||
deleteEmailAsync={deleteEmailAsync}
|
||||
/>
|
||||
{(makePrimaryAsync.isError || deleteEmailAsync.isError) && (
|
||||
<div className="text-danger small">
|
||||
<Icon type="exclamation-triangle" fw />{' '}
|
||||
{t('error_performing_request')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Actions
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import Tooltip from '../../../../../shared/components/tooltip'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
inReconfirmNotificationPeriod,
|
||||
institutionAlreadyLinked,
|
||||
} from '../../../utils/selectors'
|
||||
import { postJSON } from '../../../../../infrastructure/fetch-json'
|
||||
import {
|
||||
State,
|
||||
useUserEmailsContext,
|
||||
} from '../../../context/user-email-context'
|
||||
import { UserEmailData } from '../../../../../../../types/user-email'
|
||||
import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
|
||||
|
||||
const getDescription = (
|
||||
t: (s: string) => string,
|
||||
state: State,
|
||||
userEmailData: UserEmailData
|
||||
) => {
|
||||
if (inReconfirmNotificationPeriod(userEmailData)) {
|
||||
return t('please_reconfirm_your_affiliation_before_making_this_primary')
|
||||
}
|
||||
|
||||
if (userEmailData.confirmedAt) {
|
||||
return t('make_email_primary_description')
|
||||
}
|
||||
|
||||
if (!institutionAlreadyLinked(state, userEmailData)) {
|
||||
return userEmailData.ssoAvailable
|
||||
? t('please_link_before_making_primary')
|
||||
: t('please_confirm_your_email_before_making_it_default')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function PrimaryButton({ children, disabled, onClick }: Button.ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
bsSize="small"
|
||||
bsStyle="success"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type MakePrimaryProps = {
|
||||
userEmailData: UserEmailData
|
||||
makePrimaryAsync: UseAsyncReturnType
|
||||
}
|
||||
|
||||
function MakePrimary({ userEmailData, makePrimaryAsync }: MakePrimaryProps) {
|
||||
const { t } = useTranslation()
|
||||
const { state, makePrimary } = useUserEmailsContext()
|
||||
|
||||
const handleSetDefaultUserEmail = () => {
|
||||
makePrimaryAsync
|
||||
.runAsync(
|
||||
postJSON('/user/emails/default', {
|
||||
body: {
|
||||
email: userEmailData.email,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
makePrimary(userEmailData.email)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (userEmailData.default) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (makePrimaryAsync.isLoading) {
|
||||
return <PrimaryButton disabled>{t('sending')}...</PrimaryButton>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
id={`tooltip-make-primary-${userEmailData.email}`}
|
||||
description={getDescription(t, state, userEmailData)}
|
||||
>
|
||||
{/*
|
||||
Disabled buttons don't work with tooltips, due to pointer-events: none,
|
||||
so create a wrapper for the tooltip
|
||||
*/}
|
||||
<span>
|
||||
<PrimaryButton
|
||||
disabled={
|
||||
state.isLoading ||
|
||||
inReconfirmNotificationPeriod(userEmailData) ||
|
||||
(!userEmailData.confirmedAt &&
|
||||
!institutionAlreadyLinked(state, userEmailData))
|
||||
}
|
||||
onClick={handleSetDefaultUserEmail}
|
||||
>
|
||||
{t('make_primary')}
|
||||
</PrimaryButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default MakePrimary
|
||||
@@ -0,0 +1,81 @@
|
||||
import Icon from '../../../../../shared/components/icon'
|
||||
import Tooltip from '../../../../../shared/components/tooltip'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UserEmailData } from '../../../../../../../types/user-email'
|
||||
import { useUserEmailsContext } from '../../../context/user-email-context'
|
||||
import { postJSON } from '../../../../../infrastructure/fetch-json'
|
||||
import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
|
||||
|
||||
function DeleteButton({ children, disabled, onClick }: Button.ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
bsSize="small"
|
||||
bsStyle="danger"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type RemoveProps = {
|
||||
userEmailData: UserEmailData
|
||||
deleteEmailAsync: UseAsyncReturnType
|
||||
}
|
||||
|
||||
function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) {
|
||||
const { t } = useTranslation()
|
||||
const { state, deleteEmail } = useUserEmailsContext()
|
||||
|
||||
const handleRemoveUserEmail = () => {
|
||||
deleteEmailAsync
|
||||
.runAsync(
|
||||
postJSON('/user/emails/delete', {
|
||||
body: {
|
||||
email: userEmailData.email,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
deleteEmail(userEmailData.email)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (deleteEmailAsync.isLoading) {
|
||||
return <DeleteButton disabled>{t('deleting')}...</DeleteButton>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
id={userEmailData.email}
|
||||
description={
|
||||
userEmailData.default
|
||||
? t('please_change_primary_to_remove')
|
||||
: t('remove')
|
||||
}
|
||||
overlayProps={{ placement: userEmailData.default ? 'left' : 'top' }}
|
||||
>
|
||||
<span>
|
||||
<DeleteButton
|
||||
disabled={state.isLoading || userEmailData.default}
|
||||
onClick={handleRemoveUserEmail}
|
||||
>
|
||||
<Icon
|
||||
type="trash"
|
||||
fw
|
||||
accessibilityLabel={
|
||||
userEmailData.default
|
||||
? t('please_change_primary_to_remove')
|
||||
: t('remove')
|
||||
}
|
||||
/>
|
||||
</DeleteButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default Remove
|
||||
@@ -1,9 +1,16 @@
|
||||
import classNames from 'classnames'
|
||||
|
||||
type CellProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Cell({ children }: CellProps) {
|
||||
return <div className="affiliations-table-cell">{children}</div>
|
||||
function Cell({ children, className }: CellProps) {
|
||||
return (
|
||||
<div className={classNames('affiliations-table-cell', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cell
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react'
|
||||
import { useCombobox } from 'downshift'
|
||||
import classnames from 'classnames'
|
||||
|
||||
type DownshiftInputProps = {
|
||||
items: string[]
|
||||
inputValue: string
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const filterItemsByInputValue = (
|
||||
items: DownshiftInputProps['items'],
|
||||
inputValue: DownshiftInputProps['inputValue']
|
||||
) => items.filter(item => item.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
|
||||
function DownshiftInput({
|
||||
items,
|
||||
inputValue,
|
||||
placeholder,
|
||||
setValue,
|
||||
}: DownshiftInputProps) {
|
||||
const [inputItems, setInputItems] = useState(items)
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
getComboboxProps,
|
||||
getItemProps,
|
||||
openMenu,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
inputValue,
|
||||
items: inputItems,
|
||||
initialSelectedItem: inputValue,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
setValue(selectedItem ?? '')
|
||||
},
|
||||
onInputValueChange: ({ inputValue = '' }) => {
|
||||
setInputItems(filterItemsByInputValue(items, inputValue))
|
||||
},
|
||||
onStateChange: ({ type }) => {
|
||||
if (type === useCombobox.stateChangeTypes.FunctionOpenMenu) {
|
||||
setInputItems(filterItemsByInputValue(items, inputValue))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
'ui-select-container ui-select-bootstrap dropdown',
|
||||
{
|
||||
open: isOpen && inputItems.length,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div {...getComboboxProps()} className="form-group mb-2">
|
||||
<input
|
||||
{...getInputProps({
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.value)
|
||||
},
|
||||
onFocus: () => {
|
||||
if (!isOpen) {
|
||||
openMenu()
|
||||
}
|
||||
},
|
||||
})}
|
||||
className="form-control"
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className="ui-select-choices ui-select-choices-content ui-select-dropdown dropdown-menu"
|
||||
>
|
||||
{inputItems.map((item, index) => (
|
||||
<li
|
||||
className="ui-select-choices-group"
|
||||
key={`${item}${index}`}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<div
|
||||
className={classnames('ui-select-choices-row', {
|
||||
active: selectedItem === item,
|
||||
})}
|
||||
>
|
||||
<span className="ui-select-choices-row-inner">
|
||||
<span>{item}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DownshiftInput
|
||||
@@ -8,21 +8,16 @@ function Header() {
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col sm={5} className="hidden-xs">
|
||||
<Col md={4} className="hidden-xs">
|
||||
<EmailCell>
|
||||
<strong>{t('email')}</strong>
|
||||
</EmailCell>
|
||||
</Col>
|
||||
<Col sm={5} className="hidden-xs">
|
||||
<Col md={8} className="hidden-xs">
|
||||
<EmailCell>
|
||||
<strong>{t('institution_and_role')}</strong>
|
||||
</EmailCell>
|
||||
</Col>
|
||||
<Col sm={2} className="hidden-xs">
|
||||
<EmailCell>
|
||||
<strong>todo</strong>
|
||||
</EmailCell>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="hidden-xs horizontal-divider" />
|
||||
<div className="hidden-xs horizontal-divider" />
|
||||
|
||||
+100
-17
@@ -1,6 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UserEmailData } from '../../../../../../types/user-email'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { isChangingAffiliation } from '../../utils/selectors'
|
||||
import { useUserEmailsContext } from '../../context/user-email-context'
|
||||
import DownshiftInput from './downshift-input'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import { defaults as roles } from '../../roles'
|
||||
import { defaults as departments } from '../../departments'
|
||||
|
||||
type InstitutionAndRoleProps = {
|
||||
userEmailData: UserEmailData
|
||||
@@ -8,14 +17,47 @@ type InstitutionAndRoleProps = {
|
||||
|
||||
function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isLoading, isError, runAsync } = useAsync()
|
||||
const { affiliation } = userEmailData
|
||||
const {
|
||||
state,
|
||||
setLoading: setUserEmailsContextLoading,
|
||||
setEmailAffiliationBeingEdited,
|
||||
updateAffiliation,
|
||||
} = useUserEmailsContext()
|
||||
const [role, setRole] = useState(affiliation?.role || '')
|
||||
const [department, setDepartment] = useState(affiliation?.department || '')
|
||||
|
||||
const handleAddRoleDepartment = () => {
|
||||
console.log('TODO: add role department')
|
||||
}
|
||||
useEffect(() => {
|
||||
setUserEmailsContextLoading(isLoading)
|
||||
}, [setUserEmailsContextLoading, isLoading])
|
||||
|
||||
const handleChangeAffiliation = () => {
|
||||
console.log('TODO: change affiliation')
|
||||
setEmailAffiliationBeingEdited(userEmailData.email)
|
||||
}
|
||||
|
||||
const handleCancelAffiliationChange = () => {
|
||||
setEmailAffiliationBeingEdited(null)
|
||||
setRole(affiliation?.role || '')
|
||||
setDepartment(affiliation?.department || '')
|
||||
}
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
runAsync(
|
||||
postJSON('/user/emails/endorse', {
|
||||
body: {
|
||||
email: userEmailData.email,
|
||||
role,
|
||||
department,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
updateAffiliation(userEmailData.email, role, department)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (!affiliation?.institution) {
|
||||
@@ -25,23 +67,64 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
|
||||
return (
|
||||
<>
|
||||
<div>{affiliation.institution.name}</div>
|
||||
{!affiliation.department && !affiliation.role && (
|
||||
{!isChangingAffiliation(state, userEmailData.email) ? (
|
||||
<div className="small">
|
||||
<Button className="btn-inline-link" onClick={handleAddRoleDepartment}>
|
||||
{t('add_role_and_department')}
|
||||
{(affiliation.role || affiliation.department) && (
|
||||
<>
|
||||
{[affiliation.role, affiliation.department]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<Button className="btn-inline-link" onClick={handleChangeAffiliation}>
|
||||
{!affiliation.department && !affiliation.role
|
||||
? t('add_role_and_department')
|
||||
: t('change')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="affiliation-change-container small">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DownshiftInput
|
||||
items={roles}
|
||||
inputValue={role}
|
||||
placeholder={t('role')}
|
||||
setValue={setRole}
|
||||
/>
|
||||
<DownshiftInput
|
||||
items={departments}
|
||||
inputValue={department}
|
||||
placeholder={t('department')}
|
||||
setValue={setDepartment}
|
||||
/>
|
||||
<Button
|
||||
bsSize="sm"
|
||||
bsStyle="success"
|
||||
type="submit"
|
||||
disabled={!role || !department || isLoading || state.isLoading}
|
||||
>
|
||||
{isLoading ? <>{t('saving')}…</> : t('save_or_cancel-save')}
|
||||
</Button>
|
||||
{!isLoading && (
|
||||
<>
|
||||
<span className="mx-2">{t('save_or_cancel-or')}</span>
|
||||
<Button
|
||||
className="btn-inline-link"
|
||||
onClick={handleCancelAffiliationChange}
|
||||
>
|
||||
{t('save_or_cancel-cancel')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{(affiliation.role || affiliation.department) && (
|
||||
<div className="small">
|
||||
{[affiliation.role, affiliation.department]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
<br />
|
||||
<Button className="btn-inline-link" onClick={handleChangeAffiliation}>
|
||||
{t('change')}
|
||||
</Button>
|
||||
</div>
|
||||
{isError && (
|
||||
<span className="text-danger small">
|
||||
<Icon type="exclamation-triangle" fw />{' '}
|
||||
{t('error_performing_request')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
+7
-5
@@ -2,8 +2,8 @@ import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { UserEmailData } from '../../../../../../types/user-email'
|
||||
import { useUserEmailsContext } from '../../context/user-email-context'
|
||||
|
||||
@@ -16,12 +16,13 @@ function ResendConfirmationEmailButton({
|
||||
}: ResendConfirmationEmailButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isLoading, isError, runAsync } = useAsync()
|
||||
const { setLoading } = useUserEmailsContext()
|
||||
const { state, setLoading: setUserEmailsContextLoading } =
|
||||
useUserEmailsContext()
|
||||
|
||||
// Update global isLoading prop
|
||||
useEffect(() => {
|
||||
setLoading(isLoading)
|
||||
}, [setLoading, isLoading])
|
||||
setUserEmailsContextLoading(isLoading)
|
||||
}, [setUserEmailsContextLoading, isLoading])
|
||||
|
||||
const handleResendConfirmationEmail = () => {
|
||||
runAsync(
|
||||
@@ -30,7 +31,7 @@ function ResendConfirmationEmailButton({
|
||||
email,
|
||||
},
|
||||
})
|
||||
)
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -45,6 +46,7 @@ function ResendConfirmationEmailButton({
|
||||
<>
|
||||
<Button
|
||||
className="btn-inline-link"
|
||||
disabled={state.isLoading}
|
||||
onClick={handleResendConfirmationEmail}
|
||||
>
|
||||
{t('resend_confirmation_email')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Row, Col } from 'react-bootstrap'
|
||||
import Email from './email'
|
||||
import InstitutionAndRole from './institution-and-role'
|
||||
import EmailCell from './cell'
|
||||
import Actions from './actions'
|
||||
|
||||
type EmailsRowProps = {
|
||||
userEmailData: UserEmailData
|
||||
@@ -11,20 +12,22 @@ type EmailsRowProps = {
|
||||
function EmailsRow({ userEmailData }: EmailsRowProps) {
|
||||
return (
|
||||
<Row>
|
||||
<Col sm={5}>
|
||||
<Col md={4}>
|
||||
<EmailCell>
|
||||
<Email userEmailData={userEmailData} />
|
||||
</EmailCell>
|
||||
</Col>
|
||||
<Col sm={5}>
|
||||
<Col sm={4}>
|
||||
{userEmailData.affiliation?.institution && (
|
||||
<EmailCell>
|
||||
<InstitutionAndRole userEmailData={userEmailData} />
|
||||
</EmailCell>
|
||||
)}
|
||||
</Col>
|
||||
<Col sm={2}>
|
||||
<EmailCell>todo</EmailCell>
|
||||
<Col md={4}>
|
||||
<EmailCell className="text-md-right">
|
||||
<Actions userEmailData={userEmailData} />
|
||||
</EmailCell>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
const integrationLinkingWidgets = importOverleafModules(
|
||||
'integrationLinkingWidgets'
|
||||
)
|
||||
|
||||
function IntegrationLinkingSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('integrations')}</h3>
|
||||
<p>{t('linked_accounts_explained')}</p>
|
||||
<div className="settings-widgets-container">
|
||||
{integrationLinkingWidgets.map(
|
||||
({ import: importObject, path }, widgetIndex) => (
|
||||
<IntegrationLinkingWidget
|
||||
key={Object.keys(importObject)[0]}
|
||||
ModuleComponent={Object.values(importObject)[0]}
|
||||
isLast={widgetIndex === integrationLinkingWidgets.length - 1}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type IntegrationLinkingWidgetProps = {
|
||||
ModuleComponent: any
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
function IntegrationLinkingWidget({
|
||||
ModuleComponent,
|
||||
isLast,
|
||||
}: IntegrationLinkingWidgetProps) {
|
||||
return (
|
||||
<>
|
||||
<ModuleComponent />
|
||||
{isLast ? null : <hr />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntegrationLinkingSection
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useState, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccessibleModal from '../../../../shared/components/accessible-modal'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
|
||||
type IntegrationLinkingWidgetProps = {
|
||||
logo: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
helpPath: string
|
||||
hasFeature?: boolean
|
||||
statusIndicator?: ReactNode
|
||||
linked?: boolean
|
||||
linkPath: string
|
||||
unlinkPath: string
|
||||
unlinkConfirmationTitle: string
|
||||
unlinkConfirmationText: string
|
||||
}
|
||||
|
||||
export function IntegrationLinkingWidget({
|
||||
logo,
|
||||
title,
|
||||
description,
|
||||
helpPath,
|
||||
hasFeature,
|
||||
statusIndicator,
|
||||
linked,
|
||||
linkPath,
|
||||
unlinkPath,
|
||||
unlinkConfirmationTitle,
|
||||
unlinkConfirmationText,
|
||||
}: IntegrationLinkingWidgetProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const handleUnlinkClick = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleModalHide = useCallback(() => {
|
||||
setShowModal(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>{logo}</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>{title}</h4>
|
||||
{!hasFeature && (
|
||||
<span className="label label-info">{t('premium_feature')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
{description}{' '}
|
||||
<a href={helpPath} target="_blank" rel="noreferrer">
|
||||
{t('learn_more')}
|
||||
</a>
|
||||
</p>
|
||||
{linked && statusIndicator}
|
||||
</div>
|
||||
<div>
|
||||
<ActionButton
|
||||
hasFeature={hasFeature}
|
||||
upgradePath="user/subscription/plans"
|
||||
linked={linked}
|
||||
handleUnlinkClick={handleUnlinkClick}
|
||||
linkPath={linkPath}
|
||||
/>
|
||||
</div>
|
||||
<UnlinkConfirmationModal
|
||||
show={showModal}
|
||||
title={unlinkConfirmationTitle}
|
||||
content={unlinkConfirmationText}
|
||||
unlinkPath={unlinkPath}
|
||||
handleHide={handleModalHide}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionButtonProps = {
|
||||
hasFeature?: boolean
|
||||
upgradePath: string
|
||||
linked?: boolean
|
||||
handleUnlinkClick: () => void
|
||||
linkPath: string
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
hasFeature,
|
||||
upgradePath,
|
||||
linked,
|
||||
handleUnlinkClick,
|
||||
linkPath,
|
||||
}: ActionButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!hasFeature) {
|
||||
return (
|
||||
<a href={upgradePath} className="btn btn-info text-capitalize">
|
||||
{t('upgrade')}
|
||||
</a>
|
||||
)
|
||||
} else if (linked) {
|
||||
return (
|
||||
<button className="btn btn-danger" onClick={handleUnlinkClick}>
|
||||
{t('unlink')}
|
||||
</button>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<a href={linkPath} className="btn btn-primary text-capitalize">
|
||||
{t('link')}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnlinkConfirmModalProps = {
|
||||
show: boolean
|
||||
title: string
|
||||
content: string
|
||||
unlinkPath: string
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
function UnlinkConfirmationModal({
|
||||
show,
|
||||
title,
|
||||
content,
|
||||
unlinkPath,
|
||||
handleHide,
|
||||
}: UnlinkConfirmModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<AccessibleModal show={show} onHide={handleHide}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className="modal-body-share">
|
||||
<p>{content}</p>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<button className="btn btn-default" onClick={handleHide}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<a href={unlinkPath} className="btn btn-danger text-capitalize">
|
||||
{t('unlink')}
|
||||
</a>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Modal, Button } from 'react-bootstrap'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import LeaveModalForm, { LeaveModalFormProps } from './modal-form'
|
||||
import { ExposedSettings } from '../../../../../../types/exposed-settings'
|
||||
|
||||
type LeaveModalContentProps = {
|
||||
handleHide: () => void
|
||||
@@ -16,10 +17,10 @@ function LeaveModalContentBlock({
|
||||
setIsFormValid,
|
||||
}: LeaveModalFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const isSaas = getMeta('ol-isSaas') as boolean
|
||||
const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings
|
||||
const hasPassword = getMeta('ol-hasPassword') as boolean
|
||||
|
||||
if (isSaas && !hasPassword) {
|
||||
if (isOverleaf && !hasPassword) {
|
||||
return (
|
||||
<p>
|
||||
<b>
|
||||
|
||||
@@ -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 = (
|
||||
<Trans
|
||||
i18nKey="user_deletion_password_reset_tip"
|
||||
|
||||
@@ -17,7 +17,7 @@ function LeaveModalForm({
|
||||
setIsFormValid,
|
||||
}: LeaveModalFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const userDefaultEmail = getMeta('ol-userDefaultEmail') as string
|
||||
const userDefaultEmail = getMeta('ol-usersEmail') as string
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, 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 { PasswordStrengthOptions } from '../../../../../types/password-strength-options'
|
||||
import useAsync from '../../../shared/hooks/use-async'
|
||||
|
||||
function PasswordSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('change_password')}</h3>
|
||||
<PasswordInnerSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 <p>{t('password_managed_externally')}</p>
|
||||
}
|
||||
|
||||
if (!hasPassword) {
|
||||
return (
|
||||
<p>
|
||||
<a href="/user/password/reset" target="_blank">
|
||||
{t('no_existing_password')}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return <PasswordForm />
|
||||
}
|
||||
|
||||
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 (
|
||||
<form id="password-change-form" onSubmit={handleSubmit}>
|
||||
<PasswordFormGroup
|
||||
id="current-password-input"
|
||||
label={t('current_password')}
|
||||
value={currentPassword}
|
||||
handleChange={handleCurrentPasswordChange}
|
||||
/>
|
||||
<PasswordFormGroup
|
||||
id="new-password-1-input"
|
||||
label={t('new_password')}
|
||||
value={newPassword1}
|
||||
handleChange={handleNewPassword1Change}
|
||||
minLength={passwordStrengthOptions?.length?.min || 6}
|
||||
/>
|
||||
<PasswordFormGroup
|
||||
id="new-password-2-input"
|
||||
label={t('confirm_new_password')}
|
||||
value={newPassword2}
|
||||
handleChange={handleNewPassword2Change}
|
||||
validationMessage={
|
||||
newPassword1 !== newPassword2 ? t('doesnt_match') : ''
|
||||
}
|
||||
/>
|
||||
{isSuccess && data?.message?.text ? (
|
||||
<FormGroup>
|
||||
<Alert bsStyle="success">{data.message.text}</Alert>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
{error ? (
|
||||
<FormGroup>
|
||||
<Alert bsStyle="danger">{error.getUserFacingMessage()}</Alert>
|
||||
</FormGroup>
|
||||
) : null}
|
||||
<Button
|
||||
form="password-change-form"
|
||||
type="submit"
|
||||
bsStyle="primary"
|
||||
disabled={isLoading || !isFormValid}
|
||||
>
|
||||
{isLoading ? <>{t('saving')}…</> : t('change')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<FormGroup>
|
||||
<ControlLabel htmlFor={id}>{label}</ControlLabel>
|
||||
<FormControl
|
||||
id={id}
|
||||
type="password"
|
||||
placeholder="*********"
|
||||
value={value}
|
||||
data-ol-dirty={!!validationMessage}
|
||||
onChange={handleChangeAndValidity}
|
||||
onInvalid={handleInvalid}
|
||||
required={hadInteraction}
|
||||
minLength={minLength}
|
||||
/>
|
||||
{hadInteraction && (parentValidationMessage || validationMessage) ? (
|
||||
<span className="small text-danger">
|
||||
{parentValidationMessage || validationMessage}
|
||||
</span>
|
||||
) : null}
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordSection
|
||||
@@ -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 (
|
||||
<UserProvider>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-12 col-lg-10 col-lg-offset-1">
|
||||
{ssoError ? (
|
||||
<Alert bsStyle="danger">
|
||||
{t('sso_link_error')}: {t(ssoError)}
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="card">
|
||||
<div className="page-header">
|
||||
<h1>{t('account_settings')}</h1>
|
||||
</div>
|
||||
<div className="account-settings">
|
||||
<EmailsSection />
|
||||
<div className="row">
|
||||
<div className="col-md-5">
|
||||
<AccountInfoSection />
|
||||
</div>
|
||||
<div className="col-md-5 col-md-offset-1">
|
||||
<PasswordSection />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<IntegrationLinkingSection />
|
||||
<hr />
|
||||
<SSOLinkingSection />
|
||||
<hr />
|
||||
<LeaveSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPageRoot
|
||||
@@ -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 (
|
||||
<SSOProvider>
|
||||
<h3 className="text-capitalize">{t('linked_accounts')}</h3>
|
||||
<p>{t('linked_accounts_explained')}</p>
|
||||
<SSOLinkingWidgets />
|
||||
</SSOProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SSOLinkingWidgets() {
|
||||
const { subscriptions } = useSSOContext()
|
||||
|
||||
return (
|
||||
<div className="settings-widgets-container">
|
||||
{Object.values(subscriptions).map((subscription, subscriptionIndex) => (
|
||||
<SSOLinkingWidgetContainer
|
||||
key={subscription.providerId}
|
||||
subscription={subscription}
|
||||
isLast={subscriptionIndex === Object.keys(subscriptions).length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SSOLinkingWidgetContainerProps = {
|
||||
subscription: SSOSubscription
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
function SSOLinkingWidgetContainer({
|
||||
subscription,
|
||||
isLast,
|
||||
}: SSOLinkingWidgetContainerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { unlink } = useSSOContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SSOLinkingWidget
|
||||
providerId={subscription.providerId}
|
||||
title={subscription.provider.name}
|
||||
description={t(
|
||||
subscription.provider.descriptionKey,
|
||||
subscription.provider.descriptionOptions
|
||||
)}
|
||||
helpPath={subscription.provider.descriptionOptions?.link}
|
||||
linked={subscription.linked}
|
||||
linkPath={subscription.provider.linkPath}
|
||||
onUnlink={() => unlink(subscription.providerId)}
|
||||
/>
|
||||
{isLast ? null : <hr />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SSOLinkingSection
|
||||
+41
-27
@@ -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: <IEEELogo />,
|
||||
google: <GoogleLogo />,
|
||||
orcid: <OrcidLogo />,
|
||||
}
|
||||
|
||||
type SSOLinkingWidgetProps = {
|
||||
logoSrc: string
|
||||
providerId: string
|
||||
title: string
|
||||
description: string
|
||||
helpPath?: string
|
||||
linked?: boolean
|
||||
linkPath: string
|
||||
onUnlink: () => Promise<void>
|
||||
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 (
|
||||
<div className="row">
|
||||
<div className="col-xs-2 col-sm-2 col-md-2">
|
||||
<img alt={title} src={logoSrc} />
|
||||
</div>
|
||||
<div className="col-xs-10 col-sm-6 col-md-8">
|
||||
<h4>{title}</h4>
|
||||
<p>{description}</p>
|
||||
<div className="settings-widget-container">
|
||||
<div>{providerLogos[providerId]}</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>{title}</h4>
|
||||
</div>
|
||||
<p>
|
||||
{description?.replace(/<[^>]+>/g, '')}{' '}
|
||||
{helpPath ? (
|
||||
<a href={helpPath} target="_blank" rel="noreferrer">
|
||||
{t('learn_more')}
|
||||
</a>
|
||||
) : null}
|
||||
</p>
|
||||
{errorMessage && <div>{errorMessage} </div>}
|
||||
</div>
|
||||
<div className="col-xs-2 col-sm-4 col-md-2 text-right">
|
||||
<div>
|
||||
<ActionButton
|
||||
unlinkRequestInflight={unlinkRequestInflight}
|
||||
accountIsLinked={linked}
|
||||
@@ -67,9 +82,8 @@ export function SSOLinkingWidget({
|
||||
/>
|
||||
</div>
|
||||
<UnlinkConfirmModal
|
||||
title={title}
|
||||
show={showModal}
|
||||
title={unlinkConfirmationTitle}
|
||||
content={unlinkConfirmationText}
|
||||
handleConfirmation={handleUnlinkConfirmationClick}
|
||||
handleHide={handleModalHide}
|
||||
/>
|
||||
@@ -92,15 +106,15 @@ function ActionButton({
|
||||
const { t } = useTranslation()
|
||||
if (unlinkRequestInflight) {
|
||||
return (
|
||||
<button disabled className="btn default">
|
||||
<Button bsStyle="danger" disabled>
|
||||
{t('unlinking')}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
} else if (accountIsLinked) {
|
||||
return (
|
||||
<button className="btn default" onClick={onUnlinkClick}>
|
||||
<Button bsStyle="danger" onClick={onUnlinkClick}>
|
||||
{t('unlink')}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
} 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 (
|
||||
<AccessibleModal show={show} onHide={handleHide}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
<Modal.Title>
|
||||
{t('unlink_provider_account_title', { provider: title })}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className="modal-body-share">
|
||||
<p>{content}</p>
|
||||
<p>{t('unlink_provider_account_warning', { provider: title })}</p>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
+23
-12
@@ -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<string, SSOSubscription>
|
||||
>(() => {
|
||||
const initialSubscriptions: Record<string, SSOSubscription> = {}
|
||||
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
|
||||
@@ -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<UserEmailData['email']>
|
||||
}
|
||||
|
||||
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<UserEmailData>
|
||||
linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
|
||||
emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
|
||||
}
|
||||
}
|
||||
|
||||
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<UserEmailData>(getMeta('ol-userEmails'), {
|
||||
const setData = (state: State, action: ActionSetData) => {
|
||||
const normalized = normalize<UserEmailData>(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<UserEmailData[]>(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<UserEmailData['email']>) =>
|
||||
dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)),
|
||||
[dispatch]
|
||||
),
|
||||
updateAffiliation: useCallback(
|
||||
(
|
||||
email: UserEmailData['email'],
|
||||
role: Affiliation['role'],
|
||||
department: Affiliation['department']
|
||||
) => dispatch(ActionCreators.updateAffiliation(email, role, department)),
|
||||
[dispatch]
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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<UserEmailData['email']>
|
||||
): 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 },
|
||||
})
|
||||
@@ -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
|
||||
@@ -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(<SettingsPageRoot />, element)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
OverlayTrigger,
|
||||
OverlayTriggerProps,
|
||||
Tooltip as BSTooltip,
|
||||
} from 'react-bootstrap'
|
||||
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode
|
||||
description: string
|
||||
id: string
|
||||
overlayProps?: Omit<OverlayTriggerProps, 'overlay'>
|
||||
tooltipProps?: BSTooltip.TooltipProps
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
id,
|
||||
description,
|
||||
children,
|
||||
tooltipProps,
|
||||
overlayProps,
|
||||
}: TooltipProps) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<BSTooltip id={`${id}-tooltip`} {...tooltipProps}>
|
||||
{description}
|
||||
</BSTooltip>
|
||||
}
|
||||
{...overlayProps}
|
||||
placement={overlayProps?.placement || 'top'}
|
||||
>
|
||||
{children}
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ type State = {
|
||||
type Action = Partial<State>
|
||||
|
||||
const defaultInitialState: State = { status: 'idle', data: null, error: null }
|
||||
const initializer = (initialState: State) => ({ ...initialState })
|
||||
|
||||
function useAsync(initialState?: Partial<State>) {
|
||||
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<State>) {
|
||||
[safeSetState]
|
||||
)
|
||||
|
||||
const reset = React.useCallback(
|
||||
() => safeSetState(initialStateRef.current),
|
||||
[safeSetState]
|
||||
)
|
||||
|
||||
const runAsync = React.useCallback(
|
||||
(promise: Promise<Record<string, unknown>>) => {
|
||||
<T>(promise: Promise<T>) => {
|
||||
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<State>) {
|
||||
status,
|
||||
data,
|
||||
runAsync,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
function DropboxLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="40" height="40" fill="white" />
|
||||
<g clipPath="url(#clip0_10_138)">
|
||||
<path
|
||||
d="M10.8328 4.33334L1.6665 10.1732L10.8328 16.0131L20.0006 10.1732L10.8328 4.33334Z"
|
||||
fill="#0061FF"
|
||||
/>
|
||||
<path
|
||||
d="M29.1668 4.33334L20.0005 10.1732L29.1668 16.0131L38.333 10.1732L29.1668 4.33334Z"
|
||||
fill="#0061FF"
|
||||
/>
|
||||
<path
|
||||
d="M1.6665 21.853L10.8328 27.6929L20.0006 21.853L10.8328 16.0131L1.6665 21.853Z"
|
||||
fill="#0061FF"
|
||||
/>
|
||||
<path
|
||||
d="M29.1668 16.0131L20.0005 21.853L29.1668 27.6929L38.333 21.853L29.1668 16.0131Z"
|
||||
fill="#0061FF"
|
||||
/>
|
||||
<path
|
||||
d="M10.833 29.6395L20.0008 35.4794L29.1671 29.6395L20.0008 23.7996L10.833 29.6395Z"
|
||||
fill="#0061FF"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10_138">
|
||||
<rect
|
||||
width="36.6667"
|
||||
height="31.146"
|
||||
fill="white"
|
||||
transform="translate(1.6665 4.33334)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default DropboxLogo
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
function GoogleLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="40" height="40" fill="white" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M36 20.3788C36 19.197 35.8939 18.0606 35.697 16.9697H20V23.4167H28.9697C28.5833 25.5 27.4091 27.2652 25.6439 28.447V32.6288H31.0303C34.1818 29.7273 36 25.4546 36 20.3788Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M19.9999 36.6666C24.4999 36.6666 28.2726 35.1742 31.0302 32.6287L25.6438 28.4469C24.1514 29.4469 22.2423 30.0378 19.9999 30.0378C15.659 30.0378 11.9847 27.106 10.6741 23.1666H5.10596V27.4848C7.84838 32.9317 13.4847 36.6666 19.9999 36.6666Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.6742 23.1666C10.3408 22.1666 10.1514 21.0984 10.1514 19.9999C10.1514 18.9014 10.3408 17.8332 10.6742 16.8332V12.515H5.10598C3.97719 14.765 3.33325 17.3105 3.33325 19.9999C3.33325 22.6893 3.97719 25.2347 5.10598 27.4847L10.6742 23.1666Z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M19.9999 9.96212C22.4469 9.96212 24.6438 10.803 26.3711 12.4545L31.1514 7.67424C28.265 4.98484 24.4923 3.33333 19.9999 3.33333C13.4847 3.33333 7.84838 7.06818 5.10596 12.5151L10.6741 16.8333C11.9847 12.8939 15.659 9.96212 19.9999 9.96212Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleLogo
|
||||
@@ -0,0 +1,23 @@
|
||||
function IEEELogo() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="40" height="40" fill="white" />
|
||||
<path
|
||||
d="M18.7638 7.59142C19.922 6.69149 21.3758 7.43967 22.3476 8.2677C23.3647 9.04516 24.4271 9.92645 25.3563 10.829L25.5267 10.9249C28.104 13.3185 30.5002 15.9757 32.4678 18.7873C32.7847 19.2772 33.0669 19.831 32.8885 20.4807C32.2336 22.1208 30.8996 23.4281 29.7361 24.8578C27.2147 27.5736 24.5549 30.1989 21.5755 32.2943C20.9738 32.7203 20.1617 33.1117 19.4401 32.7762C17.2489 31.6286 15.4011 29.7489 13.4814 28.0369C11.2582 25.9734 9.02438 23.5719 7.339 21.0584C7.08874 20.707 7.01687 20.2703 7.02485 19.8071C7.24317 18.9391 7.83424 18.2255 8.39869 17.4933C10.3902 14.9559 12.8291 12.5464 15.2946 10.3471C15.3532 10.2912 15.4571 10.1714 15.5476 10.1128C16.6046 9.19692 17.6563 8.38751 18.7639 7.59142L18.7638 7.59142ZM22.1533 4.9502L20.8459 3.09443C20.6835 2.99858 20.4839 2.83616 20.3081 2.78824C19.9221 2.62049 19.5041 2.82286 19.1899 3.06248L16.8229 6.14568C13.0235 10.8503 8.52651 15.2435 3.61948 18.6116C3.23343 18.9151 2.65561 19.2107 2.5358 19.7112C2.40808 20.1585 2.64229 20.5313 2.91924 20.8082C6.8491 23.5159 10.6059 26.7269 13.9474 30.3906C14.5411 31.0163 15.023 31.634 15.6035 32.2197C16.5806 33.5217 17.7309 34.8316 18.6281 36.2295C18.9103 36.525 18.9582 37.0069 19.4002 37.1507C19.7437 37.2705 20.1697 37.3531 20.4998 37.1507L20.8273 36.8232C24.893 31.0642 30.0476 25.8936 35.8066 21.7187C36.3844 21.2022 37.484 21.0105 37.5 20.0547C37.46 19.6154 37.2045 19.1787 36.8237 18.9391L36.7306 18.923C33.7672 16.9075 31.0036 14.6257 28.4023 12.0404L25.5267 9.03711C24.3606 7.74579 23.2316 6.31335 22.1533 4.95014L22.1533 4.9502ZM19.19 8.2996C20.3721 7.49551 21.4318 8.6191 22.3477 9.26342C25.8888 12.1123 29.2303 15.3739 31.773 19.0588C32.0952 19.5328 32.2389 20.2703 31.954 20.8081C31.291 21.9104 30.4018 22.9142 29.5578 23.9366V23.9898C27.4517 26.2316 25.2312 28.5347 22.8269 30.4304C21.6554 31.1466 20.5798 32.7042 19.0702 31.7377C15.6328 29.2243 12.342 26.1012 9.55431 22.7517C9.08038 22.0142 8.34285 21.4125 8.01272 20.5764C7.55475 19.4449 8.56652 18.6328 9.14428 17.7808C11.9879 14.2743 15.521 10.8343 19.19 8.29957V8.2996ZM19.9354 10.6106L19.616 11.6384L18.0105 16.2419C18.4125 16.2818 18.9104 16.2419 19.3044 16.2818V16.3191L19.0702 21.5243L19.1101 21.5802C19.624 21.6281 20.2657 21.6547 20.7902 21.5641V21.4816L20.5798 16.4361L20.6117 16.2657L22.0255 16.2417C21.3199 14.378 20.6357 12.4956 19.9781 10.6105L19.9354 10.6106ZM14.4693 17.8207C13.5694 18.2734 12.2434 18.955 12.3899 20.1904C12.5815 20.864 13.3138 21.3006 13.8915 21.5802C17.0866 22.986 21.328 23.0339 24.7014 21.9849C25.5348 21.6627 26.6529 21.1968 26.8047 20.1771C26.797 19.333 25.9048 18.8032 25.2631 18.481V18.4411C25.4868 18.3506 25.7424 18.2734 25.974 18.2467V18.2255C24.8451 18.0231 23.7535 17.7276 22.6592 17.4374C22.8695 17.9193 23.0213 18.4251 23.197 18.923C23.5272 18.8192 23.8653 18.7393 24.2194 18.6914C24.7972 18.9151 25.6226 19.2372 25.7051 19.9588C25.7691 20.6324 25.0288 20.9625 24.5629 21.2341C22.0894 22.134 19.3045 22.2219 16.7245 21.5403C16.0109 21.314 14.9831 21.0264 14.8953 20.1185C15.4011 18.923 16.7084 18.6754 17.8027 18.3852C17.241 18.0231 16.6686 17.7009 16.1254 17.2989C15.537 17.3335 14.9992 17.5891 14.4693 17.8208V17.8207ZM19.0382 23.01C18.9424 24.8578 18.9184 26.5671 18.7826 28.4229C19.5121 28.4868 20.3401 28.5507 21.1176 28.4389L20.886 23.2736L20.8459 23.0286C20.2495 23.0499 19.6878 23.0739 19.0381 23.0101"
|
||||
fill="#0A70A3"
|
||||
/>
|
||||
<path
|
||||
d="M25.8593 33.5803C25.2123 33.5803 24.6372 34.0276 24.6372 34.7944C24.6372 35.5638 25.2123 36.0138 25.8593 36.0138C26.5009 36.0138 27.076 35.5638 27.076 34.7944C27.076 34.0276 26.5009 33.5803 25.8593 33.5803ZM25.8593 35.7316V35.7289C25.3774 35.7316 24.9886 35.3561 24.9886 34.7943C24.9886 34.2378 25.3774 33.8651 25.8593 33.8651C26.3305 33.8651 26.7299 34.2378 26.7299 34.7943C26.7299 35.3561 26.3305 35.7315 25.8593 35.7315V35.7316ZM26.4024 34.536C26.4024 34.2378 26.2054 34.142 25.8646 34.142H25.3774V35.4493H25.6569V34.9009H25.7901L26.0909 35.4494H26.4237L26.0856 34.8769C26.2639 34.8609 26.4024 34.7757 26.4024 34.5361V34.536ZM25.9045 34.6772H25.6569V34.3604H25.854C25.9604 34.3604 26.1042 34.371 26.1042 34.5068C26.1042 34.6533 26.0323 34.6772 25.9045 34.6772"
|
||||
fill="#0A70A3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default IEEELogo
|
||||
@@ -0,0 +1,39 @@
|
||||
function MendeleyLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="40" height="40" fill="white" />
|
||||
<mask
|
||||
id="mask0_10_136"
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="1"
|
||||
y="10"
|
||||
width="38"
|
||||
height="22"
|
||||
>
|
||||
<path
|
||||
d="M38.3311 27.922C38.1859 23.7926 33.6702 24.893 32.4544 22.3593C31.2671 19.8856 33.3084 19.0379 33.3084 16.2773C33.3084 13.0071 30.6561 10.3558 27.3844 10.3558C23.1604 10.3558 23.2198 13.9091 20.0001 13.9091H19.9998C16.7802 13.9091 16.8395 10.3558 12.6153 10.3558C9.34353 10.3558 6.69126 13.0071 6.69126 16.2773C6.69126 19.0379 8.73252 19.8856 7.54546 22.3593C6.32951 24.893 1.81397 23.7926 1.66857 27.922C1.6 29.8702 3.24963 31.4519 5.20021 31.4519C7.15061 31.4519 8.74248 29.8713 8.73168 27.922C8.72289 26.3285 7.63175 25.761 8.02373 23.8616H8.0244C8.33953 22.2732 10.0408 20.9126 12.2365 20.639C14.6969 20.3326 17.477 21.547 17.477 24.2892C17.477 25.9872 16.471 26.2171 16.471 27.922C16.471 29.7956 17.9982 31.4519 19.9972 31.4519C21.9963 31.4519 23.5287 29.7956 23.5287 27.922C23.5287 26.2171 22.5228 25.9872 22.5228 24.2892C22.5228 21.547 25.3028 20.3326 27.7632 20.639C29.959 20.9126 31.6605 22.2732 31.9753 23.8616H31.9761C32.3678 25.761 31.2768 26.3285 31.268 27.922C31.2574 29.8713 32.8491 31.4519 34.7995 31.4519C36.75 31.4519 38.3997 29.8702 38.3311 27.922ZM20.0025 22.0539C18.052 22.0539 16.471 20.4734 16.471 18.5239C16.471 16.5745 18.052 14.994 20.0025 14.994C21.953 14.994 23.5341 16.5745 23.5341 18.5239C23.5341 20.4734 21.953 22.0539 20.0025 22.0539Z"
|
||||
fill="white"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_10_136)">
|
||||
<path
|
||||
d="M38.3311 27.922C38.1859 23.7926 33.6702 24.893 32.4544 22.3593C31.2671 19.8856 33.3084 19.0379 33.3084 16.2773C33.3084 13.0071 30.6561 10.3558 27.3844 10.3558C23.1604 10.3558 23.2198 13.9091 20.0001 13.9091H19.9998C16.7802 13.9091 16.8395 10.3558 12.6153 10.3558C9.34353 10.3558 6.69126 13.0071 6.69126 16.2773C6.69126 19.0379 8.73252 19.8856 7.54546 22.3593C6.32951 24.893 1.81397 23.7926 1.66857 27.922C1.6 29.8702 3.24963 31.4519 5.20021 31.4519C7.15061 31.4519 8.74248 29.8713 8.73168 27.922C8.72289 26.3285 7.63175 25.761 8.02373 23.8616H8.0244C8.33953 22.2732 10.0408 20.9126 12.2365 20.639C14.6969 20.3326 17.477 21.547 17.477 24.2892C17.477 25.9872 16.471 26.2171 16.471 27.922C16.471 29.7956 17.9982 31.4519 19.9972 31.4519C21.9963 31.4519 23.5287 29.7956 23.5287 27.922C23.5287 26.2171 22.5228 25.9872 22.5228 24.2892C22.5228 21.547 25.3028 20.3326 27.7632 20.639C29.959 20.9126 31.6605 22.2732 31.9753 23.8616H31.9761C32.3678 25.761 31.2768 26.3285 31.268 27.922C31.2574 29.8713 32.8491 31.4519 34.7995 31.4519C36.75 31.4519 38.3997 29.8702 38.3311 27.922ZM20.0025 22.0539C18.052 22.0539 16.471 20.4734 16.471 18.5239C16.471 16.5745 18.052 14.994 20.0025 14.994C21.953 14.994 23.5341 16.5745 23.5341 18.5239C23.5341 20.4734 21.953 22.0539 20.0025 22.0539Z"
|
||||
fill="#E5222E"
|
||||
/>
|
||||
<path
|
||||
d="M38.3135 10.3559H1.6665V31.4563H38.3135V10.3559Z"
|
||||
fill="#E81C2D"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default MendeleyLogo
|
||||
@@ -0,0 +1,43 @@
|
||||
function OrcidLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="40" height="40" fill="white" />
|
||||
<g clipPath="url(#clip0_10_162)">
|
||||
<path
|
||||
d="M36.6666 20C36.6666 29.2057 29.2056 36.6667 19.9999 36.6667C10.7942 36.6667 3.33325 29.2057 3.33325 20C3.33325 10.7943 10.7942 3.33334 19.9999 3.33334C29.2056 3.33334 36.6666 10.7943 36.6666 20Z"
|
||||
fill="#A6CE39"
|
||||
/>
|
||||
<path
|
||||
d="M14.5702 27.5781H12.5649V13.6328H14.5702V19.9349V27.5781Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M17.5129 13.6328H22.9296C28.0859 13.6328 30.3515 17.3177 30.3515 20.612C30.3515 24.1927 27.552 27.5911 22.9556 27.5911H17.5129V13.6328ZM19.5181 25.7812H22.7083C27.2525 25.7812 28.2942 22.3307 28.2942 20.612C28.2942 17.8125 26.5103 15.4427 22.6041 15.4427H19.5181V25.7812Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M14.8826 10.7292C14.8826 11.4453 14.2967 12.0443 13.5675 12.0443C12.8384 12.0443 12.2524 11.4453 12.2524 10.7292C12.2524 10 12.8384 9.41406 13.5675 9.41406C14.2967 9.41406 14.8826 10.013 14.8826 10.7292Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10_162">
|
||||
<rect
|
||||
width="33.3333"
|
||||
height="33.3333"
|
||||
fill="white"
|
||||
transform="translate(3.33325 3.33334)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrcidLogo
|
||||
File diff suppressed because one or more lines are too long
@@ -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 <EmailsSection {...args} />
|
||||
}
|
||||
|
||||
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 <EmailsSection {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Emails and Affiliations',
|
||||
component: EmailsSection,
|
||||
}
|
||||
@@ -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 <AccountInfoSection {...args} />
|
||||
}
|
||||
|
||||
export const ReadOnly = args => {
|
||||
setDefaultMeta()
|
||||
window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true)
|
||||
window.metaAttributesCache.set('ol-shouldAllowEditingDetails', false)
|
||||
|
||||
return <AccountInfoSection {...args} />
|
||||
}
|
||||
|
||||
export const NoEmailInput = args => {
|
||||
setDefaultMeta()
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', {
|
||||
hasAffiliationsFeature: true,
|
||||
})
|
||||
useFetchMock(defaultSetupMocks)
|
||||
|
||||
return <AccountInfoSection {...args} />
|
||||
}
|
||||
|
||||
export const Error = args => {
|
||||
setDefaultMeta()
|
||||
useFetchMock(fetchMock => fetchMock.post(/\/user\/settings/, 500))
|
||||
|
||||
return <AccountInfoSection {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Account Settings / Account Info',
|
||||
component: AccountInfoSection,
|
||||
}
|
||||
@@ -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 <EmailsSection {...args} />
|
||||
}
|
||||
|
||||
export const NetworkErrors = args => {
|
||||
useFetchMock(errorsMocks)
|
||||
setDefaultMeta()
|
||||
|
||||
return <EmailsSection {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Account Settings / Emails and Affiliations',
|
||||
component: EmailsSection,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 (
|
||||
<UserProvider>
|
||||
<IntegrationLinkingSection {...args} />
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Account Settings / Integration Linking / Section',
|
||||
component: IntegrationLinkingSection,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<UserProvider>
|
||||
<SettingsPageRoot {...args} />
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Account Settings / Full Page',
|
||||
component: SettingsPageRoot,
|
||||
}
|
||||
@@ -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 <PasswordSection {...args} />
|
||||
}
|
||||
|
||||
export const ManagedExternally = args => {
|
||||
setDefaultMeta()
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', {
|
||||
isOverleaf: false,
|
||||
})
|
||||
window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true)
|
||||
useFetchMock(defaultSetupMocks)
|
||||
|
||||
return <PasswordSection {...args} />
|
||||
}
|
||||
|
||||
export const NoExistingPassword = args => {
|
||||
setDefaultMeta()
|
||||
window.metaAttributesCache.set('ol-hasPassword', false)
|
||||
useFetchMock(defaultSetupMocks)
|
||||
|
||||
return <PasswordSection {...args} />
|
||||
}
|
||||
|
||||
export const Error = args => {
|
||||
setDefaultMeta()
|
||||
useFetchMock(fetchMock =>
|
||||
fetchMock.post(/\/user\/password\/update/, {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Your old password is wrong',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return <PasswordSection {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Account Settings / Password',
|
||||
component: PasswordSection,
|
||||
}
|
||||
@@ -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 <SSOLinkingSection {...args} />
|
||||
}
|
||||
|
||||
export const SectionAllUnlinked = args => {
|
||||
useFetchMock(defaultSetupMocks)
|
||||
setDefaultMeta()
|
||||
window.metaAttributesCache.set('ol-thirdPartyIds', {})
|
||||
|
||||
return <SSOLinkingSection {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Account Settings / SSO Linking / Section',
|
||||
component: SSOLinkingSection,
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": " <a href=\"__link__\">Securely establish your identity by linking your ORCID iD to your __appName__ account</a>. 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 more</0> 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 email</0>) and confirm it. Then click the <0>Make Primary</0> button. <1>Learn more</1> 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 survey</0>."
|
||||
"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 survey</0>.",
|
||||
"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?"
|
||||
}
|
||||
|
||||
+9
-12
@@ -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('<LayoutDropdownButton />', function () {
|
||||
let openStub
|
||||
let sendMBSpy
|
||||
const defaultUi = {
|
||||
pdfLayout: 'flat',
|
||||
view: 'pdf',
|
||||
@@ -17,11 +16,13 @@ describe('<LayoutDropdownButton />', 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('<LayoutDropdownButton />', 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('<LayoutDropdownButton />', 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 () {
|
||||
|
||||
+198
@@ -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('<AccountInfoSection />', 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(<AccountInfoSection />)
|
||||
|
||||
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(<AccountInfoSection />)
|
||||
|
||||
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(<AccountInfoSection />)
|
||||
|
||||
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(<AccountInfoSection />)
|
||||
|
||||
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(<AccountInfoSection />)
|
||||
|
||||
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(<AccountInfoSection />)
|
||||
|
||||
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(<AccountInfoSection />)
|
||||
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(<AccountInfoSection />)
|
||||
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(<AccountInfoSection />)
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
+120
@@ -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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
+77
-3
@@ -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(<InstitutionAndRole userEmailData={userEmailData} />)
|
||||
render(
|
||||
<UserEmailsProvider>
|
||||
<InstitutionAndRole userEmailData={userEmailData} />
|
||||
</UserEmailsProvider>
|
||||
)
|
||||
|
||||
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(<InstitutionAndRole userEmailData={userEmailData} />)
|
||||
render(
|
||||
<UserEmailsProvider>
|
||||
<InstitutionAndRole userEmailData={userEmailData} />
|
||||
</UserEmailsProvider>
|
||||
)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
+55
-24
@@ -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('<EmailsSection />', 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(<EmailsSection />)
|
||||
|
||||
screen.getByRole('heading', { name: /emails and affiliations/i })
|
||||
})
|
||||
|
||||
it('renders translated description', function () {
|
||||
window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
|
||||
render(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
|
||||
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(<EmailsSection />)
|
||||
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('<EmailsSection />', 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(<EmailsSection />)
|
||||
await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
|
||||
|
||||
fetchMock.post('/user/emails/resend_confirmation', 503)
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /resend confirmation email/i,
|
||||
|
||||
+100
@@ -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('<IntegrationLinkingWidgetTest/>', 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(<IntegrationLinkingWidget {...defaultProps} hasFeature={false} />)
|
||||
})
|
||||
|
||||
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(
|
||||
<IntegrationLinkingWidget {...defaultProps} hasFeature linked={false} />
|
||||
)
|
||||
})
|
||||
|
||||
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(
|
||||
<IntegrationLinkingWidget
|
||||
{...defaultProps}
|
||||
hasFeature
|
||||
linked
|
||||
statusIndicator={<div>status indicator</div>}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,17 @@ import {
|
||||
import LeaveSection from '../../../../../frontend/js/features/settings/components/leave-section'
|
||||
|
||||
describe('<LeaveSection />', 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(<LeaveSection />)
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import LeaveModalContent from '../../../../../../frontend/js/features/settings/c
|
||||
describe('<LeaveModalContent />', 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)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import LeaveModalForm from '../../../../../../frontend/js/features/settings/comp
|
||||
describe('<LeaveModalForm />', 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('<LeaveModalForm />', function () {
|
||||
assign: locationStub,
|
||||
},
|
||||
})
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
@@ -110,7 +112,8 @@ describe('<LeaveModalForm />', 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(
|
||||
<LeaveModalForm
|
||||
@@ -129,9 +132,8 @@ describe('<LeaveModalForm />', 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(
|
||||
<LeaveModalForm
|
||||
setInFlight={() => {}}
|
||||
|
||||
@@ -7,7 +7,9 @@ import LeaveModal from '../../../../../../frontend/js/features/settings/componen
|
||||
describe('<LeaveModal />', 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 () {
|
||||
|
||||
@@ -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('<PasswordSection />', 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(<PasswordSection />)
|
||||
|
||||
screen.getByText('Password settings are managed externally')
|
||||
})
|
||||
|
||||
it('shows no existing password message', async function () {
|
||||
window.metaAttributesCache.set('ol-hasPassword', false)
|
||||
render(<PasswordSection />)
|
||||
|
||||
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(<PasswordSection />)
|
||||
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(<PasswordSection />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Change',
|
||||
})
|
||||
)
|
||||
expect(updateMock.called()).to.be.false
|
||||
})
|
||||
|
||||
it('validates inputs', async function () {
|
||||
render(<PasswordSection />)
|
||||
|
||||
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(<PasswordSection />)
|
||||
|
||||
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(<PasswordSection />)
|
||||
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(<PasswordSection />)
|
||||
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(<PasswordSection />)
|
||||
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',
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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('<SettingsPageRoot />', 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(<SettingsPageRoot />)
|
||||
|
||||
screen.getByRole('button', {
|
||||
name: 'Delete your account',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends tracking event on load', async function () {
|
||||
render(<SettingsPageRoot />)
|
||||
|
||||
sinon.assert.calledOnce(sendMBSpy)
|
||||
sinon.assert.calledWith(sendMBSpy, 'settings-view')
|
||||
})
|
||||
})
|
||||
+12
-11
@@ -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('<SSOLinkingWidget />', 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('<SSOLinkingWidget />', function () {
|
||||
render(<SSOLinkingWidget {...defaultProps} />)
|
||||
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(<SSOLinkingWidget {...defaultProps} linked={false} />)
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'link' }).getAttribute('href')
|
||||
screen.getByRole('link', { name: 'Link' }).getAttribute('href')
|
||||
).to.equal('integration/link')
|
||||
})
|
||||
})
|
||||
@@ -45,13 +47,14 @@ describe('<SSOLinkingWidget />', 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('<SSOLinkingWidget />', function () {
|
||||
})
|
||||
|
||||
it('should display the unlink button ', async function () {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Unlink' }))
|
||||
)
|
||||
await waitFor(() => screen.getByRole('button', { name: 'Unlink' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
+33
-22
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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<unknown>
|
||||
act(() => {
|
||||
p = result.current.runAsync(promise)
|
||||
p = result.current.runAsync(promise).catch(() => {})
|
||||
})
|
||||
|
||||
expect(result.current).to.include(pendingState)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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<string, OAuthProvider>
|
||||
@@ -0,0 +1,6 @@
|
||||
export type PasswordStrengthOptions = {
|
||||
length?: {
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ThirdPartyId = string
|
||||
|
||||
export type ThirdPartyIds = Record<string, ThirdPartyId>
|
||||
@@ -5,5 +5,6 @@ export type UserEmailData = {
|
||||
confirmedAt?: string
|
||||
email: string
|
||||
default: boolean
|
||||
samlProviderId?: string
|
||||
ssoAvailable?: boolean
|
||||
}
|
||||
|
||||
@@ -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<string, OAuthProvider>
|
||||
oauthProviders: OAuthProviders
|
||||
thirdPartyIds: Record<string, string>
|
||||
metaAttributesCache: Map<string, unknown>
|
||||
metaAttributesCache: Map<string, any>
|
||||
i18n: {
|
||||
currentLangCode: string
|
||||
}
|
||||
ExposedSettings: Record<string, unknown>
|
||||
ExposedSettings: ExposedSettings
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user