diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.js b/services/web/frontend/js/features/form-helpers/hydrate-form.js index 831dc54c54..7c153a548d 100644 --- a/services/web/frontend/js/features/form-helpers/hydrate-form.js +++ b/services/web/frontend/js/features/form-helpers/hydrate-form.js @@ -146,7 +146,7 @@ function formSentHelper(el) { }) } -function toggleDisplay(hide, show) { +export function toggleDisplay(hide, show) { hide.forEach(el => { el.hidden = true }) diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.js b/services/web/modules/launchpad/app/src/LaunchpadController.js index 008c252765..ab40ddcf77 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.js +++ b/services/web/modules/launchpad/app/src/LaunchpadController.js @@ -102,7 +102,9 @@ module.exports = LaunchpadController = { const { email } = req.body if (!email) { logger.log({}, 'no email address supplied') - return res.sendStatus(400) + return res.status(400).json({ + message: 'no email address supplied', + }) } logger.log({ email }, 'sending test email') const emailOptions = { to: email } @@ -114,7 +116,7 @@ module.exports = LaunchpadController = { return next(err) } logger.log({ email }, 'sent test email') - return res.sendStatus(201) + res.json({ message: res.locals.translate('email_sent') }) }) }, @@ -245,19 +247,11 @@ module.exports = LaunchpadController = { return next(err) } - AuthenticationController.setRedirectInSession(req, '/launchpad') logger.log( { email, user_id: user._id }, 'created first admin account' ) - return res.json({ - redir: '', - id: user._id.toString(), - first_name: user.first_name, - last_name: user.last_name, - email: user.email, - created: Date.now(), - }) + res.json({ redir: '/launchpad' }) } ) } diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index 5d0647c58f..9296c0b56a 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -1,24 +1,37 @@ -extends ../../../../app/views/layout +extends ../../../../app/views/layout-marketing + +mixin launchpad-check(section) + div(data-ol-launchpad-check=section) + span(data-ol-inflight="pending") + i.fa.fa-fw.fa-spinner.fa-spin + span  #{translate('checking')} + + span(hidden data-ol-inflight="idle") + div(data-ol-result="success") + i.fa.fa-check + span  #{translate('ok')} + button.btn.btn-inline-link + span.text-danger  #{translate('retry')} + div(hidden data-ol-result="error") + i.fa.fa-exclamation + span  #{translate('error')} + button.btn.btn-inline-link + span.text-danger  #{translate('retry')} + div.alert.alert-danger + span(data-ol-error) + +block vars + - entrypoint = 'modules/launchpad/pages/launchpad' + - metadata = metadata || {} block append meta meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists) meta(name="ol-ideJsPath" content=buildJsPath('ide.js')) - meta(name="ol-authMethod" content=authMethod) block content script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js') - style. - hr { margin-bottom: 5px; } - .status-check { - margin-bottom: 15px; - } - .alert { margin-top: 15px; margin-bottom: 15px; } - a small { cursor: pointer; color: #a93529; } - .launchpad-body img { width: 150px; margin-top: 15px; } - - - .content.content-alt(ng-controller="LaunchpadController") + .content.content-alt .container(ng-cloak) .row .col-md-8.col-md-offset-2 @@ -36,191 +49,133 @@ block content - .row(ng-if="shouldShowAdminForm()") - .col-md-12 - h2 #{translate('create_first_admin_account')} + if !adminUserExists + .row(data-ol-not-sent) + .col-md-12 + h2 #{translate('create_first_admin_account')} - // Local Auth Form - div(ng-if="authMethod == 'local'") - form(async-form="register", name="registerForm", on-success="onCreateAdminSuccess" - on-error="onCreateAdminError" - action="/launchpad/register_admin", method="POST", ng-cloak) - input(name='_csrf', type='hidden', value=csrfToken) - form-messages(for="registerForm") - .form-group - label(for='email') #{translate("email")} - input.form-control( - type='email', - name='email', - placeholder="email@example.com" - required, - ng-model="email", - ng-model-options="{ updateOn: 'blur' }", - autofocus="true" - ) - span.small.text-primary(ng-show="registerForm.email.$invalid && registerForm.email.$dirty") - | #{translate("must_be_email_address")} - .form-group - label(for='password') #{translate("password")} - input.form-control#passwordField( - type='password', - name='password', - placeholder="********", - required, - ng-model="password", - complex-password - ) - span.small.text-primary(ng-show="registerForm.password.$error.complexPassword", - ng-bind-html="complexPasswordErrorMessage") - .actions - button.btn-primary.btn( - type='submit' - ng-disabled="registerForm.inflight || registerForm.password.$error.required|| registerForm.password.$error.complexPassword || createAdminSuccess" - ) - span(ng-show="!registerForm.inflight") #{translate("register")} - span(ng-show="registerForm.inflight") #{translate("registering")}… + // Local Auth Form + if authMethod === 'local' + form( + data-ol-async-form + data-ol-register-admin + action="/launchpad/register_admin" + method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + div(data-ol-form-messages) + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + autofocus="true" + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password', + name='password', + placeholder="********", + required, + ) + .actions + button.btn-primary.btn( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… - // Ldap Form - div(ng-if="authMethod == 'ldap'") - h3 #{translate('ldap')} - p - | #{translate('ldap_create_admin_instructions')} + // Ldap Form + if authMethod === 'ldap' + h3 #{translate('ldap')} + p + | #{translate('ldap_create_admin_instructions')} - form(async-form="register", name="registerLdapForm", on-success="onCreateAdminSuccess" - on-error="onCreateAdminError" - action="/launchpad/register_ldap_admin", method="POST", ng-cloak) - input(name='_csrf', type='hidden', value=csrfToken) - form-messages(for="registerLdapForm") - .form-group - label(for='email') #{translate("email")} - input.form-control( - type='email', - name='email', - placeholder="email@example.com" - required, - ng-model="email", - ng-model-options="{ updateOn: 'blur' }", - autofocus="true" - ) - span.small.text-primary(ng-show="registerLdapForm.email.$invalid && registerLdapForm.email.$dirty") - | #{translate("must_be_email_address")} - .actions - button.btn-primary.btn( - type='submit' - ng-disabled="registerLdapForm.inflight || registerLdapForm.password.$error.required|| registerLdapForm.password.$error.complexPassword || createAdminSuccess" - ) - span(ng-show="!registerLdapForm.inflight") #{translate("register")} - span(ng-show="registerLdapForm.inflight") #{translate("registering")}… + form( + data-ol-async-form + data-ol-register-admin + action="/launchpad/register_ldap_admin" + method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + div(data-ol-form-messages) + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + autofocus="true" + ) + .actions + button.btn-primary.btn( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + // Saml Form + if authMethod === 'saml' + h3 #{translate('saml')} + p + | #{translate('saml_create_admin_instructions')} - // Saml Form - div(ng-if="authMethod == 'saml'") - h3 #{translate('saml')} - p - | #{translate('saml_create_admin_instructions')} + form( + data-ol-async-form + data-ol-register-admin + action="/launchpad/register_saml_admin" + method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + div(data-ol-form-messages) + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + required, + autofocus="true" + ) + .actions + button.btn-primary.btn( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… - form(async-form="register", name="registerSamlForm", on-success="onCreateAdminSuccess" - on-error="onCreateAdminError" - action="/launchpad/register_saml_admin", method="POST", ng-cloak) - input(name='_csrf', type='hidden', value=csrfToken) - form-messages(for="registerSamlForm") - .form-group - label(for='email') #{translate("email")} - input.form-control( - type='email', - name='email', - placeholder="email@example.com" - required, - ng-model="email", - ng-model-options="{ updateOn: 'blur' }", - autofocus="true" - ) - span.small.text-primary(ng-show="registerSamlForm.email.$invalid && registerSamlForm.email.$dirty") - | #{translate("must_be_email_address")} - .actions - button.btn-primary.btn( - type='submit' - ng-disabled="registerSamlForm.inflight || registerSamlForm.password.$error.required|| registerSamlForm.password.$error.complexPassword || createAdminSuccess" - ) - span(ng-show="!registerSamlForm.inflight") #{translate("register")} - span(ng-show="registerSamlForm.inflight") #{translate("registering")}… + br - - - .row(ng-if="createAdminSuccess") - .col-md-12.text-center - .alert.alert-success - | !{translate('admin_user_created_message', {link: '/login?redir=/launchpad'})} - - - .row(ng-if="createAdminError") - .col-md-12.text-center - .alert.alert-danger - | #{translate('generic_something_went_wrong')} - - br - - - div(ng-if="!shouldShowAdminForm()") + + if adminUserExists .row .col-md-12.status-indicators h2 #{translate('status_checks')} - .row.status-check + .row.row-spaced-small .col-sm-5 | #{translate('editor_resources')} .col-sm-7 - div(ng-switch="statusChecks.ideJs.status") - - span(ng-switch-when="inflight") - i.fa.fa-fw.fa-spinner.fa-spin - span  #{translate('checking')} - - span(ng-switch-when="ok") - i.fa.fa-check - span  #{translate('ok')} - a(ng-click="tryFetchIdeJs()") - small  #{translate('retry')} - - span(ng-switch-when="error") - i.fa.fa-exclamation - span  #{translate('error')} - a(ng-click="tryFetchIdeJs()") - small  #{translate('retry')} - div.alert.alert-danger - | {{ statusChecks.ideJs.error }} - + +launchpad-check('ide') - .row.status-check + .row.row-spaced-small .col-sm-5 | #{translate('websockets')} .col-sm-7 - div(ng-switch="statusChecks.websocket.status") - - span(ng-switch-when="inflight") - i.fa.fa-fw.fa-spinner.fa-spin - span  #{translate('checking')} - - span(ng-switch-when="ok") - i.fa.fa-check - span  #{translate('ok')} - a(ng-click="tryOpenWebSocket()") - small  #{translate('retry')} - - span(ng-switch-when="error") - i.fa.fa-exclamation - span  #{translate('error')} - a(ng-click="tryOpenWebSocket()") - small  #{translate('retry')} - div.alert.alert-danger - | {{ statusChecks.websocket.error }} - + +launchpad-check('websocket') - hr + hr.thin .row @@ -228,26 +183,33 @@ block content h2 #{translate('other_actions')} h3 #{translate('send_test_email')} - form.form + form.form( + data-ol-async-form + action="/launchpad/send_test_email" + method="POST" + ) .form-group - label(for="emailInput") Email - input(type="text", name="emailInput" ng-model="testEmail.emailAddress").form-control - button(ng-click="sendTestEmail()", ng-disabled="testEmail.inflight").btn.btn-primary - span(ng-show="!testEmail.inflight") #{translate("send")} - span(ng-show="testEmail.inflight") #{translate("sending")}… - div(ng-if="testEmail.status == 'ok'") - .alert.alert-success - | #{translate('email_sent')} - div(ng-if="testEmail.status == 'error'") - .alert.alert-danger - | #{translate('generic_something_went_wrong')} - + label(for="email") Email + input.form-control( + type="text" + id="email" + name="email" + required + ) + button.btn-primary.btn( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("send")} + span(hidden data-ol-inflight="pending") #{translate("sending")}… + p + div(data-ol-form-messages) - hr + hr.thin diff --git a/services/web/modules/launchpad/frontend/js/main/controllers/LaunchpadController.js b/services/web/modules/launchpad/frontend/js/main/controllers/LaunchpadController.js deleted file mode 100644 index c716a4adf3..0000000000 --- a/services/web/modules/launchpad/frontend/js/main/controllers/LaunchpadController.js +++ /dev/null @@ -1,157 +0,0 @@ -/* global io */ - -/* eslint-disable - max-len, - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -import App from '../../../../../../frontend/js/base' - -export default App.controller( - 'LaunchpadController', - function ($scope, $http, $timeout) { - $scope.adminUserExists = window.data.adminUserExists - $scope.ideJsPath = window.data.ideJsPath - $scope.authMethod = window.data.authMethod - - $scope.createAdminSuccess = null - $scope.createAdminError = null - - $scope.statusChecks = { - ideJs: { status: 'inflight', error: null }, - websocket: { status: 'inflight', error: null }, - healthCheck: { status: 'inflight', error: null }, - } - - $scope.testEmail = { - emailAddress: '', - inflight: false, - status: null, // | 'ok' | 'success' - } - - $scope.shouldShowAdminForm = () => !$scope.adminUserExists - - $scope.onCreateAdminSuccess = function (response) { - const { status } = response - if (status >= 200 && status < 300) { - return ($scope.createAdminSuccess = true) - } - } - - $scope.onCreateAdminError = () => ($scope.createAdminError = true) - - $scope.sendTestEmail = function () { - $scope.testEmail.inflight = true - $scope.testEmail.status = null - return $http - .post('/launchpad/send_test_email', { - email: $scope.testEmail.emailAddress, - _csrf: window.csrfToken, - }) - .then(function (response) { - const { status } = response - $scope.testEmail.inflight = false - if (status >= 200 && status < 300) { - return ($scope.testEmail.status = 'ok') - } - }) - .catch(function () { - $scope.testEmail.inflight = false - return ($scope.testEmail.status = 'error') - }) - } - - $scope.tryFetchIdeJs = function () { - $scope.statusChecks.ideJs.status = 'inflight' - return $timeout( - () => - $http - .get($scope.ideJsPath) - .then(function (response) { - const { status } = response - if (status >= 200 && status < 300) { - return ($scope.statusChecks.ideJs.status = 'ok') - } - }) - .catch(function (response) { - const { status } = response - $scope.statusChecks.ideJs.status = 'error' - return ($scope.statusChecks.ideJs.error = new Error( - `Http status: ${status}` - )) - }), - - 1000 - ) - } - - $scope.tryOpenWebSocket = function () { - $scope.statusChecks.websocket.status = 'inflight' - return $timeout(function () { - if (typeof io === 'undefined' || io === null) { - $scope.statusChecks.websocket.status = 'error' - $scope.statusChecks.websocket.error = 'socket.io not loaded' - return - } - const socket = io.connect(null, { - reconnect: false, - 'connect timeout': 30 * 1000, - 'force new connection': true, - }) - - socket.on('connectionAccepted', function () { - $scope.statusChecks.websocket.status = 'ok' - return $scope.$apply(function () {}) - }) - - socket.on('connectionRejected', function (err) { - $scope.statusChecks.websocket.status = 'error' - $scope.statusChecks.websocket.error = err - return $scope.$apply(function () {}) - }) - - return socket.on('connect_failed', function (err) { - $scope.statusChecks.websocket.status = 'error' - $scope.statusChecks.websocket.error = err - return $scope.$apply(function () {}) - }) - }, 1000) - } - - $scope.tryHealthCheck = function () { - $scope.statusChecks.healthCheck.status = 'inflight' - return $http - .get('/health_check') - .then(function (response) { - const { status } = response - if (status >= 200 && status < 300) { - return ($scope.statusChecks.healthCheck.status = 'ok') - } - }) - .catch(function (response) { - const { status } = response - $scope.statusChecks.healthCheck.status = 'error' - return ($scope.statusChecks.healthCheck.error = new Error( - `Http status: ${status}` - )) - }) - } - - $scope.runStatusChecks = function () { - $timeout(() => $scope.tryFetchIdeJs(), 1000) - return $timeout(() => $scope.tryOpenWebSocket(), 2000) - } - - // kick off the status checks on load - if ($scope.adminUserExists) { - return $scope.runStatusChecks() - } - } -) diff --git a/services/web/modules/launchpad/frontend/js/main/index.js b/services/web/modules/launchpad/frontend/js/main/index.js deleted file mode 100644 index ee560bf4d2..0000000000 --- a/services/web/modules/launchpad/frontend/js/main/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// TODO: This file was created by bulk-decaffeinate. -// Sanity-check the conversion and remove this comment. -import './controllers/LaunchpadController' diff --git a/services/web/modules/launchpad/frontend/js/pages/launchpad.js b/services/web/modules/launchpad/frontend/js/pages/launchpad.js new file mode 100644 index 0000000000..38f68e2dec --- /dev/null +++ b/services/web/modules/launchpad/frontend/js/pages/launchpad.js @@ -0,0 +1,88 @@ +/* global io */ +import '../../../../../frontend/js/marketing' +import { + inflightHelper, + toggleDisplay, +} from '../../../../../frontend/js/features/form-helpers/hydrate-form' +import getMeta from '../../../../../frontend/js/utils/meta' + +function setUpStatusIndicator(el, fn) { + inflightHelper(el) + + const displaySuccess = el.querySelectorAll('[data-ol-result="success"]') + const displayError = el.querySelectorAll('[data-ol-result="error"]') + + // The checks are very lightweight and do not appear to do anything + // from looking at the UI. Add an artificial delay of 1s to show that + // we are actually doing something. :) + const artificialProgressDelay = 1000 + + function run() { + setTimeout(() => { + fn() + .then(() => { + toggleDisplay(displayError, displaySuccess) + }) + .catch(error => { + el.querySelector('[data-ol-error]').textContent = error.message + toggleDisplay(displaySuccess, displayError) + }) + .finally(() => { + el.dispatchEvent(new Event('idle')) + }) + }, artificialProgressDelay) + } + + el.querySelectorAll('button').forEach(retryBtn => { + retryBtn.addEventListener('click', function (e) { + e.preventDefault() + el.dispatchEvent(new Event('pending')) + run() + }) + }) + + run() +} + +function setUpStatusIndicators() { + setUpStatusIndicator( + document.querySelector('[data-ol-launchpad-check="websocket"]'), + () => { + const timeout = 10 * 1000 + const socket = io.connect(null, { + reconnect: false, + 'connect timeout': timeout, + 'force new connection': true, + }) + return new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('timed out')), timeout) + socket.on('connectionAccepted', function () { + resolve() + }) + socket.on('connectionRejected', function (err) { + reject(new Error(err && err.message)) + }) + socket.on('connect_failed', function (err) { + reject(new Error(err && err.message)) + }) + }).finally(() => { + socket.disconnect() + }) + } + ) + + setUpStatusIndicator( + document.querySelector('[data-ol-launchpad-check="ide"]'), + () => { + return fetch(getMeta('ol-ideJsPath')).then(res => { + if (!res.ok) { + throw new Error(`Http status: ${res.status}`) + } + }) + } + ) +} + +if (getMeta('ol-adminUserExists')) { + setUpStatusIndicators() +} diff --git a/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.js b/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.js index 7145add869..5b1b1d053f 100644 --- a/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.js +++ b/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.js @@ -37,9 +37,7 @@ describe('Launchpad', function () { }, }) expect(postResponse.statusCode).to.equal(200) - expect(postResponse.body.redir).to.equal('') - expect(postResponse.body.email).to.equal(adminEmail) - expect(postResponse.body.id).to.exist + expect(postResponse.body).to.deep.equal({ redir: '/launchpad' }) // Try to load the page again const secondPageResponse = await user.request.get('/launchpad', { diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadControllerTests.js b/services/web/modules/launchpad/test/unit/src/LaunchpadControllerTests.js index 632c8788fa..18aed24770 100644 --- a/services/web/modules/launchpad/test/unit/src/LaunchpadControllerTests.js +++ b/services/web/modules/launchpad/test/unit/src/LaunchpadControllerTests.js @@ -53,9 +53,16 @@ describe('LaunchpadController', function () { this.res = { render: sinon.stub(), redirect: sinon.stub(), + json: sinon.stub(), send: sinon.stub(), sendStatus: sinon.stub(), + locals: { + translate(key) { + return key + }, + }, } + this.res.status = sinon.stub().returns(this.res) return (this.next = sinon.stub()) }) @@ -252,10 +259,13 @@ describe('LaunchpadController', function () { return (this.next = sinon.stub()) }) - it('should produce a 201 response', function () { + it('should produce a 200 response', function () { this.LaunchpadController.sendTestEmail(this.req, this.res, this.next) - this.res.sendStatus.callCount.should.equal(1) - return this.res.sendStatus.calledWith(201).should.equal(true) + this.res.json + .calledWith({ + message: 'email_sent', + }) + .should.equal(true) }) it('should not call next with an error', function () { @@ -292,8 +302,12 @@ describe('LaunchpadController', function () { it('should produce a 400 response', function () { this.LaunchpadController.sendTestEmail(this.req, this.res, this.next) - this.res.sendStatus.callCount.should.equal(1) - return this.res.sendStatus.calledWith(400).should.equal(true) + this.res.status.calledWith(400).should.equal(true) + this.res.json + .calledWith({ + message: 'no email address supplied', + }) + .should.equal(true) }) }) }) @@ -337,7 +351,7 @@ describe('LaunchpadController', function () { it('should send back a json response', function () { this.res.json.callCount.should.equal(1) - return expect(this.res.json.lastCall.args[0].email).to.equal(this.email) + expect(this.res.json).to.have.been.calledWith({ redir: '/launchpad' }) }) it('should have checked for existing admins', function () { @@ -365,15 +379,6 @@ describe('LaunchpadController', function () { ) .should.equal(true) }) - - it('should have set a redirect in session', function () { - this.AuthenticationController.setRedirectInSession.callCount.should.equal( - 1 - ) - return this.AuthenticationController.setRedirectInSession - .calledWith(this.req, '/launchpad') - .should.equal(true) - }) }) describe('when no email is supplied', function () { @@ -648,7 +653,7 @@ describe('LaunchpadController', function () { it('should send back a json response', function () { this.res.json.callCount.should.equal(1) - return expect(this.res.json.lastCall.args[0].email).to.equal(this.email) + expect(this.res.json).to.have.been.calledWith({ redir: '/launchpad' }) }) it('should have checked for existing admins', function () { @@ -675,15 +680,6 @@ describe('LaunchpadController', function () { ) .should.equal(true) }) - - it('should have set a redirect in session', function () { - this.AuthenticationController.setRedirectInSession.callCount.should.equal( - 1 - ) - return this.AuthenticationController.setRedirectInSession - .calledWith(this.req, '/launchpad') - .should.equal(true) - }) }) })