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

Fix/customer journey (#297)

* fix: add onClose to archive toggle dialoge

* fix: add link to ConfirmUserLink component

* fix: remove icons from admin menu

* fix: move button on user list to top right

* refactor: move add new api key to header

* refactor: button order

* fix: lowercase dropdown buttons on feature toggle list

* refactor: reorganize reporting dashboard

* refactor: consistent buttons

* feat: enhance gradual rollout strategy creation

* feat: ui tweaks on project access

* fix: adjust divider

* fix: remove unused imports

* fix: update snapshots

* fix: add auth options to new user page

* fix: add divider

* fix: uncontrolled input

* fix: add data-loading to sorted by

* fix: update snapshots

* fix: navigate to project view on create and edit

* fix: rename project

* fix: add placeholder for feature toggle list component

* fix: conditonally render link
This commit is contained in:
Fredrik Strand Oseberg 2021-05-18 12:59:48 +02:00 committed by GitHub
parent e1034a458b
commit cbd4773cf6
44 changed files with 891 additions and 363 deletions

View File

@ -13,7 +13,7 @@ body {
}
.MuiButton-root {
border-radius: 25px;
border-radius: 3px;
}
.skeleton {

View File

@ -0,0 +1,99 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="46px"
height="46px"
viewBox="0 0 46 46"
>
<defs>
<filter
x="-50%"
y="-50%"
width="200%"
height="200%"
filterUnits="objectBoundingBox"
id="filter-1"
>
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1" />
<feGaussianBlur
stdDeviation="0.5"
in="shadowOffsetOuter1"
result="shadowBlurOuter1"
/>
<feColorMatrix
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0"
in="shadowBlurOuter1"
type="matrix"
result="shadowMatrixOuter1"
/>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2" />
<feGaussianBlur
stdDeviation="0.5"
in="shadowOffsetOuter2"
result="shadowBlurOuter2"
/>
<feColorMatrix
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0"
in="shadowBlurOuter2"
type="matrix"
result="shadowMatrixOuter2"
/>
<feMerge>
<feMergeNode in="shadowMatrixOuter1" />
<feMergeNode in="shadowMatrixOuter2" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2" />
</defs>
<g
id="Google-Button"
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
>
<g id="9-PATCH" transform="translate(-608.000000, -160.000000)" />
<g
id="btn_google_light_normal"
transform="translate(-1.000000, -1.000000)"
>
<g
id="button"
transform="translate(4.000000, 4.000000)"
filter="url(#filter-1)"
>
<g id="button-bg">
<use fill="#FFFFFF" fillRule="evenodd" />
<use fill="none" />
<use fill="none" />
<use fill="none" />
</g>
</g>
<g id="logo_googleg_48dp" transform="translate(15.000000, 15.000000)">
<path
d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z"
id="Shape"
fill="#4285F4"
/>
<path
d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z"
id="Shape"
fill="#34A853"
/>
<path
d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z"
id="Shape"
fill="#FBBC05"
/>
<path
d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z"
id="Shape"
fill="#EA4335"
/>
<path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" />
</g>
<g id="handles_square" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -47,9 +47,8 @@ const ReportCard = ({ features }) => {
const total = features.length;
const activeTogglesArray = getActiveToggles();
const potentiallyStaleToggles = getPotentiallyStaleToggles(
activeTogglesArray
);
const potentiallyStaleToggles =
getPotentiallyStaleToggles(activeTogglesArray);
const activeTogglesCount = activeTogglesArray.length;
const staleTogglesCount = features.length - activeTogglesCount;
@ -95,6 +94,17 @@ const ReportCard = ({ features }) => {
return (
<Paper className={styles.card}>
<div className={styles.reportCardContainer}>
<div className={styles.reportCardHealth}>
<h2 className={styles.header}>Health rating</h2>
<div className={styles.reportCardHealthInnerContainer}>
<ConditionallyRender
condition={healthRating > -1}
show={
<p className={healthClasses}>{healthRating}%</p>
}
/>
</div>
</div>
<div className={styles.reportCardListContainer}>
<h2 className={styles.header}>Toggle report</h2>
<ul className={styles.reportCardList}>
@ -118,17 +128,7 @@ const ReportCard = ({ features }) => {
</li>
</ul>
</div>
<div className={styles.reportCardHealth}>
<h2 className={styles.header}>Health rating</h2>
<div className={styles.reportCardHealthInnerContainer}>
<ConditionallyRender
condition={healthRating > -1}
show={
<p className={healthClasses}>{healthRating}%</p>
}
/>
</div>
</div>
<div className={styles.reportCardAction}>
<h2 className={styles.header}>Potential actions</h2>
<div className={styles.reportCardActionContainer}>

View File

@ -8,12 +8,27 @@ import CheckIcon from '@material-ui/icons/Check';
import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { pluralize, getDates, expired, toggleExpiryByTypeMap, getDiffInDays } from '../../utils';
import {
pluralize,
getDates,
expired,
toggleExpiryByTypeMap,
getDiffInDays,
} from '../../utils';
import { KILLSWITCH, PERMISSION } from '../../constants';
import styles from '../ReportToggleList.module.scss';
const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checked, bulkActionsOn, setFeatures }) => {
const ReportToggleListItem = ({
name,
stale,
lastSeenAt,
createdAt,
type,
checked,
bulkActionsOn,
setFeatures,
}) => {
const nameMatches = feature => feature.name === name;
const history = useHistory();
@ -80,17 +95,26 @@ const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checke
const formatReportStatus = () => {
if (type === KILLSWITCH || type === PERMISSION) {
return renderStatus(<CheckIcon className={styles.reportIcon} />, 'Active');
return renderStatus(
<CheckIcon className={styles.reportIcon} />,
'Healthy'
);
}
const [date, now] = getDates(createdAt);
const diff = getDiffInDays(date, now);
if (expired(diff, type)) {
return renderStatus(<ReportProblemOutlinedIcon className={styles.reportIcon} />, 'Potentially stale');
return renderStatus(
<ReportProblemOutlinedIcon className={styles.reportIcon} />,
'Potentially stale'
);
}
return renderStatus(<CheckIcon className={styles.reportIcon} />, 'Active');
return renderStatus(
<CheckIcon className={styles.reportIcon} />,
'Healthy'
);
};
const navigateToFeature = () => {
@ -102,7 +126,12 @@ const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checke
});
return (
<tr role="button" tabIndex={0} onClick={navigateToFeature} className={styles.tableRow}>
<tr
role="button"
tabIndex={0}
onClick={navigateToFeature}
className={styles.tableRow}
>
<ConditionallyRender
condition={bulkActionsOn}
show={

View File

@ -0,0 +1,12 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
emptyStateListItem: {
border: `2px dashed ${theme.palette.borders.main}`,
padding: '0.8rem',
textAlign: 'center',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
}));

View File

@ -0,0 +1,26 @@
import { ListItem } from '@material-ui/core';
import { Link } from 'react-router-dom';
import ConditionallyRender from '../ConditionallyRender';
import { useStyles } from './ListPlaceholder.styles';
interface IListPlaceholderProps {
text: string;
link?: string;
linkText?: string;
}
const ListPlaceholder = ({ text, link, linkText }: IListPlaceholderProps) => {
const styles = useStyles();
return (
<ListItem className={styles.emptyStateListItem}>
{text}
<ConditionallyRender
condition={Boolean(link && linkText)}
show={<Link to="/features/create">Add your first toggle</Link>}
/>
</ListItem>
);
};
export default ListPlaceholder;

View File

@ -78,7 +78,6 @@
}
.dropdownButton {
text-transform: none;
font-weight: normal;
}

View File

@ -84,7 +84,6 @@ export const FormButtons = ({
type="submit"
color="primary"
variant="contained"
startIcon={<Icon>add</Icon>}
>
{submitText}
</Button>

View File

@ -14,6 +14,8 @@ import {
ListItemIcon,
ListItemText,
Tooltip,
useMediaQuery,
Button,
} from '@material-ui/core';
import { useContext, useState } from 'react';
import { Link } from 'react-router-dom';
@ -24,6 +26,7 @@ import AccessContext from '../../../contexts/AccessContext';
const ContextList = ({ removeContextField, history, contextFields }) => {
const { hasAccess } = useContext(AccessContext);
const [showDelDialogue, setShowDelDialogue] = useState(false);
const smallScreen = useMediaQuery('(max-width:700px)');
const [name, setName] = useState();
const styles = useStyles();
@ -62,13 +65,29 @@ const ContextList = ({ removeContextField, history, contextFields }) => {
const headerButton = () => (
<ConditionallyRender
condition={hasAccess(CREATE_CONTEXT_FIELD)}
show={
<ConditionallyRender
condition={smallScreen}
show={
<Tooltip title="Add context type">
<IconButton onClick={() => history.push('/context/create')}>
<IconButton
onClick={() => history.push('/context/create')}
>
<Icon>add</Icon>
</IconButton>
</Tooltip>
}
elseShow={
<Button
onClick={() => history.push('/context/create')}
color="primary"
variant="contained"
>
Add new context field
</Button>
}
/>
}
/>
);
return (

View File

@ -26,6 +26,7 @@ import { CREATE_FEATURE } from '../../AccessProvider/permissions';
import AccessContext from '../../../contexts/AccessContext';
import { useStyles } from './styles';
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
const FeatureToggleList = ({
fetcher,
@ -41,7 +42,7 @@ const FeatureToggleList = ({
}) => {
const { hasAccess } = useContext(AccessContext);
const styles = useStyles();
const smallScreen = useMediaQuery('(max-width:700px)');
const smallScreen = useMediaQuery('(max-width:800px)');
useLayoutEffect(() => {
fetcher();
@ -102,13 +103,12 @@ const FeatureToggleList = ({
</ListItem>
}
elseShow={
<ListItem className={styles.emptyStateListItem}>
No features available. Get started by adding a
new feature toggle.
<Link to="/features/create">
Add your first toggle
</Link>
</ListItem>
<ListPlaceholder
text="No features available. Get started by adding a
new feature toggle."
link="/features/create"
linkText="Add your first toggle"
/>
}
/>
}

View File

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MenuItem } from '@material-ui/core';
import { MenuItem, Typography } from '@material-ui/core';
import { MenuItemWithIcon } from '../../../common';
import DropdownMenu from '../../../common/DropdownMenu/DropdownMenu';
import ProjectSelect from '../../../common/ProjectSelect';
@ -66,6 +66,9 @@ const FeatureToggleListActions = ({
return (
<div className={styles.actions} ref={ref}>
<Typography variant="body2" data-loading>
Sorted by:
</Typography>
<DropdownMenu
id={'metric'}
label={`Last ${settings.showLastHour ? 'hour' : 'minute'}`}
@ -73,6 +76,7 @@ const FeatureToggleListActions = ({
callback={toggleMetrics}
renderOptions={renderMetricsOptions}
className=""
style={{ textTransform: 'lowercase' }}
data-loading
/>
<DropdownMenu
@ -82,11 +86,13 @@ const FeatureToggleListActions = ({
renderOptions={renderSortingOptions}
title="Sort by"
className=""
style={{ textTransform: 'lowercase' }}
data-loading
/>
<ProjectSelect
settings={settings}
updateSetting={updateSetting}
style={{ textTransform: 'lowercase' }}
data-loading
/>
</div>

View File

@ -6,5 +6,7 @@ export const useStyles = makeStyles({
margin: '0 0.25rem',
},
marginRight: '0.25rem',
display: 'flex',
alignItems: 'center',
},
});

View File

@ -66,6 +66,12 @@ exports[`renders correctly with one feature 1`] = `
<div
className="makeStyles-actions-15"
>
<p
className="MuiTypography-root MuiTypography-body2"
data-loading={true}
>
Sorted by:
</p>
<button
aria-controls="metric"
aria-haspopup="true"
@ -85,6 +91,11 @@ exports[`renders correctly with one feature 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={
Object {
"textTransform": "lowercase",
}
}
tabIndex={0}
title="Metric interval"
type="button"
@ -124,6 +135,11 @@ exports[`renders correctly with one feature 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={
Object {
"textTransform": "lowercase",
}
}
tabIndex={0}
title="Sort by"
type="button"
@ -268,6 +284,12 @@ exports[`renders correctly with one feature without permissions 1`] = `
<div
className="makeStyles-actions-15"
>
<p
className="MuiTypography-root MuiTypography-body2"
data-loading={true}
>
Sorted by:
</p>
<button
aria-controls="metric"
aria-haspopup="true"
@ -287,6 +309,11 @@ exports[`renders correctly with one feature without permissions 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={
Object {
"textTransform": "lowercase",
}
}
tabIndex={0}
title="Metric interval"
type="button"
@ -329,6 +356,11 @@ exports[`renders correctly with one feature without permissions 1`] = `
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={
Object {
"textTransform": "lowercase",
}
}
tabIndex={0}
title="Sort by"
type="button"

View File

@ -318,10 +318,6 @@ const FeatureView = ({
<AddTagDialog
featureToggleName={featureToggle.name}
/>
<StatusUpdateComponent
stale={featureToggle.stale}
updateStale={updateStale}
/>
<Button
title="Create new feature toggle by cloning configuration"
component={Link}
@ -329,6 +325,10 @@ const FeatureView = ({
>
Clone
</Button>
<StatusUpdateComponent
stale={featureToggle.stale}
updateStale={updateStale}
/>
<Button
disabled={!hasAccess(DELETE_FEATURE, project)}
@ -368,6 +368,7 @@ const FeatureView = ({
setDelDialog(false);
removeToggle();
}}
onClose={() => setDelDialog(false)}
/>
</Paper>
);

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import InputPercentage from './input-percentage';
import Select from '../../../common/select';
import { TextField, Typography } from '@material-ui/core';
import { Icon, TextField, Tooltip, Typography } from '@material-ui/core';
const builtInStickinessOptions = [
{ key: 'default', label: 'default' },
@ -21,7 +21,9 @@ const FlexibleStrategy = ({ updateParameter, parameters, context }) => {
builtInStickinessOptions.concat(
context
.filter(c => c.stickiness)
.filter(c => !builtInStickinessOptions.find(s => s.key === c.name))
.filter(
c => !builtInStickinessOptions.find(s => s.key === c.name)
)
.map(c => ({ key: c.name, label: c.name }))
);
@ -33,13 +35,37 @@ const FlexibleStrategy = ({ updateParameter, parameters, context }) => {
return (
<div>
<InputPercentage name="Rollout" value={1 * rollout} onChange={onUpdate('rollout')} />
<InputPercentage
name="Rollout"
value={1 * rollout}
onChange={onUpdate('rollout')}
/>
<br />
<div>
<Typography variant="subtitle2" gutterBottom>
<Tooltip
placement="right-start"
title="Stickiness defines what parameter should be used to ensure that your users get consistency in features. By default unleash will use the first value present in the context in the order of userId, sessionId and random."
>
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
Stickiness
<Icon
style={{
fontSize: '1rem',
color: 'gray',
marginLeft: '0.2rem',
}}
>
info
</Icon>
</Typography>
<br />
</Tooltip>
<Select
name="stickiness"
label="Stickiness"
@ -48,6 +74,32 @@ const FlexibleStrategy = ({ updateParameter, parameters, context }) => {
onChange={e => onUpdate('stickiness')(e, e.target.value)}
/>
&nbsp;
<br />
<br />
<Tooltip
placement="right-start"
title="GroupId is used to ensure that different toggles will hash differently for the same user. The groupId defaults to feature toggle name, but you can override it to correlate rollout of multiple feature toggles."
>
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
GroupId
<Icon
style={{
fontSize: '1rem',
color: 'gray',
marginLeft: '0.2rem',
}}
>
info
</Icon>
</Typography>
</Tooltip>
<TextField
label="groupId"
size="small"

View File

@ -4,12 +4,22 @@ import { Tooltip, Icon, Typography } from '@material-ui/core';
import StrategyConstraintInputField from '../StrategyConstraintInputField';
import { useCommonStyles } from '../../../../../common.styles';
const StrategyConstraintInput = ({ constraints, updateConstraints, contextNames, contextFields, enabled }) => {
const StrategyConstraintInput = ({
constraints,
updateConstraints,
contextNames,
contextFields,
enabled,
}) => {
const commonStyles = useCommonStyles();
const addConstraint = evt => {
evt.preventDefault();
const updatedConstraints = [...constraints];
updatedConstraints.push({ contextName: contextNames[0], operator: 'IN', values: [] });
updatedConstraints.push({
contextName: contextNames[0],
operator: 'IN',
values: [],
});
updateConstraints(updatedConstraints);
};
@ -35,12 +45,22 @@ const StrategyConstraintInput = ({ constraints, updateConstraints, contextNames,
return (
<div className={commonStyles.contentSpacingY}>
<Tooltip
placement="right-start"
title={
<span>
Use context fields to constrain the activation strategy.
</span>
}
>
<Typography variant="subtitle2">
{'Constraints '}
<Tooltip title={<span>Use context fields to constrain the activation strategy.</span>}>
<Icon style={{ fontSize: '0.9em', color: 'gray' }}>info</Icon>
</Tooltip>
<Icon style={{ fontSize: '0.9rem', color: 'gray' }}>
info
</Icon>
</Typography>
</Tooltip>
<table style={{ margin: 0 }}>
<tbody>
{constraints.map((c, index) => (
@ -56,7 +76,11 @@ const StrategyConstraintInput = ({ constraints, updateConstraints, contextNames,
</tbody>
</table>
<small>
<a href="#add-constraint" title="Add constraint" onClick={addConstraint}>
<a
href="#add-constraint"
title="Add constraint"
onClick={addConstraint}
>
Add constraint
</a>
</small>

View File

@ -216,6 +216,32 @@ exports[`renders correctly with one feature 1`] = `
<AddTagDialog
featureToggleName="Another"
/>
<a
aria-disabled={false}
className="MuiButtonBase-root MuiButton-root MuiButton-text"
href="/features/copy/Another"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
title="Create new feature toggle by cloning configuration"
>
<span
className="MuiButton-label"
>
Clone
</span>
</a>
<button
aria-controls="feature-stale-dropdown"
aria-haspopup="true"
@ -258,32 +284,6 @@ exports[`renders correctly with one feature 1`] = `
</span>
</span>
</button>
<a
aria-disabled={false}
className="MuiButtonBase-root MuiButton-root MuiButton-text"
href="/features/copy/Another"
onBlur={[Function]}
onClick={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onMouseDown={[Function]}
onMouseLeave={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
role="button"
tabIndex={0}
title="Create new feature toggle by cloning configuration"
>
<span
className="MuiButton-label"
>
Clone
</span>
</a>
<button
className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false}

View File

@ -35,7 +35,7 @@ import AdminAuth from '../../page/admin/auth';
import Reporting from '../../page/reporting';
import Login from '../user/Login';
import { P, C } from '../common/flags';
import NewUser from '../user/NewUser/NewUser';
import NewUser from '../user/NewUser';
import ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import HeaderTitle from '../../common/HeaderTitle';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import {
@ -15,6 +15,8 @@ import {
ListItemAvatar,
ListItemText,
Tooltip,
Button,
useMediaQuery,
} from '@material-ui/core';
import { Link } from 'react-router-dom';
import ConfirmDialogue from '../../common/Dialogue';
@ -24,6 +26,7 @@ import AccessContext from '../../../contexts/AccessContext';
const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
const { hasAccess } = useContext(AccessContext);
const smallScreen = useMediaQuery('(max-width:700px)');
const [showDelDialogue, setShowDelDialogue] = useState(false);
const [project, setProject] = useState(undefined);
const styles = useStyles();
@ -34,16 +37,29 @@ const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
const addProjectButton = () => (
<ConditionallyRender
condition={hasAccess(CREATE_PROJECT)}
show={
<ConditionallyRender
condition={smallScreen}
show={
<Tooltip title="Add new project">
<IconButton
aria-label="add-project"
onClick={() => history.push('/projects/create')}
>
<Icon>add</Icon>
</IconButton>
</Tooltip>
}
elseShow={
<Button
onClick={() => history.push('/projects/create')}
color="primary"
variant="contained"
>
Add new project
</Button>
}
/>
}
/>
);

View File

@ -1,5 +1,5 @@
import { useContext, useEffect } from 'react';
import { Typography, Button, List } from '@material-ui/core';
import { Typography, Button, List, ListItem } from '@material-ui/core';
import { Link } from 'react-router-dom';
import AccessContext from '../../../contexts/AccessContext';
import HeaderTitle from '../../common/HeaderTitle';
@ -7,8 +7,9 @@ import PageContent from '../../common/PageContent';
import FeatureToggleListItem from '../../feature/FeatureToggleList/FeatureToggleListItem';
import ConditionallyRender from '../../common/ConditionallyRender';
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
const ViewProject = ({
const ProjectView = ({
project,
features,
settings,
@ -81,10 +82,21 @@ const ViewProject = ({
<Typography variant="subtitle2">
Feature toggles in this project
</Typography>
<List>{renderProjectFeatures()}</List>
<List>
<ConditionallyRender
condition={features.length > 0}
show={renderProjectFeatures()}
elseShow={
<ListPlaceholder
text="No features available. Get started by adding a
new feature toggle."
/>
}
/>
</List>
</PageContent>
</div>
);
};
export default ViewProject;
export default ProjectView;

View File

@ -3,7 +3,7 @@ import {
fetchFeatureToggles,
toggleFeature,
} from '../../../store/feature-toggle/actions';
import ViewProject from './ViewProject';
import ViewProject from './ProjectView';
const mapStateToProps = (state, props) => {
const projectBase = { id: '', name: '', description: '' };

View File

@ -68,7 +68,7 @@ function AddUserComponent({ roles, addUserToRole }) {
};
return (
<Grid container justify="center" spacing={3} alignItems="flex-end">
<Grid container justify="left" spacing={3} alignItems="flex-end">
<Grid item>
<Autocomplete
id="add-user-component"
@ -93,6 +93,8 @@ function AddUserComponent({ roles, addUserToRole }) {
<TextField
{...params}
label="User"
variant="outlined"
size="small"
name="search"
onChange={handleQueryUpdate}
InputProps={{
@ -119,8 +121,15 @@ function AddUserComponent({ roles, addUserToRole }) {
/>
</Grid>
<Grid item>
<FormControl>
<InputLabel id="add-user-select-role-label">
<FormControl
variant="outlined"
size="small"
style={{ minWidth: '125px' }}
>
<InputLabel
style={{ backgroundColor: '#fff' }}
id="add-user-select-role-label"
>
Role
</InputLabel>
<Select

View File

@ -1,15 +1,14 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Avatar,
Button,
Card,
CardHeader,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
InputLabel,
Icon,
IconButton,
List,
@ -19,21 +18,28 @@ import {
ListItemText,
MenuItem,
Select,
FormControl,
} from '@material-ui/core';
import AddUserComponent from './access-add-user';
import projectApi from '../../store/project/api';
import PageContent from '../common/PageContent';
import HeaderTitle from '../common/HeaderTitle';
import { Link, useHistory } from 'react-router-dom';
function AccessComponent({ projectId, project }) {
const [roles, setRoles] = useState([]);
const [users, setUsers] = useState([]);
const [error, setError] = useState();
const history = useHistory();
const fetchAccess = async () => {
const access = await projectApi.fetchAccess(projectId);
setRoles(access.roles);
setUsers(access.users.map(u => ({ ...u, name: u.name || '(No name)' })));
setUsers(
access.users.map(u => ({ ...u, name: u.name || '(No name)' }))
);
};
useEffect(() => {
@ -85,8 +91,23 @@ function AccessComponent({ projectId, project }) {
};
return (
<Card style={{ minHeight: '400px' }}>
<CardHeader title={`Managed Access for project "${project.name}"`} />
<PageContent
style={{ minHeight: '400px' }}
headerContent={
<HeaderTitle
title={`Manage Access for project "${project.name}"`}
actions={
<Button
variant="contained"
color="primary"
onClick={() => history.goBack()}
>
Back
</Button>
}
/>
}
>
<AddUserComponent roles={roles} addUserToRole={addUser} />
<Dialog
open={!!error}
@ -96,14 +117,29 @@ function AccessComponent({ projectId, project }) {
>
<DialogTitle id="alert-dialog-title">{'Error'}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">{error}</DialogContentText>
<DialogContentText id="alert-dialog-description">
{error}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseError} color="secondary" autoFocus>
<Button
onClick={handleCloseError}
color="secondary"
autoFocus
>
Close
</Button>
</DialogActions>
</Dialog>
<div
style={{
height: '1px',
width: '106.65%',
marginLeft: '-2rem',
backgroundColor: '#efefef',
marginTop: '2rem',
}}
></div>
<List>
{users.map(user => {
const labelId = `checkbox-list-secondary-label-${user.id}`;
@ -112,25 +148,50 @@ function AccessComponent({ projectId, project }) {
<ListItemAvatar>
<Avatar alt={user.name} src={user.imageUrl} />
</ListItemAvatar>
<ListItemText id={labelId} primary={user.name} secondary={user.email || user.username} />
<ListItemSecondaryAction>
<ListItemText
id={labelId}
primary={user.name}
secondary={user.email || user.username}
/>
<ListItemSecondaryAction
style={{
display: 'flex',
alignItems: 'center',
}}
>
<FormControl variant="outlined" size="small">
<InputLabel
style={{ backgroundColor: '#fff' }}
for="add-user-select-role-label"
>
Role
</InputLabel>
<Select
labelId={`role-${user.id}-select-label`}
id={`role-${user.id}-select`}
key={user.id}
placeholder="Choose role"
value={user.roleId}
onChange={handleRoleChange(user.id, user.roleId)}
value={user.roleId || ''}
onChange={handleRoleChange(
user.id,
user.roleId
)}
>
<MenuItem value="" disabled>
Choose role
</MenuItem>
{roles.map(role => (
<MenuItem key={`${user.id}:${role.id}`} value={role.id}>
<MenuItem
key={`${user.id}:${role.id}`}
value={role.id}
>
{role.name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
style={{ marginLeft: '0.5rem' }}
edge="end"
aria-label="delete"
title="Remove access"
@ -143,7 +204,7 @@ function AccessComponent({ projectId, project }) {
);
})}
</List>
</Card>
</PageContent>
);
}

View File

@ -75,8 +75,15 @@ class ProjectFormComponent extends Component {
};
onCancel = evt => {
const { editMode } = this.props;
const { project } = this.state;
evt.preventDefault();
if (editMode) {
this.props.history.push(`/projects/view/${project.id}`);
} else {
this.props.history.push('/projects');
}
};
onSubmit = async evt => {
@ -87,7 +94,7 @@ class ProjectFormComponent extends Component {
if (valid) {
await this.props.submit(project);
this.props.history.push('/projects');
this.props.history.push(`/projects/view/${project.id}`);
}
};
@ -99,7 +106,8 @@ class ProjectFormComponent extends Component {
return (
<PageContent
headerContent={<div>
headerContent={
<div>
<span>{submitText} Project</span>
<ConditionallyRender
condition={hasAccess(CREATE_PROJECT) && editMode}
@ -112,7 +120,9 @@ class ProjectFormComponent extends Component {
</Link>
}
/>
</div>}>
</div>
}
>
<Typography
variant="subtitle1"
style={{ marginBottom: '0.5rem' }}
@ -170,8 +180,6 @@ class ProjectFormComponent extends Component {
}
/>
<ConditionallyRender
condition={hasAccess(CREATE_PROJECT)}
show={

View File

@ -56,12 +56,8 @@ class WrapperComponent extends Component {
onSubmit = async evt => {
evt.preventDefault();
const {
createStrategy,
updateStrategy,
history,
editMode,
} = this.props;
const { createStrategy, updateStrategy, history, editMode } =
this.props;
const { strategy } = this.state;
const parameters = (strategy.parameters || [])

View File

@ -60,16 +60,6 @@ exports[`it supports editMode 1`] = `
<span
className="MuiButton-label"
>
<span
className="MuiButton-startIcon MuiButton-iconSizeMedium"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
add
</span>
</span>
Update
</span>
</button>
@ -166,16 +156,6 @@ exports[`renders correctly for creating 1`] = `
<span
className="MuiButton-label"
>
<span
className="MuiButton-startIcon MuiButton-iconSizeMedium"
>
<span
aria-hidden={true}
className="material-icons MuiIcon-root"
>
add
</span>
</span>
Create
</span>
</button>

View File

@ -2,13 +2,12 @@ import React, { useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Button, Grid, TextField, Typography } from '@material-ui/core';
import LockRounded from '@material-ui/icons/LockRounded';
import { useHistory } from 'react-router';
import { useCommonStyles } from '../../../common.styles';
import { useStyles } from './HostedAuth.styles';
import { Link } from 'react-router-dom';
import { GoogleSvg } from './Icons';
import useQueryParams from '../../../hooks/useQueryParams';
import AuthOptions from '../common/AuthOptions/AuthOptions';
const PasswordAuth = ({ authDetails, passwordLogin }) => {
const commonStyles = useCommonStyles();
@ -64,7 +63,6 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
}
};
const { usernameError, passwordError, apiError } = errors;
const { options = [] } = authDetails;
@ -72,19 +70,7 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
<div>
<br />
<div>
{options.map(o => (
<div
key={o.type}
className={classnames(
styles.contentContainer,
commonStyles.contentSpacingY
)}
>
<Button color="primary" variant="outlined" href={o.path} startIcon={o.type === 'google' ? <GoogleSvg /> : <LockRounded />}>
{o.message}
</Button>
</div>
))}
<AuthOptions options={options} />
</div>
<p className={styles.fancyLine}>or</p>
<form onSubmit={handleSubmit} action={authDetails.path}>
@ -140,9 +126,12 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
</Grid>
<br />
<br />
<Typography variant="body2" align="center">Don't have an account? <br /> <a href="https://www.unleash-hosted.com/pricing">Sign up</a></Typography>
<Typography variant="body2" align="center">
Don't have an account? <br />{' '}
<a href="https://www.unleash-hosted.com/pricing">
Sign up
</a>
</Typography>
</div>
</form>
</div>

View File

@ -23,6 +23,9 @@ export const useStyles = makeStyles(theme => ({
marginBottom: '0.5rem',
fontSize: '1.1rem',
},
passwordHeader: {
marginTop: '2rem',
},
emailField: {
minWidth: '300px',
[theme.breakpoints.down('xs')]: {

View File

@ -10,15 +10,16 @@ import useResetPassword from '../../../hooks/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import ConditionallyRender from '../../common/ConditionallyRender';
import InvalidToken from '../common/InvalidToken/InvalidToken';
import { IAuthStatus } from '../../../interfaces/user';
import AuthOptions from '../common/AuthOptions/AuthOptions';
const NewUser = () => {
const {
token,
data,
loading,
setLoading,
invalidToken,
} = useResetPassword();
interface INewUserProps {
user: IAuthStatus;
}
const NewUser = ({ user }: INewUserProps) => {
const { token, data, loading, setLoading, invalidToken } =
useResetPassword();
const ref = useLoading(loading);
const commonStyles = useCommonStyles();
const styles = useStyles();
@ -57,7 +58,7 @@ const NewUser = () => {
</Typography>
<TextField
data-loading
value={data?.email}
value={data?.email || ''}
variant="outlined"
size="small"
className={styles.emailField}
@ -79,9 +80,46 @@ const NewUser = () => {
className={commonStyles.largeDivider}
data-loading
/>
<Typography variant="body1" data-loading>
<ConditionallyRender
condition={
user?.authDetails?.options?.length > 0
}
show={
<>
<Typography data-loading>
Login with 3rd party providers
</Typography>
<AuthOptions
options={
user?.authDetails?.options
}
/>
<div
className={
commonStyles.largeDivider
}
data-loading
/>
<Typography
className={
styles.passwordHeader
}
data-loading
>
OR set a new password for your
account
</Typography>
</>
}
elseShow={
<Typography
variant="body1"
data-loading
>
Set a password for your account.
</Typography>
}
/>
</div>
</ResetPasswordDetails>
}

View File

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import NewUser from './NewUser';
const mapStateToProps = (state: any) => ({
user: state.user.toJS(),
});
export default connect(mapStateToProps)(NewUser);

View File

@ -19,7 +19,7 @@ export const useStyles = makeStyles(theme => ({
},
},
switchesContainer: {
position: 'fixed',
position: 'absolute',
bottom: '40px',
display: 'flex',
flexDirection: 'column',

View File

@ -0,0 +1,47 @@
import { Button } from '@material-ui/core';
import classnames from 'classnames';
import { useCommonStyles } from '../../../../common.styles';
import { IAuthOptions } from '../../../../interfaces/user';
import { ReactComponent as GoogleSvg } from '../../../../assets/icons/google.svg';
import LockRounded from '@material-ui/icons/LockRounded';
interface IAuthOptionProps {
options?: IAuthOptions[];
}
const AuthOptions = ({ options }: IAuthOptionProps) => {
const commonStyles = useCommonStyles();
return (
<>
{options?.map(o => (
<div
key={o.type}
className={classnames(
commonStyles.flexColumn,
commonStyles.contentSpacingY
)}
>
<Button
color="primary"
data-loading
variant="outlined"
href={o.path}
size="small"
style={{ maxWidth: '300px' }}
startIcon={
o.type === 'google' ? (
<GoogleSvg />
) : (
<LockRounded />
)
}
>
{o.message}
</Button>
</div>
))}
</>
);
};
export default AuthOptions;

View File

@ -111,17 +111,19 @@ const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
size="small"
type="password"
placeholder="Password"
value={password}
value={password || ''}
onChange={e => setPassword(e.target.value)}
autoComplete="password"
data-loading
/>
<TextField
variant="outlined"
size="small"
type="password"
value={confirmPassword}
value={confirmPassword || ''}
placeholder="Confirm password"
onChange={e => setConfirmPassword(e.target.value)}
autoComplete="confirm-password"
data-loading
/>
<PasswordMatcher

View File

@ -14,7 +14,13 @@ interface IAuthDetails {
type: string;
path: string;
message: string;
options: string[];
options: IAuthOptions[];
}
export interface IAuthOptions {
type: string;
message: string;
path: string;
}
export interface IUser {

View File

@ -1,6 +1,6 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Paper, Icon, Tabs, Tab } from '@material-ui/core';
import { Paper, Tabs, Tab } from '@material-ui/core';
const navLinkStyle = {
display: 'flex',
@ -18,38 +18,48 @@ const activeNavLinkStyle = {
padding: '0.8rem 1.5rem',
};
const iconStyle = {
marginRight: '5px',
};
function AdminMenu({ history }) {
const { location } = history;
const { pathname } = location;
return (
<Paper style={{ marginBottom: '1rem' }}>
<Tabs centered value={pathname}>
<Tab value="/admin/users" label={
<NavLink to="/admin/users" activeStyle={activeNavLinkStyle} style={navLinkStyle}>
<Icon style={iconStyle}>supervised_user_circle</Icon>
<Tab
value="/admin/users"
label={
<NavLink
to="/admin/users"
activeStyle={activeNavLinkStyle}
style={navLinkStyle}
>
<span>Users</span>
</NavLink>
}
></Tab>
<Tab
value="/admin/api"
label={
<NavLink
to="/admin/api"
activeStyle={activeNavLinkStyle}
style={navLinkStyle}
>
</Tab>
<Tab value="/admin/api" label={
<NavLink to="/admin/api" activeStyle={activeNavLinkStyle} style={navLinkStyle}>
<Icon style={iconStyle}>apps</Icon>
API Access
</NavLink>
}>
</Tab>
<Tab value="/admin/auth" label={
<NavLink to="/admin/auth" activeStyle={activeNavLinkStyle} style={navLinkStyle}>
<Icon style={iconStyle}>lock</Icon>
}
></Tab>
<Tab
value="/admin/auth"
label={
<NavLink
to="/admin/auth"
activeStyle={activeNavLinkStyle}
style={navLinkStyle}
>
Authentication
</NavLink>
}>
</Tab>
}
></Tab>
</Tabs>
</Paper>
);

View File

@ -13,10 +13,9 @@ import classnames from 'classnames';
import { styles as commonStyles } from '../../../component/common';
import { useStyles } from './styles';
function CreateApiKey({ addKey }) {
function CreateApiKey({ addKey, show, setShow }) {
const styles = useStyles();
const [type, setType] = useState('CLIENT');
const [show, setShow] = useState(false);
const [username, setUsername] = useState();
const [error, setError] = useState();
@ -107,6 +106,9 @@ function CreateApiKey({ addKey }) {
CreateApiKey.propTypes = {
addKey: PropTypes.func.isRequired,
setShow: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
};
export default CreateApiKey;

View File

@ -21,6 +21,8 @@ import {
DELETE_API_TOKEN,
CREATE_API_TOKEN,
} from '../../../component/AccessProvider/permissions';
import PageContent from '../../../component/common/PageContent';
import HeaderTitle from '../../../component/common/HeaderTitle';
function ApiKeyList({
location,
@ -30,6 +32,8 @@ function ApiKeyList({
keys,
unleashUrl,
}) {
const [show, setShow] = useState(false);
const { hasAccess } = useContext(AccessContext);
const [showDelete, setShowDelete] = useState(false);
const [delKey, setDelKey] = useState(undefined);
@ -45,6 +49,25 @@ function ApiKeyList({
}, []);
return (
<PageContent
headerContent={
<HeaderTitle
title="API Access"
actions={
<ConditionallyRender
condition={hasAccess(CREATE_API_TOKEN)}
show={
<CreateApiKey
addKey={addKey}
setShow={setShow}
show={show}
/>
}
/>
}
/>
}
>
<div>
<Alert severity="info">
<p>
@ -57,8 +80,8 @@ function ApiKeyList({
Getting started guide
</a>{' '}
to learn how to connect to the Unleash API from your
application or programmatically. Please note it can take up
to 1 minute before a new API key is activated.
application or programmatically. Please note it can take
up to 1 minute before a new API key is activated.
</p>
<br />
<strong>API URL: </strong>{' '}
@ -100,7 +123,9 @@ function ApiKeyList({
<ConditionallyRender
condition={hasAccess(DELETE_API_TOKEN)}
show={
<TableCell style={{ textAlign: 'right' }}>
<TableCell
style={{ textAlign: 'right' }}
>
<IconButton
onClick={() => {
setDelKey(item.secret);
@ -127,11 +152,8 @@ function ApiKeyList({
>
<div>Are you sure you want to delete?</div>
</Dialogue>
<ConditionallyRender
condition={hasAccess(CREATE_API_TOKEN)}
show={<CreateApiKey addKey={addKey} />}
/>
</div>
</PageContent>
);
}

View File

@ -1,18 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import ApiKeyList from './api-key-list-container';
import AdminMenu from '../admin-menu';
import PageContent from '../../../component/common/PageContent/PageContent';
const render = ({history}) => (
const render = ({ history }) => {
return (
<div>
<AdminMenu history={history} />
<PageContent headerContent="API Access">
<ApiKeyList />
</PageContent>
</div>
);
};
render.propTypes = {
match: PropTypes.object.isRequired,

View File

@ -40,7 +40,7 @@ const ConfirmUserLink = ({
Want to avoid this step in the future?{' '}
{/* TODO - ADD LINK HERE ONCE IT EXISTS*/}
<a
href="https://docs.getunleash.ai/"
href="https://docs.getunleash.io/docs/deploy/email"
target="_blank"
rel="noopener noreferrer"
>

View File

@ -2,7 +2,6 @@
import { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
Table,
TableBody,
TableCell,
@ -23,7 +22,7 @@ import UserListItem from './UserListItem/UserListItem';
import loadingData from './loadingData';
import useLoading from '../../../../hooks/useLoading';
function UsersList({ location }) {
function UsersList({ location, closeDialog, showDialog }) {
const { users, roles, refetch, loading } = useUsers();
const {
addUser,
@ -35,7 +34,6 @@ function UsersList({ location }) {
userApiErrors,
} = useAdminUsersApi();
const { hasAccess } = useContext(AccessContext);
const [showDialog, setDialog] = useState(false);
const [pwDialog, setPwDialog] = useState({ open: false });
const [delDialog, setDelDialog] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
@ -45,15 +43,6 @@ function UsersList({ location }) {
const [updateDialog, setUpdateDialog] = useState({ open: false });
const ref = useLoading(loading);
const openDialog = e => {
e.preventDefault();
setDialog(true);
};
const closeDialog = () => {
setDialog(false);
};
const closeDelDialog = () => {
setDelDialog(false);
setDelUser(undefined);
@ -177,19 +166,6 @@ function UsersList({ location }) {
<TableBody>{renderUsers()}</TableBody>
</Table>
<br />
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<Button
variant="contained"
color="primary"
onClick={openDialog}
>
Add new user
</Button>
}
elseShow={<small>PS! Only admins can add/remove users.</small>}
/>
<ConfirmUserAdded
open={showConfirm}

View File

@ -1,9 +1,11 @@
import { connect } from 'react-redux';
import UsersList from './UsersList';
const mapStateToProps = state => ({
const mapStateToProps = state => {
return {
location: state.settings.toJS().location || {},
});
};
};
const Container = connect(mapStateToProps)(UsersList);

View File

@ -78,7 +78,7 @@ const DelUserComponent = ({
DelUserComponent.propTypes = {
showDialog: propTypes.bool.isRequired,
closeDialog: propTypes.func.isRequired,
user: propTypes.object.isRequired,
user: propTypes.object,
removeUser: propTypes.func.isRequired,
};

View File

@ -1,4 +1,4 @@
import { useContext } from 'react';
import { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import UsersList from './UsersList';
import AdminMenu from '../admin-menu';
@ -7,17 +7,60 @@ import AccessContext from '../../../contexts/AccessContext';
import ConditionallyRender from '../../../component/common/ConditionallyRender';
import { ADMIN } from '../../../component/AccessProvider/permissions';
import { Alert } from '@material-ui/lab';
import HeaderTitle from '../../../component/common/HeaderTitle';
import { Button } from '@material-ui/core';
const UsersAdmin = ({ history }) => {
const { hasAccess } = useContext(AccessContext);
const [showDialog, setDialog] = useState(false);
const openDialog = e => {
e.preventDefault();
setDialog(true);
};
const closeDialog = () => {
setDialog(false);
};
return (
<div>
<AdminMenu history={history} />
<PageContent headerContent="Users">
<PageContent
headerContent={
<HeaderTitle
title="Users"
actions={
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UsersList />}
show={
<Button
variant="contained"
color="primary"
onClick={openDialog}
>
Add new user
</Button>
}
elseShow={
<small>
PS! Only admins can add/remove users.
</small>
}
/>
}
/>
}
>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={
<UsersList
openDialog={openDialog}
closeDialog={closeDialog}
showDialog={showDialog}
/>
}
elseShow={
<Alert severity="error">
You need instance admin to access this section.

View File

@ -1,5 +1,5 @@
import React from 'react';
import ViewProject from '../../component/project/ViewProject';
import ViewProject from '../../component/project/ProjectView';
import PropTypes from 'prop-types';
const render = ({ match: { params }, history }) => (