diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index cd94c37e68..daafe85fb4 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -112,8 +112,8 @@ The latest version of this document is always available in - feat: added time-ago to toggle-list - feat: Add stale marking of feature toggles - feat: add support for toggle type (#220) -- feat: stort by stale -- fix: imporve type-chip color +- feat: sort by stale +- fix: improve type-chip color - fix: some ux cleanup for toggle types # [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: Update react-dnd to the latest version 🚀 (#213) - fix: read unleash version from ui-config (#219) -- fix: flag inital context fields +- fix: flag initial context fields # [3.3.5] diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index fed2443a05..857aef80d5 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon_old.ico b/frontend/public/favicon_old.ico new file mode 100644 index 0000000000..fed2443a05 Binary files /dev/null and b/frontend/public/favicon_old.ico differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png index 97e9ef9e8c..27ff43307c 100644 Binary files a/frontend/public/logo.png and b/frontend/public/logo.png differ diff --git a/frontend/public/logo_old.png b/frontend/public/logo_old.png new file mode 100644 index 0000000000..97e9ef9e8c Binary files /dev/null and b/frontend/public/logo_old.png differ diff --git a/frontend/src/component/addons/form-addon-container.js b/frontend/src/component/addons/form-addon-container.js index 01f2e456d5..8631f5d4a6 100644 --- a/frontend/src/component/addons/form-addon-container.js +++ b/frontend/src/component/addons/form-addon-container.js @@ -3,7 +3,7 @@ import FormComponent from './form-addon-component'; import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions'; import { cloneDeep } from 'lodash'; -// Required for to fill the inital form. +// Required for to fill the initial form. const DEFAULT_DATA = { provider: '', description: '', diff --git a/frontend/src/component/application/application-list-component.js b/frontend/src/component/application/application-list-component.js index 976d5de7a4..46416be213 100644 --- a/frontend/src/component/application/application-list-component.js +++ b/frontend/src/component/application/application-list-component.js @@ -13,8 +13,8 @@ const Empty = () => ( you will require a Client SDK.

- You can read more about the available Client SDKs in the{' '} - documentation. + You can read more about how to use Unleash in your application in the{' '} + documentation. ); diff --git a/frontend/src/component/common/flags.js b/frontend/src/component/common/flags.js new file mode 100644 index 0000000000..d365a4c113 --- /dev/null +++ b/frontend/src/component/common/flags.js @@ -0,0 +1,2 @@ +export const P = 'P'; +export const C = 'C'; diff --git a/frontend/src/component/common/index.js b/frontend/src/component/common/index.js index 09b1fba230..5e207a462a 100644 --- a/frontend/src/component/common/index.js +++ b/frontend/src/component/common/index.js @@ -140,14 +140,15 @@ IconLink.propTypes = { icon: PropTypes.string, }; -export const DropdownButton = ({ label, id, className = styles.dropdownButton, title }) => ( - ); DropdownButton.propTypes = { label: PropTypes.string, + style: PropTypes.object, id: PropTypes.string, title: PropTypes.string, }; @@ -185,3 +186,15 @@ export function calc(value, total, decimal) { export function getDisplayName(WrappedComponent) { 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', + }, + }), +}; diff --git a/frontend/src/component/context/edit-context-container.js b/frontend/src/component/context/edit-context-container.js index f48a783652..f7c7d998fe 100644 --- a/frontend/src/component/context/edit-context-container.js +++ b/frontend/src/component/context/edit-context-container.js @@ -6,6 +6,9 @@ const mapStateToProps = (state, props) => { const contextFieldBase = { name: '', description: '', legalValues: [] }; const field = state.context.toJS().find(n => n.name === props.contextFieldName); const contextField = Object.assign(contextFieldBase, field); + if (!field) { + contextField.initial = true; + } return { contextField, diff --git a/frontend/src/component/context/form-context-component.jsx b/frontend/src/component/context/form-context-component.jsx index 75b519d32e..8da86e2214 100644 --- a/frontend/src/component/context/form-context-component.jsx +++ b/frontend/src/component/context/form-context-component.jsx @@ -5,6 +5,14 @@ import { Button, Chip, Textfield, Card, CardTitle, CardText, CardActions, Checkb import { FormButtons, styles as commonStyles } from '../common'; 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 { constructor(props) { super(props); @@ -17,7 +25,7 @@ class AddContextComponent extends Component { } static getDerivedStateFromProps(props, state) { - if (!state.contextField.name && props.contextField.name) { + if (state.contextField.initial && !props.contextField.initial) { return { contextField: props.contextField }; } else { return null; @@ -62,6 +70,10 @@ class AddContextComponent extends Component { evt.preventDefault(); const { contextField, currentLegalValue, errors } = this.state; + if (!currentLegalValue) { + return; + } + if (contextField.legalValues.indexOf(currentLegalValue) !== -1) { errors.currentLegalValue = 'Duplicate legal value'; this.setState({ errors }); @@ -69,7 +81,7 @@ class AddContextComponent extends Component { } const legalValues = contextField.legalValues.concat(trim(currentLegalValue)); - contextField.legalValues = legalValues; + contextField.legalValues = legalValues.sort(sortIgnoreCase); this.setState({ contextField, currentLegalValue: '', @@ -148,7 +160,9 @@ class AddContextComponent extends Component { error={errors.currentLegalValue} onChange={this.updateCurrentLegalValue} /> - +
{contextField.legalValues.map(this.renderLegalValue)}

diff --git a/frontend/src/component/feature/feature-type-select-component.jsx b/frontend/src/component/feature/feature-type-select-component.jsx index c286cd4968..ddbee02f73 100644 --- a/frontend/src/component/feature/feature-type-select-component.jsx +++ b/frontend/src/component/feature/feature-type-select-component.jsx @@ -5,7 +5,7 @@ import MySelect from '../common/select'; class FeatureTypeSelectComponent extends Component { componentDidMount() { const { fetchFeatureTypes, types } = this.props; - if (types[0].inital && fetchFeatureTypes) { + if (types[0].initial && fetchFeatureTypes) { this.props.fetchFeatureTypes(); } } diff --git a/frontend/src/component/feature/list/list-container.jsx b/frontend/src/component/feature/list/list-container.jsx index 3ada7dd8dd..836e85d12a 100644 --- a/frontend/src/component/feature/list/list-container.jsx +++ b/frontend/src/component/feature/list/list-container.jsx @@ -5,6 +5,13 @@ import { updateSettingForGroup } from '../../../store/settings/actions'; import FeatureListComponent from './list-component'; 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 => { const featureMetrics = state.featureMetrics.toJS(); const settings = state.settings.toJS().feature || {}; @@ -19,6 +26,7 @@ export const mapStateToPropsConfigurable = isFeature => state => { const regex = new RegExp(settings.filter, 'i'); features = features.filter( feature => + feature.strategies.some(s => checkConstraints(s, regex)) || regex.test(feature.name) || regex.test(feature.description) || feature.strategies.some(s => s && s.name && regex.test(s.name)) || diff --git a/frontend/src/component/feature/list/project-component.jsx b/frontend/src/component/feature/list/project-component.jsx index bd0b9493de..ddc313e74c 100644 --- a/frontend/src/component/feature/list/project-component.jsx +++ b/frontend/src/component/feature/list/project-component.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Menu, MenuItem } from 'react-mdl'; import { DropdownButton } from '../../common'; import PropTypes from 'prop-types'; @@ -13,13 +13,7 @@ function projectItem(selectedId, item) { ); } -function ProjectComponent({ fetchProjects, projects, currentProjectId, updateCurrentProject }) { - useEffect(() => { - if (projects[0].inital) { - fetchProjects(); - } - }); - +function ProjectComponent({ projects, currentProjectId, updateCurrentProject }) { function setProject(v) { const id = typeof v === 'string' ? v.trim() : ''; updateCurrentProject(id); @@ -38,6 +32,7 @@ function ProjectComponent({ fetchProjects, projects, currentProjectId, updateCur { - const projects = state.projects.toJS(); - - return { - projects, - }; -}; +const mapStateToProps = state => ({ + projects: state.projects.toJS(), + enabled: !!state.uiConfig.toJS().flags[P], +}); const ProjectContainer = connect(mapStateToProps, { fetchProjects })(ProjectSelectComponent); diff --git a/frontend/src/component/feature/strategy/constraint/strategy-constraint-input-container.jsx b/frontend/src/component/feature/strategy/constraint/strategy-constraint-input-container.jsx new file mode 100644 index 0000000000..ae3ac21c9e --- /dev/null +++ b/frontend/src/component/feature/strategy/constraint/strategy-constraint-input-container.jsx @@ -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); diff --git a/frontend/src/component/feature/strategy/constraint/strategy-constraint-input-field.jsx b/frontend/src/component/feature/strategy/constraint/strategy-constraint-input-field.jsx new file mode 100644 index 0000000000..3e10e8635d --- /dev/null +++ b/frontend/src/component/feature/strategy/constraint/strategy-constraint-input-field.jsx @@ -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 ( + + + updateConstraint(evt.target.value, 'contextName')} + style={{ width: 'auto' }} + /> + + + updateConstraint(evt.target.value, 'operator')} + style={{ width: 'auto' }} + /> + + + {options ? ( + + + ) : ( + + )} + + + + + + ); + }); +} + +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); diff --git a/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap index a7615c238d..89f0ad6f8f 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap @@ -43,6 +43,20 @@ exports[`should render DrawerMenu 1`] = ` > Applications + + Context Fields + + + Projects + Applications + + Context Fields + + + Projects + { - expect(routes.length).toEqual(28); + expect(routes.length).toEqual(32); expect(routes).toMatchSnapshot(); }); test('returns all baseRoutes', () => { - expect(baseRoutes.length).toEqual(8); + expect(baseRoutes.length).toEqual(10); expect(baseRoutes).toMatchSnapshot(); }); diff --git a/frontend/src/component/menu/drawer.jsx b/frontend/src/component/menu/drawer.jsx index 1732215378..34f6e782d4 100644 --- a/frontend/src/component/menu/drawer.jsx +++ b/frontend/src/component/menu/drawer.jsx @@ -6,6 +6,13 @@ import styles from '../styles.module.scss'; import { baseRoutes as routes } from './routes'; +const filterByFlags = flags => r => { + if (r.flag && !flags[r.flag]) { + return false; + } + return true; +}; + function getIcon(name) { if (name === 'c_github') { return ; @@ -41,7 +48,7 @@ function renderLink(link) { } } -export const DrawerMenu = ({ links = [], title = 'Unleash' }) => ( +export const DrawerMenu = ({ links = [], title = 'Unleash', flags = {} }) => ( @@ -49,7 +56,7 @@ export const DrawerMenu = ({ links = [], title = 'Unleash' }) => (
- {routes.map(item => ( + {routes.filter(filterByFlags(flags)).map(item => ( ( DrawerMenu.propTypes = { links: PropTypes.array, title: PropTypes.string, + flags: PropTypes.object, }; diff --git a/frontend/src/component/menu/header.jsx b/frontend/src/component/menu/header.jsx index 366bb498f6..063e762441 100644 --- a/frontend/src/component/menu/header.jsx +++ b/frontend/src/component/menu/header.jsx @@ -6,17 +6,18 @@ import { Route } from 'react-router-dom'; import { DrawerMenu } from './drawer'; import Breadcrum from './breadcrumb'; import ShowUserContainer from '../user/show-user-container'; -import { loadInitalData } from './../../store/loader'; +import { loadInitialData } from './../../store/loader'; class HeaderComponent extends PureComponent { static propTypes = { uiConfig: PropTypes.object.isRequired, - loadInitalData: PropTypes.func.isRequired, + init: PropTypes.func.isRequired, location: PropTypes.object.isRequired, }; componentDidMount() { - this.props.loadInitalData(); + const { init, uiConfig } = this.props; + init(uiConfig.flags); } // eslint-disable-next-line camelcase @@ -35,7 +36,7 @@ class HeaderComponent extends PureComponent { } render() { - const { headerBackground, links, name } = this.props.uiConfig; + const { headerBackground, links, name, flags } = this.props.uiConfig; const style = headerBackground ? { background: headerBackground } : {}; return ( @@ -44,10 +45,10 @@ class HeaderComponent extends PureComponent { - +
); } } -export default connect(state => ({ uiConfig: state.uiConfig.toJS() }), { loadInitalData })(HeaderComponent); +export default connect(state => ({ uiConfig: state.uiConfig.toJS() }), { init: loadInitialData })(HeaderComponent); diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index e494ecaa0e..c88da7f933 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -26,6 +26,12 @@ import CreateTag from '../../page/tags/create'; import Addons from '../../page/addons'; import AddonsCreate from '../../page/addons/create'; 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 = [ // Features { path: '/features/create', parent: '/features', title: 'Create', component: CreateFeatureToggle }, @@ -58,12 +64,18 @@ export const routes = [ // Context { path: '/context/create', parent: '/context', title: 'Create', component: CreateContextField }, { 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 { path: '/projects/create', parent: '/projects', title: 'Create', component: CreateProject }, { 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/edit/:name', parent: '/tag-types', title: ':name', component: EditTagType }, diff --git a/frontend/src/component/user/authentication-component.jsx b/frontend/src/component/user/authentication-component.jsx index 470f9b8844..63f8365fb5 100644 --- a/frontend/src/component/user/authentication-component.jsx +++ b/frontend/src/component/user/authentication-component.jsx @@ -37,7 +37,7 @@ class AuthComponent extends React.Component { user: PropTypes.object.isRequired, unsecureLogin: PropTypes.func.isRequired, passwordLogin: PropTypes.func.isRequired, - loadInitalData: PropTypes.func.isRequired, + loadInitialData: PropTypes.func.isRequired, history: PropTypes.object.isRequired, }; @@ -51,7 +51,7 @@ class AuthComponent extends React.Component { ); @@ -60,7 +60,7 @@ class AuthComponent extends React.Component { ); diff --git a/frontend/src/component/user/authentication-container.jsx b/frontend/src/component/user/authentication-container.jsx index e62202f35f..b1fbbf5349 100644 --- a/frontend/src/component/user/authentication-container.jsx +++ b/frontend/src/component/user/authentication-container.jsx @@ -1,16 +1,17 @@ import { connect } from 'react-redux'; import AuthenticationComponent from './authentication-component'; -import { unsecureLogin, passwordLogin } from '../../store/user/actions'; -import { loadInitalData } from './../../store/loader'; +import { insecureLogin, passwordLogin } from '../../store/user/actions'; +import { loadInitialData } from './../../store/loader'; -const mapDispatchToProps = { - unsecureLogin, - passwordLogin, - loadInitalData, -}; +const mapDispatchToProps = (dispatch, props) => ({ + insecureLogin: (path, user) => insecureLogin(path, user)(dispatch), + passwordLogin: (path, user) => passwordLogin(path, user)(dispatch), + loadInitialData: () => loadInitialData(props.flags)(dispatch), +}); const mapStateToProps = state => ({ user: state.user.toJS(), + flags: state.uiConfig.toJS().flags, }); export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationComponent); diff --git a/frontend/src/component/user/authentication-password-component.jsx b/frontend/src/component/user/authentication-password-component.jsx index a81004829b..797b9c37a5 100644 --- a/frontend/src/component/user/authentication-password-component.jsx +++ b/frontend/src/component/user/authentication-password-component.jsx @@ -6,7 +6,7 @@ class EnterpriseAuthenticationComponent extends React.Component { static propTypes = { authDetails: PropTypes.object.isRequired, passwordLogin: PropTypes.func.isRequired, - loadInitalData: PropTypes.func.isRequired, + loadInitialData: PropTypes.func.isRequired, history: PropTypes.object.isRequired, }; @@ -37,7 +37,7 @@ class EnterpriseAuthenticationComponent extends React.Component { try { await this.props.passwordLogin(path, user); - await this.props.loadInitalData(); + await this.props.loadInitialData(); this.props.history.push(`/`); } catch (error) { if (error.statusCode === 404) { diff --git a/frontend/src/component/user/authentication-simple-component.jsx b/frontend/src/component/user/authentication-simple-component.jsx index 76d69db45d..34b6cce1be 100644 --- a/frontend/src/component/user/authentication-simple-component.jsx +++ b/frontend/src/component/user/authentication-simple-component.jsx @@ -6,7 +6,7 @@ class SimpleAuthenticationComponent extends React.Component { static propTypes = { authDetails: PropTypes.object.isRequired, unsecureLogin: PropTypes.func.isRequired, - loadInitalData: PropTypes.func.isRequired, + loadInitialData: PropTypes.func.isRequired, history: PropTypes.object.isRequired, }; @@ -18,7 +18,7 @@ class SimpleAuthenticationComponent extends React.Component { this.props .unsecureLogin(path, user) - .then(this.props.loadInitalData) + .then(this.props.loadInitialData) .then(() => this.props.history.push(`/`)); }; diff --git a/frontend/src/page/admin/admin-menu.jsx b/frontend/src/page/admin/admin-menu.jsx new file mode 100644 index 0000000000..1aa660546a --- /dev/null +++ b/frontend/src/page/admin/admin-menu.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +function AdminMenu() { + return ( +
+ Users | API Access |{' '} + Authentication +
+ ); +} + +export default AdminMenu; diff --git a/frontend/src/page/admin/api/api-howto.jsx b/frontend/src/page/admin/api/api-howto.jsx new file mode 100644 index 0000000000..3a4b6d9938 --- /dev/null +++ b/frontend/src/page/admin/api/api-howto.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +function ApiHowTo() { + return ( +
+

+ Read the{' '} + + Getting started guide + {' '} + to learn how to connect to the Unleash API form your application or programmatically.

+ Please note it can take up to 1 minute before a new API key is activated. +

+
+ ); +} + +export default ApiHowTo; diff --git a/frontend/src/page/admin/api/api-key-create.jsx b/frontend/src/page/admin/api/api-key-create.jsx new file mode 100644 index 0000000000..fc542f5897 --- /dev/null +++ b/frontend/src/page/admin/api/api-key-create.jsx @@ -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 ( +
+ {show ? ( +
+ setUsername(e.target.value)} + label="Username" + floatingLabel + style={{ width: '200px' }} + error={error} + /> + + + + + + ) : ( + + Add new access key + + )} +
+ ); +} + +CreateApiKey.propTypes = { + addKey: PropTypes.func.isRequired, +}; + +export default CreateApiKey; diff --git a/frontend/src/page/admin/api/api-key-list-container.js b/frontend/src/page/admin/api/api-key-list-container.js new file mode 100644 index 0000000000..6988d6d963 --- /dev/null +++ b/frontend/src/page/admin/api/api-key-list-container.js @@ -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); diff --git a/frontend/src/page/admin/api/api-key-list.jsx b/frontend/src/page/admin/api/api-key-list.jsx new file mode 100644 index 0000000000..582a4acdca --- /dev/null +++ b/frontend/src/page/admin/api/api-key-list.jsx @@ -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 ( +
+ + + + + + + + + + + + + {keys.map(item => ( + + + + + + {hasPermission('ADMIN') ? ( + + ) : ( + + ))} + +
+ Created + + Username + + Acess Type + + Secret + + Action +
+ {formatFullDateTimeWithLocale(item.created, location.locale)} + {item.username}{item.priviliges[0]} + + + { + e.preventDefault(); + deleteKey(item.key); + }} + > + + + + )} +
+ {hasPermission('ADMIN') ? : null} +
+ ); +} + +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; diff --git a/frontend/src/page/admin/api/index.js b/frontend/src/page/admin/api/index.js new file mode 100644 index 0000000000..8917e9bdb3 --- /dev/null +++ b/frontend/src/page/admin/api/index.js @@ -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 = () => ( +
+ +

API Access

+ +
+); + +render.propTypes = { + match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, +}; + +export default render; diff --git a/frontend/src/page/admin/api/secret.jsx b/frontend/src/page/admin/api/secret.jsx new file mode 100644 index 0000000000..9af6876128 --- /dev/null +++ b/frontend/src/page/admin/api/secret.jsx @@ -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 ( +
+ {show ? ( + + ) : ( + *************************** + )} + + + + +
+ ); +} + +Secret.propTypes = { + value: PropTypes.string, +}; + +export default Secret; diff --git a/frontend/src/page/admin/auth/google-auth-container.js b/frontend/src/page/admin/auth/google-auth-container.js new file mode 100644 index 0000000000..6557d9c059 --- /dev/null +++ b/frontend/src/page/admin/auth/google-auth-container.js @@ -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; diff --git a/frontend/src/page/admin/auth/google-auth.jsx b/frontend/src/page/admin/auth/google-auth.jsx new file mode 100644 index 0000000000..e6194d0002 --- /dev/null +++ b/frontend/src/page/admin/auth/google-auth.jsx @@ -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 You need admin privileges to access this section.; + } + + 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 ( +
+ + +

+ Please read the{' '} + + documentation + {' '} + to learn how to integrate with Google OAuth 2.0.
+
+ Callback URL: https://[unleash.hostname.com]/auth/google/callback +

+
+
+
+ + + Enable +

+ Enable Google users to login. Value is ignored if Client ID and Client Secret are not + defined. +

+
+ + + {data.enabled ? 'Enabled' : 'Disabled'} + + +
+ + + Client ID +

(Required) The Client ID provided by Google when registering the application.

+
+ + + +
+ + + Client Secret +

(Required) Client Secret provided by Google when registering the application.

+
+ + + +
+ + + Unleash hostname +

+ (Required) The hostname you are running Unleash on that Google should send the user back to. + The final callback URL will be{' '} + + https://[unleash.hostname.com]/auth/google/callback + +

+
+ + + +
+ + + Auto-create users +

Enable automatic creation of new users when signing in with Google.

+
+ + + Auto-create users + + +
+ + + Email domains +

(Optional) Comma separated list of email domains that should be allowed to sign in.

+
+ + + +
+ + + {' '} + {info} + + +
+
+ ); +} + +GoogleAuth.propTypes = { + config: PropTypes.object, + getGoogleConfig: PropTypes.func.isRequired, + updateGoogleConfig: PropTypes.func.isRequired, + hasPermission: PropTypes.func.isRequired, +}; + +export default GoogleAuth; diff --git a/frontend/src/page/admin/auth/index.js b/frontend/src/page/admin/auth/index.js new file mode 100644 index 0000000000..5a47ebd760 --- /dev/null +++ b/frontend/src/page/admin/auth/index.js @@ -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 ( +
+ +

Authentication

+
+ + SAML 2.0 + Google + +
{activeTab === 0 ? : }
+
+
+ ); +} + +AdminAuthPage.propTypes = { + match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, +}; + +export default AdminAuthPage; diff --git a/frontend/src/page/admin/auth/saml-auth-container.js b/frontend/src/page/admin/auth/saml-auth-container.js new file mode 100644 index 0000000000..4340f8e798 --- /dev/null +++ b/frontend/src/page/admin/auth/saml-auth-container.js @@ -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; diff --git a/frontend/src/page/admin/auth/saml-auth.jsx b/frontend/src/page/admin/auth/saml-auth.jsx new file mode 100644 index 0000000000..1690af3984 --- /dev/null +++ b/frontend/src/page/admin/auth/saml-auth.jsx @@ -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 You need admin privileges to access this section.; + } + + 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 ( +
+ + +

+ Please read the{' '} + + documentation + {' '} + to learn how to integrate with specific SMAL 2.0 providers (Okta, Keycloak, etc).
+
+ Callback URL: https://[unleash.hostname.com]/auth/saml/callback +

+
+
+
+ + + Enable +

Enable SAML 2.0 Authentication.

+
+ + + {data.enabled ? 'Enabled' : 'Disabled'} + + +
+ + + Entity ID +

(Required) The Entity Identity provider issuer.

+
+ + + +
+ + + Single Sign-On URL +

(Required) The url to redirect the user to for signing in.

+
+ + + +
+ + + X.509 Certificate +

(Required) The certificate used to sign the SAML 2.0 request.

+
+ +