diff --git a/services/web/.gitignore b/services/web/.gitignore index 57ebc26c5a..26e9f30502 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -56,6 +56,7 @@ public/js/*.map public/js/libs/sharejs.js public/js/analytics/ public/js/directives/ +public/js/components/ public/js/es/ public/js/filters/ public/js/ide/ diff --git a/services/web/app/coffee/Features/User/UserPagesController.coffee b/services/web/app/coffee/Features/User/UserPagesController.coffee index 5e6ea7d62b..2a7ed62d02 100644 --- a/services/web/app/coffee/Features/User/UserPagesController.coffee +++ b/services/web/app/coffee/Features/User/UserPagesController.coffee @@ -68,6 +68,7 @@ module.exports = shouldAllowEditingDetails: shouldAllowEditingDetails languages: Settings.languages, accountSettingsTabActive: true + showAffiliationsUI: (req.query?.aff == "true") or false sessionsPage: (req, res, next) -> user = AuthenticationController.getSessionUser(req) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index d7d16ae6a1..8993bb45dc 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -114,7 +114,7 @@ module.exports = class Router webRouter.post '/user/emails', AuthenticationController.requireLogin(), UserEmailsController.add - webRouter.delete '/user/emails', + webRouter.post '/user/emails/delete', AuthenticationController.requireLogin(), UserEmailsController.remove webRouter.post '/user/emails/default', diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 317214f4d7..a781cd708a 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -4,117 +4,122 @@ block content .content.content-alt .container .row - .col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 + .col-md-12.col-lg-10.col-lg-offset-1 .card .page-header h1 #{translate("account_settings")} .account-settings(ng-controller="AccountSettingsController", ng-cloak) + if locals.showAffiliationsUI + include settings/user-affiliations + form-messages(for="settingsForm") .alert.alert-success(ng-show="settingsForm.response.success") | #{translate("thanks_settings_updated")} form-messages(for="changePasswordForm") - .container-fluid - .row - .col-md-5 - h3 #{translate("update_account_info")} - form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate) + + .row + .col-md-5 + h3 #{translate("update_account_info")} + form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate) + input(type="hidden", name="_csrf", value=csrfToken) + if !externalAuthenticationSystemUsed() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + ng-model="email", + ng-init="email = "+JSON.stringify(user.email), + ng-model-options="{ updateOn: 'blur' }" + ) + span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") + | #{translate("must_be_email_address")} + else + // show the email, non-editable + .form-group + label.control-label #{translate("email")} + div.form-control(readonly="true") #{user.email} + + if shouldAllowEditingDetails + .form-group + label(for='firstName').control-label #{translate("first_name")} + input.form-control( + type='text', + name='first_name', + value=user.first_name + ng-non-bindable + ) + .form-group + label(for='lastName').control-label #{translate("last_name")} + input.form-control( + type='text', + name='last_name', + value=user.last_name + ng-non-bindable + ) + .actions + button.btn.btn-primary( + type='submit', + ng-disabled="settingsForm.$invalid" + ) #{translate("update")} + else + .form-group + label.control-label #{translate("first_name")} + div.form-control(readonly="true") #{user.first_name} + .form-group + label.control-label #{translate("last_name")} + div.form-control(readonly="true") #{user.last_name} + + if !externalAuthenticationSystemUsed() + .col-md-5.col-md-offset-1 + h3 #{translate("change_password")} + form(async-form="changepassword", name="changePasswordForm", action="/user/password/update", method="POST", novalidate) input(type="hidden", name="_csrf", value=csrfToken) - if !externalAuthenticationSystemUsed() - .form-group - label(for='email') #{translate("email")} - input.form-control( - type='email', - name='email', - placeholder="email@example.com" - required, - ng-model="email", - ng-init="email = "+JSON.stringify(user.email), - ng-model-options="{ updateOn: 'blur' }" - ) - span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") - | #{translate("must_be_email_address")} - else - // show the email, non-editable - .form-group - label.control-label #{translate("email")} - div.form-control(readonly="true") #{user.email} + .form-group + label(for='currentPassword') #{translate("current_password")} + input.form-control( + type='password', + name='currentPassword', + placeholder='*********', + ng-model="currentPassword", + required + ) + span.small.text-primary(ng-show="changePasswordForm.currentPassword.$invalid && changePasswordForm.currentPassword.$dirty") + | #{translate("required")} + .form-group + label(for='newPassword1') #{translate("new_password")} + input.form-control( + id='passwordField', + type='password', + name='newPassword1', + placeholder='*********', + ng-model="newPassword1", + required, + complex-password + ) + span.small.text-primary(ng-show="changePasswordForm.newPassword1.$error.complexPassword && changePasswordForm.newPassword1.$dirty", ng-bind-html="complexPasswordErrorMessage") + .form-group + label(for='newPassword2') #{translate("confirm_new_password")} + input.form-control( + type='password', + name='newPassword2', + placeholder='*********', + ng-model="newPassword2", + equals="passwordField" + ) + span.small.text-primary(ng-show="changePasswordForm.newPassword2.$error.areEqual && changePasswordForm.newPassword2.$dirty") + | #{translate("doesnt_match")} + span.small.text-primary(ng-show="!changePasswordForm.newPassword2.$error.areEqual && changePasswordForm.newPassword2.$invalid && changePasswordForm.newPassword2.$dirty") + | #{translate("invalid_password")} + .actions + button.btn.btn-primary( + type='submit', + ng-disabled="changePasswordForm.$invalid" + ) #{translate("change")} - if shouldAllowEditingDetails - .form-group - label(for='firstName').control-label #{translate("first_name")} - input.form-control( - type='text', - name='first_name', - value=user.first_name - ng-non-bindable - ) - .form-group - label(for='lastName').control-label #{translate("last_name")} - input.form-control( - type='text', - name='last_name', - value=user.last_name - ng-non-bindable - ) - .actions - button.btn.btn-primary( - type='submit', - ng-disabled="settingsForm.$invalid" - ) #{translate("update")} - else - .form-group - label.control-label #{translate("first_name")} - div.form-control(readonly="true") #{user.first_name} - .form-group - label.control-label #{translate("last_name")} - div.form-control(readonly="true") #{user.last_name} - - if !externalAuthenticationSystemUsed() - .col-md-5.col-md-offset-1 - h3 #{translate("change_password")} - form(async-form="changepassword", name="changePasswordForm", action="/user/password/update", method="POST", novalidate) - input(type="hidden", name="_csrf", value=csrfToken) - .form-group - label(for='currentPassword') #{translate("current_password")} - input.form-control( - type='password', - name='currentPassword', - placeholder='*********', - ng-model="currentPassword", - required - ) - span.small.text-primary(ng-show="changePasswordForm.currentPassword.$invalid && changePasswordForm.currentPassword.$dirty") - | #{translate("required")} - .form-group - label(for='newPassword1') #{translate("new_password")} - input.form-control( - id='passwordField', - type='password', - name='newPassword1', - placeholder='*********', - ng-model="newPassword1", - required, - complex-password - ) - span.small.text-primary(ng-show="changePasswordForm.newPassword1.$error.complexPassword && changePasswordForm.newPassword1.$dirty", ng-bind-html="complexPasswordErrorMessage") - .form-group - label(for='newPassword2') #{translate("confirm_new_password")} - input.form-control( - type='password', - name='newPassword2', - placeholder='*********', - ng-model="newPassword2", - equals="passwordField" - ) - span.small.text-primary(ng-show="changePasswordForm.newPassword2.$error.areEqual && changePasswordForm.newPassword2.$dirty") - | #{translate("doesnt_match")} - span.small.text-primary(ng-show="!changePasswordForm.newPassword2.$error.areEqual && changePasswordForm.newPassword2.$invalid && changePasswordForm.newPassword2.$dirty") - | #{translate("invalid_password")} - .actions - button.btn.btn-primary( - type='submit', - ng-disabled="changePasswordForm.$invalid" - ) #{translate("change")} + | !{moduleIncludes("userSettings", locals)} diff --git a/services/web/app/views/user/settings/user-affiliations.pug b/services/web/app/views/user/settings/user-affiliations.pug new file mode 100644 index 0000000000..6b08b09346 --- /dev/null +++ b/services/web/app/views/user/settings/user-affiliations.pug @@ -0,0 +1,172 @@ +form.row( + ng-controller="UserAffiliationsController" + name="affiliationsForm" +) + .col-md-12 + h3 Emails and Affiliations + p.small Add additional email addresses to your account to access any upgrades your university or institution has, to make it easier for collaborators to find you, and to make sure you can recover your account. + table.table.affiliations-table + thead + tr + th.affiliations-table-email Email + th.affiliations-table-institution Institution and role + th.affiliations-table-inline-actions + tbody + tr( + ng-repeat="userEmail in userEmails" + ) + td {{ userEmail.email + (userEmail.default ? ' (default)' : '') }} + td + div(ng-if="userEmail.affiliation.institution") {{ userEmail.affiliation.institution.name }} + div(ng-if="userEmail.affiliation.role || userEmail.affiliation.department") + span(ng-if="userEmail.affiliation.role") {{ userEmail.affiliation.role }} + span(ng-if="userEmail.affiliation.role && userEmail.affiliation.department") , + span(ng-if="userEmail.affiliation.department") {{ userEmail.affiliation.department }} + td + a( + href + ng-if="!userEmail.default" + ng-click="setDefaultUserEmail(userEmail.email)" + ) Make default + br + a( + href + ng-if="!userEmail.default" + ng-click="removeUserEmail(userEmail.email)" + ) Remove + tr.affiliations-table-highlighted-row( + ng-if="ui.isLoadingEmails" + ) + td.text-center(colspan="3") + i.fa.fa-fw.fa-spin.fa-refresh + | Loading... + + tr.affiliations-table-highlighted-row( + ng-if="!ui.showAddEmailUI && !ui.isLoadingEmails" + ) + td(colspan="3") + a( + href + ng-click="showAddEmailForm()" + ) Add another email + + tr.affiliations-table-highlighted-row( + ng-if="ui.showAddEmailUI" + ) + td + .affiliations-form-group + input-suggestions( + ng-model="newAffiliation.email" + ng-model-options="{ allowInvalid: true }" + get-suggestion="getEmailSuggestion(userInput)" + on-blur="handleEmailInputBlur()" + input-id="affilitations-email" + input-name="affilitationsEmail" + input-placeholder="e.g. johndoe@mit.edu" + input-type="email" + input-required="true" + ) + td + .affiliations-table-label( + ng-if="newAffiliation.university && !ui.showManualUniversitySelectionUI" + ) + | {{ newAffiliation.university.name }} ( + a( + href + ng-click="selectUniversityManually();" + ) change + | ) + .affiliations-table-label( + ng-if="!newAffiliation.university && !ui.isValidEmail && !ui.showManualUniversitySelectionUI" + ) Start by adding your email address. + .affiliations-table-label( + ng-if="!newAffiliation.university && ui.isValidEmail && !ui.isBlacklistedEmail && !ui.showManualUniversitySelectionUI" + ) + | Is your email affiliated with an institution? + br + a( + href + ng-click="selectUniversityManually();" + ) Let us know + .affiliations-form-group( + ng-if="ui.showManualUniversitySelectionUI" + ) + ui-select( + ng-model="newAffiliation.country" + ) + ui-select-match( + placeholder="Country" + ) {{ $select.selected.name }} + ui-select-choices( + repeat="country in countries | filter: $select.search" + ) + span( + ng-bind="country.name" + s) + .affiliations-form-group( + ng-if="ui.showManualUniversitySelectionUI" + ) + ui-select( + ng-model="newAffiliation.university" + ng-disabled="!newAffiliation.country" + tagging="addUniversityToSelection" + tagging-label="false" + ) + ui-select-match( + placeholder="Institution" + ) {{ $select.selected.name }} + ui-select-choices( + repeat="university in universities | filter: $select.search" + ) + span( + ng-bind="university.name" + ) + .affiliations-form-group( + ng-if="ui.isValidEmail && newAffiliation.university" + ) + ui-select( + ng-model="newAffiliation.role" + tagging + tagging-label="false" + ) + ui-select-match( + placeholder="Role" + ) {{ $select.selected }} + ui-select-choices( + repeat="role in roles | filter: $select.search" + ) + span( + ng-bind="role" + ) + + .affiliations-form-group( + ng-if="ui.isValidEmail && newAffiliation.university" + ) + ui-select( + ng-model="newAffiliation.department" + tagging + tagging-label="false" + ) + ui-select-match( + placeholder="Department" + ) {{ $select.selected }} + ui-select-choices( + repeat="department in departments | filter: $select.search" + ) + span( + ng-bind="department" + ) + td + button.btn.btn-primary( + ng-disabled="affiliationsForm.$invalid || ui.isAddingNewEmail" + ng-click="addNewEmail()" + ) + span( + ng-if="!ui.isAddingNewEmail" + ) Add new email + span( + ng-if="ui.isAddingNewEmail" + ) + i.fa.fa-fw.fa-spin.fa-refresh + | Adding... + hr \ No newline at end of file diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 7078a9f64c..9d471e6642 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -15,6 +15,11 @@ httpAuthUsers[httpAuthUser] = httpAuthPass sessionSecret = "secret-please-change" +v1Api = + url: "http://#{process.env['V1_HOST'] or 'localhost'}:5000" + user: 'overleaf' + pass: 'password' + module.exports = settings = allowAnonymousReadAndWriteSharing: @@ -157,9 +162,9 @@ module.exports = settings = thirdpartyreferences: url: "http://#{process.env['THIRD_PARTY_REFERENCES_HOST'] or 'localhost'}:3046" v1: - url: "http://#{process.env['V1_HOST'] or 'localhost'}:5000" - user: 'overleaf' - pass: 'password' + url: v1Api.url + user: v1Api.user + pass: v1Api.pass templates: user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2" @@ -420,7 +425,9 @@ module.exports = settings = redirects: "/templates/index": "/templates/" - proxyUrls: {} + proxyUrls: + '/institutions/list': { baseUrl: v1Api.url, path: '/universities/list' } + '/institutions/domains': { baseUrl: v1Api.url, path: '/university/domains' } reloadModuleViewsOnEachRequest: true diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee index c3847c9342..cb9e70b6bf 100644 --- a/services/web/public/coffee/base.coffee +++ b/services/web/public/coffee/base.coffee @@ -17,14 +17,22 @@ define [ "ErrorCatcher" "localStorage" "ngTagsInput" - ]).config ($qProvider, sixpackProvider, $httpProvider)-> + "ui.select" + ]).config ($qProvider, sixpackProvider, $httpProvider, uiSelectConfig) -> $qProvider.errorOnUnhandledRejections(false) + uiSelectConfig.spinnerClass = 'fa fa-refresh ui-select-spin' sixpackProvider.setOptions({ debug: false baseUrl: window.sharelatex.sixpackDomain client_id: window.user_id }) + App.run ($templateCache) -> + # UI Select templates are hard-coded and use Glyphicon icons (which we don't import). + # The line below simply overrides the hard-coded template with our own, which is + # basically the same but using Font Awesome icons. + $templateCache.put "bootstrap/match.tpl.html", "
" + sl_debugging = window.location?.search?.match(/debug=true/)? window.sl_console = log: (args...) -> console.log(args...) if sl_debugging diff --git a/services/web/public/coffee/components/inputSuggestions.coffee b/services/web/public/coffee/components/inputSuggestions.coffee new file mode 100644 index 0000000000..9153fef602 --- /dev/null +++ b/services/web/public/coffee/components/inputSuggestions.coffee @@ -0,0 +1,74 @@ +define [ + "base" +], (App) -> + inputSuggestionsController = ($scope, $element, $attrs, Keys) -> + ctrl = @ + ctrl.showHint = false + ctrl.hasFocus = false + ctrl.handleFocus = () -> + ctrl.hasFocus = true + ctrl.suggestion = null + ctrl.handleBlur = () -> + ctrl.showHint = false + ctrl.hasFocus = false + ctrl.suggestion = null + ctrl.onBlur() + ctrl.handleKeyDown = ($event) -> + if ($event.which == Keys.TAB or $event.which == Keys.ENTER) and ctrl.suggestion? and ctrl.suggestion != "" + $event.preventDefault() + ctrl.localNgModel += ctrl.suggestion + ctrl.suggestion = null + ctrl.showHint = false + $scope.$watch "$ctrl.localNgModel", (newVal, oldVal) -> + if ctrl.hasFocus and newVal != oldVal + ctrl.suggestion = null + ctrl.showHint = false + ctrl.getSuggestion({ userInput: newVal }) + .then (suggestion) -> + if suggestion? and newVal == ctrl.localNgModel + ctrl.showHint = true + ctrl.suggestion = suggestion.replace newVal, "" + .catch () -> ctrl.suggestion = null + return + + App.component "inputSuggestions", { + bindings: + localNgModel: "=ngModel" + localNgModelOptions: "=?ngModelOptions" + getSuggestion: "&" + onBlur: "&?" + inputId: "@?" + inputName: "@?" + inputPlaceholder: "@?" + inputType: "@?" + inputRequired: "=?" + controller: inputSuggestionsController + template: [ + '