feat(xod-client, xod-client-electron, cod-client-browser): add account pane

Closes #889
This commit is contained in:
Evgeny Kochetkov
2017-11-27 16:55:50 +03:00
parent f360adf8cf
commit d706e8f2cc
21 changed files with 477 additions and 12 deletions

View File

@@ -35,6 +35,7 @@ module.exports = {
beforeEach: true,
after: true,
afterEach: true,
URLSearchParams: true,
},
rules: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
'base/base';
@import
'components/AccountPane',
'components/App',
'components/BackgroundLayer',
'components/Breadcrumbs',

View File

@@ -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 {
</Workarea>
</FocusTrap>
<Helpbar />
<AccountPane />
<DragLayer />
</HotKeys>
);

View File

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

View File

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

View File

@@ -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 (
<div>
<div className="whyLogin">
Log in to increase quotas and access more features
</div>
<input
className="inspectorInput inspectorInput--full-width"
type="text"
placeholder="username or email"
value={username}
onChange={this.onUsernameChange}
/>
<input
className="inspectorInput inspectorInput--full-width"
placeholder="password"
type="password"
value={password}
onChange={this.onPasswordChange}
/>
{authError ? <div className="authError">{authError}</div> : null}
<a
className="forgotPassword"
href={getPasswordResetUrl()}
target="_blank"
rel="noopener noreferrer"
>Forgot password</a>
<div className="ButtonsRow">
<Button
light
onClick={this.onLogin}
disabled={isAuthorising}
>
Log In
</Button>
<Button
as="a"
target="_blank"
rel="noopener noreferrer"
href={getAuthFormUrl()}
>
Sign Up
</Button>
</div>
</div>
);
}
}
AuthForm.propTypes = {
onLogin: PropTypes.func.isRequired,
isAuthorising: PropTypes.bool.isRequired,
authError: PropTypes.string,
};
export default AuthForm;

View File

@@ -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 (
<div className="AccountPane">
<a
role="button"
tabIndex="0"
className="close-button"
onClick={actions.toggleAccountPane}
>
&times;
</a>
{foldMaybe(
<div className="username">Guest Xoder</div>,
({ username }) => (
<div>
<div>Logged in as</div>
<div className="username">{username}</div>
</div>
),
user
)}
<div className="dailyQuotas">
<div className="title">Daily quotas</div>
<div>
Compilations: {compilationsLeft == null ? 'currently offline' : compilationsLeft}
</div>
</div>
{foldMaybe(
<AuthForm onLogin={actions.login} {...{ isAuthorising, authError }} />,
() => (<Button onClick={actions.logout}>Logout</Button>),
user
)}
</div>
);
};
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);

View File

@@ -0,0 +1,2 @@
export const INCORRECT_CREDENTIALS = 'Incorrect username or password';
export const SERVICE_UNAVAILABLE = 'Service unavailable';

View File

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

View File

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

View File

@@ -1,3 +1,7 @@
export default {
limit: null,
isAccountPaneVisible: false,
grant: null,
isAuthorising: false,
authError: null,
};

View File

@@ -131,6 +131,10 @@ const rawItems = {
label: 'Toggle Debugger',
command: COMMAND.TOGGLE_DEBUGGER,
},
toggleAccountPane: {
key: 'toggleAccountPane',
label: 'Toggle Account Pane',
},
help: {
key: 'help',

View File

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

View File

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