1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

fix: one and only one front (#244)

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Ivar Conradi Østhus 2021-02-24 11:03:18 +01:00 committed by GitHub
parent 8a72a7ef5d
commit 5342c86b60
73 changed files with 2179 additions and 81 deletions

View File

@ -112,8 +112,8 @@ The latest version of this document is always available in
- feat: added time-ago to toggle-list - feat: added time-ago to toggle-list
- feat: Add stale marking of feature toggles - feat: Add stale marking of feature toggles
- feat: add support for toggle type (#220) - feat: add support for toggle type (#220)
- feat: stort by stale - feat: sort by stale
- fix: imporve type-chip color - fix: improve type-chip color
- fix: some ux cleanup for toggle types - fix: some ux cleanup for toggle types
# [3.4.0] # [3.4.0]
@ -124,7 +124,7 @@ The latest version of this document is always available in
- fix: upgrade react-dnd to version 11.1.3 - fix: upgrade react-dnd to version 11.1.3
- fix: Update react-dnd to the latest version 🚀 (#213) - fix: Update react-dnd to the latest version 🚀 (#213)
- fix: read unleash version from ui-config (#219) - fix: read unleash version from ui-config (#219)
- fix: flag inital context fields - fix: flag initial context fields
# [3.3.5] # [3.3.5]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -3,7 +3,7 @@ import FormComponent from './form-addon-component';
import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions'; import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
// Required for to fill the inital form. // Required for to fill the initial form.
const DEFAULT_DATA = { const DEFAULT_DATA = {
provider: '', provider: '',
description: '', description: '',

View File

@ -13,8 +13,8 @@ const Empty = () => (
you will require a Client SDK. you will require a Client SDK.
<br /> <br />
<br /> <br />
You can read more about the available Client SDKs in the{' '} You can read more about how to use Unleash in your application in the{' '}
<a href="https://unleash.github.io/docs/client_sdk">documentation.</a> <a href="https://www.unleash-hosted.com/docs/use-feature-toggle">documentation.</a>
</CardText> </CardText>
</React.Fragment> </React.Fragment>
); );

View File

@ -0,0 +1,2 @@
export const P = 'P';
export const C = 'C';

View File

@ -140,14 +140,15 @@ IconLink.propTypes = {
icon: PropTypes.string, icon: PropTypes.string,
}; };
export const DropdownButton = ({ label, id, className = styles.dropdownButton, title }) => ( export const DropdownButton = ({ label, id, className = styles.dropdownButton, title, style }) => (
<Button id={id} className={className} title={title}> <Button id={id} className={className} title={title} style={style}>
{label} {label}
<Icon name="arrow_drop_down" className="mdl-color-text--grey-600" /> <Icon name="arrow_drop_down" className="mdl-color-text--grey-600" />
</Button> </Button>
); );
DropdownButton.propTypes = { DropdownButton.propTypes = {
label: PropTypes.string, label: PropTypes.string,
style: PropTypes.object,
id: PropTypes.string, id: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
}; };
@ -185,3 +186,15 @@ export function calc(value, total, decimal) {
export function getDisplayName(WrappedComponent) { export function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component'; return WrappedComponent.displayName || WrappedComponent.name || 'Component';
} }
export const selectStyles = {
control: provided => ({
...provided,
border: '1px solid #607d8b',
boxShadow: '0',
':hover': {
borderColor: '#607d8b',
boxShadow: '0 0 0 1px #607d8b',
},
}),
};

View File

@ -6,6 +6,9 @@ const mapStateToProps = (state, props) => {
const contextFieldBase = { name: '', description: '', legalValues: [] }; const contextFieldBase = { name: '', description: '', legalValues: [] };
const field = state.context.toJS().find(n => n.name === props.contextFieldName); const field = state.context.toJS().find(n => n.name === props.contextFieldName);
const contextField = Object.assign(contextFieldBase, field); const contextField = Object.assign(contextFieldBase, field);
if (!field) {
contextField.initial = true;
}
return { return {
contextField, contextField,

View File

@ -5,6 +5,14 @@ import { Button, Chip, Textfield, Card, CardTitle, CardText, CardActions, Checkb
import { FormButtons, styles as commonStyles } from '../common'; import { FormButtons, styles as commonStyles } from '../common';
import { trim } from '../common/util'; import { trim } from '../common/util';
const sortIgnoreCase = (a, b) => {
a = a.toLowerCase();
b = b.toLowerCase();
if (a === b) return 0;
if (a > b) return 1;
return -1;
};
class AddContextComponent extends Component { class AddContextComponent extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -17,7 +25,7 @@ class AddContextComponent extends Component {
} }
static getDerivedStateFromProps(props, state) { static getDerivedStateFromProps(props, state) {
if (!state.contextField.name && props.contextField.name) { if (state.contextField.initial && !props.contextField.initial) {
return { contextField: props.contextField }; return { contextField: props.contextField };
} else { } else {
return null; return null;
@ -62,6 +70,10 @@ class AddContextComponent extends Component {
evt.preventDefault(); evt.preventDefault();
const { contextField, currentLegalValue, errors } = this.state; const { contextField, currentLegalValue, errors } = this.state;
if (!currentLegalValue) {
return;
}
if (contextField.legalValues.indexOf(currentLegalValue) !== -1) { if (contextField.legalValues.indexOf(currentLegalValue) !== -1) {
errors.currentLegalValue = 'Duplicate legal value'; errors.currentLegalValue = 'Duplicate legal value';
this.setState({ errors }); this.setState({ errors });
@ -69,7 +81,7 @@ class AddContextComponent extends Component {
} }
const legalValues = contextField.legalValues.concat(trim(currentLegalValue)); const legalValues = contextField.legalValues.concat(trim(currentLegalValue));
contextField.legalValues = legalValues; contextField.legalValues = legalValues.sort(sortIgnoreCase);
this.setState({ this.setState({
contextField, contextField,
currentLegalValue: '', currentLegalValue: '',
@ -148,7 +160,9 @@ class AddContextComponent extends Component {
error={errors.currentLegalValue} error={errors.currentLegalValue}
onChange={this.updateCurrentLegalValue} onChange={this.updateCurrentLegalValue}
/> />
<Button onClick={this.addLegalValue}>Add</Button> <Button onClick={this.addLegalValue} colored accent raised>
Add
</Button>
<div>{contextField.legalValues.map(this.renderLegalValue)}</div> <div>{contextField.legalValues.map(this.renderLegalValue)}</div>
</section> </section>
<br /> <br />

View File

@ -5,7 +5,7 @@ import MySelect from '../common/select';
class FeatureTypeSelectComponent extends Component { class FeatureTypeSelectComponent extends Component {
componentDidMount() { componentDidMount() {
const { fetchFeatureTypes, types } = this.props; const { fetchFeatureTypes, types } = this.props;
if (types[0].inital && fetchFeatureTypes) { if (types[0].initial && fetchFeatureTypes) {
this.props.fetchFeatureTypes(); this.props.fetchFeatureTypes();
} }
} }

View File

@ -5,6 +5,13 @@ import { updateSettingForGroup } from '../../../store/settings/actions';
import FeatureListComponent from './list-component'; import FeatureListComponent from './list-component';
import { hasPermission } from '../../../permissions'; import { hasPermission } from '../../../permissions';
function checkConstraints(strategy, regex) {
if (!strategy.constraints) {
return;
}
return strategy.constraints.some(c => c.values.some(v => regex.test(v)));
}
export const mapStateToPropsConfigurable = isFeature => state => { export const mapStateToPropsConfigurable = isFeature => state => {
const featureMetrics = state.featureMetrics.toJS(); const featureMetrics = state.featureMetrics.toJS();
const settings = state.settings.toJS().feature || {}; const settings = state.settings.toJS().feature || {};
@ -19,6 +26,7 @@ export const mapStateToPropsConfigurable = isFeature => state => {
const regex = new RegExp(settings.filter, 'i'); const regex = new RegExp(settings.filter, 'i');
features = features.filter( features = features.filter(
feature => feature =>
feature.strategies.some(s => checkConstraints(s, regex)) ||
regex.test(feature.name) || regex.test(feature.name) ||
regex.test(feature.description) || regex.test(feature.description) ||
feature.strategies.some(s => s && s.name && regex.test(s.name)) || feature.strategies.some(s => s && s.name && regex.test(s.name)) ||

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Menu, MenuItem } from 'react-mdl'; import { Menu, MenuItem } from 'react-mdl';
import { DropdownButton } from '../../common'; import { DropdownButton } from '../../common';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -13,13 +13,7 @@ function projectItem(selectedId, item) {
); );
} }
function ProjectComponent({ fetchProjects, projects, currentProjectId, updateCurrentProject }) { function ProjectComponent({ projects, currentProjectId, updateCurrentProject }) {
useEffect(() => {
if (projects[0].inital) {
fetchProjects();
}
});
function setProject(v) { function setProject(v) {
const id = typeof v === 'string' ? v.trim() : ''; const id = typeof v === 'string' ? v.trim() : '';
updateCurrentProject(id); updateCurrentProject(id);
@ -38,6 +32,7 @@ function ProjectComponent({ fetchProjects, projects, currentProjectId, updateCur
<React.Fragment> <React.Fragment>
<DropdownButton <DropdownButton
className="mdl-color--amber-50" className="mdl-color--amber-50"
style={{ textTransform: 'none', fontWeight: 'normal' }}
id="project" id="project"
label={`${curentProject.name}`} label={`${curentProject.name}`}
title="Select project" title="Select project"

View File

@ -4,16 +4,16 @@ import MySelect from '../common/select';
class ProjectSelectComponent extends Component { class ProjectSelectComponent extends Component {
componentDidMount() { componentDidMount() {
const { fetchProjects, projects } = this.props; const { fetchProjects, projects, enabled } = this.props;
if (projects[0].inital && fetchProjects) { if (projects[0].initial && enabled) {
this.props.fetchProjects(); fetchProjects();
} }
} }
render() { render() {
const { value, projects, onChange, filled } = this.props; const { value, projects, onChange, filled, enabled } = this.props;
if (!projects || projects.length === 1) { if (!enabled) {
return null; return null;
} }
@ -30,6 +30,7 @@ class ProjectSelectComponent extends Component {
ProjectSelectComponent.propTypes = { ProjectSelectComponent.propTypes = {
value: PropTypes.string, value: PropTypes.string,
filled: PropTypes.bool, filled: PropTypes.bool,
enabled: PropTypes.bool,
projects: PropTypes.array.isRequired, projects: PropTypes.array.isRequired,
fetchProjects: PropTypes.func, fetchProjects: PropTypes.func,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,

View File

@ -1,14 +1,12 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ProjectSelectComponent from './project-select-component'; import ProjectSelectComponent from './project-select-component';
import { fetchProjects } from './../../store/project/actions'; import { fetchProjects } from './../../store/project/actions';
import { P } from '../common/flags';
const mapStateToProps = state => { const mapStateToProps = state => ({
const projects = state.projects.toJS(); projects: state.projects.toJS(),
enabled: !!state.uiConfig.toJS().flags[P],
return { });
projects,
};
};
const ProjectContainer = connect(mapStateToProps, { fetchProjects })(ProjectSelectComponent); const ProjectContainer = connect(mapStateToProps, { fetchProjects })(ProjectSelectComponent);

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import StrategyConstraintInput from './strategy-constraint-input';
import { C } from '../../../common/flags';
export default connect(
state => ({
contextNames: state.context.toJS().map(c => c.name),
contextFields: state.context.toJS(),
enabled: !!state.uiConfig.toJS().flags[C],
}),
{}
)(StrategyConstraintInput);

View File

@ -0,0 +1,128 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { IconButton } from 'react-mdl';
import Select from 'react-select';
import MySelect from '../../../common/select';
import InputListField from '../../../common/input-list-field';
import { selectStyles } from '../../../common';
const constraintOperators = [
{ key: 'IN', label: 'IN' },
{ key: 'NOT_IN', label: 'NOT_IN' },
];
export default class StrategyConstraintInputField extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
constraint: PropTypes.object.isRequired,
updateConstraint: PropTypes.func.isRequired,
removeConstraint: PropTypes.func.isRequired,
contextFields: PropTypes.array.isRequired,
};
constructor() {
super();
this.state = { error: undefined };
}
onBlur = evt => {
evt.preventDefault();
const { constraint, updateConstraint } = this.props;
const values = constraint.values;
const filtered = values.filter(v => v).map(v => v.trim());
if (filtered.length !== values.length) {
updateConstraint(filtered, 'values');
}
if (filtered.length === 0) {
this.setState({ error: 'You need to specify at least one value' });
} else {
this.setState({ error: undefined });
}
};
updateConstraintValues = evt => {
const { updateConstraint } = this.props;
const values = evt.target.value.split(/,\s?/);
const trimmedValues = values.map(v => v.trim());
updateConstraint(trimmedValues, 'values');
};
handleKeyDownConstraintValues = evt => {
const { updateConstraint } = this.props;
if (evt.key === 'Backspace') {
const currentValue = evt.target.value;
if (currentValue.endsWith(', ')) {
evt.preventDefault();
const value = currentValue.slice(0, -2);
updateConstraint(value.split(/,\s*/), 'values');
}
}
};
handleChangeValue = selectedOptions => {
const { updateConstraint } = this.props;
const values = selectedOptions ? selectedOptions.map(o => o.value) : [];
updateConstraint(values, 'values');
};
render() {
const { contextFields, constraint, removeConstraint, updateConstraint } = this.props;
const constraintContextNames = contextFields.map(f => ({ key: f.name, label: f.name }));
const constraintDef = contextFields.find(c => c.name === constraint.contextName);
const options =
constraintDef && constraintDef.legalValues && constraintDef.legalValues.length > 0
? constraintDef.legalValues.map(l => ({ value: l, label: l }))
: undefined;
const values = constraint.values.map(v => ({ value: v, label: v }));
return (
<tr>
<td>
<MySelect
name="contextName"
label="Context Field"
options={constraintContextNames}
value={constraint.contextName}
onChange={evt => updateConstraint(evt.target.value, 'contextName')}
style={{ width: 'auto' }}
/>
</td>
<td>
<MySelect
name="operator"
label="Operator"
options={constraintOperators}
value={constraint.operator}
onChange={evt => updateConstraint(evt.target.value, 'operator')}
style={{ width: 'auto' }}
/>
</td>
<td style={{ width: '100%' }}>
{options ? (
<Select
styles={selectStyles}
value={values}
options={options}
isMulti
onChange={this.handleChangeValue}
/>
) : (
<InputListField
name="values"
error={this.state.error}
onBlur={this.onBlur}
values={constraint.values}
label="Values (v1, v2, v3)"
updateValues={values => updateConstraint(values, 'values')}
/>
)}
</td>
<td>
<IconButton name="delete" onClick={removeConstraint} />
</td>
</tr>
);
}
}

View File

@ -0,0 +1,87 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Tooltip, Icon } from 'react-mdl';
import StrategyConstraintInputField from './strategy-constraint-input-field';
export default class StrategyConstraintInput extends Component {
static propTypes = {
constraints: PropTypes.array.isRequired,
updateConstraints: PropTypes.func.isRequired,
contextNames: PropTypes.array.isRequired,
contextFields: PropTypes.array.isRequired,
enabled: PropTypes.bool.isRequired,
};
constructor() {
super();
this.state = { errors: [] };
}
addConstraint = evt => {
evt.preventDefault();
const { constraints, updateConstraints, contextNames } = this.props;
const updatedConstraints = [...constraints];
updatedConstraints.push({ contextName: contextNames[0], operator: 'IN', values: [] });
updateConstraints(updatedConstraints);
};
removeConstraint = (index, evt) => {
evt.preventDefault();
const { constraints, updateConstraints } = this.props;
const updatedConstraints = [...constraints];
updatedConstraints.splice(index, 1);
updateConstraints(updatedConstraints);
};
updateConstraint = (index, value, field) => {
const { constraints } = this.props;
// TOOD: value should be array
const updatedConstraints = [...constraints];
const constraint = updatedConstraints[index];
constraint[field] = value;
this.props.updateConstraints(updatedConstraints);
};
render() {
const { constraints, contextFields, enabled } = this.props;
if (!enabled) {
return null;
}
return (
<div>
<strong>{'Constraints '}</strong>
<Tooltip label={<span>Use context fields to constrain the activation strategy.</span>}>
<Icon name="info" style={{ fontSize: '0.9em', color: 'gray' }} />
</Tooltip>
<table>
<tbody>
{constraints.map((c, index) => (
<StrategyConstraintInputField
key={`${c.contextName}-${index}`}
id={`${c.contextName}-${index}`}
constraint={c}
contextFields={contextFields}
updateConstraint={this.updateConstraint.bind(this, index)}
removeConstraint={this.removeConstraint.bind(this, index)}
/>
))}
</tbody>
</table>
<p>
<a href="#add-constraint" title="Add constraint" onClick={this.addConstraint}>
Add constraint
</a>
</p>
</div>
);
}
}

View File

@ -12,6 +12,8 @@ import LoadingStrategy from './loading-strategy';
import styles from './strategy.module.scss'; import styles from './strategy.module.scss';
import StrategyConstraints from './constraint/strategy-constraint-input-container';
export default class StrategyConfigureComponent extends React.Component { export default class StrategyConfigureComponent extends React.Component {
/* eslint-enable */ /* eslint-enable */
static propTypes = { static propTypes = {
@ -113,6 +115,10 @@ export default class StrategyConfigureComponent extends React.Component {
</CardTitle> </CardTitle>
<CardText style={{ width: 'unset' }}> <CardText style={{ width: 'unset' }}>
<StrategyConstraints
updateConstraints={this.updateConstraints}
constraints={strategy.constraints || []}
/>
<InputType <InputType
parameters={parameters} parameters={parameters}
strategy={strategy} strategy={strategy}

View File

@ -7,6 +7,7 @@ import { UPDATE_FEATURE } from '../../../../permissions';
import { weightTypes } from '../enums'; import { weightTypes } from '../enums';
jest.mock('react-mdl'); jest.mock('react-mdl');
jest.mock('../e-override-config', () => 'OverrideConfig');
test('renders correctly with without variants', () => { test('renders correctly with without variants', () => {
const tree = renderer.create( const tree = renderer.create(

View File

@ -6,7 +6,7 @@ import styles from './variant.module.scss';
import MySelect from '../../common/select'; import MySelect from '../../common/select';
import { trim, modalStyles } from '../../common/util'; import { trim, modalStyles } from '../../common/util';
import { weightTypes } from './enums'; import { weightTypes } from './enums';
import OverrideConfig from './override-config'; import OverrideConfig from './e-override-config';
Modal.setAppElement('#app'); Modal.setAppElement('#app');

View File

@ -0,0 +1,81 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { Grid, Cell, IconButton } from 'react-mdl';
import Select from 'react-select';
import MySelect from '../../common/select';
import InputListField from '../../common/input-list-field';
import { selectStyles } from '../../common';
function OverrideConfig({ overrides, updateOverrideType, updateOverrideValues, removeOverride, contextDefinitions }) {
const contextNames = contextDefinitions.map(c => ({ key: c.name, label: c.name }));
const updateValues = i => values => {
updateOverrideValues(i, values);
};
const updateSelectValues = i => values => {
updateOverrideValues(i, values ? values.map(v => v.value) : undefined);
};
const mapSelectValues = (values = []) => values.map(v => ({ label: v, value: v }));
return overrides.map((o, i) => {
const legalValues = contextDefinitions.find(c => c.name === o.contextName).legalValues || [];
const options = legalValues.map(v => ({ value: v, label: v, key: v }));
return (
<Grid noSpacing key={`override=${i}`}>
<Cell col={3}>
<MySelect
name="contextName"
label="Context Field"
value={o.contextName}
options={contextNames}
onChange={updateOverrideType(i)}
/>
</Cell>
<Cell col={8}>
{legalValues && legalValues.length > 0 ? (
<div style={{ paddingTop: '12px' }}>
<Select
key={`override-select=${i}`}
styles={selectStyles}
value={mapSelectValues(o.values)}
options={options}
isMulti
onChange={updateSelectValues(i)}
/>
</div>
) : (
<InputListField
label="Values (v1, v2, ...)"
name="values"
placeholder=""
style={{ width: '100%' }}
values={o.values}
updateValues={updateValues(i)}
/>
)}
</Cell>
<Cell col={1} style={{ textAlign: 'right', paddingTop: '12px' }}>
<IconButton name="delete" onClick={removeOverride(i)} />
</Cell>
</Grid>
);
});
}
OverrideConfig.propTypes = {
overrides: PropTypes.array.isRequired,
updateOverrideType: PropTypes.func.isRequired,
updateOverrideValues: PropTypes.func.isRequired,
removeOverride: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
contextDefinitions: state.context.toJS(),
});
export default connect(mapStateToProps, {})(OverrideConfig);

View File

@ -43,6 +43,20 @@ exports[`should render DrawerMenu 1`] = `
> >
Applications Applications
</a> </a>
<a
aria-current={null}
href="/context"
onClick={[Function]}
>
Context Fields
</a>
<a
aria-current={null}
href="/projects"
onClick={[Function]}
>
Projects
</a>
<a <a
aria-current={null} aria-current={null}
href="/tag-types" href="/tag-types"
@ -161,6 +175,20 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
> >
Applications Applications
</a> </a>
<a
aria-current={null}
href="/context"
onClick={[Function]}
>
Context Fields
</a>
<a
aria-current={null}
href="/projects"
onClick={[Function]}
>
Projects
</a>
<a <a
aria-current={null} aria-current={null}
href="/tag-types" href="/tag-types"

View File

@ -32,6 +32,20 @@ Array [
"path": "/applications", "path": "/applications",
"title": "Applications", "title": "Applications",
}, },
Object {
"component": [Function],
"flag": "C",
"icon": "album",
"path": "/context",
"title": "Context Fields",
},
Object {
"component": [Function],
"flag": "P",
"icon": "folder_open",
"path": "/projects",
"title": "Projects",
},
Object { Object {
"component": [Function], "component": [Function],
"icon": "label", "icon": "label",
@ -148,8 +162,8 @@ Array [
}, },
Object { Object {
"component": [Function], "component": [Function],
"hidden": true, "flag": "C",
"icon": "apps", "icon": "album",
"path": "/context", "path": "/context",
"title": "Context Fields", "title": "Context Fields",
}, },
@ -167,11 +181,36 @@ Array [
}, },
Object { Object {
"component": [Function], "component": [Function],
"hidden": true, "flag": "P",
"icon": "folder_open", "icon": "folder_open",
"path": "/projects", "path": "/projects",
"title": "Projects", "title": "Projects",
}, },
Object {
"component": [Function],
"parent": "/admin",
"path": "/admin/api",
"title": "API access",
},
Object {
"component": [Function],
"parent": "/admin",
"path": "/admin/users",
"title": "Users",
},
Object {
"component": [Function],
"parent": "/admin",
"path": "/admin/auth",
"title": "Authentication",
},
Object {
"component": [Function],
"hidden": true,
"icon": "album",
"path": "/admin",
"title": "Admin",
},
Object { Object {
"component": [Function], "component": [Function],
"parent": "/tag-types", "parent": "/tag-types",

View File

@ -1,12 +1,12 @@
import { routes, baseRoutes, getRoute } from '../routes'; import { routes, baseRoutes, getRoute } from '../routes';
test('returns all defined routes', () => { test('returns all defined routes', () => {
expect(routes.length).toEqual(28); expect(routes.length).toEqual(32);
expect(routes).toMatchSnapshot(); expect(routes).toMatchSnapshot();
}); });
test('returns all baseRoutes', () => { test('returns all baseRoutes', () => {
expect(baseRoutes.length).toEqual(8); expect(baseRoutes.length).toEqual(10);
expect(baseRoutes).toMatchSnapshot(); expect(baseRoutes).toMatchSnapshot();
}); });

View File

@ -6,6 +6,13 @@ import styles from '../styles.module.scss';
import { baseRoutes as routes } from './routes'; import { baseRoutes as routes } from './routes';
const filterByFlags = flags => r => {
if (r.flag && !flags[r.flag]) {
return false;
}
return true;
};
function getIcon(name) { function getIcon(name) {
if (name === 'c_github') { if (name === 'c_github') {
return <i className={['material-icons', styles.navigationIcon, styles.iconGitHub].join(' ')} />; return <i className={['material-icons', styles.navigationIcon, styles.iconGitHub].join(' ')} />;
@ -41,7 +48,7 @@ function renderLink(link) {
} }
} }
export const DrawerMenu = ({ links = [], title = 'Unleash' }) => ( export const DrawerMenu = ({ links = [], title = 'Unleash', flags = {} }) => (
<Drawer style={{ boxShadow: 'none', border: 0 }}> <Drawer style={{ boxShadow: 'none', border: 0 }}>
<span className={[styles.drawerTitle, 'mdl-layout-title'].join(' ')}> <span className={[styles.drawerTitle, 'mdl-layout-title'].join(' ')}>
<img src="public/logo.png" width="32" height="32" className={styles.drawerTitleLogo} /> <img src="public/logo.png" width="32" height="32" className={styles.drawerTitleLogo} />
@ -49,7 +56,7 @@ export const DrawerMenu = ({ links = [], title = 'Unleash' }) => (
</span> </span>
<hr /> <hr />
<Navigation className={styles.navigation}> <Navigation className={styles.navigation}>
{routes.map(item => ( {routes.filter(filterByFlags(flags)).map(item => (
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
@ -70,4 +77,5 @@ export const DrawerMenu = ({ links = [], title = 'Unleash' }) => (
DrawerMenu.propTypes = { DrawerMenu.propTypes = {
links: PropTypes.array, links: PropTypes.array,
title: PropTypes.string, title: PropTypes.string,
flags: PropTypes.object,
}; };

View File

@ -6,17 +6,18 @@ import { Route } from 'react-router-dom';
import { DrawerMenu } from './drawer'; import { DrawerMenu } from './drawer';
import Breadcrum from './breadcrumb'; import Breadcrum from './breadcrumb';
import ShowUserContainer from '../user/show-user-container'; import ShowUserContainer from '../user/show-user-container';
import { loadInitalData } from './../../store/loader'; import { loadInitialData } from './../../store/loader';
class HeaderComponent extends PureComponent { class HeaderComponent extends PureComponent {
static propTypes = { static propTypes = {
uiConfig: PropTypes.object.isRequired, uiConfig: PropTypes.object.isRequired,
loadInitalData: PropTypes.func.isRequired, init: PropTypes.func.isRequired,
location: PropTypes.object.isRequired, location: PropTypes.object.isRequired,
}; };
componentDidMount() { componentDidMount() {
this.props.loadInitalData(); const { init, uiConfig } = this.props;
init(uiConfig.flags);
} }
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
@ -35,7 +36,7 @@ class HeaderComponent extends PureComponent {
} }
render() { render() {
const { headerBackground, links, name } = this.props.uiConfig; const { headerBackground, links, name, flags } = this.props.uiConfig;
const style = headerBackground ? { background: headerBackground } : {}; const style = headerBackground ? { background: headerBackground } : {};
return ( return (
<React.Fragment> <React.Fragment>
@ -44,10 +45,10 @@ class HeaderComponent extends PureComponent {
<ShowUserContainer /> <ShowUserContainer />
</Navigation> </Navigation>
</Header> </Header>
<DrawerMenu links={links} title={name} /> <DrawerMenu links={links} title={name} flags={flags} />
</React.Fragment> </React.Fragment>
); );
} }
} }
export default connect(state => ({ uiConfig: state.uiConfig.toJS() }), { loadInitalData })(HeaderComponent); export default connect(state => ({ uiConfig: state.uiConfig.toJS() }), { init: loadInitialData })(HeaderComponent);

View File

@ -26,6 +26,12 @@ import CreateTag from '../../page/tags/create';
import Addons from '../../page/addons'; import Addons from '../../page/addons';
import AddonsCreate from '../../page/addons/create'; import AddonsCreate from '../../page/addons/create';
import AddonsEdit from '../../page/addons/edit'; import AddonsEdit from '../../page/addons/edit';
import Admin from '../../page/admin';
import AdminApi from '../../page/admin/api';
import AdminUsers from '../../page/admin/users';
import AdminAuth from '../../page/admin/auth';
import { P, C } from '../common/flags';
export const routes = [ export const routes = [
// Features // Features
{ path: '/features/create', parent: '/features', title: 'Create', component: CreateFeatureToggle }, { path: '/features/create', parent: '/features', title: 'Create', component: CreateFeatureToggle },
@ -58,12 +64,18 @@ export const routes = [
// Context // Context
{ path: '/context/create', parent: '/context', title: 'Create', component: CreateContextField }, { path: '/context/create', parent: '/context', title: 'Create', component: CreateContextField },
{ path: '/context/edit/:name', parent: '/context', title: ':name', component: EditContextField }, { path: '/context/edit/:name', parent: '/context', title: ':name', component: EditContextField },
{ path: '/context', title: 'Context Fields', icon: 'apps', component: ContextFields, hidden: true }, { path: '/context', title: 'Context Fields', icon: 'album', component: ContextFields, flag: C },
// Project // Project
{ path: '/projects/create', parent: '/projects', title: 'Create', component: CreateProject }, { path: '/projects/create', parent: '/projects', title: 'Create', component: CreateProject },
{ path: '/projects/edit/:id', parent: '/projects', title: ':id', component: EditProject }, { path: '/projects/edit/:id', parent: '/projects', title: ':id', component: EditProject },
{ path: '/projects', title: 'Projects', icon: 'folder_open', component: ListProjects, hidden: true }, { path: '/projects', title: 'Projects', icon: 'folder_open', component: ListProjects, flag: P },
// Admin
{ path: '/admin/api', parent: '/admin', title: 'API access', component: AdminApi },
{ path: '/admin/users', parent: '/admin', title: 'Users', component: AdminUsers },
{ path: '/admin/auth', parent: '/admin', title: 'Authentication', component: AdminAuth },
{ path: '/admin', title: 'Admin', icon: 'album', component: Admin, hidden: true },
{ path: '/tag-types/create', parent: '/tag-types', title: 'Create', component: CreateTagType }, { path: '/tag-types/create', parent: '/tag-types', title: 'Create', component: CreateTagType },
{ path: '/tag-types/edit/:name', parent: '/tag-types', title: ':name', component: EditTagType }, { path: '/tag-types/edit/:name', parent: '/tag-types', title: ':name', component: EditTagType },

View File

@ -37,7 +37,7 @@ class AuthComponent extends React.Component {
user: PropTypes.object.isRequired, user: PropTypes.object.isRequired,
unsecureLogin: PropTypes.func.isRequired, unsecureLogin: PropTypes.func.isRequired,
passwordLogin: PropTypes.func.isRequired, passwordLogin: PropTypes.func.isRequired,
loadInitalData: PropTypes.func.isRequired, loadInitialData: PropTypes.func.isRequired,
history: PropTypes.object.isRequired, history: PropTypes.object.isRequired,
}; };
@ -51,7 +51,7 @@ class AuthComponent extends React.Component {
<AuthenticationPasswordComponent <AuthenticationPasswordComponent
passwordLogin={this.props.passwordLogin} passwordLogin={this.props.passwordLogin}
authDetails={authDetails} authDetails={authDetails}
loadInitalData={this.props.loadInitalData} loadInitialData={this.props.loadInitialData}
history={this.props.history} history={this.props.history}
/> />
); );
@ -60,7 +60,7 @@ class AuthComponent extends React.Component {
<AuthenticationSimpleComponent <AuthenticationSimpleComponent
unsecureLogin={this.props.unsecureLogin} unsecureLogin={this.props.unsecureLogin}
authDetails={authDetails} authDetails={authDetails}
loadInitalData={this.props.loadInitalData} loadInitialData={this.props.loadInitialData}
history={this.props.history} history={this.props.history}
/> />
); );

View File

@ -1,16 +1,17 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import AuthenticationComponent from './authentication-component'; import AuthenticationComponent from './authentication-component';
import { unsecureLogin, passwordLogin } from '../../store/user/actions'; import { insecureLogin, passwordLogin } from '../../store/user/actions';
import { loadInitalData } from './../../store/loader'; import { loadInitialData } from './../../store/loader';
const mapDispatchToProps = { const mapDispatchToProps = (dispatch, props) => ({
unsecureLogin, insecureLogin: (path, user) => insecureLogin(path, user)(dispatch),
passwordLogin, passwordLogin: (path, user) => passwordLogin(path, user)(dispatch),
loadInitalData, loadInitialData: () => loadInitialData(props.flags)(dispatch),
}; });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
user: state.user.toJS(), user: state.user.toJS(),
flags: state.uiConfig.toJS().flags,
}); });
export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationComponent); export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationComponent);

View File

@ -6,7 +6,7 @@ class EnterpriseAuthenticationComponent extends React.Component {
static propTypes = { static propTypes = {
authDetails: PropTypes.object.isRequired, authDetails: PropTypes.object.isRequired,
passwordLogin: PropTypes.func.isRequired, passwordLogin: PropTypes.func.isRequired,
loadInitalData: PropTypes.func.isRequired, loadInitialData: PropTypes.func.isRequired,
history: PropTypes.object.isRequired, history: PropTypes.object.isRequired,
}; };
@ -37,7 +37,7 @@ class EnterpriseAuthenticationComponent extends React.Component {
try { try {
await this.props.passwordLogin(path, user); await this.props.passwordLogin(path, user);
await this.props.loadInitalData(); await this.props.loadInitialData();
this.props.history.push(`/`); this.props.history.push(`/`);
} catch (error) { } catch (error) {
if (error.statusCode === 404) { if (error.statusCode === 404) {

View File

@ -6,7 +6,7 @@ class SimpleAuthenticationComponent extends React.Component {
static propTypes = { static propTypes = {
authDetails: PropTypes.object.isRequired, authDetails: PropTypes.object.isRequired,
unsecureLogin: PropTypes.func.isRequired, unsecureLogin: PropTypes.func.isRequired,
loadInitalData: PropTypes.func.isRequired, loadInitialData: PropTypes.func.isRequired,
history: PropTypes.object.isRequired, history: PropTypes.object.isRequired,
}; };
@ -18,7 +18,7 @@ class SimpleAuthenticationComponent extends React.Component {
this.props this.props
.unsecureLogin(path, user) .unsecureLogin(path, user)
.then(this.props.loadInitalData) .then(this.props.loadInitialData)
.then(() => this.props.history.push(`/`)); .then(() => this.props.history.push(`/`));
}; };

View File

@ -0,0 +1,13 @@
import React from 'react';
import { Link } from 'react-router-dom';
function AdminMenu() {
return (
<div>
<Link to="/admin/users">Users</Link> | <Link to="/admin/api">API Access</Link> |{' '}
<Link to="/admin/auth">Authentication</Link>
</div>
);
}
export default AdminMenu;

View File

@ -0,0 +1,25 @@
import React from 'react';
function ApiHowTo() {
return (
<div>
<p
style={{
backgroundColor: '#cfe5ff',
border: '2px solid #c4e1ff',
padding: '8px',
borderRadius: '5px',
}}
>
Read the{' '}
<a href="https://www.unleash-hosted.com/docs" target="_blank">
Getting started guide
</a>{' '}
to learn how to connect to the Unleash API form your application or programmatically. <br /> <br />
Please note it can take up to 1 minute before a new API key is activated.
</p>
</div>
);
}
export default ApiHowTo;

View File

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Textfield, Button } from 'react-mdl';
function CreateApiKey({ addKey }) {
const [type, setType] = useState('CLIENT');
const [show, setShow] = useState(false);
const [username, setUsername] = useState();
const [error, setError] = useState();
const toggle = evt => {
evt.preventDefault();
setShow(!show);
};
const submit = async e => {
e.preventDefault();
if (!username) {
setError('You must define a username');
return;
}
await addKey({ username, type });
setUsername('');
setType('CLIENT');
setShow(false);
};
return (
<div style={{ margin: '5px' }}>
{show ? (
<form onSubmit={submit}>
<Textfield
value={username}
name="username"
onChange={e => setUsername(e.target.value)}
label="Username"
floatingLabel
style={{ width: '200px' }}
error={error}
/>
<select value={type} onChange={e => setType(e.target.value)}>
<option value="CLIENT">Client</option>
<option value="ADMIN">Admin</option>
</select>
<Button primary mini="true" type="submit">
Create new key
</Button>
</form>
) : (
<a href="" onClick={toggle}>
Add new access key
</a>
)}
</div>
);
}
CreateApiKey.propTypes = {
addKey: PropTypes.func.isRequired,
};
export default CreateApiKey;

View File

@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import Component from './api-key-list';
import { fetchApiKeys, removeKey, addKey } from './../../../store/e-api-admin/actions';
import { hasPermission } from '../../../permissions';
export default connect(
state => ({
location: state.settings.toJS().location || {},
keys: state.apiAdmin.toJS(),
hasPermission: permission => hasPermission(state.user.get('profile'), permission),
}),
{ fetchApiKeys, removeKey, addKey }
)(Component);

View File

@ -0,0 +1,89 @@
/* eslint-disable no-alert */
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Icon } from 'react-mdl';
import { formatFullDateTimeWithLocale } from '../../../component/common/util';
import CreateApiKey from './api-key-create';
import Secret from './secret';
import ApiHowTo from './api-howto';
function ApiKeyList({ location, fetchApiKeys, removeKey, addKey, keys, hasPermission }) {
const deleteKey = async key => {
const shouldDelte = confirm('Are you sure?');
if (shouldDelte) {
await removeKey(key);
}
};
useEffect(() => {
fetchApiKeys();
}, []);
return (
<div>
<ApiHowTo />
<table className="mdl-data-table mdl-shadow--2dp">
<thead>
<tr>
<th className="mdl-data-table__cell--non-numeric" width="20" style={{ textAlign: 'left' }}>
Created
</th>
<th className="mdl-data-table__cell--non-numeric" width="20" style={{ textAlign: 'left' }}>
Username
</th>
<th className="mdl-data-table__cell--non-numeric" width="10" style={{ textAlign: 'left' }}>
Acess Type
</th>
<th className="mdl-data-table__cell--non-numeric" style={{ textAlign: 'left' }}>
Secret
</th>
<th className="mdl-data-table__cell--non-numeric" width="10" style={{ textAlign: 'lerightft' }}>
Action
</th>
</tr>
</thead>
<tbody>
{keys.map(item => (
<tr key={item.key}>
<td style={{ textAlign: 'left' }}>
{formatFullDateTimeWithLocale(item.created, location.locale)}
</td>
<td style={{ textAlign: 'left' }}>{item.username}</td>
<td style={{ textAlign: 'left' }}>{item.priviliges[0]}</td>
<td style={{ textAlign: 'left' }}>
<Secret value={item.key} />
</td>
{hasPermission('ADMIN') ? (
<td style={{ textAlign: 'right' }}>
<a
href=""
onClick={e => {
e.preventDefault();
deleteKey(item.key);
}}
>
<Icon name="delete" />
</a>
</td>
) : (
<td />
)}
</tr>
))}
</tbody>
</table>
{hasPermission('ADMIN') ? <CreateApiKey addKey={addKey} /> : null}
</div>
);
}
ApiKeyList.propTypes = {
location: PropTypes.object,
fetchApiKeys: PropTypes.func.isRequired,
removeKey: PropTypes.func.isRequired,
addKey: PropTypes.func.isRequired,
keys: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
};
export default ApiKeyList;

View File

@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import ApiKeyList from './api-key-list-container';
import AdminMenu from '../admin-menu';
const render = () => (
<div>
<AdminMenu />
<h3>API Access</h3>
<ApiKeyList />
</div>
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -0,0 +1,31 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Icon } from 'react-mdl';
function Secret({ value }) {
const [show, setShow] = useState(false);
const toggle = evt => {
evt.preventDefault();
setShow(!show);
};
return (
<div>
{show ? (
<input readOnly value={value} style={{ width: '240px' }} />
) : (
<span>***************************</span>
)}
<a href="" onClick={toggle} title="Show token">
<Icon style={{ marginLeft: '5px', fontSize: '1.2em' }} name="visibility" />
</a>
</div>
);
}
Secret.propTypes = {
value: PropTypes.string,
};
export default Secret;

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import GoogleAuth from './google-auth';
import { getGoogleConfig, updateGoogleConfig } from './../../../store/e-admin-auth/actions';
import { hasPermission } from '../../../permissions';
const mapStateToProps = state => ({
config: state.authAdmin.get('google'),
hasPermission: permission => hasPermission(state.user.get('profile'), permission),
});
const Container = connect(mapStateToProps, { getGoogleConfig, updateGoogleConfig })(GoogleAuth);
export default Container;

View File

@ -0,0 +1,191 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Button, Grid, Cell, Switch, Textfield } from 'react-mdl';
const initialState = {
enabled: false,
autoCreate: false,
unleashHostname: location.hostname,
};
function GoogleAuth({ config, getGoogleConfig, updateGoogleConfig, hasPermission }) {
const [data, setData] = useState(initialState);
const [info, setInfo] = useState();
useEffect(() => {
getGoogleConfig();
}, []);
useEffect(() => {
if (config.clientId) {
setData(config);
}
}, [config]);
if (!hasPermission('ADMIN')) {
return <span>You need admin privileges to access this section.</span>;
}
const updateField = e => {
setData({
...data,
[e.target.name]: e.target.value,
});
};
const updateEnabled = () => {
setData({ ...data, enabled: !data.enabled });
};
const updateAutoCreate = () => {
setData({ ...data, autoCreate: !data.autoCreate });
};
const onSubmit = async e => {
e.preventDefault();
setInfo('...saving');
try {
await updateGoogleConfig(data);
setInfo('Settings stored');
setTimeout(() => setInfo(''), 2000);
} catch (e) {
setInfo(e.message);
}
};
return (
<div>
<Grid style={{ background: '#EFEFEF' }}>
<Cell col={12}>
<p>
Please read the{' '}
<a href="https://www.unleash-hosted.com/docs/enterprise-authentication/google" target="_blank">
documentation
</a>{' '}
to learn how to integrate with Google OAuth 2.0. <br />
<br />
Callback URL: <code>https://[unleash.hostname.com]/auth/google/callback</code>
</p>
</Cell>
</Grid>
<form onSubmit={onSubmit}>
<Grid>
<Cell col={5}>
<strong>Enable</strong>
<p>
Enable Google users to login. Value is ignored if Client ID and Client Secret are not
defined.
</p>
</Cell>
<Cell col={6} style={{ padding: '20px' }}>
<Switch onChange={updateEnabled} name="enabled" checked={data.enabled}>
{data.enabled ? 'Enabled' : 'Disabled'}
</Switch>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Client ID</strong>
<p>(Required) The Client ID provided by Google when registering the application.</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Client ID"
name="clientId"
placeholder=""
value={data.clientId}
floatingLabel
style={{ width: '400px' }}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Client Secret</strong>
<p>(Required) Client Secret provided by Google when registering the application.</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Client Secret"
name="clientSecret"
value={data.clientSecret}
placeholder=""
floatingLabel
style={{ width: '400px' }}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Unleash hostname</strong>
<p>
(Required) The hostname you are running Unleash on that Google should send the user back to.
The final callback URL will be{' '}
<small>
<code>https://[unleash.hostname.com]/auth/google/callback</code>
</small>
</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Unleash Hostname"
name="unleashHostname"
placeholder=""
value={data.unleashHostname}
floatingLabel
style={{ width: '400px' }}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Auto-create users</strong>
<p>Enable automatic creation of new users when signing in with Google.</p>
</Cell>
<Cell col={6} style={{ padding: '20px' }}>
<Switch onChange={updateAutoCreate} name="enabled" checked={data.autoCreate}>
Auto-create users
</Switch>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Email domains</strong>
<p>(Optional) Comma separated list of email domains that should be allowed to sign in.</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Email domains"
name="emailDomains"
value={data.emailDomains}
placeholder="@company.com, @anotherCompany.com"
floatingLabel
style={{ width: '400px' }}
rows={2}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<Button raised accent type="submit">
Save
</Button>{' '}
<small>{info}</small>
</Cell>
</Grid>
</form>
</div>
);
}
GoogleAuth.propTypes = {
config: PropTypes.object,
getGoogleConfig: PropTypes.func.isRequired,
updateGoogleConfig: PropTypes.func.isRequired,
hasPermission: PropTypes.func.isRequired,
};
export default GoogleAuth;

View File

@ -0,0 +1,31 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Tabs, Tab } from 'react-mdl';
import AdminMenu from '../admin-menu';
import GoogleAuth from './google-auth-container';
import SamlAuth from './saml-auth-container';
function AdminAuthPage() {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<AdminMenu />
<h3>Authentication</h3>
<div className="demo-tabs">
<Tabs activeTab={activeTab} onChange={setActiveTab} ripple>
<Tab>SAML 2.0</Tab>
<Tab>Google</Tab>
</Tabs>
<section>{activeTab === 0 ? <SamlAuth /> : <GoogleAuth />}</section>
</div>
</div>
);
}
AdminAuthPage.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default AdminAuthPage;

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import SamlAuth from './saml-auth';
import { getSamlConfig, updateSamlConfig } from './../../../store/e-admin-auth/actions';
import { hasPermission } from '../../../permissions';
const mapStateToProps = state => ({
config: state.authAdmin.get('saml'),
hasPermission: permission => hasPermission(state.user.get('profile'), permission),
});
const Container = connect(mapStateToProps, { getSamlConfig, updateSamlConfig })(SamlAuth);
export default Container;

View File

@ -0,0 +1,183 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Button, Grid, Cell, Switch, Textfield } from 'react-mdl';
const initialState = {
enabled: false,
autoCreate: false,
unleashHostname: location.hostname,
};
function SamlAuth({ config, getSamlConfig, updateSamlConfig, hasPermission }) {
const [data, setData] = useState(initialState);
const [info, setInfo] = useState();
useEffect(() => {
getSamlConfig();
}, []);
useEffect(() => {
if (config.entityId) {
setData(config);
}
}, [config]);
if (!hasPermission('ADMIN')) {
return <span>You need admin privileges to access this section.</span>;
}
const updateField = e => {
setData({
...data,
[e.target.name]: e.target.value,
});
};
const updateEnabled = () => {
setData({ ...data, enabled: !data.enabled });
};
const updateAutoCreate = () => {
setData({ ...data, autoCreate: !data.autoCreate });
};
const onSubmit = async e => {
e.preventDefault();
setInfo('...saving');
try {
await updateSamlConfig(data);
setInfo('Settings stored');
setTimeout(() => setInfo(''), 2000);
} catch (e) {
setInfo(e.message);
}
};
return (
<div>
<Grid style={{ background: '#EFEFEF' }}>
<Cell col={12}>
<p>
Please read the{' '}
<a href="https://www.unleash-hosted.com/docs/enterprise-authentication" target="_blank">
documentation
</a>{' '}
to learn how to integrate with specific SMAL 2.0 providers (Okta, Keycloak, etc). <br />
<br />
Callback URL: <code>https://[unleash.hostname.com]/auth/saml/callback</code>
</p>
</Cell>
</Grid>
<form onSubmit={onSubmit}>
<Grid>
<Cell col={5}>
<strong>Enable</strong>
<p>Enable SAML 2.0 Authentication.</p>
</Cell>
<Cell col={6} style={{ padding: '20px' }}>
<Switch onChange={updateEnabled} name="enabled" checked={data.enabled}>
{data.enabled ? 'Enabled' : 'Disabled'}
</Switch>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Entity ID</strong>
<p>(Required) The Entity Identity provider issuer.</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Entity ID"
name="entityId"
placeholder=""
value={data.entityId}
floatingLabel
style={{ width: '400px' }}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Single Sign-On URL</strong>
<p>(Required) The url to redirect the user to for signing in.</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Single Sign-On URL"
name="signOnUrl"
value={data.signOnUrl}
placeholder=""
floatingLabel
style={{ width: '400px' }}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>X.509 Certificate</strong>
<p>(Required) The certificate used to sign the SAML 2.0 request.</p>
</Cell>
<Cell col={7}>
<textarea
onChange={updateField}
label="X.509 Certificate"
name="certificate"
placeholder=""
value={data.certificate}
floatingLabel
style={{ width: '100%', fontSize: '0.7em', fontFamily: 'monospace' }}
rows={14}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Auto-create users</strong>
<p>Enable automatic creation of new users when signing in with Saml.</p>
</Cell>
<Cell col={6} style={{ padding: '20px' }}>
<Switch onChange={updateAutoCreate} name="enabled" checked={data.autoCreate}>
Auto-create users
</Switch>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<strong>Email domains</strong>
<p>(Optional) Comma separated list of email domains that should be allowed to sign in.</p>
</Cell>
<Cell col={6}>
<Textfield
onChange={updateField}
label="Email domains"
name="emailDomains"
value={data.emailDomains}
placeholder="@company.com, @anotherCompany.com"
floatingLabel
style={{ width: '400px' }}
rows={2}
/>
</Cell>
</Grid>
<Grid>
<Cell col={5}>
<Button raised accent type="submit">
Save
</Button>{' '}
<small>{info}</small>
</Cell>
</Grid>
</form>
</div>
);
}
SamlAuth.propTypes = {
config: PropTypes.object,
getSamlConfig: PropTypes.func.isRequired,
updateSamlConfig: PropTypes.func.isRequired,
hasPermission: PropTypes.func.isRequired,
};
export default SamlAuth;

View File

@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icon, Grid, Cell } from 'react-mdl';
import { Link } from 'react-router-dom';
const render = () => (
<Grid style={{ textAlign: 'center' }}>
<Cell col={4}>
<Icon name="supervised_user_circle" style={{ fontSize: '5em' }} />
<br />
<Link to="/admin/users">Users</Link>
</Cell>
<Cell col={4}>
<Icon name="apps" style={{ fontSize: '5em' }} />
<br />
<Link to="/admin/api">API Access</Link>
</Cell>
<Cell col={4}>
<Icon name="lock" style={{ fontSize: '5em' }} />
<br />
<Link to="/admin/auth">Authentication</Link>
</Cell>
</Grid>
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-modal';
import { Button, Textfield, DialogTitle, DialogContent, DialogActions, RadioGroup, Radio } from 'react-mdl';
import { trim } from '../../../component/common/util';
import { modalStyles } from './util';
Modal.setAppElement('#app');
const EMPTY = { userType: 'regular' };
function AddUser({ showDialog, closeDialog, addUser, validatePassword }) {
const [data, setData] = useState(EMPTY);
const [error, setError] = useState({});
const updateField = e => {
setData({
...data,
[e.target.name]: e.target.value,
});
};
const updateFieldWithTrim = e => {
setData({
...data,
[e.target.name]: trim(e.target.value),
});
};
const submit = async e => {
e.preventDefault();
if (!data.email) {
setError({ general: 'You must specify the email adress' });
return;
}
try {
await addUser(data);
setData(EMPTY);
closeDialog();
} catch (error) {
const msg = error.message || 'Could not create user';
setError({ general: msg });
}
};
const onPasswordBlur = async e => {
e.preventDefault();
setError({ password: '' });
if (data.password) {
try {
await validatePassword(data.password);
} catch (error) {
const msg = error.message || '';
setError({ password: msg });
}
}
};
const onCancel = e => {
e.preventDefault();
setData(EMPTY);
closeDialog();
};
return (
<Modal isOpen={showDialog} style={modalStyles} onRequestClose={onCancel}>
<form onSubmit={submit}>
<DialogTitle>Add new user</DialogTitle>
<DialogContent>
<p style={{ color: 'red' }}>{error.general}</p>
<Textfield
floatingLabel
label="Full name"
name="name"
value={data.name}
error={error.name}
type="name"
onChange={updateField}
/>
<Textfield
floatingLabel
label="Email"
name="email"
value={data.email}
error={error.email}
type="email"
onChange={updateFieldWithTrim}
/>
<Textfield
floatingLabel
label="Password"
name="password"
type="password"
value={data.password}
error={error.password}
onChange={updateField}
onBlur={onPasswordBlur}
/>
<br />
<br />
<RadioGroup name="userType" value={data.userType} onChange={updateField} childContainer="div">
<Radio value="regular" ripple>
Regular user
</Radio>
<Radio value="admin" ripple>
Admin user
</Radio>
<Radio value="read" ripple>
Read only
</Radio>
</RadioGroup>
</DialogContent>
<DialogActions>
<Button type="button" raised colored type="submit">
Add
</Button>
<Button type="button" onClick={onCancel}>
Cancel
</Button>
</DialogActions>
</form>
</Modal>
);
}
AddUser.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
addUser: PropTypes.func.isRequired,
validatePassword: PropTypes.func.isRequired,
};
export default AddUser;

View File

@ -0,0 +1,107 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-modal';
import { Button, Textfield, DialogTitle, DialogContent, DialogActions } from 'react-mdl';
import { trim } from '../../../component/common/util';
import { modalStyles } from './util';
function ChangePassword({ showDialog, closeDialog, changePassword, validatePassword, user = {} }) {
const [data, setData] = useState({});
const [error, setError] = useState({});
const updateField = e => {
setData({
...data,
[e.target.name]: trim(e.target.value),
});
};
const submit = async e => {
e.preventDefault();
if (!data.password || data.password.length < 8) {
setError({ password: 'You must specify a password with at least 8 chars.' });
return;
}
if (!(data.password === data.confirm)) {
setError({ confirm: 'Passwords does not match' });
return;
}
try {
await changePassword(user, data.password);
setData({});
closeDialog();
} catch (error) {
const msg = error.message || 'Could not update password';
setError({ general: msg });
}
};
const onPasswordBlur = async e => {
e.preventDefault();
setError({ password: '' });
if (data.password) {
try {
await validatePassword(data.password);
} catch (error) {
const msg = error.message || '';
setError({ password: msg });
}
}
};
const onCancel = e => {
e.preventDefault();
setData({});
closeDialog();
};
return (
<Modal isOpen={showDialog} style={modalStyles} onRequestClose={onCancel}>
<form onSubmit={submit}>
<DialogTitle>Update password</DialogTitle>
<DialogContent>
<p>User: {user.username || user.email}</p>
<p style={{ color: 'red' }}>{error.general}</p>
<Textfield
floatingLabel
label="New passord"
name="password"
type="password"
value={data.password}
error={error.password}
onChange={updateField}
onBlur={onPasswordBlur}
/>
<Textfield
floatingLabel
label="Confirm passord"
name="confirm"
type="password"
value={data.confirm}
error={error.confirm}
onChange={updateField}
/>
</DialogContent>
<DialogActions>
<Button type="button" raised colored type="submit">
Save
</Button>
<Button type="button" onClick={onCancel}>
Cancel
</Button>
</DialogActions>
</form>
</Modal>
);
}
ChangePassword.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
changePassword: PropTypes.func.isRequired,
validatePassword: PropTypes.func.isRequired,
user: PropTypes.object,
};
export default ChangePassword;

View File

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import UsersList from './users-list-container';
import AdminMenu from '../admin-menu';
const render = () => (
<div>
<AdminMenu />
<h3>Users</h3>
<UsersList />
</div>
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-modal';
import { Button, Textfield, DialogTitle, DialogContent, DialogActions, RadioGroup, Radio } from 'react-mdl';
import { showPermissions, modalStyles } from './util';
Modal.setAppElement('#app');
function AddUser({ user, showDialog, closeDialog, updateUser }) {
if (!user) {
return null;
}
const [data, setData] = useState(user);
const [error, setError] = useState({});
const updateField = e => {
setData({
...data,
[e.target.name]: e.target.value,
});
};
const submit = async e => {
e.preventDefault();
try {
await updateUser(data);
closeDialog();
} catch (error) {
setError({ general: 'Could not create user' });
}
};
const onCancel = e => {
e.preventDefault();
closeDialog();
};
const userType = data.userType || showPermissions(user.permissions);
return (
<Modal isOpen={showDialog} style={modalStyles} onRequestClose={onCancel}>
<form onSubmit={submit}>
<DialogTitle>Edit user</DialogTitle>
<DialogContent>
<p>{error.general}</p>
<Textfield
floatingLabel
label="Full name"
name="name"
value={data.name}
error={error.name}
type="name"
onChange={updateField}
/>
<Textfield
floatingLabel
label="Email"
name="email"
contentEditable="false"
editable="false"
readOnly
value={data.email}
type="email"
/>
<br />
<br />
<RadioGroup name="userType" value={userType} onChange={updateField} childContainer="div">
<Radio value="regular" ripple>
Regular user
</Radio>
<Radio value="admin" ripple>
Admin user
</Radio>
<Radio value="read" ripple>
Read only
</Radio>
</RadioGroup>
</DialogContent>
<DialogActions>
<Button type="button" raised colored type="submit">
Update
</Button>
<Button type="button" onClick={onCancel}>
Cancel
</Button>
</DialogActions>
</form>
</Modal>
);
}
AddUser.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
updateUser: PropTypes.func.isRequired,
user: PropTypes.object,
};
export default AddUser;

View File

@ -0,0 +1,147 @@
/* eslint-disable no-alert */
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Icon } from 'react-mdl';
import { formatFullDateTimeWithLocale } from '../../../component/common/util';
import AddUser from './add-user-component';
import ChangePassword from './change-password-component';
import UpdateUser from './update-user-component';
import { showPermissions } from './util';
function UsersList({
fetchUsers,
removeUser,
addUser,
updateUser,
changePassword,
users,
location,
hasPermission,
validatePassword,
}) {
const [showDialog, setDialog] = useState(false);
const [pwDialog, setPwDiaog] = useState({ open: false });
const [updateDialog, setUpdateDiaog] = useState({ open: false });
const openDialog = e => {
e.preventDefault();
setDialog(true);
};
const closeDialog = () => {
setDialog(false);
};
const onDelete = user => e => {
e.preventDefault();
const doIt = confirm(`Are you sure you want to delete ${user.username || user.email}?`);
if (doIt) {
removeUser(user);
}
};
const openPwDialog = user => e => {
e.preventDefault();
setPwDiaog({ open: true, user });
};
const closePwDialog = () => {
setPwDiaog({ open: false });
};
const openUpdateDialog = user => e => {
e.preventDefault();
setUpdateDiaog({ open: true, user });
};
const closeUpdateDialog = () => {
setUpdateDiaog({ open: false });
};
useEffect(() => {
fetchUsers();
}, []);
return (
<div>
<table className="mdl-data-table mdl-shadow--2dp">
<thead>
<tr>
<th className="mdl-data-table__cell--non-numeric">Id</th>
<th className="mdl-data-table__cell--non-numeric">Created</th>
<th className="mdl-data-table__cell--non-numeric">Username</th>
<th className="mdl-data-table__cell--non-numeric">Name</th>
<th className="mdl-data-table__cell--non-numeric">Access</th>
<th className="mdl-data-table__cell--non-numeric">{hasPermission('ADMIN') ? 'Action' : ''}</th>
</tr>
</thead>
<tbody>
{users.map(item => (
<tr key={item.id}>
<td>{item.id}</td>
<td>{formatFullDateTimeWithLocale(item.createdAt, location.locale)}</td>
<td style={{ textAlign: 'left' }}>{item.username || item.email}</td>
<td style={{ textAlign: 'left' }}>{item.name}</td>
<td>{showPermissions(item.permissions)}</td>
{hasPermission('ADMIN') ? (
<td>
<a href="" title="Edit" onClick={openUpdateDialog(item)}>
<Icon name="edit" />
</a>
<a href="" title="Change password" onClick={openPwDialog(item)}>
<Icon name="lock" />
</a>
<a href="" title="Remove user" onClick={onDelete(item)}>
<Icon name="delete" />
</a>
</td>
) : (
<td>
<a href="" title="Change password" onClick={openPwDialog(item)}>
<Icon name="lock" />
</a>
</td>
)}
</tr>
))}
</tbody>
</table>
<br />
<a href="" onClick={openDialog}>
Add new user
</a>
<AddUser
showDialog={showDialog}
closeDialog={closeDialog}
addUser={addUser}
validatePassword={validatePassword}
/>
<UpdateUser
showDialog={updateDialog.open}
closeDialog={closeUpdateDialog}
updateUser={updateUser}
user={updateDialog.user}
/>
<ChangePassword
showDialog={pwDialog.open}
closeDialog={closePwDialog}
changePassword={changePassword}
validatePassword={validatePassword}
user={pwDialog.user}
/>
</div>
);
}
UsersList.propTypes = {
users: PropTypes.array.isRequired,
fetchUsers: PropTypes.func.isRequired,
removeUser: PropTypes.func.isRequired,
addUser: PropTypes.func.isRequired,
hasPermission: PropTypes.func.isRequired,
validatePassword: PropTypes.func.isRequired,
updateUser: PropTypes.func.isRequired,
changePassword: PropTypes.func.isRequired,
location: PropTypes.object.isRequired,
};
export default UsersList;

View File

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import UsersList from './users-list-component';
import {
fetchUsers,
removeUser,
addUser,
changePassword,
updateUser,
validatePassword,
} from './../../../store/e-user-admin/actions';
import { hasPermission } from '../../../permissions';
const mapStateToProps = state => ({
users: state.userAdmin.toJS(),
location: state.settings.toJS().location || {},
hasPermission: permission => hasPermission(state.user.get('profile'), permission),
});
const Container = connect(mapStateToProps, {
fetchUsers,
removeUser,
addUser,
changePassword,
updateUser,
validatePassword,
})(UsersList);
export default Container;

View File

@ -0,0 +1,31 @@
export const showPermissions = permissions => {
if (!permissions || permissions.length === 0) {
return 'read';
} else if (permissions.includes('ADMIN')) {
return 'admin';
} else {
return 'regular';
}
};
export const modalStyles = {
overlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.25)',
zIndex: 5,
},
content: {
width: '500px',
maxWidth: '90%',
margin: '0',
top: '50%',
left: '50%',
right: 'auto',
bottom: 'auto',
transform: 'translate(-50%, -50%)',
},
};

View File

@ -3,9 +3,9 @@ import { RECEIVE_CONTEXT, REMOVE_CONTEXT, ADD_CONTEXT_FIELD, UPDATE_CONTEXT_FIEL
import { USER_LOGOUT, USER_LOGIN } from '../user/actions'; import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
const DEFAULT_CONTEXT_FIELDS = [ const DEFAULT_CONTEXT_FIELDS = [
{ name: 'environment', inital: true }, { name: 'environment', initial: true },
{ name: 'userId', inital: true }, { name: 'userId', initial: true },
{ name: 'appName', inital: true }, { name: 'appName', initial: true },
]; ];
function getInitState() { function getInitState() {

View File

@ -0,0 +1,56 @@
import api from './api';
import { dispatchAndThrow } from '../util';
export const RECIEVE_GOOGLE_CONFIG = 'RECIEVE_GOOGLE_CONFIG';
export const RECIEVE_GOOGLE_CONFIG_ERROR = 'RECIEVE_GOOGLE_CONFIG_ERROR';
export const UPDATE_GOOGLE_AUTH = 'UPDATE_GOOGLE_AUTH';
export const UPDATE_GOOGLE_AUTH_ERROR = 'UPDATE_GOOGLE_AUTH_ERROR';
export const RECIEVE_SAML_CONFIG = 'RECIEVE_SAML_CONFIG';
export const RECIEVE_SAML_CONFIG_ERROR = 'RECIEVE_SAML_CONFIG_ERROR';
export const UPDATE_SAML_AUTH = 'UPDATE_SAML_AUTH';
export const UPDATE_SAML_AUTH_ERROR = 'UPDATE_SAML_AUTH_ERROR';
const debug = require('debug')('unleash:e-admin-auth-actions');
export function getGoogleConfig() {
debug('Start fetching google-auth config');
return dispatch =>
api
.getGoogleConfig()
.then(config =>
dispatch({
type: RECIEVE_GOOGLE_CONFIG,
config,
})
)
.catch(dispatchAndThrow(dispatch, RECIEVE_GOOGLE_CONFIG_ERROR));
}
export function updateGoogleConfig(data) {
return dispatch =>
api
.updateGoogleConfig(data)
.then(config => dispatch({ type: UPDATE_GOOGLE_AUTH, config }))
.catch(dispatchAndThrow(dispatch, UPDATE_GOOGLE_AUTH_ERROR));
}
export function getSamlConfig() {
debug('Start fetching Saml-auth config');
return dispatch =>
api
.getSamlConfig()
.then(config =>
dispatch({
type: RECIEVE_SAML_CONFIG,
config,
})
)
.catch(dispatchAndThrow(dispatch, RECIEVE_SAML_CONFIG_ERROR));
}
export function updateSamlConfig(data) {
return dispatch =>
api
.updateSamlConfig(data)
.then(config => dispatch({ type: UPDATE_SAML_AUTH, config }))
.catch(dispatchAndThrow(dispatch, UPDATE_SAML_AUTH_ERROR));
}

View File

@ -0,0 +1,45 @@
import { throwIfNotSuccess, headers } from '../api-helper';
const GOOGLE_URI = 'api/admin/auth/google/settings';
const SAML_URI = 'api/admin/auth/saml/settings';
function getGoogleConfig() {
return fetch(GOOGLE_URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function updateGoogleConfig(data) {
return fetch(GOOGLE_URI, {
method: 'POST',
headers,
body: JSON.stringify(data),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function getSamlConfig() {
return fetch(SAML_URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function updateSamlConfig(data) {
return fetch(SAML_URI, {
method: 'POST',
headers,
body: JSON.stringify(data),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
export default {
getGoogleConfig,
updateGoogleConfig,
getSamlConfig,
updateSamlConfig,
};

View File

@ -0,0 +1,17 @@
import { Map as $Map } from 'immutable';
import { RECIEVE_GOOGLE_CONFIG, UPDATE_GOOGLE_AUTH, RECIEVE_SAML_CONFIG, UPDATE_SAML_AUTH } from './actions';
const store = (state = new $Map({ google: {}, saml: {} }), action) => {
switch (action.type) {
case UPDATE_GOOGLE_AUTH:
case RECIEVE_GOOGLE_CONFIG:
return state.set('google', action.config);
case UPDATE_SAML_AUTH:
case RECIEVE_SAML_CONFIG:
return state.set('saml', action.config);
default:
return state;
}
};
export default store;

View File

@ -0,0 +1,40 @@
import api from './api';
import { dispatchAndThrow } from '../util';
export const RECIEVE_KEYS = 'RECIEVE_KEYS';
export const ERROR_FETCH_KEYS = 'ERROR_FETCH_KEYS';
export const REMOVE_KEY = 'REMOVE_KEY';
export const REMOVE_KEY_ERROR = 'REMOVE_KEY_ERROR';
export const ADD_KEY = 'ADD_KEY';
export const ADD_KEY_ERROR = 'ADD_KEY_ERROR';
const debug = require('debug')('unleash:e-api-admin-actions');
export function fetchApiKeys() {
debug('Start fetching api-keys');
return dispatch =>
api
.fetchAll()
.then(value =>
dispatch({
type: RECIEVE_KEYS,
keys: value,
})
)
.catch(dispatchAndThrow(dispatch, ERROR_FETCH_KEYS));
}
export function removeKey(key) {
return dispatch =>
api
.remove(key)
.then(() => dispatch({ type: REMOVE_KEY, key }))
.catch(dispatchAndThrow(dispatch, REMOVE_KEY));
}
export function addKey(data) {
return dispatch =>
api
.create(data)
.then(newToken => dispatch({ type: ADD_KEY, token: newToken }))
.catch(dispatchAndThrow(dispatch, ADD_KEY_ERROR));
}

View File

@ -0,0 +1,34 @@
import { throwIfNotSuccess, headers } from '../api-helper';
const URI = 'api/admin/api-tokens';
function fetchAll() {
return fetch(URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function create(data) {
return fetch(URI, {
method: 'POST',
headers,
body: JSON.stringify(data),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function remove(key) {
return fetch(`${URI}/${key}`, {
method: 'DELETE',
headers,
credentials: 'include',
}).then(throwIfNotSuccess);
}
export default {
fetchAll,
create,
remove,
};

View File

@ -0,0 +1,17 @@
import { List } from 'immutable';
import { RECIEVE_KEYS, ADD_KEY, REMOVE_KEY } from './actions';
const store = (state = new List(), action) => {
switch (action.type) {
case RECIEVE_KEYS:
return new List(action.keys);
case ADD_KEY:
return state.push(action.token);
case REMOVE_KEY:
return state.filter(v => v.key !== action.key);
default:
return state;
}
};
export default store;

View File

@ -0,0 +1,64 @@
import api from './api';
import { dispatchAndThrow } from '../util';
export const START_FETCH_USERS = 'START_FETCH_USERS';
export const RECIEVE_USERS = 'RECIEVE_USERS';
export const ERROR_FETCH_USERS = 'ERROR_FETCH_USERS';
export const REMOVE_USER = 'REMOVE_USER';
export const REMOVE_USER_ERROR = 'REMOVE_USER_ERROR';
export const ADD_USER = 'ADD_USER';
export const ADD_USER_ERROR = 'ADD_USER_ERROR';
export const UPDATE_USER = 'UPDATE_USER';
export const UPDATE_USER_ERROR = 'UPDATE_USER_ERROR';
export const CHANGE_PASSWORD_ERROR = 'CHANGE_PASSWORD_ERROR';
export const VALIDATE_PASSWORD_ERROR = 'VALIDATE_PASSWORD_ERROR';
const debug = require('debug')('unleash:e-user-admin-actions');
const gotUsers = value => ({
type: RECIEVE_USERS,
value,
});
export function fetchUsers() {
debug('Start fetching user');
return dispatch => {
dispatch({ type: START_FETCH_USERS });
return api
.fetchAll()
.then(json => dispatch(gotUsers(json)))
.catch(dispatchAndThrow(dispatch, ERROR_FETCH_USERS));
};
}
export function removeUser(user) {
return dispatch =>
api
.remove(user)
.then(() => dispatch({ type: REMOVE_USER, user }))
.catch(dispatchAndThrow(dispatch, REMOVE_USER_ERROR));
}
export function addUser(user) {
return dispatch =>
api
.create(user)
.then(newUser => dispatch({ type: ADD_USER, user: newUser }))
.catch(dispatchAndThrow(dispatch, ADD_USER_ERROR));
}
export function updateUser(user) {
return dispatch =>
api
.update(user)
.then(newUser => dispatch({ type: UPDATE_USER, user: newUser }))
.catch(dispatchAndThrow(dispatch, UPDATE_USER_ERROR));
}
export function changePassword(user, newPassword) {
return dispatch => api.changePassword(user, newPassword).catch(dispatchAndThrow(dispatch, CHANGE_PASSWORD_ERROR));
}
export function validatePassword(password) {
return dispatch => api.validatePassword(password).catch(dispatchAndThrow(dispatch, VALIDATE_PASSWORD_ERROR));
}

View File

@ -0,0 +1,66 @@
import { throwIfNotSuccess, headers } from '../api-helper';
const URI = 'api/admin/user-admin';
function fetchAll() {
return fetch(URI, { headers, credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function create(user) {
return fetch(URI, {
method: 'POST',
headers,
body: JSON.stringify(user),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function update(user) {
return fetch(`${URI}/${user.id}`, {
method: 'PUT',
headers,
body: JSON.stringify(user),
credentials: 'include',
})
.then(throwIfNotSuccess)
.then(response => response.json());
}
function changePassword(user, newPassword) {
return fetch(`${URI}/${user.id}/change-password`, {
method: 'POST',
headers,
body: JSON.stringify({ password: newPassword }),
credentials: 'include',
}).then(throwIfNotSuccess);
}
function validatePassword(password) {
return fetch(`${URI}/validate-password`, {
method: 'POST',
headers,
body: JSON.stringify({ password }),
credentials: 'include',
}).then(throwIfNotSuccess);
}
function remove(user) {
return fetch(`${URI}/${user.id}`, {
method: 'DELETE',
headers,
credentials: 'include',
}).then(throwIfNotSuccess);
}
export default {
fetchAll,
create,
update,
changePassword,
validatePassword,
remove,
};

View File

@ -0,0 +1,25 @@
import { List } from 'immutable';
import { RECIEVE_USERS, ADD_USER, REMOVE_USER, UPDATE_USER } from './actions';
const store = (state = new List(), action) => {
switch (action.type) {
case RECIEVE_USERS:
return new List(action.value);
case ADD_USER:
return state.push(action.user);
case UPDATE_USER:
return state.map(user => {
if (user.id === action.user.id) {
return action.user;
} else {
return user;
}
});
case REMOVE_USER:
return state.filter(v => v.id !== action.user.id);
default:
return state;
}
};
export default store;

View File

@ -1,7 +1,7 @@
import { List } from 'immutable'; import { List } from 'immutable';
import { RECEIVE_FEATURE_TYPES } from './actions'; import { RECEIVE_FEATURE_TYPES } from './actions';
const DEFAULT_FEATURE_TYPES = [{ id: 'release', name: 'Release', inital: true }]; const DEFAULT_FEATURE_TYPES = [{ id: 'release', name: 'Release', initial: true }];
function getInitState() { function getInitState() {
return new List(DEFAULT_FEATURE_TYPES); return new List(DEFAULT_FEATURE_TYPES);

View File

@ -16,6 +16,9 @@ import uiConfig from './ui-config';
import context from './context'; import context from './context';
import projects from './project'; import projects from './project';
import addons from './addons'; import addons from './addons';
import userAdmin from './e-user-admin';
import apiAdmin from './e-api-admin';
import authAdmin from './e-admin-auth';
const unleashStore = combineReducers({ const unleashStore = combineReducers({
features, features,
@ -35,6 +38,9 @@ const unleashStore = combineReducers({
context, context,
projects, projects,
addons, addons,
userAdmin,
apiAdmin,
authAdmin,
}); });
export default unleashStore; export default unleashStore;

View File

@ -4,13 +4,14 @@ import { fetchFeatureTypes } from './feature-type/actions';
import { fetchProjects } from './project/actions'; import { fetchProjects } from './project/actions';
import { fetchStrategies } from './strategy/actions'; import { fetchStrategies } from './strategy/actions';
import { fetchTagTypes } from './tag-type/actions'; import { fetchTagTypes } from './tag-type/actions';
import { C, P } from '../component/common/flags';
export function loadInitalData() { export function loadInitialData(flags = {}) {
return dispatch => { return dispatch => {
fetchUIConfig()(dispatch); fetchUIConfig()(dispatch);
fetchContext()(dispatch); if (flags[C]) fetchContext()(dispatch);
fetchFeatureTypes()(dispatch); fetchFeatureTypes()(dispatch);
fetchProjects()(dispatch); if (flags[P]) fetchProjects()(dispatch);
fetchStrategies()(dispatch); fetchStrategies()(dispatch);
fetchTagTypes()(dispatch); fetchTagTypes()(dispatch);
}; };

View File

@ -15,8 +15,6 @@ const upProject = project => ({ type: UPDATE_PROJECT, project });
const delProject = project => ({ type: REMOVE_PROJECT, project }); const delProject = project => ({ type: REMOVE_PROJECT, project });
export function fetchProjects() { export function fetchProjects() {
return () => {};
/*
const receiveProjects = value => ({ type: RECEIVE_PROJECT, value }); const receiveProjects = value => ({ type: RECEIVE_PROJECT, value });
return dispatch => return dispatch =>
api api
@ -25,7 +23,6 @@ export function fetchProjects() {
dispatch(receiveProjects(json.projects)); dispatch(receiveProjects(json.projects));
}) })
.catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_PROJECT)); .catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_PROJECT));
*/
} }
export function removeProject(project) { export function removeProject(project) {

View File

@ -2,7 +2,7 @@ import { List } from 'immutable';
import { RECEIVE_PROJECT, REMOVE_PROJECT, ADD_PROJECT, UPDATE_PROJECT } from './actions'; import { RECEIVE_PROJECT, REMOVE_PROJECT, ADD_PROJECT, UPDATE_PROJECT } from './actions';
import { USER_LOGOUT, USER_LOGIN } from '../user/actions'; import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
const DEFAULT_PROJECTS = [{ id: 'default', name: 'Default', inital: true }]; const DEFAULT_PROJECTS = [{ id: 'default', name: 'Default', initial: true }];
function getInitState() { function getInitState() {
return new List(DEFAULT_PROJECTS); return new List(DEFAULT_PROJECTS);

View File

@ -3,6 +3,7 @@
exports[`should be default state 1`] = ` exports[`should be default state 1`] = `
Object { Object {
"environment": "", "environment": "",
"flags": Object {},
"headerBackground": undefined, "headerBackground": undefined,
"links": Array [ "links": Array [
Object { Object {
@ -27,6 +28,7 @@ Object {
exports[`should be merged state all 1`] = ` exports[`should be merged state all 1`] = `
Object { Object {
"environment": "dev", "environment": "dev",
"flags": Object {},
"headerBackground": "red", "headerBackground": "red",
"links": Array [ "links": Array [
Object { Object {
@ -51,6 +53,7 @@ Object {
exports[`should only update headerBackground 1`] = ` exports[`should only update headerBackground 1`] = `
Object { Object {
"environment": "", "environment": "",
"flags": Object {},
"headerBackground": "black", "headerBackground": "black",
"links": Array [ "links": Array [
Object { Object {

View File

@ -15,6 +15,7 @@ const DEFAULT = new $Map({
version: '3.x', version: '3.x',
environment: '', environment: '',
slogan: 'The enterprise ready feature toggle service.', slogan: 'The enterprise ready feature toggle service.',
flags: {},
links: [ links: [
{ {
value: 'User documentation', value: 'User documentation',

View File

@ -1,6 +1,6 @@
import api from './api'; import api from './api';
import { dispatchAndThrow } from '../util'; import { dispatchAndThrow } from '../util';
export const UPDATE_USER = 'UPDATE_USER'; export const USER_CHANGE_CURRENT = 'USER_CHANGE_CURRENT';
export const USER_LOGOUT = 'USER_LOGOUT'; export const USER_LOGOUT = 'USER_LOGOUT';
export const USER_LOGIN = 'USER_LOGIN'; export const USER_LOGIN = 'USER_LOGIN';
export const START_FETCH_USER = 'START_FETCH_USER'; export const START_FETCH_USER = 'START_FETCH_USER';
@ -8,7 +8,7 @@ export const ERROR_FETCH_USER = 'ERROR_FETCH_USER';
const debug = require('debug')('unleash:user-actions'); const debug = require('debug')('unleash:user-actions');
const updateUser = value => ({ const updateUser = value => ({
type: UPDATE_USER, type: USER_CHANGE_CURRENT,
value, value,
}); });
@ -28,12 +28,12 @@ export function fetchUser() {
}; };
} }
export function unsecureLogin(path, user) { export function insecureLogin(path, user) {
return dispatch => { return dispatch => {
dispatch({ type: START_FETCH_USER }); dispatch({ type: START_FETCH_USER });
return api return api
.unsecureLogin(path, user) .insecureLogin(path, user)
.then(json => dispatch(updateUser(json))) .then(json => dispatch(updateUser(json)))
.catch(handleError); .catch(handleError);
}; };

View File

@ -14,7 +14,7 @@ function fetchUser() {
.then(response => response.json()); .then(response => response.json());
} }
function unsecureLogin(path, user) { function insecureLogin(path, user) {
return fetch(path, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(user) }) return fetch(path, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(user) })
.then(throwIfNotSuccess) .then(throwIfNotSuccess)
.then(response => response.json()); .then(response => response.json());
@ -33,7 +33,7 @@ function passwordLogin(path, data) {
export default { export default {
fetchUser, fetchUser,
unsecureLogin, insecureLogin,
logoutUser, logoutUser,
passwordLogin, passwordLogin,
}; };

View File

@ -1,10 +1,10 @@
import { Map as $Map } from 'immutable'; import { Map as $Map } from 'immutable';
import { UPDATE_USER, USER_LOGOUT } from './actions'; import { USER_CHANGE_CURRENT, USER_LOGOUT } from './actions';
import { AUTH_REQUIRED } from '../util'; import { AUTH_REQUIRED } from '../util';
const userStore = (state = new $Map(), action) => { const userStore = (state = new $Map(), action) => {
switch (action.type) { switch (action.type) {
case UPDATE_USER: case USER_CHANGE_CURRENT:
state = state state = state
.set('profile', action.value) .set('profile', action.value)
.set('showDialog', false) .set('showDialog', false)