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"