1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02: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 { .MuiButton-root {
border-radius: 25px; border-radius: 3px;
} }
.skeleton { .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 total = features.length;
const activeTogglesArray = getActiveToggles(); const activeTogglesArray = getActiveToggles();
const potentiallyStaleToggles = getPotentiallyStaleToggles( const potentiallyStaleToggles =
activeTogglesArray getPotentiallyStaleToggles(activeTogglesArray);
);
const activeTogglesCount = activeTogglesArray.length; const activeTogglesCount = activeTogglesArray.length;
const staleTogglesCount = features.length - activeTogglesCount; const staleTogglesCount = features.length - activeTogglesCount;
@ -95,6 +94,17 @@ const ReportCard = ({ features }) => {
return ( return (
<Paper className={styles.card}> <Paper className={styles.card}>
<div className={styles.reportCardContainer}> <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}> <div className={styles.reportCardListContainer}>
<h2 className={styles.header}>Toggle report</h2> <h2 className={styles.header}>Toggle report</h2>
<ul className={styles.reportCardList}> <ul className={styles.reportCardList}>
@ -118,17 +128,7 @@ const ReportCard = ({ features }) => {
</li> </li>
</ul> </ul>
</div> </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}> <div className={styles.reportCardAction}>
<h2 className={styles.header}>Potential actions</h2> <h2 className={styles.header}>Potential actions</h2>
<div className={styles.reportCardActionContainer}> <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 ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender'; 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 { KILLSWITCH, PERMISSION } from '../../constants';
import styles from '../ReportToggleList.module.scss'; 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 nameMatches = feature => feature.name === name;
const history = useHistory(); const history = useHistory();
@ -80,17 +95,26 @@ const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checke
const formatReportStatus = () => { const formatReportStatus = () => {
if (type === KILLSWITCH || type === PERMISSION) { 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 [date, now] = getDates(createdAt);
const diff = getDiffInDays(date, now); const diff = getDiffInDays(date, now);
if (expired(diff, type)) { 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 = () => { const navigateToFeature = () => {
@ -102,7 +126,12 @@ const ReportToggleListItem = ({ name, stale, lastSeenAt, createdAt, type, checke
}); });
return ( return (
<tr role="button" tabIndex={0} onClick={navigateToFeature} className={styles.tableRow}> <tr
role="button"
tabIndex={0}
onClick={navigateToFeature}
className={styles.tableRow}
>
<ConditionallyRender <ConditionallyRender
condition={bulkActionsOn} condition={bulkActionsOn}
show={ 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 { .dropdownButton {
text-transform: none;
font-weight: normal; font-weight: normal;
} }

View File

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

View File

@ -14,6 +14,8 @@ import {
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Tooltip, Tooltip,
useMediaQuery,
Button,
} from '@material-ui/core'; } from '@material-ui/core';
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -24,6 +26,7 @@ import AccessContext from '../../../contexts/AccessContext';
const ContextList = ({ removeContextField, history, contextFields }) => { const ContextList = ({ removeContextField, history, contextFields }) => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const [showDelDialogue, setShowDelDialogue] = useState(false); const [showDelDialogue, setShowDelDialogue] = useState(false);
const smallScreen = useMediaQuery('(max-width:700px)');
const [name, setName] = useState(); const [name, setName] = useState();
const styles = useStyles(); const styles = useStyles();
@ -63,11 +66,27 @@ const ContextList = ({ removeContextField, history, contextFields }) => {
<ConditionallyRender <ConditionallyRender
condition={hasAccess(CREATE_CONTEXT_FIELD)} condition={hasAccess(CREATE_CONTEXT_FIELD)}
show={ show={
<Tooltip title="Add context type"> <ConditionallyRender
<IconButton onClick={() => history.push('/context/create')}> condition={smallScreen}
<Icon>add</Icon> show={
</IconButton> <Tooltip title="Add context type">
</Tooltip> <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>
}
/>
} }
/> />
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import InputPercentage from './input-percentage'; import InputPercentage from './input-percentage';
import Select from '../../../common/select'; import Select from '../../../common/select';
import { TextField, Typography } from '@material-ui/core'; import { Icon, TextField, Tooltip, Typography } from '@material-ui/core';
const builtInStickinessOptions = [ const builtInStickinessOptions = [
{ key: 'default', label: 'default' }, { key: 'default', label: 'default' },
@ -21,7 +21,9 @@ const FlexibleStrategy = ({ updateParameter, parameters, context }) => {
builtInStickinessOptions.concat( builtInStickinessOptions.concat(
context context
.filter(c => c.stickiness) .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 })) .map(c => ({ key: c.name, label: c.name }))
); );
@ -33,13 +35,37 @@ const FlexibleStrategy = ({ updateParameter, parameters, context }) => {
return ( return (
<div> <div>
<InputPercentage name="Rollout" value={1 * rollout} onChange={onUpdate('rollout')} /> <InputPercentage
name="Rollout"
value={1 * rollout}
onChange={onUpdate('rollout')}
/>
<br /> <br />
<div> <div>
<Typography variant="subtitle2" gutterBottom> <Tooltip
Stickiness placement="right-start"
</Typography> 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."
<br /> >
<Typography
variant="subtitle2"
style={{
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
}}
>
Stickiness
<Icon
style={{
fontSize: '1rem',
color: 'gray',
marginLeft: '0.2rem',
}}
>
info
</Icon>
</Typography>
</Tooltip>
<Select <Select
name="stickiness" name="stickiness"
label="Stickiness" label="Stickiness"
@ -48,6 +74,32 @@ const FlexibleStrategy = ({ updateParameter, parameters, context }) => {
onChange={e => onUpdate('stickiness')(e, e.target.value)} onChange={e => onUpdate('stickiness')(e, e.target.value)}
/> />
&nbsp; &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 <TextField
label="groupId" label="groupId"
size="small" size="small"

View File

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

View File

@ -216,6 +216,32 @@ exports[`renders correctly with one feature 1`] = `
<AddTagDialog <AddTagDialog
featureToggleName="Another" 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 <button
aria-controls="feature-stale-dropdown" aria-controls="feature-stale-dropdown"
aria-haspopup="true" aria-haspopup="true"
@ -258,32 +284,6 @@ exports[`renders correctly with one feature 1`] = `
</span> </span>
</span> </span>
</button> </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 <button
className="MuiButtonBase-root MuiButton-root MuiButton-text" className="MuiButtonBase-root MuiButton-root MuiButton-text"
disabled={false} disabled={false}

View File

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

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import HeaderTitle from '../../common/HeaderTitle'; import HeaderTitle from '../../common/HeaderTitle';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import { import {
@ -15,6 +15,8 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
Tooltip, Tooltip,
Button,
useMediaQuery,
} from '@material-ui/core'; } from '@material-ui/core';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ConfirmDialogue from '../../common/Dialogue'; import ConfirmDialogue from '../../common/Dialogue';
@ -24,6 +26,7 @@ import AccessContext from '../../../contexts/AccessContext';
const ProjectList = ({ projects, fetchProjects, removeProject, history }) => { const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const smallScreen = useMediaQuery('(max-width:700px)');
const [showDelDialogue, setShowDelDialogue] = useState(false); const [showDelDialogue, setShowDelDialogue] = useState(false);
const [project, setProject] = useState(undefined); const [project, setProject] = useState(undefined);
const styles = useStyles(); const styles = useStyles();
@ -35,14 +38,27 @@ const ProjectList = ({ projects, fetchProjects, removeProject, history }) => {
<ConditionallyRender <ConditionallyRender
condition={hasAccess(CREATE_PROJECT)} condition={hasAccess(CREATE_PROJECT)}
show={ show={
<Tooltip title="Add new project"> <ConditionallyRender
<IconButton condition={smallScreen}
aria-label="add-project" show={
onClick={() => history.push('/projects/create')} <Tooltip title="Add new project">
> <IconButton
<Icon>add</Icon> onClick={() => history.push('/projects/create')}
</IconButton> >
</Tooltip> <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 { 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 { Link } from 'react-router-dom';
import AccessContext from '../../../contexts/AccessContext'; import AccessContext from '../../../contexts/AccessContext';
import HeaderTitle from '../../common/HeaderTitle'; import HeaderTitle from '../../common/HeaderTitle';
@ -7,8 +7,9 @@ import PageContent from '../../common/PageContent';
import FeatureToggleListItem from '../../feature/FeatureToggleList/FeatureToggleListItem'; import FeatureToggleListItem from '../../feature/FeatureToggleList/FeatureToggleListItem';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import ListPlaceholder from '../../common/ListPlaceholder/ListPlaceholder';
const ViewProject = ({ const ProjectView = ({
project, project,
features, features,
settings, settings,
@ -81,10 +82,21 @@ const ViewProject = ({
<Typography variant="subtitle2"> <Typography variant="subtitle2">
Feature toggles in this project Feature toggles in this project
</Typography> </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> </PageContent>
</div> </div>
); );
}; };
export default ViewProject; export default ProjectView;

View File

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

View File

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

View File

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

View File

@ -75,8 +75,15 @@ class ProjectFormComponent extends Component {
}; };
onCancel = evt => { onCancel = evt => {
const { editMode } = this.props;
const { project } = this.state;
evt.preventDefault(); evt.preventDefault();
this.props.history.push('/projects'); if (editMode) {
this.props.history.push(`/projects/view/${project.id}`);
} else {
this.props.history.push('/projects');
}
}; };
onSubmit = async evt => { onSubmit = async evt => {
@ -87,7 +94,7 @@ class ProjectFormComponent extends Component {
if (valid) { if (valid) {
await this.props.submit(project); await this.props.submit(project);
this.props.history.push('/projects'); this.props.history.push(`/projects/view/${project.id}`);
} }
}; };
@ -98,21 +105,24 @@ class ProjectFormComponent extends Component {
const submitText = editMode ? 'Update' : 'Create'; const submitText = editMode ? 'Update' : 'Create';
return ( return (
<PageContent <PageContent
headerContent={<div> headerContent={
<span>{submitText} Project</span> <div>
<ConditionallyRender <span>{submitText} Project</span>
condition={hasAccess(CREATE_PROJECT) && editMode} <ConditionallyRender
show={ condition={hasAccess(CREATE_PROJECT) && editMode}
<Link show={
to={`/projects/${project.id}/access`} <Link
style={{float: 'right'}} to={`/projects/${project.id}/access`}
> style={{ float: 'right' }}
Manage access >
</Link> Manage access
} </Link>
/> }
</div>}> />
</div>
}
>
<Typography <Typography
variant="subtitle1" variant="subtitle1"
style={{ marginBottom: '0.5rem' }} style={{ marginBottom: '0.5rem' }}
@ -169,8 +179,6 @@ class ProjectFormComponent extends Component {
this.setValue('description', v.target.value) this.setValue('description', v.target.value)
} }
/> />
<ConditionallyRender <ConditionallyRender
condition={hasAccess(CREATE_PROJECT)} condition={hasAccess(CREATE_PROJECT)}

View File

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

View File

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

View File

@ -2,13 +2,12 @@ import React, { useState } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Grid, TextField, Typography } from '@material-ui/core'; import { Button, Grid, TextField, Typography } from '@material-ui/core';
import LockRounded from '@material-ui/icons/LockRounded';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { useCommonStyles } from '../../../common.styles'; import { useCommonStyles } from '../../../common.styles';
import { useStyles } from './HostedAuth.styles'; import { useStyles } from './HostedAuth.styles';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { GoogleSvg } from './Icons';
import useQueryParams from '../../../hooks/useQueryParams'; import useQueryParams from '../../../hooks/useQueryParams';
import AuthOptions from '../common/AuthOptions/AuthOptions';
const PasswordAuth = ({ authDetails, passwordLogin }) => { const PasswordAuth = ({ authDetails, passwordLogin }) => {
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
@ -64,7 +63,6 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
} }
}; };
const { usernameError, passwordError, apiError } = errors; const { usernameError, passwordError, apiError } = errors;
const { options = [] } = authDetails; const { options = [] } = authDetails;
@ -72,19 +70,7 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
<div> <div>
<br /> <br />
<div> <div>
{options.map(o => ( <AuthOptions options={options} />
<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>
))}
</div> </div>
<p className={styles.fancyLine}>or</p> <p className={styles.fancyLine}>or</p>
<form onSubmit={handleSubmit} action={authDetails.path}> <form onSubmit={handleSubmit} action={authDetails.path}>
@ -140,9 +126,12 @@ const PasswordAuth = ({ authDetails, passwordLogin }) => {
</Grid> </Grid>
<br /> <br />
<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> </div>
</form> </form>
</div> </div>

View File

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

View File

@ -10,15 +10,16 @@ import useResetPassword from '../../../hooks/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout'; import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import ConditionallyRender from '../../common/ConditionallyRender'; import ConditionallyRender from '../../common/ConditionallyRender';
import InvalidToken from '../common/InvalidToken/InvalidToken'; import InvalidToken from '../common/InvalidToken/InvalidToken';
import { IAuthStatus } from '../../../interfaces/user';
import AuthOptions from '../common/AuthOptions/AuthOptions';
const NewUser = () => { interface INewUserProps {
const { user: IAuthStatus;
token, }
data,
loading, const NewUser = ({ user }: INewUserProps) => {
setLoading, const { token, data, loading, setLoading, invalidToken } =
invalidToken, useResetPassword();
} = useResetPassword();
const ref = useLoading(loading); const ref = useLoading(loading);
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const styles = useStyles(); const styles = useStyles();
@ -57,7 +58,7 @@ const NewUser = () => {
</Typography> </Typography>
<TextField <TextField
data-loading data-loading
value={data?.email} value={data?.email || ''}
variant="outlined" variant="outlined"
size="small" size="small"
className={styles.emailField} className={styles.emailField}
@ -79,9 +80,46 @@ const NewUser = () => {
className={commonStyles.largeDivider} className={commonStyles.largeDivider}
data-loading data-loading
/> />
<Typography variant="body1" data-loading> <ConditionallyRender
Set a password for your account. condition={
</Typography> 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> </div>
</ResetPasswordDetails> </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: { switchesContainer: {
position: 'fixed', position: 'absolute',
bottom: '40px', bottom: '40px',
display: 'flex', display: 'flex',
flexDirection: 'column', 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" size="small"
type="password" type="password"
placeholder="Password" placeholder="Password"
value={password} value={password || ''}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
autoComplete="password"
data-loading data-loading
/> />
<TextField <TextField
variant="outlined" variant="outlined"
size="small" size="small"
type="password" type="password"
value={confirmPassword} value={confirmPassword || ''}
placeholder="Confirm password" placeholder="Confirm password"
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
autoComplete="confirm-password"
data-loading data-loading
/> />
<PasswordMatcher <PasswordMatcher

View File

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

View File

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

View File

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

View File

@ -21,6 +21,8 @@ import {
DELETE_API_TOKEN, DELETE_API_TOKEN,
CREATE_API_TOKEN, CREATE_API_TOKEN,
} from '../../../component/AccessProvider/permissions'; } from '../../../component/AccessProvider/permissions';
import PageContent from '../../../component/common/PageContent';
import HeaderTitle from '../../../component/common/HeaderTitle';
function ApiKeyList({ function ApiKeyList({
location, location,
@ -30,6 +32,8 @@ function ApiKeyList({
keys, keys,
unleashUrl, unleashUrl,
}) { }) {
const [show, setShow] = useState(false);
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [delKey, setDelKey] = useState(undefined); const [delKey, setDelKey] = useState(undefined);
@ -45,93 +49,111 @@ function ApiKeyList({
}, []); }, []);
return ( return (
<div> <PageContent
<Alert severity="info"> headerContent={
<p> <HeaderTitle
Read the{' '} title="API Access"
<a actions={
href="https://docs.getunleash.io/docs" <ConditionallyRender
target="_blank" condition={hasAccess(CREATE_API_TOKEN)}
rel="noreferrer" show={
> <CreateApiKey
Getting started guide addKey={addKey}
</a>{' '} setShow={setShow}
to learn how to connect to the Unleash API from your show={show}
application or programmatically. Please note it can take up />
to 1 minute before a new API key is activated. }
</p> />
}
/>
}
>
<div>
<Alert severity="info">
<p>
Read the{' '}
<a
href="https://docs.getunleash.io/docs"
target="_blank"
rel="noreferrer"
>
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.
</p>
<br />
<strong>API URL: </strong>{' '}
<pre style={{ display: 'inline' }}>{unleashUrl}/api/</pre>
</Alert>
<br />
<br /> <br />
<strong>API URL: </strong>{' '}
<pre style={{ display: 'inline' }}>{unleashUrl}/api/</pre>
</Alert>
<br /> <br />
<br /> <Table>
<TableHead>
<br /> <TableRow>
<Table> <TableCell>Created</TableCell>
<TableHead> <TableCell>Username</TableCell>
<TableRow> <TableCell>Access Type</TableCell>
<TableCell>Created</TableCell> <TableCell>Secret</TableCell>
<TableCell>Username</TableCell> <TableCell>Action</TableCell>
<TableCell>Access Type</TableCell>
<TableCell>Secret</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{keys.map(item => (
<TableRow key={item.secret}>
<TableCell style={{ textAlign: 'left' }}>
{formatFullDateTimeWithLocale(
item.createdAt,
location.locale
)}
</TableCell>
<TableCell style={{ textAlign: 'left' }}>
{item.username}
</TableCell>
<TableCell style={{ textAlign: 'left' }}>
{item.type}
</TableCell>
<TableCell style={{ textAlign: 'left' }}>
<Secret value={item.secret} />
</TableCell>
<ConditionallyRender
condition={hasAccess(DELETE_API_TOKEN)}
show={
<TableCell style={{ textAlign: 'right' }}>
<IconButton
onClick={() => {
setDelKey(item.secret);
setShowDelete(true);
}}
>
<Icon>delete</Icon>
</IconButton>
</TableCell>
}
/>
</TableRow> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {keys.map(item => (
<Dialogue <TableRow key={item.secret}>
open={showDelete} <TableCell style={{ textAlign: 'left' }}>
onClick={deleteKey} {formatFullDateTimeWithLocale(
onClose={() => { item.createdAt,
setShowDelete(false); location.locale
setDelKey(undefined); )}
}} </TableCell>
title="Really delete API key?" <TableCell style={{ textAlign: 'left' }}>
> {item.username}
<div>Are you sure you want to delete?</div> </TableCell>
</Dialogue> <TableCell style={{ textAlign: 'left' }}>
<ConditionallyRender {item.type}
condition={hasAccess(CREATE_API_TOKEN)} </TableCell>
show={<CreateApiKey addKey={addKey} />} <TableCell style={{ textAlign: 'left' }}>
/> <Secret value={item.secret} />
</div> </TableCell>
<ConditionallyRender
condition={hasAccess(DELETE_API_TOKEN)}
show={
<TableCell
style={{ textAlign: 'right' }}
>
<IconButton
onClick={() => {
setDelKey(item.secret);
setShowDelete(true);
}}
>
<Icon>delete</Icon>
</IconButton>
</TableCell>
}
/>
</TableRow>
))}
</TableBody>
</Table>
<Dialogue
open={showDelete}
onClick={deleteKey}
onClose={() => {
setShowDelete(false);
setDelKey(undefined);
}}
title="Really delete API key?"
>
<div>Are you sure you want to delete?</div>
</Dialogue>
</div>
</PageContent>
); );
} }

View File

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

View File

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

View File

@ -2,7 +2,6 @@
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
Button,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@ -23,7 +22,7 @@ import UserListItem from './UserListItem/UserListItem';
import loadingData from './loadingData'; import loadingData from './loadingData';
import useLoading from '../../../../hooks/useLoading'; import useLoading from '../../../../hooks/useLoading';
function UsersList({ location }) { function UsersList({ location, closeDialog, showDialog }) {
const { users, roles, refetch, loading } = useUsers(); const { users, roles, refetch, loading } = useUsers();
const { const {
addUser, addUser,
@ -35,7 +34,6 @@ function UsersList({ location }) {
userApiErrors, userApiErrors,
} = useAdminUsersApi(); } = useAdminUsersApi();
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const [showDialog, setDialog] = useState(false);
const [pwDialog, setPwDialog] = useState({ open: false }); const [pwDialog, setPwDialog] = useState({ open: false });
const [delDialog, setDelDialog] = useState(false); const [delDialog, setDelDialog] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
@ -45,15 +43,6 @@ function UsersList({ location }) {
const [updateDialog, setUpdateDialog] = useState({ open: false }); const [updateDialog, setUpdateDialog] = useState({ open: false });
const ref = useLoading(loading); const ref = useLoading(loading);
const openDialog = e => {
e.preventDefault();
setDialog(true);
};
const closeDialog = () => {
setDialog(false);
};
const closeDelDialog = () => { const closeDelDialog = () => {
setDelDialog(false); setDelDialog(false);
setDelUser(undefined); setDelUser(undefined);
@ -177,19 +166,6 @@ function UsersList({ location }) {
<TableBody>{renderUsers()}</TableBody> <TableBody>{renderUsers()}</TableBody>
</Table> </Table>
<br /> <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 <ConfirmUserAdded
open={showConfirm} open={showConfirm}

View File

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

View File

@ -78,7 +78,7 @@ const DelUserComponent = ({
DelUserComponent.propTypes = { DelUserComponent.propTypes = {
showDialog: propTypes.bool.isRequired, showDialog: propTypes.bool.isRequired,
closeDialog: propTypes.func.isRequired, closeDialog: propTypes.func.isRequired,
user: propTypes.object.isRequired, user: propTypes.object,
removeUser: propTypes.func.isRequired, 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 PropTypes from 'prop-types';
import UsersList from './UsersList'; import UsersList from './UsersList';
import AdminMenu from '../admin-menu'; import AdminMenu from '../admin-menu';
@ -7,17 +7,60 @@ import AccessContext from '../../../contexts/AccessContext';
import ConditionallyRender from '../../../component/common/ConditionallyRender'; import ConditionallyRender from '../../../component/common/ConditionallyRender';
import { ADMIN } from '../../../component/AccessProvider/permissions'; import { ADMIN } from '../../../component/AccessProvider/permissions';
import { Alert } from '@material-ui/lab'; import { Alert } from '@material-ui/lab';
import HeaderTitle from '../../../component/common/HeaderTitle';
import { Button } from '@material-ui/core';
const UsersAdmin = ({ history }) => { const UsersAdmin = ({ history }) => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const [showDialog, setDialog] = useState(false);
const openDialog = e => {
e.preventDefault();
setDialog(true);
};
const closeDialog = () => {
setDialog(false);
};
return ( return (
<div> <div>
<AdminMenu history={history} /> <AdminMenu history={history} />
<PageContent headerContent="Users"> <PageContent
headerContent={
<HeaderTitle
title="Users"
actions={
<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>
}
/>
}
/>
}
>
<ConditionallyRender <ConditionallyRender
condition={hasAccess(ADMIN)} condition={hasAccess(ADMIN)}
show={<UsersList />} show={
<UsersList
openDialog={openDialog}
closeDialog={closeDialog}
showDialog={showDialog}
/>
}
elseShow={ elseShow={
<Alert severity="error"> <Alert severity="error">
You need instance admin to access this section. You need instance admin to access this section.

View File

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