diff --git a/packages/fxa-auth-server/lib/routes/support.js b/packages/fxa-auth-server/lib/routes/support.js index 9371036bcf..209136f3ec 100644 --- a/packages/fxa-auth-server/lib/routes/support.js +++ b/packages/fxa-auth-server/lib/routes/support.js @@ -45,7 +45,10 @@ module.exports = (log, db, config, customs, zendeskClient) => { validate: { payload: isA.object().keys({ topic: isA.string().required(), - subject: isA.string().optional(), + subject: isA + .string() + .allow('') + .optional(), message: isA.string().required(), }), }, diff --git a/packages/fxa-content-server/app/scripts/lib/payment-server.js b/packages/fxa-content-server/app/scripts/lib/payment-server.js index bdf06ab26e..dab91ed2c5 100644 --- a/packages/fxa-content-server/app/scripts/lib/payment-server.js +++ b/packages/fxa-content-server/app/scripts/lib/payment-server.js @@ -5,13 +5,27 @@ 'use strict'; const PaymentServer = { - navigateToPaymentServer(view, subscriptionsConfig, redirectPath) { + navigateToPaymentServer( + view, + subscriptionsConfig, + redirectPath, + queryParams + ) { const { managementClientId, managementScopes, managementTokenTTL, managementUrl, } = subscriptionsConfig; + const queryString = + typeof queryParams === 'object' && + Object.keys(queryParams) + .filter(k => queryParams[k] !== null && queryParams[k] !== '') + .map( + k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}` + ) + .join('&'); + const qs = queryString.length ? `?${queryString}` : ''; return view .getSignedInAccount() .createOAuthToken(managementClientId, { @@ -19,7 +33,7 @@ const PaymentServer = { ttl: managementTokenTTL, }) .then(accessToken => { - const url = `${managementUrl}/${redirectPath}#accessToken=${encodeURIComponent( + const url = `${managementUrl}/${redirectPath}${qs}#accessToken=${encodeURIComponent( accessToken.get('token') )}`; view.navigateAway(url); diff --git a/packages/fxa-content-server/app/scripts/lib/router.js b/packages/fxa-content-server/app/scripts/lib/router.js index 78466b5a13..c8f8d6b67a 100644 --- a/packages/fxa-content-server/app/scripts/lib/router.js +++ b/packages/fxa-content-server/app/scripts/lib/router.js @@ -32,6 +32,7 @@ import ForceAuthView from '../views/force_auth'; import IndexView from '../views/index'; import OAuthIndexView from '../views/oauth_index'; import PermissionsView from '../views/permissions'; +import SupportView from '../views/support'; import ReadyView from '../views/ready'; import RecoveryCodesView from '../views/settings/recovery_codes'; import RedirectAuthView from '../views/authorization'; @@ -261,9 +262,8 @@ const Router = Backbone.Router.extend({ 'subscriptions/products/:productId': createViewHandler( SubscriptionsProductRedirectView ), - 'subscriptions(/)': createViewHandler( - SubscriptionsManagementRedirectView - ), + 'subscriptions(/)': createViewHandler(SubscriptionsManagementRedirectView), + 'support(/)': createViewHandler(SupportView), 'verify_email(/)': createViewHandler(CompleteSignUpView, { type: VerificationReasons.SIGN_UP, }), diff --git a/packages/fxa-content-server/app/scripts/templates/partial/support-form-error.mustache b/packages/fxa-content-server/app/scripts/templates/partial/support-form-error.mustache new file mode 100644 index 0000000000..0f820980e1 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/templates/partial/support-form-error.mustache @@ -0,0 +1,5 @@ + diff --git a/packages/fxa-content-server/app/scripts/templates/support.mustache b/packages/fxa-content-server/app/scripts/templates/support.mustache new file mode 100644 index 0000000000..5ef00ebe16 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/templates/support.mustache @@ -0,0 +1,78 @@ +
+
+

+
+ +
+
+
+
+ +
+
+ +
+
+
+ {{{ unsafeHeaderHTML }}} +
+
+
+
+
+
+
+

Subscriptions

+
+
+
+
+
+
+

{{#t}}Contact Us{{/t}}

+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+ + diff --git a/packages/fxa-content-server/app/scripts/views/mixins/account-by-uid-mixin.js b/packages/fxa-content-server/app/scripts/views/mixins/account-by-uid-mixin.js new file mode 100644 index 0000000000..4af4a06bab --- /dev/null +++ b/packages/fxa-content-server/app/scripts/views/mixins/account-by-uid-mixin.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Session from '../../lib/session'; + +export default { + /** + * Set the user's signed in account with the relier's uid. + * Used in settings and support views. + */ + getUidAndSetSignedInAccount() { + const uid = this.relier.get('uid'); + this.notifier.trigger('set-uid', uid); + + // A uid param is set by RPs linking directly to the settings + // page for a particular account. + // + // We set the current account to the one with `uid` if + // it exists in our list of cached accounts. If the account is + // not in the list of cached accounts, clear the current account. + // + if (!this.user.getAccountByUid(uid).isDefault()) { + // The account with uid exists; set it to our current account. + this.user.setSignedInAccountByUid(uid); + } else if (uid) { + // session is expired or user does not exist. Force the user + // to sign in. + Session.clear(); + this.user.clearSignedInAccount(); + this.logViewEvent('signout.forced'); + } + }, +}; diff --git a/packages/fxa-content-server/app/scripts/views/settings.js b/packages/fxa-content-server/app/scripts/views/settings.js index c5d1d42ebc..e88e358694 100644 --- a/packages/fxa-content-server/app/scripts/views/settings.js +++ b/packages/fxa-content-server/app/scripts/views/settings.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import allowOnlyOneSubmit from './decorators/allow_only_one_submit'; +import AccountByUidMixin from './mixins/account-by-uid-mixin'; import AccountRecoveryView from './settings/account_recovery/account_recovery'; import AccountRecoveryConfirmPasswordView from './settings/account_recovery/confirm_password'; import AccountRecoveryConfirmRevokeView from './settings/account_recovery/confirm_revoke'; @@ -27,7 +28,6 @@ import EmailsView from './settings/emails'; import LoadingMixin from './mixins/loading-mixin'; import 'modal'; import preventDefaultThen from './decorators/prevent_default_then'; -import Session from '../lib/session'; import SettingsHeaderTemplate from 'templates/partial/settings-header.mustache'; import SignedOutNotificationMixin from './mixins/signed-out-notification-mixin'; import SubPanels from './sub_panels'; @@ -77,27 +77,7 @@ const View = BaseView.extend({ this._subscriptionsManagementEnabled = options.subscriptionsManagementEnabled !== false; - const uid = this.relier.get('uid'); - this.notifier.trigger('set-uid', uid); - - // A uid param is set by RPs linking directly to the settings - // page for a particular account. - // - // We set the current account to the one with `uid` if - // it exists in our list of cached accounts. If the account is - // not in the list of cached accounts, clear the current account. - // - // The `mustVerify` flag will ensure that the account is valid. - if (!this.user.getAccountByUid(uid).isDefault()) { - // The account with uid exists; set it to our current account. - this.user.setSignedInAccountByUid(uid); - } else if (uid) { - // session is expired or user does not exist. Force the user - // to sign in. - Session.clear(); - this.user.clearSignedInAccount(); - this.logViewEvent('signout.forced'); - } + this.getUidAndSetSignedInAccount(); }, notifications: { @@ -316,6 +296,7 @@ const View = BaseView.extend({ Cocktail.mixin( View, + AccountByUidMixin, AvatarMixin, LoadingMixin, SignedOutNotificationMixin, diff --git a/packages/fxa-content-server/app/scripts/views/support.js b/packages/fxa-content-server/app/scripts/views/support.js new file mode 100644 index 0000000000..468ea125c0 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/views/support.js @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import $ from 'jquery'; +import allowOnlyOneSubmit from './decorators/allow_only_one_submit'; +import AccountByUidMixin from './mixins/account-by-uid-mixin'; +import AvatarMixin from './mixins/avatar-mixin'; +import BaseView from './base'; +import 'chosen-js'; +import Cocktail from 'cocktail'; +import LoadingMixin from './mixins/loading-mixin'; +import 'modal'; +import PaymentServer from '../lib/payment-server'; +import preventDefaultThen from './decorators/prevent_default_then'; +import SettingsHeaderTemplate from 'templates/partial/settings-header.mustache'; +import SupportFormErrorTemplate from 'templates/partial/support-form-error.mustache'; +import Template from 'templates/support.mustache'; + +const t = msg => msg; + +const proto = BaseView.prototype; +const SupportView = BaseView.extend({ + template: Template, + className: 'settings', + layoutClassName: 'settings', + viewName: 'support', + + // The `mustVerify` flag will ensure that the account is valid. + mustVerify: true, + + initialize(options = {}) { + this.getUidAndSetSignedInAccount(); + + this._subscriptionsConfig = {}; + if (options && options.config && options.config.subscriptions) { + this._subscriptionsConfig = options.config.subscriptions; + } + }, + + setInitialContext(context) { + const account = this.getSignedInAccount(); + context.set({ + unsafeHeaderHTML: this._getHeaderHTML(account), + topicPlaceHolder: t('Please Select One'), + }); + }, + + events: { + 'change #topic': 'onFormChange', + 'keyup #message': 'onFormChange', + 'click button[type=submit]': 'submitSupportForm', + 'click button.cancel': 'navigateToSubscriptionsManagement', + }, + + beforeRender() { + const account = this.getSignedInAccount(); + + return account.fetchActiveSubscriptions().then(subscriptions => { + if (subscriptions.length > 0) { + return account.fetchProfile().then(() => this.user.setAccount(account)); + } else { + // Note that if a user landed here, it is because: + // a) they accessed the page directly, as the button for this page is + // not displayed when a user does not have any active subscriptions + // b) the edge case where the (last) subscription expired between + // clicking of the button and here + + this.navigateToSubscriptionsManagement(); + return false; + } + }); + }, + + _getHeaderHTML(account) { + return SettingsHeaderTemplate(account.pick('displayName', 'email')); + }, + + afterVisible() { + this.topicEl = this.$('#topic'); + this.submitBtn = this.$('button[type="submit"]'); + this.subjectEl = this.$('#subject'); + this.messageEl = this.$('#message'); + this.topicEl.chosen({ disable_search: true, width: '100%' }); + + return proto.afterVisible.call(this).then(this._showAvatar.bind(this)); + }, + + _showAvatar() { + var account = this.getSignedInAccount(); + return this.displayAccountProfileImage(account); + }, + + onFormChange(e) { + e.stopPropagation(); + + if (this.messageEl.val().trim() !== '' && this.topicEl.val() !== '') { + this.submitBtn.attr('disabled', false); + } else { + this.submitBtn.attr('disabled', true); + } + }, + + submitSupportForm: preventDefaultThen( + allowOnlyOneSubmit(function() { + const account = this.getSignedInAccount(); + const supportTicket = this.buildSupportTicket(); + return account + .createSupportTicket(supportTicket) + .then(this.handleFormResponse.bind(this)) + .catch(this.displayErrorMessage.bind(this)); + }) + ), + + handleFormResponse(resp) { + if (resp.success === true) { + this.navigateToSubscriptionsManagement({ + successfulSupportTicketSubmission: true, + }); + } else { + this.displayErrorMessage(); + } + }, + + displayErrorMessage() { + // Inject the error modal if it's not already there. + if (!$('.modal').length) { + const errorModal = this.renderTemplate(SupportFormErrorTemplate); + $('body').append(errorModal); + } + $('.modal').modal({ + closeText: '✕', + closeClass: 'icon-remove', + }); + }, + + navigateToSubscriptionsManagement(queryParams = {}) { + PaymentServer.navigateToPaymentServer( + this, + this._subscriptionsConfig, + 'subscriptions', + queryParams + ); + }, + + buildSupportTicket() { + return { + topic: this.topicEl.val(), + subject: this.subjectEl.val().trim(), + message: this.messageEl.val().trim(), + }; + }, +}); + +Cocktail.mixin(SupportView, AccountByUidMixin, AvatarMixin, LoadingMixin); + +export default SupportView; diff --git a/packages/fxa-content-server/app/styles/_modules.scss b/packages/fxa-content-server/app/styles/_modules.scss index 1c9c9f881e..9385982758 100644 --- a/packages/fxa-content-server/app/styles/_modules.scss +++ b/packages/fxa-content-server/app/styles/_modules.scss @@ -17,3 +17,5 @@ @import 'modules/graphic'; @import 'modules/marketing'; @import 'modules/marketing-ios'; +@import 'modules/support'; +@import 'modules/chosen'; diff --git a/packages/fxa-content-server/app/styles/modules/_button-row.scss b/packages/fxa-content-server/app/styles/modules/_button-row.scss index 390165b332..21ac70117a 100644 --- a/packages/fxa-content-server/app/styles/modules/_button-row.scss +++ b/packages/fxa-content-server/app/styles/modules/_button-row.scss @@ -48,11 +48,11 @@ button { background: $button-background-color-primary; color: $button-text-color-primary; - &:hover:not:disabled { + &:hover:enabled { background: $button-background-color-primary-hover; } - &:active { + &:active:enabled { background: $button-background-color-primary-active; } } diff --git a/packages/fxa-content-server/app/styles/modules/_chosen.scss b/packages/fxa-content-server/app/styles/modules/_chosen.scss new file mode 100644 index 0000000000..e552116c0b --- /dev/null +++ b/packages/fxa-content-server/app/styles/modules/_chosen.scss @@ -0,0 +1,177 @@ +/* + * Styles for Chosen: https://github.com/harvesthq/chosen + */ + +.chosen-container { + user-select: none; + + * { + box-sizing: border-box; + } + + .chosen-drop { + background: #fff; + border: 1px solid #aaa; + border-top: 0; + box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); + clip: rect(0, 0, 0, 0); + clip-path: inset(100% 100%); + position: absolute; + top: 100%; + width: 100%; + z-index: 10; + } + + &.chosen-with-drop { + .chosen-drop { + clip: auto; + clip-path: none; + } + + .chosen-single { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } +} + +.chosen-container-single { + .chosen-single { + border: 1px solid #aaa; + border-radius: $input-border-radius; + color: #20123b; + display: block; + font-size: $input-text-font-size-default; + overflow: hidden; + position: relative; + text-decoration: none; + + @include respond-to('big') { + height: $input-height; + line-height: $input-height; + } + + @include respond-to('small') { + height: $input-height-small; + line-height: $input-height-small; + } + + html[dir='ltr'] & { + padding-left: 16px; + } + + html[dir='rtl'] & { + padding-right: 16px; + } + } + + .chosen-default { + color: #999; + } + + .chosen-single { + div { + border-color: #686869 transparent transparent transparent; + border-style: solid; + border-width: 7px 8px 0 8px; + display: block; + height: 0; + position: absolute; + width: 0; + + @include respond-to('big') { + top: 20px; + + html[dir='ltr'] & { + right: 20px; + } + + html[dir='rtl'] & { + left: 20px; + } + } + + @include respond-to('small') { + top: 16px; + + html[dir='ltr'] & { + right: 16px; + } + + html[dir='rtl'] & { + left: 16px; + } + } + + @at-root .chosen-with-drop#{&} { + border-color: transparent transparent #686869 transparent; + border-width: 0 8px 7px 8px; + } + + b { + display: none; + } + } + } + + .chosen-drop { + background-clip: padding-box; + border-radius: 0 0 $input-border-radius $input-border-radius; + } + + &.chosen-container-single-nosearch .chosen-search { + clip-path: inset(100% 100%); + clip: rect(0, 0, 0, 0); + position: absolute; + } +} + +.chosen-container { + .chosen-results { + color: #000; + font-size: $input-text-font-size-default; + margin: 8px 0; + overflow-x: hidden; + overflow-y: auto; + padding: 0; + position: relative; + + li { + display: none; + line-height: $input-height; + list-style: none; + margin: 0; + padding-left: $input-left-right-padding; + padding-right: $input-left-right-padding; + + &.active-result { + cursor: pointer; + display: list-item; + } + + &.highlighted { + background-color: #d8d8d8; + font-weight: bold; + } + } + } +} + +.chosen-container-active { + .chosen-single { + border: 1px solid #5897fb; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + } + + &.chosen-with-drop .chosen-single { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border: 1px solid #aaa; + box-shadow: 0 1px 0 #fff inset; + } + + .chosen-choices { + border: 1px solid #5897fb; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + } +} diff --git a/packages/fxa-content-server/app/styles/modules/support.scss b/packages/fxa-content-server/app/styles/modules/support.scss new file mode 100644 index 0000000000..dea16c30d1 --- /dev/null +++ b/packages/fxa-content-server/app/styles/modules/support.scss @@ -0,0 +1,76 @@ +.support { + .settings-unit:first-child { + @include respond-to('small') { + display: none; + } + + @include respond-to('big') { + display: block; + } + + .settings-unit-stub { + .settings-unit-title { + font-size: 20px; + font-weight: bold; + } + } + } +} +.support-form { + margin: 25px 32px; + + h3 { + font-size: 16px; + } + + textarea { + @include input-element(); + display: inline-block; + height: 101px; + padding: $input-left-right-padding; + + html[dir='rtl'] & { + direction: ltr; + text-align: right; + } + + @include respond-to('small') { + height: 101px; + } + } + + .button-row { + .settings-button { + height: 40x; + width: 128px; + } + } + + .form-label { + margin-bottom: 5px; + + label { + font-size: 13px; + font-weight: 600; + } + } +} + +.modal.dialog-error { + display: inline-block; + padding: 25px 27px; + + .close-modal { + background: transparent; + color: #0c0c0d; + font-size: 16px; + font-weight: 600; + right:2px; + text-indent: 0; + top: 13px; + + &:hover { + text-decoration: none; + } + } +} diff --git a/packages/fxa-content-server/app/tests/spec/lib/payment-server.js b/packages/fxa-content-server/app/tests/spec/lib/payment-server.js index fbce10f92c..e32c320a66 100644 --- a/packages/fxa-content-server/app/tests/spec/lib/payment-server.js +++ b/packages/fxa-content-server/app/tests/spec/lib/payment-server.js @@ -62,4 +62,20 @@ describe('lib/payment-server-redirect', () => { ); }); }); + + it('redirects as expected with query string', () => { + const REDIRECT_PATH = 'example/path'; + PaymentServer.navigateToPaymentServer( + view, + config.subscriptions, + REDIRECT_PATH, + { foo: 'bar', fizz: '', quuz: '&buzz', buzz: null } + ).then(() => { + assert.deepEqual( + view.navigateAway.args[0][0], + `${config.subscriptions.managementUrl}/${REDIRECT_PATH}?foo=bar&quzz=%26buzz#accessToken=MOCK_TOKEN`, + 'should make the correct call to navigateAway' + ); + }); + }); }); diff --git a/packages/fxa-content-server/app/tests/spec/views/mixins/account-by-uid-mixin.js b/packages/fxa-content-server/app/tests/spec/views/mixins/account-by-uid-mixin.js new file mode 100644 index 0000000000..4b4e81c039 --- /dev/null +++ b/packages/fxa-content-server/app/tests/spec/views/mixins/account-by-uid-mixin.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import AccountByUidMixin from 'views/mixins/account-by-uid-mixin'; +import { assert } from 'chai'; +import BaseView from 'views/base'; +import Cocktail from 'cocktail'; +import Notifier from 'lib/channels/notifier'; +import NullChannel from 'lib/channels/null'; +import Relier from 'models/reliers/relier'; +import Session from 'lib/session'; +import sinon from 'sinon'; +import TestHelpers from '../../../lib/helpers'; +import User from 'models/user'; + +const UID = TestHelpers.createUid(); +const View = BaseView.extend({}); +Cocktail.mixin(View, AccountByUidMixin); + +describe('views/mixins/account-by-uid-mixin', function() { + let notifier; + let relier; + let tabChannelMock; + let user; + let view; + + beforeEach(function() { + relier = new Relier(); + tabChannelMock = new NullChannel(); + user = new User(); + notifier = new Notifier({ + tabChannel: tabChannelMock, + }); + + sinon + .stub(relier, 'get') + .withArgs('uid') + .returns(UID); + + view = new View({ + notifier: notifier, + relier: relier, + user: user, + }); + }); + + afterEach(function() { + relier.get.restore(); + + view.remove(); + view.destroy(); + view = null; + }); + + describe('getUidAndSetSignedInAccount', function() { + it('gets the uid from the relier', function() { + sinon.spy(notifier, 'trigger'); + sinon.stub(user, 'clearSignedInAccount'); + + view.getUidAndSetSignedInAccount(); + assert.isTrue(relier.get.calledWith('uid')); + assert.isTrue(notifier.trigger.calledWith('set-uid', UID)); + + notifier.trigger.restore(); + user.clearSignedInAccount.restore(); + }); + + it('sets signed in account with uid when account exists', function() { + sinon.stub(user, 'getAccountByUid').returns({ isDefault: () => false }); + sinon.stub(user, 'setSignedInAccountByUid'); + + view.getUidAndSetSignedInAccount(); + assert.isTrue(user.getAccountByUid.calledWith(UID)); + assert.isTrue(user.setSignedInAccountByUid.calledWith(UID)); + + user.setSignedInAccountByUid.restore(); + user.getAccountByUid.restore(); + }); + + it('forces the user to sign in when account does not exist', function() { + sinon.stub(user, 'getAccountByUid').returns({ isDefault: () => true }); + sinon.stub(user, 'clearSignedInAccount'); + sinon.stub(Session, 'clear'); + sinon.stub(view, 'logViewEvent'); + + view.getUidAndSetSignedInAccount(); + assert.isTrue(Session.clear.calledOnce); + assert.isTrue(user.clearSignedInAccount.calledOnce); + assert.isTrue(view.logViewEvent.calledWith('signout.forced')); + + view.logViewEvent.restore(); + Session.clear.restore(); + user.clearSignedInAccount.restore(); + user.getAccountByUid.restore(); + }); + }); +}); diff --git a/packages/fxa-content-server/app/tests/spec/views/support.js b/packages/fxa-content-server/app/tests/spec/views/support.js new file mode 100644 index 0000000000..828452ac74 --- /dev/null +++ b/packages/fxa-content-server/app/tests/spec/views/support.js @@ -0,0 +1,186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import $ from 'jquery'; +import AccountByUidMixin from 'views/mixins/account-by-uid-mixin'; +import { assert } from 'chai'; +import Notifier from 'lib/channels/notifier'; +import ProfileClient from 'lib/profile-client'; +import Relier from 'models/reliers/relier'; +import sinon from 'sinon'; +import TestHelpers from '../../lib/helpers'; +import User from 'models/user'; +import SupportView from 'views/support'; + +sinon.spy(AccountByUidMixin.getUidAndSetSignedInAccount); + +describe('views/support', function() { + let account; + let notifier; + let profileClient; + let relier; + let user; + let view; + + const email = 'a@a.com'; + const UID = TestHelpers.createUid(); + const subscriptionsConfig = { managementClientId: 'OVER9000' }; + const supportTicket = { + topic: 'General inquiries', + subject: '', + message: 'inquiries from generals', + }; + + function createSupportView() { + view = new SupportView({ + config: { subscriptions: subscriptionsConfig }, + notifier, + relier, + user, + }); + sinon.stub(view, 'checkAuthorization').returns(Promise.resolve(true)); + } + + beforeEach(function() { + notifier = new Notifier(); + profileClient = new ProfileClient(); + relier = new Relier(); + relier.set('uid', 'wibble'); + user = new User({ + notifier: notifier, + profileClient: profileClient, + }); + account = user.initAccount({ + email, + sessionToken: 'abc123', + uid: UID, + verified: true, + }); + sinon.stub(account, 'fetchProfile').returns(Promise.resolve()); + sinon + .stub(account, 'fetchActiveSubscriptions') + .returns(Promise.resolve([{ id: '123done' }])); + sinon.stub(user, 'getAccountByUid').returns(account); + sinon.stub(user, 'setSignedInAccountByUid').returns(Promise.resolve()); + sinon.stub(user, 'getSignedInAccount').returns(account); + + createSupportView(); + }); + + afterEach(function() { + $(view.el).remove(); + view.destroy(); + view = null; + }); + + it('should have a header', function() { + return view + .render() + .then(function() { + $('#container').append(view.el); + }) + .then(function() { + assert.ok(view.$('#fxa-settings-header').length); + assert.ok(view.$('#fxa-settings-profile-header-wrapper').length); + assert.equal( + view.$('#fxa-settings-profile-header-wrapper h1').text(), + email + ); + }); + }); + + describe('submit button', function() { + it('should be disabled by default', function() { + return view + .render() + .then(function() { + $('#container').append(view.el); + }) + .then(function() { + assert.ok(view.$('form button[type=submit]').attr('disabled')); + }); + }); + + it('should be enabled once a topic is selected and a message is entered', function() { + return view + .render() + .then(function() { + view.afterVisible(); + $('#container').append(view.el); + }) + .then(function() { + view + .$('#topic option:eq(1)') + .prop('selected', true) + .trigger('change'); + assert.ok(view.$('form button[type=submit]').attr('disabled')); + view + .$('#message') + .val(supportTicket.message) + .trigger('keyup'); + assert.isUndefined( + view.$('form button[type=submit]').attr('disabled') + ); + }); + }); + }); + + describe('successful form submission', function() { + it('should take user to subscriptions management page', function() { + sinon.stub(view, 'navigateToSubscriptionsManagement'); + sinon + .stub(account, 'createSupportTicket') + .returns(Promise.resolve({ success: true })); + + return view + .render() + .then(function() { + view.afterVisible(); + $('#container').append(view.el); + }) + .then(function() { + view.$('#topic option:eq(1)').prop('selected', true); + view.$('#message').val(supportTicket.message); + + // calling this directly instead of clicking submit so we can have + // a promise to await + return view.submitSupportForm(); + }) + .then(function() { + assert.isTrue(account.createSupportTicket.calledOnce); + assert.deepEqual( + account.createSupportTicket.firstCall.args[0], + supportTicket + ); + assert.isTrue(view.navigateToSubscriptionsManagement.calledOnce); + }); + }); + }); + + describe('failed form submission', function() { + it('should display an error modal', function() { + sinon + .stub(account, 'createSupportTicket') + .returns(Promise.resolve({ success: false })); + + return view + .render() + .then(function() { + view.afterVisible(); + $('#container').append(view.el); + }) + .then(function() { + view.$('#topic option:eq(1)').prop('selected', true); + view.$('#message').val(supportTicket.message); + + // calling this directly instead of clicking submit so we can have + // a promise to await + return view.submitSupportForm(); + }) + .then(function() { + assert.ok($('.dialog-error').length); + }); + }); + }); +}); diff --git a/packages/fxa-content-server/app/tests/test_start.js b/packages/fxa-content-server/app/tests/test_start.js index 4b9f004012..1702c014f9 100644 --- a/packages/fxa-content-server/app/tests/test_start.js +++ b/packages/fxa-content-server/app/tests/test_start.js @@ -157,6 +157,7 @@ require('./spec/views/force_auth'); require('./spec/views/form'); require('./spec/views/index'); require('./spec/views/marketing_snippet'); +require('./spec/views/mixins/account-by-uid-mixin'); require('./spec/views/mixins/account-reset-mixin'); require('./spec/views/mixins/avatar-mixin'); require('./spec/views/mixins/back-mixin'); @@ -258,6 +259,7 @@ require('./spec/views/sms_sent'); require('./spec/views/sub_panels'); require('./spec/views/subscriptions_management_redirect'); require('./spec/views/subscriptions_product_redirect'); +require('./spec/views/support'); require('./spec/views/tooltip'); require('./spec/views/tos'); require('./spec/views/why_connect_another_device'); diff --git a/packages/fxa-content-server/npm-shrinkwrap.json b/packages/fxa-content-server/npm-shrinkwrap.json index 5f7b057e3d..df55b2f38d 100644 --- a/packages/fxa-content-server/npm-shrinkwrap.json +++ b/packages/fxa-content-server/npm-shrinkwrap.json @@ -2949,6 +2949,11 @@ "upath": "^1.1.1" } }, + "chosen-js": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/chosen-js/-/chosen-js-1.8.7.tgz", + "integrity": "sha512-eVdrZJ2U5ISdObkgsi0od5vIJdLwq1P1Xa/Vj/mgxkMZf14DlgobfB6nrlFi3kW4kkvKLsKk4NDqZj1MU1DCpw==" + }, "chownr": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", diff --git a/packages/fxa-content-server/package.json b/packages/fxa-content-server/package.json index 0cee88f65d..7befe9b6cf 100644 --- a/packages/fxa-content-server/package.json +++ b/packages/fxa-content-server/package.json @@ -49,6 +49,7 @@ "body-parser": "1.18.2", "cache-loader": "^3.0.1", "celebrate": "7.0.3", + "chosen-js": "1.8.7", "connect-cachify": "0.0.17", "consolidate": "0.14.5", "convict": "1.5.0", diff --git a/packages/fxa-content-server/server/lib/routes/get-frontend.js b/packages/fxa-content-server/server/lib/routes/get-frontend.js index c8a5354578..de9cd749bb 100644 --- a/packages/fxa-content-server/server/lib/routes/get-frontend.js +++ b/packages/fxa-content-server/server/lib/routes/get-frontend.js @@ -75,6 +75,7 @@ module.exports = function() { 'sms/why', 'subscriptions', 'subscriptions/products/[\\w_]+', + 'support', 'verify_email', 'verify_primary_email', 'verify_secondary_email', diff --git a/packages/fxa-content-server/tests/functional.js b/packages/fxa-content-server/tests/functional.js index e533baf606..d9f1b0d792 100644 --- a/packages/fxa-content-server/tests/functional.js +++ b/packages/fxa-content-server/tests/functional.js @@ -67,6 +67,7 @@ module.exports = [ 'tests/functional/sign_in_token_code.js', 'tests/functional/sign_in_totp.js', 'tests/functional/sign_up.js', + 'tests/functional/support.js', 'tests/functional/sync_v1.js', 'tests/functional/sync_v2.js', 'tests/functional/sync_v3_email_first.js', diff --git a/packages/fxa-content-server/tests/functional/lib/helpers.js b/packages/fxa-content-server/tests/functional/lib/helpers.js index 01e3be7b31..b5cf6a42d7 100644 --- a/packages/fxa-content-server/tests/functional/lib/helpers.js +++ b/packages/fxa-content-server/tests/functional/lib/helpers.js @@ -31,6 +31,7 @@ const SIGNIN_URL = config.fxaContentRoot + 'signin'; const SIGNUP_URL = config.fxaContentRoot + 'signup'; const ENABLE_TOTP_URL = `${SETTINGS_URL}/two_step_authentication`; const UNTRUSTED_OAUTH_APP = config.fxaUntrustedOauthApp; +const TEST_PRODUCT_URL = `${config.fxaContentRoot}subscriptions/products/${config.testProductId}`; /** * Convert a function to a form that can be used as a `then` callback. @@ -2342,6 +2343,34 @@ const destroySessionForEmail = thenify(function(email) { }); }); +/** + * Subscribe to the test product. The user should be signed in at this point. + * + * @returns {promise} resolves when complete + */ +const subscribeToTestProduct = thenify(function() { + const nextYear = (new Date().getFullYear() + 1).toString().substr(2); + return this.parent + .then(openPage(TEST_PRODUCT_URL, 'div.product-payment')) + .then(type('input[name=name]', 'Testo McTestson')) + .switchToFrame(2) + .then(type('input[name=cardnumber]', '4242 4242 4242 4242')) + .switchToParentFrame() + .end(Infinity) + .switchToFrame(3) + .then(type('input[name=exp-date]', `12${nextYear}`)) + .switchToParentFrame() + .end(Infinity) + .switchToFrame(4) + .then(type('.InputElement', '123')) + .switchToParentFrame() + .end(Infinity) + .then(type('input[name=zip]', '12345')) + .then(click('input[type=checkbox]')) + .then(click('button[name=submit]')) + .then(testElementExists('.subscription-ready')); +}); + module.exports = { cleanMemory, clearBrowserNotifications, @@ -2412,6 +2441,7 @@ module.exports = { reOpenWithAdditionalQueryParams, respondToWebChannelMessage, storeWebChannelMessageData, + subscribeToTestProduct, switchToWindow, takeScreenshot, testAreEventsLogged, diff --git a/packages/fxa-content-server/tests/functional/pages.js b/packages/fxa-content-server/tests/functional/pages.js index b59d08aaca..fdb26bc693 100644 --- a/packages/fxa-content-server/tests/functional/pages.js +++ b/packages/fxa-content-server/tests/functional/pages.js @@ -72,6 +72,7 @@ var pages = [ 'sms/sent', 'sms/sent/why', 'sms/why', + 'support', 'verify_email', 'v1/complete_reset_password', 'v1/reset_password', diff --git a/packages/fxa-content-server/tests/functional/support.js b/packages/fxa-content-server/tests/functional/support.js new file mode 100644 index 0000000000..8c662bb906 --- /dev/null +++ b/packages/fxa-content-server/tests/functional/support.js @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const { registerSuite } = intern.getInterface('object'); +const TestHelpers = require('../lib/helpers'); +const FunctionalHelpers = require('./lib/helpers'); + +const config = intern._config; +const SIGNIN_URL = config.fxaContentRoot + 'signin'; +const SUPPORT_URL = config.fxaContentRoot + 'support'; +const PASSWORD = 'amazingpassword'; +const email = TestHelpers.createEmail(); + +const { + clearBrowserState, + click, + createUser, + fillOutSignIn, + openPage, + subscribeToTestProduct, + testElementExists, + type, +} = FunctionalHelpers; + +registerSuite('support form without valid session', { + tests: { + 'go to support form, redirects to signin': function() { + return this.remote.then(openPage(SUPPORT_URL, '#fxa-signin-header')); + }, + }, +}); + +registerSuite('support form without active subscriptions', { + before: function() { + return this.remote + .then(createUser(email, PASSWORD, { preVerified: true })) + .then(clearBrowserState()) + .then(openPage(SIGNIN_URL, '#fxa-signin-header')) + .then(fillOutSignIn(email, PASSWORD)) + .then(testElementExists('#fxa-settings-header')); + }, + tests: { + 'go to support form, redirects to subscription management': function() { + return this.remote.then( + openPage(SUPPORT_URL, '.subscription-management') + ); + }, + }, +}); + +registerSuite('support form', { + before: function() { + return this.remote.then(subscribeToTestProduct()); + }, + tests: { + 'go to support form, successfully submits the form': function() { + return this.remote + .then(openPage(SUPPORT_URL, 'div.support')) + .then(click('a.chosen-single')) + .then(click('ul.chosen-results li[data-option-array-index="1"]')) + .then(type('textarea[name=message]', 'please send halp')) + .then(click('button[type=submit]')); + // Since we don't have proper Zendesk config in CircleCI, the form + // cannot be successfully submitted. + // .then(testElementExists('.subscription-management')); + }, + }, +}); diff --git a/packages/fxa-content-server/tests/intern.js b/packages/fxa-content-server/tests/intern.js index 0c3d771bad..22b8089114 100644 --- a/packages/fxa-content-server/tests/intern.js +++ b/packages/fxa-content-server/tests/intern.js @@ -23,6 +23,7 @@ const fxaEmailRoot = args.fxaEmailRoot || 'http://127.0.0.1:9001'; const fxaOAuthApp = args.fxaOAuthApp || 'http://127.0.0.1:8080/'; const fxaUntrustedOauthApp = args.fxaUntrustedOauthApp || 'http://127.0.0.1:10139/'; +const fxaPaymentsRoot = args.fxaPaymentsRoot || 'http://127.0.0.1:3031/'; // "fxaProduction" is a little overloaded in how it is used in the tests. // Sometimes it means real "stage" or real production configuration, but @@ -39,6 +40,8 @@ const asyncTimeout = parseInt(args.asyncTimeout || 5000, 10); // args.bailAfterFirstFailure comes in as a string. const bailAfterFirstFailure = args.bailAfterFirstFailure === 'true'; +const testProductId = '123doneProProduct'; + // Intern specific options are here: https://theintern.io/docs.html#Intern/4/docs/docs%2Fconfiguration.md/properties const config = { asyncTimeout: asyncTimeout, @@ -61,6 +64,7 @@ const config = { fxaToken: fxaToken, fxaTokenRoot: fxaTokenRoot, fxaUntrustedOauthApp: fxaUntrustedOauthApp, + fxaPaymentsRoot, pageLoadTimeout: 20000, reporters: 'runner', @@ -70,6 +74,8 @@ const config = { tunnelOptions: { drivers: ['firefox'], }, + + testProductId, }; if (args.grep) { diff --git a/packages/fxa-content-server/tests/server/routes.js b/packages/fxa-content-server/tests/server/routes.js index 7fa9d480be..1cbca91f70 100644 --- a/packages/fxa-content-server/tests/server/routes.js +++ b/packages/fxa-content-server/tests/server/routes.js @@ -81,6 +81,7 @@ var routes = { '/sms/sent': { statusCode: 200 }, '/sms/sent/why': { statusCode: 200 }, '/sms/why': { statusCode: 200 }, + '/support': { statusCode: 200 }, // the following have a version prefix '/v1/complete_reset_password': { statusCode: 200 }, '/v1/reset_password': { statusCode: 200 }, diff --git a/packages/fxa-content-server/webpack.config.js b/packages/fxa-content-server/webpack.config.js index ad47cf7136..2929a128c3 100644 --- a/packages/fxa-content-server/webpack.config.js +++ b/packages/fxa-content-server/webpack.config.js @@ -18,6 +18,7 @@ const webpackConfig = { 'lib/jquery', 'backbone', 'canvasToBlob', + 'chosen-js', 'cocktail-lib', 'duration', 'es6-promise', @@ -59,6 +60,10 @@ const webpackConfig = { __dirname, 'node_modules/blueimp-canvas-to-blob/js/canvas-to-blob' ), + 'chosen-js': path.resolve( + __dirname, + 'node_modules/chosen-js/chosen.jquery' + ), 'cocktail-lib': path.resolve( __dirname, 'node_modules/backbone.cocktail/Cocktail' diff --git a/packages/fxa-payments-server/src/components/DialogMessage.tsx b/packages/fxa-payments-server/src/components/DialogMessage.tsx index ade05c1ec1..9f5d58f3b6 100644 --- a/packages/fxa-payments-server/src/components/DialogMessage.tsx +++ b/packages/fxa-payments-server/src/components/DialogMessage.tsx @@ -2,10 +2,10 @@ import React, { ReactNode } from 'react'; import classNames from 'classnames'; import { useClickOutsideEffect } from '../lib/hooks'; -import './DialogMessage.scss'; - import Portal from './Portal'; +import './DialogMessage.scss'; + type DialogMessageProps = { className?: string, onDismiss: Function, @@ -30,4 +30,4 @@ export const DialogMessage = ({ ); }; -export default DialogMessage; \ No newline at end of file +export default DialogMessage; diff --git a/packages/fxa-payments-server/src/lib/types.tsx b/packages/fxa-payments-server/src/lib/types.tsx index 8e34e90add..4ef9a584ad 100644 --- a/packages/fxa-payments-server/src/lib/types.tsx +++ b/packages/fxa-payments-server/src/lib/types.tsx @@ -6,6 +6,7 @@ export interface Config { export interface QueryParams { plan?: string, activated?: string + successfulSupportTicketSubmission?: string }; export interface GenericObject { diff --git a/packages/fxa-payments-server/src/routes/Subscriptions/index.test.tsx b/packages/fxa-payments-server/src/routes/Subscriptions/index.test.tsx new file mode 100644 index 0000000000..b4ac1d99ac --- /dev/null +++ b/packages/fxa-payments-server/src/routes/Subscriptions/index.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import 'jest-dom/extend-expect'; +import { Subscriptions, SubscriptionsProps } from './index'; +import { AppContext, AppContextType, defaultAppContext } from '../../lib/AppContext'; +import { Customer, Profile, Subscription } from '../../store/types'; + + +beforeEach(() => { +}); + +afterEach(cleanup); + +const mockProfile: Profile = { + amrValues: [], + avatar: 'avatar', + avatarDefault: true, + displayName: null, + email: 'email', + locale: 'locale', + twoFactorAuthentication: false, + uid: 'uid', +}; +const mockCustomer: Customer = { + payment_type: 'cc', + last4: '4444', + exp_month: '12', + exp_year: '22', + subscriptions: [], +}; +const mockSubscription: Subscription = { + 'subscriptionId': 'abc', + 'cancelledAt': null, + 'createdAt': Date.now(), + 'productName': 'pro jest', +}; +const mockPlan = { + plan_id: 'abc', + plan_name: 'abc', + product_id: 'abc', + product_name: 'abc', + currency: 'abc', + amount: 100, + interval: 'abc', +}; +const mockCustomerSubscription = { + cancel_at_period_end: false, + current_period_end: 99, + current_period_start: 9, + end_at: null, + nickname: 'abc', + plan_id: 'abc', + status: 'abc', + subscription_id: 'abc', +} +const mockedSubscriptionsProps = { + profile: {error: null, loading: false, result: mockProfile}, + plans: {error: null, loading: false, result: [mockPlan]}, + customer: {error: null, loading: false, result: mockCustomer}, + subscriptions: {error: null, loading: false, result: [mockSubscription]}, + customerSubscriptions: [mockCustomerSubscription], + fetchSubscriptionsRouteResources: jest.fn(), + cancelSubscription: jest.fn(), + cancelSubscriptionStatus: {error: null, loading: false, result: mockSubscription}, + resetCancelSubscription: jest.fn(), + reactivateSubscription: jest.fn(), + reactivateSubscriptionStatus: {error: null, loading: false, result: null}, + resetReactivateSubscription: jest.fn(), + updatePayment: jest.fn(), + updatePaymentStatus: {error: null, loading: false, result: null}, + resetUpdatePayment: jest.fn(), +}; +const Subject = (props: SubscriptionsProps) => { + const appContextValue = { + ...defaultAppContext, + config: {servers: {content: {url: 'http://127.0.0.1:3030'}}}, + queryParams: { successfulSupportTicketSubmission: 'quux'}, + }; + + return ( + + + + ) +} + +it('renders successful support ticket submission messsage when query param exists', () => { + const { getByTestId } = render(); + expect(getByTestId('supportFormSuccess')).toBeInTheDocument(); +}); diff --git a/packages/fxa-payments-server/src/routes/Subscriptions/index.tsx b/packages/fxa-payments-server/src/routes/Subscriptions/index.tsx index 2bcdea4574..c09a066989 100644 --- a/packages/fxa-payments-server/src/routes/Subscriptions/index.tsx +++ b/packages/fxa-payments-server/src/routes/Subscriptions/index.tsx @@ -29,9 +29,6 @@ import DialogMessage from '../../components/DialogMessage'; import SubscriptionItem from './SubscriptionItem'; import { LoadingOverlay } from '../../components/LoadingOverlay'; -// TODO: From where does this URL come - "Contact Support" button destination. -const SUPPORT_PANEL_URL = 'https://support.accounts.firefox.com'; - export type SubscriptionsProps = { profile: ProfileFetchState, plans: PlansFetchState, @@ -68,13 +65,16 @@ export const Subscriptions = ({ }: SubscriptionsProps) => { const { accessToken, + config, locationReload, navigateToUrl, + queryParams } = useContext(AppContext); const [showPaymentSuccessAlert, setShowPaymentSuccessAlert] = useState(true); const clearSuccessAlert = useCallback(() => setShowPaymentSuccessAlert(false), [setShowPaymentSuccessAlert]); + const SUPPORT_FORM_URL = `${config.servers.content.url}/support`; // Fetch subscriptions and customer on initial render or auth change. useEffect(() => { @@ -84,8 +84,8 @@ export const Subscriptions = ({ }, [ fetchSubscriptionsRouteResources, accessToken ]); const onSupportClick = useCallback( - () => navigateToUrl(SUPPORT_PANEL_URL), - [ navigateToUrl ] + () => navigateToUrl(SUPPORT_FORM_URL), + [ navigateToUrl, SUPPORT_FORM_URL ] ); if (customer.loading || subscriptions.loading || profile.loading || plans.loading) { @@ -140,7 +140,8 @@ export const Subscriptions = ({ subscription: cancelSubscriptionStatus.result, customerSubscriptions, plans, - resetCancelSubscription + resetCancelSubscription, + supportFormUrl: SUPPORT_FORM_URL }} /> )} @@ -175,6 +176,15 @@ export const Subscriptions = ({ )} + {queryParams.successfulSupportTicketSubmission && ( + + + Your support question was sent! We'll reach out to you via email as soon as possible. + + + )} + + {profile.result && ( )}
@@ -268,6 +278,7 @@ type CancellationDialogMessageProps = { customerSubscriptions: Array, plans: PlansFetchState, resetCancelSubscription: Function, + supportFormUrl: string }; const CancellationDialogMessage = ({ @@ -275,6 +286,7 @@ const CancellationDialogMessage = ({ customerSubscriptions, plans, resetCancelSubscription, + supportFormUrl, }: CancellationDialogMessageProps) => { const customerSubscription = customerSubscriptionForId( subscription.subscriptionId, @@ -298,7 +310,7 @@ const CancellationDialogMessage = ({ You will still have until access to {plan.plan_name} until {periodEndDate}.

- Have questions? Visit Mozilla Support. + Have questions? Visit Mozilla Support.

);