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
This commit is contained in:
Andrew Nicolaou 2017-05-03 17:46:12 +02:00 committed by Cassie Tarakajian
parent 32d3f7a76c
commit ae668f681e
14 changed files with 301 additions and 59 deletions

View File

@ -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,
};

View File

@ -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';

7
client/images/help.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<g id="Page-1" fill="none" fill-rule="evenodd">
<g id="Group" fill="#3B3B3B">
<path id="Combined-Shape" d="M14,28 C6.2680135,28 0,21.7319865 0,14 C0,6.2680135 6.2680135,0 14,0 C21.7319865,0 28,6.2680135 28,14 C28,21.7319865 21.7319865,28 14,28 Z M11.9785156,18.0493164 L13.675293,18.0493164 L13.675293,17.8295898 C13.675293,17.4715151 13.7037758,17.1582045 13.7607422,16.8896484 C13.8177086,16.6210924 13.9235018,16.3586439 14.078125,16.1022949 C14.2327482,15.845946 14.4443346,15.581463 14.7128906,15.3088379 C14.9814467,15.0362128 15.3273091,14.7167987 15.7504883,14.3505859 C16.1899436,13.968097 16.5724268,13.6059587 16.8979492,13.2641602 C17.2234717,12.9223616 17.4960927,12.5683612 17.7158203,12.2021484 C17.935548,11.8359357 18.1003412,11.4392111 18.2102051,11.0119629 C18.3200689,10.5847147 18.375,10.0984728 18.375,9.55322266 C18.375,8.8452113 18.2610688,8.20841754 18.0332031,7.64282227 C17.8053374,7.07722699 17.4798198,6.59912305 17.0566406,6.20849609 C16.6334614,5.81786914 16.1187368,5.51879987 15.5124512,5.3112793 C14.9061656,5.10375873 14.2246132,5 13.4677734,5 C12.6783815,5 11.9012083,5.12003461 11.1362305,5.36010742 C10.3712527,5.60018024 9.65918298,5.89111157 9,6.23291016 L9.76904297,8.00292969 C10.3387073,7.7262356 10.9287079,7.48209741 11.5390625,7.27050781 C12.1494171,7.05891821 12.7923143,6.953125 13.4677734,6.953125 C13.9235049,6.953125 14.3242984,7.01619403 14.670166,7.14233398 C15.0160336,7.26847394 15.3049305,7.44750861 15.5368652,7.67944336 C15.7688,7.91137811 15.9458002,8.18806805 16.0678711,8.50952148 C16.189942,8.83097491 16.2509766,9.18700977 16.2509766,9.57763672 C16.2509766,10.008954 16.2082524,10.3853337 16.1228027,10.7067871 C16.0373531,11.0282405 15.9071461,11.3293443 15.7321777,11.6101074 C15.5572094,11.8908705 15.3313816,12.1675605 15.0546875,12.4401855 C14.7779934,12.7128106 14.4443379,13.0159489 14.0537109,13.3496094 C13.6468079,13.691408 13.3090834,14.01896 13.0405273,14.3322754 C12.7719713,14.6455908 12.5583504,14.9650049 12.3996582,15.2905273 C12.240966,15.6160498 12.1311038,15.9659812 12.0700684,16.340332 C12.0090329,16.7146829 11.9785156,17.1337867 11.9785156,17.5976562 L11.9785156,18.0493164 Z M11.3925781,21.7480469 C11.3925781,22.0491552 11.4332678,22.3075347 11.5146484,22.5231934 C11.5960291,22.738852 11.7058912,22.9158522 11.8442383,23.0541992 C11.9825853,23.1925463 12.1453441,23.2942705 12.3325195,23.359375 C12.5196949,23.4244795 12.7231434,23.4570312 12.9428711,23.4570312 C13.1544607,23.4570312 13.3558747,23.4244795 13.5471191,23.359375 C13.7383636,23.2942705 13.9031569,23.1925463 14.0415039,23.0541992 C14.179851,22.9158522 14.2897131,22.738852 14.3710938,22.5231934 C14.4524744,22.3075347 14.4931641,22.0491552 14.4931641,21.7480469 C14.4931641,21.4388005 14.4524744,21.176352 14.3710938,20.9606934 C14.2897131,20.7450347 14.179851,20.570069 14.0415039,20.435791 C13.9031569,20.301513 13.7383636,20.2038577 13.5471191,20.1428223 C13.3558747,20.0817868 13.1544607,20.0512695 12.9428711,20.0512695 C12.7231434,20.0512695 12.5196949,20.0817868 12.3325195,20.1428223 C12.1453441,20.2038577 11.9825853,20.301513 11.8442383,20.435791 C11.7058912,20.570069 11.5960291,20.7450347 11.5146484,20.9606934 C11.4332678,21.176352 11.3925781,21.4388005 11.3925781,21.7480469 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -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
};
}

