Commit 012f8970 authored by Nicolas Frandeboeuf's avatar Nicolas Frandeboeuf Committed by Benjamin Bouvier
Browse files

Implement demo mode; fixes #743

parent 48f755e5
import React from 'react';
import { connect } from 'react-redux';
import { get, actions } from '../../store';
import { translate as $t } from '../../helpers';
import { MODAL_SLUG } from './disable-demo-modal';
const DemoButton = connect(
state => {
return {
isDemoMode: get.isDemoMode(state)
};
},
dispatch => {
return {
handleClick() {
actions.showModal(dispatch, MODAL_SLUG);
}
};
}
)(props => {
if (!props.isDemoMode) {
return null;
}
return (
<button type="reset" className="btn warning disable-demo-mode" onClick={props.handleClick}>
{$t('client.demo.disable')}
</button>
);
});
export default DemoButton;
import React from 'react';
import { connect } from 'react-redux';
import { actions } from '../../store';
import { translate as $t } from '../../helpers';
import { registerModal } from '../ui/modal';
import ModalContent from '../ui/modal/content';
import CancelAndWarn from '../ui/modal/cancel-and-warn-buttons';
export const MODAL_SLUG = 'disable-demo-params';
const DisableDemoModal = connect(
null,
dispatch => {
return {
handleDisable() {
actions.disableDemoMode(dispatch);
actions.hideModal(dispatch);
}
};
}
)(props => {
const body = <p>{$t('client.demo.disable_warning')}</p>;
const footer = (
<CancelAndWarn onConfirm={props.handleDisable} warningLabel={$t('client.demo.disable')} />
);
return <ModalContent title={$t('client.demo.title')} body={body} footer={footer} />;
});
registerModal(MODAL_SLUG, () => <DisableDemoModal />);
......@@ -8,6 +8,7 @@ import NewBankForm from '../settings/bank-accesses/form';
import ImportModule from '../settings/backup/import';
import LocaleSelector from '../settings/customization/locale-selector';
import Demo from './demo';
import WeboobInstallReadme from './weboob-readme';
const BASE_PATH = URL.initialize.url();
......@@ -16,7 +17,6 @@ const IMPORT_PATH = URL.initialize.url('import');
const DEMO_PATH = URL.initialize.url('demo-mode');
export default class AccountWizard extends React.Component {
// TODO: Demo mode should be implemented
renderMenu = () => (
<div>
<header>
......@@ -58,7 +58,7 @@ export default class AccountWizard extends React.Component {
</p>
</Link>
<Link to={DEMO_PATH} className="disabled">
<Link to={DEMO_PATH}>
<h3>
<i className="fa fa-laptop small-only" />
{$t('client.accountwizard.menu.demo_title')}
......@@ -105,19 +105,7 @@ export default class AccountWizard extends React.Component {
</div>
);
renderDemoMode = () => (
<div>
<header>
<h1>{$t('client.accountwizard.demomode')}</h1>
</header>
<p>{$t('client.accountwizard.demomode_description')}</p>
<Link className="btn danger" to={BASE_PATH}>
{$t('client.general.cancel')}
</Link>
</div>
);
renderDemoMode = () => <Demo />;
renderWeboobReadme = () => <WeboobInstallReadme />;
......
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { actions } from '../../store';
import { translate as $t } from '../../helpers';
import URL from '../../urls';
const BASE_PATH = URL.initialize.url();
const Demo = connect(
null,
dispatch => {
return {
handleEnableDemoMode() {
actions.enableDemoMode(dispatch);
}
};
}
)(props => {
return (
<div>
<header>
<h1>{$t('client.demo.title')}</h1>
</header>
<p>{$t('client.demo.description')}</p>
<p className="buttons-toolbar">
<Link className="btn danger" to={BASE_PATH}>
{$t('client.general.cancel')}
</Link>
<button type="button" className="btn primary" onClick={props.handleEnableDemoMode}>
{$t('client.general.continue')}
</button>
</p>
</div>
);
});
export default Demo;
......@@ -129,6 +129,10 @@ header .section-title {
text-overflow: ellipsis;
}
header .disable-demo-mode {
margin-right: 2ex;
}
header .settings-dropdown {
margin-right: 2ex;
}
......
......@@ -51,11 +51,6 @@
padding: 1em;
}
/* TODO: Remove once demo mode is implemented. */
.init-panes .disabled {
pointer-events: none;
}
.init-panes > a {
flex: auto;
width: 30%;
......@@ -118,6 +113,15 @@
margin-bottom: 2em;
}
.wizard-content .buttons-toolbar {
display: flex;
justify-content: flex-end;
}
.wizard-content .buttons-toolbar > *:not(:last-child) {
margin-right: 2ex;
}
@media (max-width: 768px) {
.wizard header {
height: auto;
......
......@@ -27,6 +27,8 @@ import AccountWizard from './components/init/account-wizard';
import Menu from './components/menu';
import DropdownMenu from './components/menu/dropdown';
import DemoButton from './components/header/demo-button';
import { LoadingMessage, LoadingOverlay } from './components/ui/loading';
import ThemeLoaderTag from './components/ui/theme-link';
import ErrorReporter from './components/ui/error-reporter';
......@@ -135,6 +137,9 @@ class BaseApp extends React.Component {
<Link to="/">{$t('client.KRESUS')}</Link>
</h1>
<Route path={URL.sections.pattern} render={this.makeSectionTitle} />
<DemoButton />
<DropdownMenu currentAccountId={currentAccountId} />
</header>
......
......@@ -50,3 +50,7 @@ export const UPDATE_WEBOOB = 'UPDATE_WEBOOB';
export const GET_WEBOOB_VERSION = 'GET_WEBOOB_VERSION';
export const GET_LOGS = 'GET_LOGS';
export const CLEAR_LOGS = 'CLEAR_LOGS';
// Demo
export const ENABLE_DEMO_MODE = 'ENABLE_DEMO_MODE';
export const DISABLE_DEMO_MODE = 'DISABLE_DEMO_MODE';
......@@ -381,3 +381,15 @@ export function clearLogs() {
method: 'DELETE'
});
}
export function enableDemoMode() {
return buildFetchPromise(`api/${API_VERSION}/demo`, {
method: 'POST'
});
}
export function disableDemoMode() {
return buildFetchPromise(`api/${API_VERSION}/demo`, {
method: 'DELETE'
});
}
......@@ -14,7 +14,7 @@ import DefaultSettings from '../../shared/default-settings';
import { FAIL, SUCCESS, fillOutcomeHandlers } from './helpers';
import { IMPORT_INSTANCE } from './actions';
import { IMPORT_INSTANCE, ENABLE_DEMO_MODE, DISABLE_DEMO_MODE } from './actions';
import { assert, assertHas, assertDefined, debug } from '../helpers';
......@@ -22,14 +22,17 @@ import * as backend from './backend';
import { genericErrorHandler } from '../errors';
const actionsWithStateReset = [IMPORT_INSTANCE, ENABLE_DEMO_MODE, DISABLE_DEMO_MODE];
// Augment basic reducers so that they can handle state reset:
// - if the event is a state reset (IMPORT_INSTANCE), pass the new sub-state to the reducer.
// - otherwise, apply to the actual reducer.
function augmentReducer(reducer, field) {
return (state, action) => {
if (action.type === IMPORT_INSTANCE && action.status === SUCCESS) {
if (actionsWithStateReset.includes(action.type) && action.status === SUCCESS) {
return reducer(action.state[field], action);
}
return reducer(state, action);
};
}
......@@ -228,6 +231,11 @@ export const get = {
return Ui.isMenuHidden(state.ui);
},
isDemoMode(state) {
assertDefined(state);
return Ui.isDemoMode(state.ui);
},
// *** Categories *********************************************************
// Categories
categories(state) {
......@@ -456,6 +464,16 @@ export const actions = {
dispatch(Ui.toggleMenu(hideMenu));
},
enableDemoMode(dispatch) {
assertDefined(dispatch);
dispatch(enableDemo());
},
disableDemoMode(dispatch) {
assertDefined(dispatch);
dispatch(disableDemo());
},
// *** Settings ***********************************************************
updateWeboob(dispatch) {
assertDefined(dispatch);
......@@ -649,7 +667,7 @@ export function init() {
state.themes = world.themes;
// The UI must be computed at the end.
state.ui = Ui.initialState();
state.ui = Ui.initialState(get.setting(state, 'demo-mode'));
return new Promise(accept => {
accept(state);
......@@ -669,6 +687,20 @@ const basic = {
data,
state
};
},
enableDemo(state) {
return {
type: ENABLE_DEMO_MODE,
state
};
},
disableDemo(state) {
return {
type: DISABLE_DEMO_MODE,
state
};
}
};
......@@ -693,3 +725,37 @@ function importInstance(data, maybePassword) {
});
};
}
function enableDemo() {
return dispatch => {
dispatch(basic.enableDemo());
return backend
.enableDemoMode()
.then(() => {
return init();
})
.then(newState => {
dispatch(success.enableDemo(newState));
})
.catch(err => {
dispatch(fail.enableDemo(err));
});
};
}
function disableDemo() {
return dispatch => {
dispatch(basic.disableDemo());
return backend
.disableDemoMode()
.then(() => {
return init();
})
.then(newState => {
dispatch(success.disableDemo(newState));
})
.catch(err => {
dispatch(fail.disableDemo(err));
});
};
}
......@@ -10,7 +10,9 @@ import {
TOGGLE_SEARCH_DETAILS,
LOAD_THEME,
UPDATE_MODAL,
TOGGLE_MENU
TOGGLE_MENU,
ENABLE_DEMO_MODE,
DISABLE_DEMO_MODE
} from './actions';
import { translate as $t, computeIsSmallScreen, notify } from '../helpers';
......@@ -81,6 +83,18 @@ const basic = {
type: TOGGLE_MENU,
hideMenu
};
},
enableDemo() {
return {
type: ENABLE_DEMO_MODE
};
},
disableDemo() {
return {
type: DISABLE_DEMO_MODE
};
}
};
......@@ -306,7 +320,9 @@ const reducers = {
UPDATE_WEBOOB: reduceUpdateWeboob,
EXPORT_INSTANCE: reduceExportInstance,
SET_IS_SMALL_SCREEN: reduceSetIsSmallScreen,
TOGGLE_MENU: reduceToggleMenu
TOGGLE_MENU: reduceToggleMenu,
ENABLE_DEMO_MODE: makeProcessingReasonReducer('client.demo.enabling'),
DISABLE_DEMO_MODE: makeProcessingReasonReducer('client.demo.disabling')
};
const uiState = u({
......@@ -314,7 +330,8 @@ const uiState = u({
displaySearchDetails: false,
processingReason: 'client.general.loading_assets',
updatingWeboob: false,
sendingTestEmail: false
sendingTestEmail: false,
isDemoMode: false
});
export const reducer = createReducerFromMap(uiState, reducers);
......@@ -332,7 +349,7 @@ function initialSearch() {
};
}
export function initialState() {
export function initialState(isDemoEnabled) {
let search = initialSearch();
return u(
{
......@@ -341,6 +358,7 @@ export function initialState() {
processingReason: 'client.general.loading_assets',
updatingWeboob: false,
sendingTestEmail: false,
isDemoMode: isDemoEnabled,
isExporting: false,
isSmallScreen: computeIsSmallScreen(),
modal: {
......@@ -402,3 +420,7 @@ export function getModal(state) {
export function isMenuHidden(state) {
return state.isMenuHidden;
}
export function isDemoMode(state) {
return state.isDemoMode;
}
......@@ -12,15 +12,6 @@
color: inherit;
}
/* TODO: Remove once demo mode is implemented. */
.init-panes .disabled {
background-color: rgba(0, 0, 0, 0.3);
}
.init-panes .disabled > * {
opacity: 0.5;
}
.init-panes > a {
border-color: #C5C5C5;
border-radius: 2px;
......
......@@ -25,7 +25,7 @@ from weboob.tools.value import Value, ValueBackendPassword
class MockModule(object):
def __init__(self, name, description, config, backend='mock'):
def __init__(self, name, description, config, backend='mock-with-errors'):
self.name = name
self.description = description
self.config = config
......@@ -52,6 +52,8 @@ MANUAL_MODULES = [MockModule('manual', 'Manual Bank', BackendConfig(
Value('login'), ValueBackendPassword('password')), backend='manual')]
MOCK_MODULES = [
MockModule('demo', 'Demo bank', BackendConfig(
Value('login'), ValueBackendPassword('password')), backend='mock'),
MockModule('fakebank1', 'Fake Bank 1', BackendConfig(
Value('login'), ValueBackendPassword('password'))),
MockModule('fakebank2', 'Fake Bank 2', BackendConfig(
......
......@@ -27,51 +27,48 @@ export async function preloadAccess(req, res, next, accessId) {
}
}
// Destroy a given access, including accounts, alerts and operations.
export async function destroy(req, res) {
try {
let { id: userId } = req.user;
let access = req.preloaded.access;
log.info(`Removing access ${access.id} for bank ${access.vendorId}...`);
export async function destroyWithData(userId, access) {
log.info(`Removing access ${access.id} for bank ${access.vendorId}...`);
// TODO arguably, this should be done in the access model.
let accounts = await Accounts.byAccess(userId, access);
for (let account of accounts) {
await AccountController.destroyWithOperations(userId, account);
}
// TODO arguably, this should be done in the access model.
let accounts = await Accounts.byAccess(userId, access);
for (let account of accounts) {
await AccountController.destroyWithOperations(userId, account);
}
// The access should have been destroyed by the last account deletion.
let stillThere = await Accesses.exists(userId, access.id);
if (stillThere) {
log.error('Access should have been deleted! Manually deleting.');
await Accesses.destroy(userId, access.id);
}
// The access should have been destroyed by the last account deletion.
let stillThere = await Accesses.exists(userId, access.id);
if (stillThere) {
log.error('Access should have been deleted! Manually deleting.');
await Accesses.destroy(userId, access.id);
}
log.info('Done!');
}
log.info('Done!');
// Destroy a given access, including accounts, alerts and operations.
export async function destroy(req, res) {
try {
await destroyWithData(req.user.id, req.preloaded.access);
res.status(204).end();
} catch (err) {
return asyncErr(res, err, 'when destroying an access');
}
}
// Creates a new bank access (expecting at least (vendorId / login /
// password)), and retrieves its accounts and operations.
export async function create(req, res) {
let access;
export async function createAndRetrieveData(userId, params) {
let error =
checkHasAllFields(params, ['vendorId', 'login', 'password']) ||
checkAllowedFields(params, ['vendorId', 'login', 'password', 'fields', 'customLabel']);
if (error) {
throw new KError(`when creating a new access: ${error}`, 400);
}
let access = null;
let createdAccess = false;
let retrievedAccounts = false;
let { id: userId } = req.user;
try {
let params = req.body;
let error =
checkHasAllFields(params, ['vendorId', 'login', 'password']) ||
checkAllowedFields(params, ['vendorId', 'login', 'password', 'fields', 'customLabel']);
if (error) {
throw new KError(`when creating a new access: ${error}`, 400);
}
access = await Accesses.create(userId, params);
createdAccess = true;
......@@ -83,11 +80,11 @@ export async function create(req, res) {
access
);
res.status(201).json({
return {
accessId: access.id,
accounts,
newOperations
});
};
} catch (err) {
log.error('The access process creation failed, cleaning up...');
......@@ -106,6 +103,18 @@ export async function create(req, res) {
await Accesses.destroy(userId, access.id);
}
// Rethrow the error
throw err;
}
}
// Creates a new bank access (expecting at least (vendorId / login /
// password)), and retrieves its accounts and operations.
export async function create(req, res) {
try {
const data = await createAndRetrieveData(req.user.id, req.body);
res.status(201).json(data);
} catch (err) {
return asyncErr(res, err, 'when creating a bank access');
}
}
......
import Accesses from '../../models/accesses';
import Categories from '../../models/categories';
import Settings from '../../models/settings';
import { asyncErr, KError, translate as $t } from '../../helpers';
import {
createAndRetrieveData as createAndRetrieveAccessData,
destroyWithData as destroyAccessWithData
} from './accesses';
import DefaultCategories from '../../shared/default-categories.json';
export async function isEnabled(userId) {
return await Settings.findOrCreateDefaultBooleanValue(userId, 'demo-mode');
}
export async function enable(req, res) {
try {
let { id: userId } = req.user;
let isDemoEnabled = await isEnabled(userId);
if (isDemoEnabled) {