From d706e8f2cc64a47c2e4b0a9e499cbda72ccbb478 Mon Sep 17 00:00:00 2001 From: Evgeny Kochetkov Date: Mon, 27 Nov 2017 16:55:50 +0300 Subject: [PATCH] feat(xod-client, xod-client-electron, cod-client-browser): add account pane Closes #889 --- .eslintrc.js | 1 + .../xod-client-browser/src/containers/App.jsx | 2 + packages/xod-client-electron/src/app/main.js | 3 + .../src/view/containers/App.jsx | 15 ++- packages/xod-client/package.json | 1 + .../xod-client/src/core/components/Button.jsx | 37 +++++++ .../xod-client/src/core/containers/App.jsx | 4 + .../core/styles/components/AccountPane.scss | 58 ++++++++++ packages/xod-client/src/core/styles/main.scss | 1 + .../src/editor/containers/Editor.jsx | 2 + packages/xod-client/src/user/actionTypes.js | 9 ++ packages/xod-client/src/user/actions.js | 76 ++++++++++++- .../src/user/components/AuthForm.jsx | 100 ++++++++++++++++++ .../src/user/containers/AccountPane.jsx | 91 ++++++++++++++++ packages/xod-client/src/user/messages.js | 2 + packages/xod-client/src/user/reducer.js | 33 +++++- packages/xod-client/src/user/selectors.js | 21 ++++ packages/xod-client/src/user/state.js | 4 + packages/xod-client/src/utils/menu.js | 4 + packages/xod-client/src/utils/urls.js | 21 +++- yarn.lock | 4 + 21 files changed, 477 insertions(+), 12 deletions(-) create mode 100644 packages/xod-client/src/core/components/Button.jsx create mode 100644 packages/xod-client/src/core/styles/components/AccountPane.scss create mode 100644 packages/xod-client/src/user/components/AuthForm.jsx create mode 100644 packages/xod-client/src/user/containers/AccountPane.jsx create mode 100644 packages/xod-client/src/user/messages.js diff --git a/.eslintrc.js b/.eslintrc.js index b1df4156..43684d15 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -35,6 +35,7 @@ module.exports = { beforeEach: true, after: true, afterEach: true, + URLSearchParams: true, }, rules: { diff --git a/packages/xod-client-browser/src/containers/App.jsx b/packages/xod-client-browser/src/containers/App.jsx index 0989f52d..55670302 100644 --- a/packages/xod-client-browser/src/containers/App.jsx +++ b/packages/xod-client-browser/src/containers/App.jsx @@ -42,6 +42,7 @@ class App extends client.App { this.hideInstallAppPopup = this.hideInstallAppPopup.bind(this); props.actions.openProject(props.initialProject); + props.actions.fetchGrant(); } onResize() { @@ -206,6 +207,7 @@ class App extends client.App { [ onClick(items.toggleHelpbar, this.props.actions.toggleHelpbar), onClick(items.toggleDebugger, this.props.actions.toggleDebugger), + onClick(items.toggleAccountPane, this.props.actions.toggleAccountPane), ] ), submenu( diff --git a/packages/xod-client-electron/src/app/main.js b/packages/xod-client-electron/src/app/main.js index 3f9661c9..7e131a52 100644 --- a/packages/xod-client-electron/src/app/main.js +++ b/packages/xod-client-electron/src/app/main.js @@ -59,6 +59,9 @@ function createWindow() { show: false, // this is required for subpixel antialiasing to work backgroundColor: '#FFF', + webPreferences: { + partition: 'persist:main', + }, }); win.maximize(); // and load the index.html of the app. diff --git a/packages/xod-client-electron/src/view/containers/App.jsx b/packages/xod-client-electron/src/view/containers/App.jsx index 6f3f85f1..a29c7c75 100644 --- a/packages/xod-client-electron/src/view/containers/App.jsx +++ b/packages/xod-client-electron/src/view/containers/App.jsx @@ -181,6 +181,7 @@ class App extends client.App { // request for data from main process ipcRenderer.send(EVENTS.GET_SIDEBAR_PANE_HEIGHT); + props.actions.fetchGrant(); } onClickMessageButton(buttonId, /* messageInfo */) { @@ -522,8 +523,18 @@ class App extends client.App { const viewMenu = { label: 'View', submenu: [ - client.menu.onClick(client.menu.items.toggleHelpbar, this.props.actions.toggleHelpbar), - client.menu.onClick(client.menu.items.toggleDebugger, this.props.actions.toggleDebugger), + client.menu.onClick( + client.menu.items.toggleHelpbar, + this.props.actions.toggleHelpbar + ), + client.menu.onClick( + client.menu.items.toggleDebugger, + this.props.actions.toggleDebugger + ), + client.menu.onClick( + client.menu.items.toggleAccountPane, + this.props.actions.toggleAccountPane + ), { type: 'separator' }, { role: 'reload' }, { role: 'toggledevtools' }, diff --git a/packages/xod-client/package.json b/packages/xod-client/package.json index d8de313b..af732c67 100644 --- a/packages/xod-client/package.json +++ b/packages/xod-client/package.json @@ -59,6 +59,7 @@ "sanctuary-def": "^0.9.0", "shortid": "^2.2.8", "url-parse": "^1.1.9", + "url-search-params-polyfill": "^2.0.1", "xod-arduino": "^0.15.0", "xod-func-tools": "^0.15.0", "xod-patch-search": "^0.15.0", diff --git a/packages/xod-client/src/core/components/Button.jsx b/packages/xod-client/src/core/components/Button.jsx new file mode 100644 index 00000000..07a70d69 --- /dev/null +++ b/packages/xod-client/src/core/components/Button.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; + +const Button = ({ + as = 'button', + className, + disabled, + light, + small, + children, + ...restProps +}) => React.createElement( + as, + { + ...restProps, + className: cn('Button', className, { + 'Button--light': light, + 'Button--small': small, + disabled, + }), + disabled, + role: as !== 'button' ? 'button' : restProps.role, + }, + children +); + +Button.propTypes = { + as: PropTypes.string, + className: PropTypes.string, + disabled: PropTypes.bool, + light: PropTypes.bool, + small: PropTypes.bool, + children: PropTypes.node, +}; + +export default Button; diff --git a/packages/xod-client/src/core/containers/App.jsx b/packages/xod-client/src/core/containers/App.jsx index cf7cc95a..19047c1f 100644 --- a/packages/xod-client/src/core/containers/App.jsx +++ b/packages/xod-client/src/core/containers/App.jsx @@ -151,6 +151,8 @@ App.propTypes = { logDebugger: PropTypes.func.isRequired, clearDebugger: PropTypes.func.isRequired, showLibSuggester: PropTypes.func.isRequired, + toggleAccountPane: PropTypes.func.isRequired, + fetchGrant: PropTypes.func.isRequired, /* eslint-enable react/no-unused-prop-types */ }), }; @@ -187,4 +189,6 @@ App.actions = { copyEntities: actions.copyEntities, pasteEntities: actions.pasteEntities, showLibSuggester: actions.showLibSuggester, + toggleAccountPane: actions.toggleAccountPane, + fetchGrant: actions.fetchGrant, }; diff --git a/packages/xod-client/src/core/styles/components/AccountPane.scss b/packages/xod-client/src/core/styles/components/AccountPane.scss new file mode 100644 index 00000000..c55bbe35 --- /dev/null +++ b/packages/xod-client/src/core/styles/components/AccountPane.scss @@ -0,0 +1,58 @@ +.AccountPane { + position: absolute; + right: 0; + width: 240px; + height: 100%; + box-sizing: border-box; + padding: 20px; + background: $sidebar-color-bg; + border-left: 1px solid $sidebar-color-border; + color: $sidebar-color-text; + + .close-button { + @include close-button(); + } + + .username { + font-size: $font-size-l; + padding-bottom: 14px; + cursor: default; + color: $color-canvas-selected; + } + + .dailyQuotas { + font-size: $font-size-m; + padding-bottom: 14px; + cursor: default; + + .title { + color: $color-canvas-selected; + padding-bottom: 4px; + } + } + + .inspectorInput { + margin-bottom: 7px; + } + + .whyLogin { + margin-bottom: 7px; + } + + .authError { + color: $color-error; + margin-bottom: 3px; + } + + .forgotPassword { + color: $color-canvas-selected; + } + + .ButtonsRow { + margin-top: 9px; + display: flex; + + flex-direction: row; + justify-content: space-between; + } +} diff --git a/packages/xod-client/src/core/styles/main.scss b/packages/xod-client/src/core/styles/main.scss index ae19f97e..4bcea5a5 100644 --- a/packages/xod-client/src/core/styles/main.scss +++ b/packages/xod-client/src/core/styles/main.scss @@ -13,6 +13,7 @@ 'base/base'; @import + 'components/AccountPane', 'components/App', 'components/BackgroundLayer', 'components/Breadcrumbs', diff --git a/packages/xod-client/src/editor/containers/Editor.jsx b/packages/xod-client/src/editor/containers/Editor.jsx index 0f7688b7..4653f8af 100644 --- a/packages/xod-client/src/editor/containers/Editor.jsx +++ b/packages/xod-client/src/editor/containers/Editor.jsx @@ -37,6 +37,7 @@ import Workarea from '../../utils/components/Workarea'; import { RenderableSelection } from '../../types'; import sanctuaryPropType from '../../utils/sanctuaryPropType'; +import AccountPane from '../../user/containers/AccountPane'; import ProjectBrowser from '../../projectBrowser/containers/ProjectBrowser'; import Tabs from './Tabs'; import Helpbar from './Helpbar'; @@ -236,6 +237,7 @@ class Editor extends React.Component { + ); diff --git a/packages/xod-client/src/user/actionTypes.js b/packages/xod-client/src/user/actionTypes.js index 475553ea..c41f8a07 100644 --- a/packages/xod-client/src/user/actionTypes.js +++ b/packages/xod-client/src/user/actionTypes.js @@ -1,2 +1,11 @@ export const UPDATE_COMPILE_LIMIT = 'UPDATE_COMPILE_LIMIT'; + +export const TOGGLE_ACCOUNT_PANE = 'TOGGLE_ACCOUNT_PANE'; + +export const SET_AUTH_GRANT = 'SET_AUTH_GRANT'; + +export const LOGIN_STARTED = 'LOGIN_STARTED'; +export const LOGIN_FAILED = 'LOGIN_FAILED'; +// no 'SUCCEEDED', because we will fetch grant on success + export default {}; diff --git a/packages/xod-client/src/user/actions.js b/packages/xod-client/src/user/actions.js index 36432bb0..db8d53bd 100644 --- a/packages/xod-client/src/user/actions.js +++ b/packages/xod-client/src/user/actions.js @@ -1,5 +1,14 @@ -import { getCompileLimitUrl } from '../utils/urls'; -import { UPDATE_COMPILE_LIMIT } from './actionTypes'; +import 'url-search-params-polyfill'; + +import { + getCompileLimitUrl, + getWhoamiUrl, + getLoginUrl, + getLogoutUrl, +} from '../utils/urls'; +import * as ActionTypes from './actionTypes'; +import * as Messages from './messages'; + export const updateCompileLimit = (startup = false) => dispatch => fetch(getCompileLimitUrl(), { @@ -7,7 +16,68 @@ export const updateCompileLimit = (startup = false) => dispatch => }).then(res => (res.ok ? res.json() : null)) .catch(() => null) .then(limit => dispatch({ - type: UPDATE_COMPILE_LIMIT, + type: ActionTypes.UPDATE_COMPILE_LIMIT, payload: limit, })); + +export const toggleAccountPane = () => ({ + type: ActionTypes.TOGGLE_ACCOUNT_PANE, +}); + +const setGrant = grant => ({ + type: ActionTypes.SET_AUTH_GRANT, + payload: grant, +}); + +/** + * User has a cookie with only a session id. + * To know his username and other data we need to fetch + * a keycloak grant from server + */ +export const fetchGrant = () => dispatch => + fetch(getWhoamiUrl(), { credentials: 'include' }) + .then(res => (res.ok ? res.json() : null)) + .catch(() => null) + .then((grant) => { + dispatch(setGrant(grant)); + dispatch(updateCompileLimit(false)); + }); + +export const login = (username, password) => (dispatch) => { + const form = new URLSearchParams(); + form.set('username', username); + form.set('password', password); + + dispatch({ type: ActionTypes.LOGIN_STARTED }); + + fetch(getLoginUrl(), { method: 'POST', credentials: 'include', body: form }) + .then((res) => { + if (!res.ok) { + const err = new Error(res.statusText); + err.status = res.status; + throw err; + } + + return dispatch(fetchGrant()); + }) + .catch((err) => { + const errMessage = err.status === 403 + ? Messages.INCORRECT_CREDENTIALS + : Messages.UNAVAILABLE; + + return dispatch({ + type: ActionTypes.LOGIN_FAILED, + payload: errMessage, + }); + }); +}; + +export const logout = () => (dispatch) => { + fetch(getLogoutUrl(), { credentials: 'include' }) + .then(() => { + dispatch(setGrant(null)); + dispatch(updateCompileLimit(false)); + }); +}; + export default {}; diff --git a/packages/xod-client/src/user/components/AuthForm.jsx b/packages/xod-client/src/user/components/AuthForm.jsx new file mode 100644 index 00000000..02ecc2e7 --- /dev/null +++ b/packages/xod-client/src/user/components/AuthForm.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { getAuthFormUrl, getPasswordResetUrl } from '../../utils/urls'; + +import Button from '../../core/components/Button'; + +class AuthForm extends React.Component { + constructor(props) { + super(props); + + this.state = { + username: '', + password: '', + }; + + this.onUsernameChange = this.onUsernameChange.bind(this); + this.onPasswordChange = this.onPasswordChange.bind(this); + this.onLogin = this.onLogin.bind(this); + } + + onUsernameChange(e) { + this.setState({ username: e.target.value }); + } + + onPasswordChange(e) { + this.setState({ password: e.target.value }); + } + + onLogin() { + this.props.onLogin( + this.state.username, + this.state.password + ); + } + + render() { + const { isAuthorising, authError } = this.props; + const { username, password } = this.state; + + return ( +
+
+ Log in to increase quotas and access more features +
+ + + + + + {authError ?
{authError}
: null} + + Forgot password + +
+ + +
+
+ ); + } +} + +AuthForm.propTypes = { + onLogin: PropTypes.func.isRequired, + isAuthorising: PropTypes.bool.isRequired, + authError: PropTypes.string, +}; + +export default AuthForm; diff --git a/packages/xod-client/src/user/containers/AccountPane.jsx b/packages/xod-client/src/user/containers/AccountPane.jsx new file mode 100644 index 00000000..c0917906 --- /dev/null +++ b/packages/xod-client/src/user/containers/AccountPane.jsx @@ -0,0 +1,91 @@ +import R from 'ramda'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { foldMaybe } from 'xod-func-tools'; + +import Button from '../../core/components/Button'; + +import * as Actions from '../actions'; +import * as Selectors from '../selectors'; + +import AuthForm from '../components/AuthForm'; + +const AccountPane = ({ + user, + isVisible, + compilationsLeft, + actions, + isAuthorising, + authError, +}) => { + if (!isVisible) return null; + + return ( +
+ + × + + + {foldMaybe( +
Guest Xoder
, + ({ username }) => ( +
+
Logged in as
+
{username}
+
+ ), + user + )} + +
+
Daily quotas
+
+ Compilations: {compilationsLeft == null ? 'currently offline' : compilationsLeft} +
+
+ + {foldMaybe( + , + () => (), + user + )} +
+ ); +}; + +AccountPane.propTypes = { + compilationsLeft: PropTypes.number, + isVisible: PropTypes.bool.isRequired, + user: PropTypes.object, + isAuthorising: PropTypes.bool.isRequired, + authError: PropTypes.string, + actions: PropTypes.shape({ + toggleAccountPane: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types + login: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types + }), +}; + +const mapStateToProps = R.applySpec({ + isVisible: Selectors.isAccountPaneVisible, + user: Selectors.getUser, + compilationsLeft: Selectors.getCompileLimitLeft, + isAuthorising: Selectors.isAuthorising, + authError: Selectors.getAuthError, +}); +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators({ + toggleAccountPane: Actions.toggleAccountPane, + login: Actions.login, + logout: Actions.logout, + }, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(AccountPane); diff --git a/packages/xod-client/src/user/messages.js b/packages/xod-client/src/user/messages.js new file mode 100644 index 00000000..973f331e --- /dev/null +++ b/packages/xod-client/src/user/messages.js @@ -0,0 +1,2 @@ +export const INCORRECT_CREDENTIALS = 'Incorrect username or password'; +export const SERVICE_UNAVAILABLE = 'Service unavailable'; diff --git a/packages/xod-client/src/user/reducer.js b/packages/xod-client/src/user/reducer.js index 9b73b456..87f82a9d 100644 --- a/packages/xod-client/src/user/reducer.js +++ b/packages/xod-client/src/user/reducer.js @@ -1,10 +1,35 @@ -import { UPDATE_COMPILE_LIMIT } from './actionTypes'; +import R from 'ramda'; + +import * as ActionTypes from './actionTypes'; const userReducer = (state = {}, action) => { switch (action.type) { - case UPDATE_COMPILE_LIMIT: { - const limit = action.payload; - return { ...state, limit }; + case ActionTypes.TOGGLE_ACCOUNT_PANE: + return R.compose( + R.over(R.lensProp('isAccountPaneVisible'), R.not), + R.assoc('authError', null) + )(state); + case ActionTypes.UPDATE_COMPILE_LIMIT: + return R.assoc('limit', action.payload, state); + case ActionTypes.LOGIN_STARTED: + return R.merge(state, { + isAuthorising: true, + }); + case ActionTypes.LOGIN_FAILED: + return R.merge(state, { + isAuthorising: false, + authError: action.payload, + }); + case ActionTypes.SET_AUTH_GRANT: { + const grant = action.payload; + + if (grant == null) return R.assoc('grant', grant, state); + + return R.merge(state, { + grant, + isAuthorising: false, + authError: null, + }); } default: return state; diff --git a/packages/xod-client/src/user/selectors.js b/packages/xod-client/src/user/selectors.js index 152103d8..4b97714d 100644 --- a/packages/xod-client/src/user/selectors.js +++ b/packages/xod-client/src/user/selectors.js @@ -1,4 +1,5 @@ import R from 'ramda'; +import { Maybe } from 'ramda-fantasy'; import { createSelector } from 'reselect'; export const getUserState = R.prop('user'); @@ -12,3 +13,23 @@ export const getCompileLimitLeft = createSelector( getCompileLimit, limit => (limit ? limit.limit - limit.pending - limit.used : null) ); + +export const isAccountPaneVisible = createSelector( + getUserState, + R.prop('isAccountPaneVisible') +); + +export const isAuthorising = createSelector( + getUserState, + R.prop('isAuthorising') +); + +export const getAuthError = createSelector( + getUserState, + R.prop('authError') +); + +export const getUser = createSelector( + getUserState, + R.pipe(R.path(['grant', 'user']), Maybe) +); diff --git a/packages/xod-client/src/user/state.js b/packages/xod-client/src/user/state.js index 92948718..3247954b 100644 --- a/packages/xod-client/src/user/state.js +++ b/packages/xod-client/src/user/state.js @@ -1,3 +1,7 @@ export default { limit: null, + isAccountPaneVisible: false, + grant: null, + isAuthorising: false, + authError: null, }; diff --git a/packages/xod-client/src/utils/menu.js b/packages/xod-client/src/utils/menu.js index b4bc91b4..08a042f4 100644 --- a/packages/xod-client/src/utils/menu.js +++ b/packages/xod-client/src/utils/menu.js @@ -131,6 +131,10 @@ const rawItems = { label: 'Toggle Debugger', command: COMMAND.TOGGLE_DEBUGGER, }, + toggleAccountPane: { + key: 'toggleAccountPane', + label: 'Toggle Account Pane', + }, help: { key: 'help', diff --git a/packages/xod-client/src/utils/urls.js b/packages/xod-client/src/utils/urls.js index b1c75779..a355fe8b 100644 --- a/packages/xod-client/src/utils/urls.js +++ b/packages/xod-client/src/utils/urls.js @@ -32,9 +32,24 @@ export const getUtmForumUrl = getUtmUrl(process.env.XOD_FORUM_DOMAIN, '', 'forum const HOSTNAME = process.env.XOD_HOSTNAME || 'xod.io'; -// :: String -> String +// :: () -> String export const getCompileLimitUrl = () => - `https://compile.${HOSTNAME}/limits`; + `https://compile.${HOSTNAME}/limits/`; export const getPmSwaggerUrl = () => - `https://pm.${HOSTNAME}/swagger`; + `https://pm.${HOSTNAME}/swagger/`; + +export const getLoginUrl = () => + `https://${HOSTNAME}/auth/login/`; + +export const getLogoutUrl = () => + `https://${HOSTNAME}/auth/logout/`; + +export const getWhoamiUrl = () => + `https://${HOSTNAME}/auth/whoami/`; + +export const getAuthFormUrl = () => + `https://${HOSTNAME}/auth/#signup`; + +export const getPasswordResetUrl = () => + `https://auth.${HOSTNAME}/auth/realms/xod/login-actions/reset-credentials`; diff --git a/yarn.lock b/yarn.lock index 806493a0..400abaa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10481,6 +10481,10 @@ url-parse@^1.1.8, url-parse@^1.1.9: querystringify "~1.0.0" requires-port "1.0.x" +url-search-params-polyfill@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/url-search-params-polyfill/-/url-search-params-polyfill-2.0.1.tgz#4db2bc1f81e253470b1d13db01b9bc9c18d4116e" + url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"