View File

@ -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

View File

@ -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: (
<div>
<p>Use the checkbox to choose whether this sketch should be loaded using HTTPS or HTTP.</p>
<p>You should choose HTTPS if you need to:</p>
<ul>
<li>access a webcam or microphone</li>
<li>access an API served over HTTPS</li>
</ul>
<p>Choose HTTP if you need to:</p>
<ul>
<li>access an API served over HTTP</li>
</ul>
</div>
)
}
};
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 (
<section className="help-modal" ref={(element) => { this.shareModal = element; }} tabIndex="0">
<header className="help-modal__header">
<h2>{content.title}</h2>
<button className="about__exit-button" onClick={this.props.closeModal}>
<InlineSVG src={exitUrl} alt="Close Help Overlay" />
</button>
</header>
<div className="help-modal__section">
{content.body}
</div>
</section>
);
}
}
HelpModal.propTypes = {
type: PropTypes.string.isRequired,
closeModal: PropTypes.func.isRequired,
};
export default HelpModal;

View File

@ -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
</label>
</div>
{
this.props.currentUser == null ?
null :
<div className="toolbar__serve-secure">
<input
id="serve-secure"
type="checkbox"
checked={this.props.project.serveSecure || false}
onChange={(event) => {
this.props.setServeSecure(event.target.checked);
}}
/>
<label htmlFor="serve-secure" className="toolbar__serve-secure-label">
HTTPS
</label>
<button
className="toolbar__serve-secure-help"
onClick={() => this.props.showHelpModal('serveSecure')}
aria-label="help"
>
<InlineSVG src={helpUrl} alt="Help" />
</button>
</div>
}
<div className={nameContainerClass}>
<a
className="toolbar__project-name"
@ -169,13 +194,16 @@ Toolbar.propTypes = {
project: PropTypes.shape({
name: PropTypes.string.isRequired,
isEditingName: PropTypes.bool,
id: PropTypes.string
id: PropTypes.string,
serveSecure: PropTypes.bool,
}).isRequired,
showEditProjectName: PropTypes.func.isRequired,
hideEditProjectName: PropTypes.func.isRequired,
showHelpModal: PropTypes.func.isRequired,
infiniteLoop: PropTypes.bool.isRequired,
autorefresh: PropTypes.bool.isRequired,
setAutorefresh: PropTypes.func.isRequired,
setServeSecure: PropTypes.func.isRequired,
startSketchAndRefresh: PropTypes.func.isRequired,
saveProject: PropTypes.func.isRequired,
currentUser: PropTypes.string,

View File

@ -14,6 +14,7 @@ import NewFolderModal from '../components/NewFolderModal';
import ShareModal from '../components/ShareModal';
import KeyboardShortcutModal from '../components/KeyboardShortcutModal';
import ErrorModal from '../components/ErrorModal';
import HelpModal from '../components/HelpModal';
import Nav from '../../../components/Nav';
import Console from '../components/Console';
import Toast from '../components/Toast';
@ -219,6 +220,8 @@ class IDEView extends React.Component {
hideEditProjectName={this.props.hideEditProjectName}
openPreferences={this.props.openPreferences}
preferencesIsVisible={this.props.ide.preferencesIsVisible}
serveSecure={this.props.project.serveSecure}
setServeSecure={this.props.setServeSecure}
setTextOutput={this.props.setTextOutput}
owner={this.props.project.owner}
project={this.props.project}
@ -229,6 +232,7 @@ class IDEView extends React.Component {
saveProject={this.props.saveProject}
currentUser={this.props.user.username}
clearConsole={this.props.clearConsole}
showHelpModal={this.props.showHelpModal}
/>
<Preferences
isVisible={this.props.ide.preferencesIsVisible}
@ -448,6 +452,18 @@ class IDEView extends React.Component {
);
}
})()}
{(() => { // eslint-disable-line
if (this.props.ide.helpType) {
return (
<Overlay>
<HelpModal
type={this.props.ide.helpType}
closeModal={this.props.hideHelpModal}
/>
</Overlay>
);
}
})()}
</div>
);
@ -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) {

View File

@ -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;
}

View File

@ -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:

View File

@ -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;
}

View File

@ -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;

View File

@ -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';

View File

@ -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 });