From ae668f681ea19aed05efd4874288a6f5105a396f Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Wed, 3 May 2017 17:46:12 +0200 Subject: [PATCH] HTTPS UI switch (#335) * Checkbox to toggle project's serveSecure flag This doesn't yet persist or reload the page. * Help button that shows modal to explain feature * Extracts protocol redirection to helper * Returns promise from saveProject() action to allow chaining * Setting serveSecure flag on project redirects after saving project * Set serveSecure on Project model in API and client * Redirect to correct protocol when project is loaded --- client/components/forceProtocol.jsx | 46 +++++----- client/constants.js | 4 + client/images/help.svg | 7 ++ client/modules/IDE/actions/ide.js | 13 +++ client/modules/IDE/actions/project.js | 98 +++++++++++++-------- client/modules/IDE/components/HelpModal.jsx | 61 +++++++++++++ client/modules/IDE/components/Toolbar.jsx | 30 ++++++- client/modules/IDE/pages/IDEView.jsx | 25 +++++- client/modules/IDE/reducers/ide.js | 4 + client/modules/IDE/reducers/project.js | 4 + client/styles/components/_help-modal.scss | 39 ++++++++ client/styles/components/_toolbar.scss | 27 ++++++ client/styles/main.scss | 1 + server/models/project.js | 1 + 14 files changed, 301 insertions(+), 59 deletions(-) create mode 100644 client/images/help.svg create mode 100644 client/modules/IDE/components/HelpModal.jsx create mode 100644 client/styles/components/_help-modal.scss diff --git a/client/components/forceProtocol.jsx b/client/components/forceProtocol.jsx index dda5451c..3009d085 100644 --- a/client/components/forceProtocol.jsx +++ b/client/components/forceProtocol.jsx @@ -1,6 +1,27 @@ -import React, { PropTypes } from 'react'; +import React from 'react'; import { format, parse } from 'url'; +const findCurrentProtocol = () => ( + parse(window.location.href).protocol +); + +const redirectToProtocol = (protocol, { appendSource, disable = false } = {}) => { + const currentProtocol = findCurrentProtocol(); + + if (protocol !== currentProtocol) { + if (disable === true) { + console.info(`forceProtocol: would have redirected from "${currentProtocol}" to "${protocol}"`); + } else { + const url = parse(window.location.href, true /* parse query string */); + url.protocol = protocol; + if (appendSource === true) { + url.query.source = currentProtocol; + } + window.location = format(url); + } + } +}; + /** * A Higher Order Component that forces the protocol to change on mount * @@ -14,29 +35,12 @@ const forceProtocol = ({ targetProtocol = 'https', sourceProtocol, disable = fal static propTypes = {} componentDidMount() { - this.redirectToProtocol(targetProtocol, { appendSource: true }); + redirectToProtocol(targetProtocol, { appendSource: true, disable }); } componentWillUnmount() { if (sourceProtocol != null) { - this.redirectToProtocol(sourceProtocol, { appendSource: false }); - } - } - - redirectToProtocol(protocol, { appendSource }) { - const currentProtocol = parse(window.location.href).protocol; - - if (protocol !== currentProtocol) { - if (disable === true) { - console.info(`forceProtocol: would have redirected from "${currentProtocol}" to "${protocol}"`); - } else { - const url = parse(window.location.href, true /* parse query string */); - url.protocol = protocol; - if (appendSource === true) { - url.query.source = currentProtocol; - } - window.location = format(url); - } + redirectToProtocol(sourceProtocol, { appendSource: false, disable }); } } @@ -65,6 +69,8 @@ const findSourceProtocol = (state, location) => { export default forceProtocol; export { + findCurrentProtocol, findSourceProtocol, + redirectToProtocol, protocols, }; diff --git a/client/constants.js b/client/constants.js index e3060237..149dc79c 100644 --- a/client/constants.js +++ b/client/constants.js @@ -26,6 +26,7 @@ export const AUTH_ERROR = 'AUTH_ERROR'; export const SETTINGS_UPDATED = 'SETTINGS_UPDATED'; export const SET_PROJECT_NAME = 'SET_PROJECT_NAME'; +export const SET_SERVE_SECURE = 'SET_SERVE_SECURE'; export const PROJECT_SAVE_SUCCESS = 'PROJECT_SAVE_SUCCESS'; export const PROJECT_SAVE_FAIL = 'PROJECT_SAVE_FAIL'; @@ -113,3 +114,6 @@ export const HIDE_ERROR_MODAL = 'HIDE_ERROR_MODAL'; export const PERSIST_STATE = 'PERSIST_STATE'; export const CLEAR_PERSISTED_STATE = 'CLEAR_PERSISTED_STATE'; + +export const SHOW_HELP_MODAL = 'SHOW_HELP_MODAL'; +export const HIDE_HELP_MODAL = 'HIDE_HELP_MODAL'; diff --git a/client/images/help.svg b/client/images/help.svg new file mode 100644 index 00000000..b812feb7 --- /dev/null +++ b/client/images/help.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/modules/IDE/actions/ide.js b/client/modules/IDE/actions/ide.js index d277ea18..4f5c0662 100644 --- a/client/modules/IDE/actions/ide.js +++ b/client/modules/IDE/actions/ide.js @@ -220,3 +220,16 @@ export function hideErrorModal() { type: ActionTypes.HIDE_ERROR_MODAL }; } + +export function showHelpModal(helpType) { + return { + type: ActionTypes.SHOW_HELP_MODAL, + helpType + }; +} + +export function hideHelpModal() { + return { + type: ActionTypes.HIDE_HELP_MODAL + }; +} diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index c5abc528..4a9ac140 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -9,10 +9,18 @@ import { setUnsavedChanges, resetJustOpenedProject, showErrorModal } from './ide'; import { clearState, saveState } from '../../../persistState'; +import { redirectToProtocol, protocols } from '../../../components/forceProtocol'; const ROOT_URL = process.env.API_URL; export function setProject(project) { + const targetProtocol = project.serveSecure === true ? + protocols.https : + protocols.http; + + // This will not reload if on same protocol + redirectToProtocol(targetProtocol); + return { type: ActionTypes.SET_PROJECT, project, @@ -66,12 +74,12 @@ export function saveProject(autosave = false) { return (dispatch, getState) => { const state = getState(); if (state.user.id && state.project.owner && state.project.owner.id !== state.user.id) { - return; + return Promise.reject(); } const formParams = Object.assign({}, state.project); formParams.files = [...state.files]; if (state.project.id) { - axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true }) + return axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true }) .then((response) => { dispatch(setUnsavedChanges(false)); console.log(response.data); @@ -103,41 +111,41 @@ export function saveProject(autosave = false) { }); } }); - } else { - axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) - .then((response) => { - dispatch(setUnsavedChanges(false)); - dispatch(setProject(response.data)); - browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); - dispatch({ - type: ActionTypes.NEW_PROJECT, - project: response.data, - owner: response.data.user, - files: response.data.files - }); - if (!autosave) { - if (state.preferences.autosave) { - dispatch(showToast(5500)); - dispatch(setToastText('Project saved.')); - setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500); - dispatch(resetJustOpenedProject()); - } else { - dispatch(showToast(1500)); - dispatch(setToastText('Project saved.')); - } - } - }) - .catch((response) => { - if (response.status === 403) { - dispatch(showErrorModal('staleSession')); - } else { - dispatch({ - type: ActionTypes.PROJECT_SAVE_FAIL, - error: response.data - }); - } - }); } + + return axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) + .then((response) => { + dispatch(setUnsavedChanges(false)); + dispatch(setProject(response.data)); + browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); + dispatch({ + type: ActionTypes.NEW_PROJECT, + project: response.data, + owner: response.data.user, + files: response.data.files + }); + if (!autosave) { + if (state.preferences.autosave) { + dispatch(showToast(5500)); + dispatch(setToastText('Project saved.')); + setTimeout(() => dispatch(setToastText('Autosave enabled.')), 1500); + dispatch(resetJustOpenedProject()); + } else { + dispatch(showToast(1500)); + dispatch(setToastText('Project saved.')); + } + } + }) + .catch((response) => { + if (response.status === 403) { + dispatch(showErrorModal('staleSession')); + } else { + dispatch({ + type: ActionTypes.PROJECT_SAVE_FAIL, + error: response.data + }); + } + }); }; } @@ -249,6 +257,24 @@ export function cloneProject() { }; } +export function setServeSecure(serveSecure, { redirect = true } = {}) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SET_SERVE_SECURE, + serveSecure + }); + + if (redirect === true) { + dispatch(saveProject(false /* autosave */)) + .then( + () => redirectToProtocol(serveSecure === true ? protocols.https : protocols.http) + ); + } + + return null; + }; +} + export function showEditProjectName() { return { type: ActionTypes.SHOW_EDIT_PROJECT_NAME diff --git a/client/modules/IDE/components/HelpModal.jsx b/client/modules/IDE/components/HelpModal.jsx new file mode 100644 index 00000000..df0adf24 --- /dev/null +++ b/client/modules/IDE/components/HelpModal.jsx @@ -0,0 +1,61 @@ +import React, { PropTypes } from 'react'; +import InlineSVG from 'react-inlinesvg'; + +const exitUrl = require('../../../images/exit.svg'); + +const helpContent = { + serveSecure: { + title: 'Serve over HTTPS', + body: ( +
+

Use the checkbox to choose whether this sketch should be loaded using HTTPS or HTTP.

+

You should choose HTTPS if you need to:

+ +

Choose HTTP if you need to:

+ +
+ ) + } +}; + +const fallbackContent = { + title: 'No content for this topic', + body: null, +}; + +class HelpModal extends React.Component { + componentDidMount() { + this.shareModal.focus(); + } + render() { + const content = helpContent[this.props.type] == null ? + fallbackContent : + helpContent[this.props.type]; + + return ( +
{ this.shareModal = element; }} tabIndex="0"> +
+

{content.title}

+ +
+
+ {content.body} +
+
+ ); + } +} + +HelpModal.propTypes = { + type: PropTypes.string.isRequired, + closeModal: PropTypes.func.isRequired, +}; + +export default HelpModal; diff --git a/client/modules/IDE/components/Toolbar.jsx b/client/modules/IDE/components/Toolbar.jsx index b1ce6b0d..f229f7b1 100644 --- a/client/modules/IDE/components/Toolbar.jsx +++ b/client/modules/IDE/components/Toolbar.jsx @@ -8,6 +8,7 @@ const logoUrl = require('../../../images/p5js-logo.svg'); const stopUrl = require('../../../images/stop.svg'); const preferencesUrl = require('../../../images/preferences.svg'); const editProjectNameUrl = require('../../../images/pencil.svg'); +const helpUrl = require('../../../images/help.svg'); class Toolbar extends React.Component { constructor(props) { @@ -102,6 +103,30 @@ class Toolbar extends React.Component { Auto-refresh + { + this.props.currentUser == null ? + null : +
+ { + this.props.setServeSecure(event.target.checked); + }} + /> + + +
+ }
{ // eslint-disable-line + if (this.props.ide.helpType) { + return ( + + + + ); + } + })()}
); @@ -491,7 +507,8 @@ IDEView.propTypes = { projectSavedTime: PropTypes.string.isRequired, previousPath: PropTypes.string.isRequired, justOpenedProject: PropTypes.bool.isRequired, - errorType: PropTypes.string + errorType: PropTypes.string, + helpType: PropTypes.string }).isRequired, stopSketch: PropTypes.func.isRequired, startTextOutput: PropTypes.func.isRequired, @@ -499,6 +516,7 @@ IDEView.propTypes = { project: PropTypes.shape({ id: PropTypes.string, name: PropTypes.string.isRequired, + serveSecure: PropTypes.bool, owner: PropTypes.shape({ username: PropTypes.string, id: PropTypes.string @@ -506,6 +524,7 @@ IDEView.propTypes = { updatedAt: PropTypes.string }).isRequired, setProjectName: PropTypes.func.isRequired, + setServeSecure: PropTypes.func.isRequired, openPreferences: PropTypes.func.isRequired, editorAccessibility: PropTypes.shape({ lintMessages: PropTypes.array.isRequired, @@ -600,7 +619,9 @@ IDEView.propTypes = { showErrorModal: PropTypes.func.isRequired, hideErrorModal: PropTypes.func.isRequired, clearPersistedState: PropTypes.func.isRequired, - persistState: PropTypes.func.isRequired + persistState: PropTypes.func.isRequired, + showHelpModal: PropTypes.func.isRequired, + hideHelpModal: PropTypes.func.isRequired }; function mapStateToProps(state) { diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js index 40f4e0e2..6ad8bf1f 100644 --- a/client/modules/IDE/reducers/ide.js +++ b/client/modules/IDE/reducers/ide.js @@ -91,6 +91,10 @@ const ide = (state = initialState, action) => { return Object.assign({}, state, { errorType: action.modalType }); case ActionTypes.HIDE_ERROR_MODAL: return Object.assign({}, state, { errorType: undefined }); + case ActionTypes.SHOW_HELP_MODAL: + return Object.assign({}, state, { helpType: action.helpType }); + case ActionTypes.HIDE_HELP_MODAL: + return Object.assign({}, state, { helpType: undefined }); default: return state; } diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index 1b8faa52..eb1de631 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -16,6 +16,8 @@ const project = (state, action) => { state = initialState(); // eslint-disable-line } switch (action.type) { + case ActionTypes.SET_SERVE_SECURE: + return Object.assign({}, { ...state }, { serveSecure: action.serveSecure }); case ActionTypes.SET_PROJECT_NAME: return Object.assign({}, { ...state }, { name: action.name }); case ActionTypes.NEW_PROJECT: @@ -23,6 +25,7 @@ const project = (state, action) => { id: action.project.id, name: action.project.name, updatedAt: action.project.updatedAt, + serveSecure: action.project.serveSecure, owner: action.owner }; case ActionTypes.SET_PROJECT: @@ -30,6 +33,7 @@ const project = (state, action) => { id: action.project.id, name: action.project.name, updatedAt: action.project.updatedAt, + serveSecure: action.project.serveSecure, owner: action.owner }; case ActionTypes.RESET_PROJECT: diff --git a/client/styles/components/_help-modal.scss b/client/styles/components/_help-modal.scss new file mode 100644 index 00000000..3182cbb2 --- /dev/null +++ b/client/styles/components/_help-modal.scss @@ -0,0 +1,39 @@ +.help-modal { + @extend %modal; + padding: #{20 / $base-font-size}rem; + width: #{500 / $base-font-size}rem; +} + +.help-modal__header { + display: flex; + justify-content: space-between; +} + +.help-modal__section { + width: 100%; + display: flex; + align-items: center; + padding: #{10 / $base-font-size}rem 0; + + // Basic text styles for help body text + ul, + ol { + margin-top: #{5 / $base-font-size}rem; + } + + p { + margin-top: #{10 / $base-font-size}rem; + } + + li { + list-style: disc inside; + } +} + +.help-modal__label { + width: #{86 / $base-font-size}rem; +} + +.help-modal__input { + flex: 1; +} diff --git a/client/styles/components/_toolbar.scss b/client/styles/components/_toolbar.scss index 6a1e8253..7271f7a8 100644 --- a/client/styles/components/_toolbar.scss +++ b/client/styles/components/_toolbar.scss @@ -120,6 +120,33 @@ font-size: #{12 / $base-font-size}rem; } +.toolbar__serve-secure { + margin-left: #{20 / $base-font-size}rem; +} + +.toolbar__serve-secure-label { + @include themify() { + color: getThemifyVariable('inactive-text-color'); + } + margin-left: #{5 / $base-font-size}rem; + font-size: #{12 / $base-font-size}rem; +} + +.toolbar__serve-secure-help { + display: inline-block; + vertical-align: top; + height: #{12 / $base-font-size}rem; + & svg { + width: #{12 / $base-font-size}rem; + height: #{12 / $base-font-size}rem; + } + @include themify() { + & path { + fill: getThemifyVariable('inactive-text-color'); + } + } +} + .toolbar__edit-name-button { display: inline-block; vertical-align: top; diff --git a/client/styles/main.scss b/client/styles/main.scss index 4ba6dce3..fe97261e 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -33,6 +33,7 @@ @import 'components/form-container'; @import 'components/error-modal'; @import 'components/preview-frame'; +@import 'components/help-modal'; @import 'layout/ide'; @import 'layout/fullscreen'; diff --git a/server/models/project.js b/server/models/project.js index 991d118d..169fbc52 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -23,6 +23,7 @@ fileSchema.set('toJSON', { const projectSchema = new Schema({ name: { type: String, default: "Hello p5.js, it's the server" }, user: { type: Schema.Types.ObjectId, ref: 'User' }, + serveSecure: { type: Boolean, default: false }, files: { type: [fileSchema] }, _id: { type: String, default: shortid.generate } }, { timestamps: true });