1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

Feat/user flow (#267)

* feat: add new user page

* feat: passwordchecker

* fix: remove loading

* feat: reset password

* fix: move swr to devDeps

* feat: generate reset link

* feat: add reset password form

* fix: remove console log

* fix: rename to forgotten password

* feat: add simple menu

* fix: change password checker title

* fix: change text in new-user view

* fix: lint errors

* fix: add status code to constants

* fix: comment

* fix: add classes for new user component

* fix: tests

* fix: remove console log

* fix: remove retry method

* fix: invalid token constant

* fix: remove console log

* fix: dependency array on useCallback

* fix: featureview

* fix: redirect on authenticated

* refactor: progresswheel

* fix: lint deps
This commit is contained in:
Fredrik Strand Oseberg 2021-04-19 10:55:15 +02:00 committed by GitHub
parent 3cca2513fb
commit 524936912d
54 changed files with 1598 additions and 611 deletions

View File

@ -37,6 +37,7 @@
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/debounce": "^1.2.0",
"@types/enzyme": "^3.10.8",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/jest": "^26.0.15",
@ -71,6 +72,7 @@
"redux-thunk": "^2.3.0",
"sass": "^1.32.8",
"typescript": "^4.2.3",
"swr": "^0.5.5",
"web-vitals": "^1.0.1"
},
"jest": {

View File

@ -10,6 +10,10 @@ body {
height: 100%;
}
.MuiButton-root {
border-radius: 25px;
}
.skeleton {
position: relative;
overflow: hidden;
@ -20,7 +24,7 @@ body {
.skeleton::before {
background-color: #e2e8f0;
content: "";
content: '';
position: absolute;
top: 0;
right: 0;
@ -45,7 +49,7 @@ body {
rgba(255, 255, 255, 0)
);
animation: shimmer 3s infinite;
content: "";
content: '';
z-index: 5001;
}

View File

@ -16,6 +16,11 @@ export const useCommonStyles = makeStyles(theme => ({
backgroundColor: theme.palette.division.main,
height: '3px',
},
largeDivider: {
margin: '2rem 0',
backgroundColor: theme.palette.division.main,
height: '3px',
},
bold: {
fontWeight: 'bold',
},
@ -38,4 +43,9 @@ export const useCommonStyles = makeStyles(theme => ({
fullHeight: {
height: '100%',
},
title: {
fontSize: theme.fontSizes.mainHeader,
fontWeight: 'bold',
marginBottom: '0.5rem',
},
}));

View File

@ -28,7 +28,6 @@ const App = ({ location, user }: IAppProps) => {
const isUnauthorized = () => {
// authDetails only exists if the user is not logged in.
if (Object.keys(user).length === 0) return false;
return user?.authDetails !== undefined;
};
@ -54,6 +53,7 @@ const App = ({ location, user }: IAppProps) => {
<route.component
{...props}
isUnauthorized={isUnauthorized}
authDetails={user.authDetails}
/>
)}
/>

View File

@ -1,32 +0,0 @@
const ConditionallyRender = ({ condition, show, elseShow }) => {
const handleFunction = renderFunc => {
const result = renderFunc();
if (!result) {
/* eslint-disable-next-line */
console.warn(
'Nothing was returned from your render function. Verify that you are returning a valid react component'
);
return null;
}
return result;
};
const isFunc = param => typeof param === 'function';
if (condition && show) {
if (isFunc(show)) {
return handleFunction(show);
}
return show;
}
if (!condition && elseShow) {
if (isFunc(elseShow)) {
return handleFunction(elseShow);
}
return elseShow;
}
return null;
};
export default ConditionallyRender;

View File

@ -0,0 +1,45 @@
interface IConditionallyRenderProps {
condition: boolean;
show: JSX.Element | RenderFunc;
elseShow?: JSX.Element | RenderFunc;
}
type RenderFunc = () => JSX.Element;
const ConditionallyRender = ({
condition,
show,
elseShow,
}: IConditionallyRenderProps): JSX.Element | null => {
const handleFunction = (renderFunc: RenderFunc): JSX.Element | null => {
const result = renderFunc();
if (!result) {
/* eslint-disable-next-line */
console.warn(
'Nothing was returned from your render function. Verify that you are returning a valid react component'
);
return null;
}
return result;
};
const isFunc = (param: JSX.Element | RenderFunc) =>
typeof param === 'function';
if (condition) {
if (isFunc(show)) {
return handleFunction(show as RenderFunc);
}
return show as JSX.Element;
}
if (!condition && elseShow) {
if (isFunc(elseShow)) {
return handleFunction(elseShow as RenderFunc);
}
return elseShow as JSX.Element;
}
return null;
};
export default ConditionallyRender;

View File

@ -0,0 +1,26 @@
interface IGradientProps {
from: string;
to: string;
}
const Gradient: React.FC<IGradientProps> = ({
children,
from,
to,
...rest
}) => {
return (
<div
style={{
background: `linear-gradient(${from}, ${to})`,
height: '100%',
width: '100%',
}}
{...rest}
>
{children}
</div>
);
};
export default Gradient;

View File

@ -152,7 +152,6 @@ const FeatureToggleList = ({
<Button
to="/features/create"
data-test="add-feature-btn"
size="large"
color="secondary"
variant="contained"
component={Link}

View File

@ -5,7 +5,7 @@ import classnames from 'classnames';
import { Link } from 'react-router-dom';
import { Switch, Icon, IconButton, ListItem } from '@material-ui/core';
import TimeAgo from 'react-timeago';
import Progress from '../../progress-component';
import Progress from '../../ProgressWheel';
import Status from '../../status-component';
import FeatureToggleListItemChip from './FeatureToggleListItemChip';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
@ -29,18 +29,38 @@ const FeatureToggleListItem = ({
const { name, description, enabled, type, stale, createdAt } = feature;
const { showLastHour = false } = settings;
const isStale = showLastHour ? metricsLastHour.isFallback : metricsLastMinute.isFallback;
const isStale = showLastHour
? metricsLastHour.isFallback
: metricsLastMinute.isFallback;
const percent =
1 *
(showLastHour
? calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0)
: calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0));
const featureUrl = toggleFeature === undefined ? `/archive/strategies/${name}` : `/features/strategies/${name}`;
? calc(
metricsLastHour.yes,
metricsLastHour.yes + metricsLastHour.no,
0
)
: calc(
metricsLastMinute.yes,
metricsLastMinute.yes + metricsLastMinute.no,
0
));
const featureUrl =
toggleFeature === undefined
? `/archive/strategies/${name}`
: `/features/strategies/${name}`;
return (
<ListItem {...rest} className={classnames(styles.listItem, rest.className)}>
<ListItem
{...rest}
className={classnames(styles.listItem, rest.className)}
>
<span className={styles.listItemMetric}>
<Progress strokeWidth={15} percentage={percent} isFallback={isStale} />
<Progress
strokeWidth={15}
percentage={percent}
isFallback={isStale}
/>
</span>
<span className={styles.listItemToggle}>
<ConditionallyRender
@ -54,21 +74,43 @@ const FeatureToggleListItem = ({
checked={enabled}
/>
}
elseShow={<Switch disabled title={`Toggle ${name}`} key="left-actions" checked={enabled} />}
elseShow={
<Switch
disabled
title={`Toggle ${name}`}
key="left-actions"
checked={enabled}
/>
}
/>
</span>
<span className={classnames(styles.listItemLink)}>
<Link to={featureUrl} className={classnames(commonStyles.listLink, commonStyles.truncate)}>
<span className={commonStyles.toggleName}>{name}&nbsp;</span>
<Link
to={featureUrl}
className={classnames(
commonStyles.listLink,
commonStyles.truncate
)}
>
<span className={commonStyles.toggleName}>
{name}&nbsp;
</span>
<small>
<TimeAgo date={createdAt} live={false} />
</small>
<div>
<span className={commonStyles.truncate}><small>{description}</small></span>
<span className={commonStyles.truncate}>
<small>{description}</small>
</span>
</div>
</Link>
</span>
<span className={classnames(styles.listItemStrategies, commonStyles.hideLt920)}>
<span
className={classnames(
styles.listItemStrategies,
commonStyles.hideLt920
)}
>
<Status stale={stale} showActive={false} />
<FeatureToggleListItemChip type={type} />
</span>

View File

@ -145,7 +145,7 @@ exports[`renders correctly with one feature 1`] = `
</div>
<a
aria-disabled={false}
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedSecondary MuiButton-containedSizeLarge MuiButton-sizeLarge"
className="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedSecondary"
data-test="add-feature-btn"
href="/features/create"
onBlur={[Function]}

View File

@ -74,7 +74,7 @@ const FeatureView = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [features]);
}, []);
const getTabComponent = key => {
switch (key) {

View File

@ -0,0 +1,153 @@
import { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import styles from './progress.module.scss';
const Progress = ({
percentage,
strokeWidth = 10,
initialAnimation = false,
animatePercentageText = false,
textForPercentage,
colorClassName,
isFallback = false,
}) => {
const [localPercentage, setLocalPercentage] = useState({
percentage: initialAnimation ? 0 : percentage,
percentageText: initialAnimation ? 0 : percentage,
});
const timeoutId = useRef();
const rafTimerInit = useRef();
const rafCounterTimer = useRef();
const nextTimer = useRef();
useEffect(() => {
if (initialAnimation) {
timeoutId.current = setTimeout(() => {
rafTimerInit.current = window.requestAnimationFrame(() => {
setLocalPercentage(prev => ({ ...prev, percentage }));
});
}, 0);
}
return () => {
clearTimeout(timeoutId.current);
clearTimeout(nextTimer);
window.cancelAnimationFrame(rafTimerInit.current);
window.cancelAnimationFrame(rafCounterTimer.current);
};
/* eslint-disable-next-line */
}, []);
useEffect(() => {
if (percentage !== localPercentage) {
const nextState = { percentage };
if (animatePercentageText) {
animateTo(percentage, getTarget(percentage));
} else {
nextState.percentageText = percentage;
}
setLocalPercentage(prev => ({ ...prev, ...nextState }));
}
/* eslint-disable-next-line */
}, [percentage]);
const getTarget = target => {
const start = localPercentage.percentageText;
const TOTAL_ANIMATION_TIME = 5000;
const diff = start > target ? -(start - target) : target - start;
const perCycle = TOTAL_ANIMATION_TIME / diff;
const cyclesCounter = Math.round(
Math.abs(TOTAL_ANIMATION_TIME / perCycle)
);
const perCycleTime = Math.round(Math.abs(perCycle));
return {
start,
target,
cyclesCounter,
perCycleTime,
increment: diff / cyclesCounter,
};
};
const animateTo = (percentage, targetState) => {
cancelAnimationFrame(rafCounterTimer.current);
clearTimeout(nextTimer.current);
const current = localPercentage.percentageText;
targetState.cyclesCounter--;
if (targetState.cyclesCounter <= 0) {
setLocalPercentage({ percentageText: targetState.target });
return;
}
const next = Math.round(current + targetState.increment);
rafCounterTimer.current = requestAnimationFrame(() => {
setLocalPercentage({ percentageText: next });
nextTimer.current = setTimeout(() => {
animateTo(next, targetState);
}, targetState.perCycleTime);
});
};
const radius = 50 - strokeWidth / 2;
const pathDescription = `
M 50,50 m 0,-${radius}
a ${radius},${radius} 0 1 1 0,${2 * radius}
a ${radius},${radius} 0 1 1 0,-${2 * radius}
`;
const diameter = Math.PI * 2 * radius;
const progressStyle = {
strokeDasharray: `${diameter}px ${diameter}px`,
strokeDashoffset: `${
((100 - localPercentage.percentage) / 100) * diameter
}px`,
};
return isFallback ? (
<svg viewBox="0 0 24 24">
{
// eslint-disable-next-line max-len
}
<path
fill="#E0E0E0"
d="M17.3,18C19,16.5 20,14.4 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12C4,14.4 5,16.5 6.7,18C8.2,16.7 10,16 12,16C14,16 15.9,16.7 17.3,18M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M7,9A1,1 0 0,1 8,10A1,1 0 0,1 7,11A1,1 0 0,1 6,10A1,1 0 0,1 7,9M10,6A1,1 0 0,1 11,7A1,1 0 0,1 10,8A1,1 0 0,1 9,7A1,1 0 0,1 10,6M17,9A1,1 0 0,1 18,10A1,1 0 0,1 17,11A1,1 0 0,1 16,10A1,1 0 0,1 17,9M14.4,6.1C14.9,6.3 15.1,6.9 15,7.4L13.6,10.8C13.8,11.1 14,11.5 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12C10,11 10.7,10.1 11.7,10L13.1,6.7C13.3,6.1 13.9,5.9 14.4,6.1Z"
/>
</svg>
) : (
<svg viewBox="0 0 100 100">
<path
className={styles.trail}
d={pathDescription}
strokeWidth={strokeWidth}
fillOpacity={0}
/>
<path
className={[styles.path, colorClassName].join(' ')}
d={pathDescription}
strokeWidth={strokeWidth}
fillOpacity={0}
style={progressStyle}
/>
<text className={styles.text} x={50} y={50}>
{localPercentage.percentageText}%
</text>
</svg>
);
};
Progress.propTypes = {
percentage: PropTypes.number.isRequired,
strokeWidth: PropTypes.number,
initialAnimation: PropTypes.bool,
animatePercentageText: PropTypes.bool,
textForPercentage: PropTypes.func,
colorClassName: PropTypes.string,
isFallback: PropTypes.bool,
};
export default Progress;

View File

@ -1,34 +1,42 @@
import React from 'react';
import Progress from '../progress-component';
import Progress from '../ProgressWheel';
import renderer from 'react-test-renderer';
jest.mock('@material-ui/core');
test('renders correctly with 15% done no fallback', () => {
const percent = 15;
const tree = renderer.create(<Progress strokeWidth={15} percentage={percent} isFallback={false} />);
const tree = renderer.create(
<Progress strokeWidth={15} percentage={percent} isFallback={false} />
);
expect(tree).toMatchSnapshot();
});
test('renders correctly with 0% done no fallback', () => {
const percent = 0;
const tree = renderer.create(<Progress strokeWidth={15} percentage={percent} isFallback={false} />);
const tree = renderer.create(
<Progress strokeWidth={15} percentage={percent} isFallback={false} />
);
expect(tree).toMatchSnapshot();
});
test('renders correctly with 15% done with fallback', () => {
const percent = 15;
const tree = renderer.create(<Progress strokeWidth={15} percentage={percent} isFallback />);
const tree = renderer.create(
<Progress strokeWidth={15} percentage={percent} isFallback />
);
expect(tree).toMatchSnapshot();
});
test('renders correctly with 0% done with fallback', () => {
const percent = 0;
const tree = renderer.create(<Progress strokeWidth={15} percentage={percent} isFallback />);
const tree = renderer.create(
<Progress strokeWidth={15} percentage={percent} isFallback />
);
expect(tree).toMatchSnapshot();
});

View File

@ -1,147 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styles from './progress.module.scss';
class Progress extends Component {
constructor(props) {
super(props);
this.state = {
percentage: props.initialAnimation ? 0 : props.percentage,
percentageText: props.initialAnimation ? 0 : props.percentage,
};
}
componentDidMount() {
if (this.props.initialAnimation) {
this.initialTimeout = setTimeout(() => {
this.rafTimerInit = window.requestAnimationFrame(() => {
this.setState({
percentage: this.props.percentage,
});
});
}, 0);
}
}
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps({ percentage }) {
if (this.state.percentage !== percentage) {
const nextState = { percentage };
if (this.props.animatePercentageText) {
this.animateTo(percentage, this.getTarget(percentage));
} else {
nextState.percentageText = percentage;
}
this.setState(nextState);
}
}
getTarget(target) {
const start = this.state.percentageText;
const TOTAL_ANIMATION_TIME = 5000;
const diff = start > target ? -(start - target) : target - start;
const perCycle = TOTAL_ANIMATION_TIME / diff;
const cyclesCounter = Math.round(Math.abs(TOTAL_ANIMATION_TIME / perCycle));
const perCycleTime = Math.round(Math.abs(perCycle));
return {
start,
target,
cyclesCounter,
perCycleTime,
increment: diff / cyclesCounter,
};
}
animateTo(percentage, targetState) {
cancelAnimationFrame(this.rafCounterTimer);
clearTimeout(this.nextTimer);
const current = this.state.percentageText;
targetState.cyclesCounter--;
if (targetState.cyclesCounter <= 0) {
this.setState({ percentageText: targetState.target });
return;
}
const next = Math.round(current + targetState.increment);
this.rafCounterTimer = requestAnimationFrame(() => {
this.setState({ percentageText: next });
this.nextTimer = setTimeout(() => {
this.animateTo(next, targetState);
}, targetState.perCycleTime);
});
}
componentWillUnmount() {
clearTimeout(this.initialTimeout);
clearTimeout(this.nextTimer);
window.cancelAnimationFrame(this.rafTimerInit);
window.cancelAnimationFrame(this.rafCounterTimer);
}
render() {
const { strokeWidth, colorClassName, isFallback } = this.props;
const radius = 50 - strokeWidth / 2;
const pathDescription = `
M 50,50 m 0,-${radius}
a ${radius},${radius} 0 1 1 0,${2 * radius}
a ${radius},${radius} 0 1 1 0,-${2 * radius}
`;
const diameter = Math.PI * 2 * radius;
const progressStyle = {
strokeDasharray: `${diameter}px ${diameter}px`,
strokeDashoffset: `${((100 - this.state.percentage) / 100) * diameter}px`,
};
return isFallback ? (
<svg viewBox="0 0 24 24">
{
// eslint-disable-next-line max-len
}
<path
fill="#E0E0E0"
d="M17.3,18C19,16.5 20,14.4 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12C4,14.4 5,16.5 6.7,18C8.2,16.7 10,16 12,16C14,16 15.9,16.7 17.3,18M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M7,9A1,1 0 0,1 8,10A1,1 0 0,1 7,11A1,1 0 0,1 6,10A1,1 0 0,1 7,9M10,6A1,1 0 0,1 11,7A1,1 0 0,1 10,8A1,1 0 0,1 9,7A1,1 0 0,1 10,6M17,9A1,1 0 0,1 18,10A1,1 0 0,1 17,11A1,1 0 0,1 16,10A1,1 0 0,1 17,9M14.4,6.1C14.9,6.3 15.1,6.9 15,7.4L13.6,10.8C13.8,11.1 14,11.5 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12C10,11 10.7,10.1 11.7,10L13.1,6.7C13.3,6.1 13.9,5.9 14.4,6.1Z"
/>
</svg>
) : (
<svg viewBox="0 0 100 100">
<path className={styles.trail} d={pathDescription} strokeWidth={strokeWidth} fillOpacity={0} />
<path
className={[styles.path, colorClassName].join(' ')}
d={pathDescription}
strokeWidth={strokeWidth}
fillOpacity={0}
style={progressStyle}
/>
<text className={styles.text} x={50} y={50}>
{this.state.percentageText}%
</text>
</svg>
);
}
}
Progress.propTypes = {
percentage: PropTypes.number.isRequired,
strokeWidth: PropTypes.number,
initialAnimation: PropTypes.bool,
animatePercentageText: PropTypes.bool,
textForPercentage: PropTypes.func,
colorClassName: PropTypes.string,
isFallback: PropTypes.bool,
};
Progress.defaultProps = {
strokeWidth: 10,
animatePercentageText: false,
initialAnimation: false,
isFallback: false,
};
export default Progress;

View File

@ -1,11 +1,11 @@
.path {
stroke: currentColor;
stroke: #5eb89b;
stroke-linecap: round;
transition: stroke-dashoffset 5s ease 0s;
}
.trail {
stroke: currentColor;
stroke: #888888;
}
.text {

View File

@ -473,10 +473,10 @@ exports[`renders correctly with with variants 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-11 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-13 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-13 PrivateNotchedOutline-legendNotched-14"
className="PrivateNotchedOutline-legendLabelled-15 PrivateNotchedOutline-legendNotched-16"
>
<span>
Stickiness

View File

@ -139,10 +139,10 @@ exports[`renders correctly with one feature 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-11 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-13 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-13"
className="PrivateNotchedOutline-legendLabelled-15"
>
<span>
Project
@ -174,7 +174,7 @@ exports[`renders correctly with one feature 1`] = `
>
<span
aria-disabled={false}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-15 MuiSwitch-switchBase MuiSwitch-colorSecondary"
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-17 MuiSwitch-switchBase MuiSwitch-colorSecondary"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
@ -193,7 +193,7 @@ exports[`renders correctly with one feature 1`] = `
>
<input
checked={false}
className="PrivateSwitchBase-input-18 MuiSwitch-input"
className="PrivateSwitchBase-input-20 MuiSwitch-input"
disabled={false}
onChange={[Function]}
type="checkbox"
@ -317,7 +317,7 @@ exports[`renders correctly with one feature 1`] = `
</div>
<hr />
<div
className="MuiPaper-root makeStyles-tabNav-19 MuiPaper-elevation1 MuiPaper-rounded"
className="MuiPaper-root makeStyles-tabNav-21 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
@ -365,7 +365,7 @@ exports[`renders correctly with one feature 1`] = `
Activation
</span>
<span
className="PrivateTabIndicator-root-20 PrivateTabIndicator-colorPrimary-21 MuiTabs-indicator"
className="PrivateTabIndicator-root-22 PrivateTabIndicator-colorPrimary-23 MuiTabs-indicator"
style={Object {}}
/>
</button>

View File

@ -6,7 +6,7 @@ import LinkIcon from '@material-ui/icons/Link';
import { Link } from 'react-router-dom';
import { AppsLinkList, calc } from '../../common';
import { formatFullDateTimeWithLocale } from '../../common/util';
import Progress from '../progress-component';
import Progress from '../ProgressWheel';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import styles from './metric.module.scss';
@ -55,7 +55,8 @@ export default class MetricComponent extends React.Component {
formatFullDateTime(v) {
return formatFullDateTimeWithLocale(v, this.props.location.locale);
}
renderLastSeen = lastSeenAt => (lastSeenAt ? this.formatFullDateTime(lastSeenAt) : 'Never reported');
renderLastSeen = lastSeenAt =>
lastSeenAt ? this.formatFullDateTime(lastSeenAt) : 'Never reported';
render() {
const { metrics = {}, featureToggle } = this.props;
@ -65,12 +66,19 @@ export default class MetricComponent extends React.Component {
seenApps = [],
} = metrics;
const lastHourPercent = 1 * calc(lastHour.yes, lastHour.yes + lastHour.no, 0);
const lastMinutePercent = 1 * calc(lastMinute.yes, lastMinute.yes + lastMinute.no, 0);
const lastHourPercent =
1 * calc(lastHour.yes, lastHour.yes + lastHour.no, 0);
const lastMinutePercent =
1 * calc(lastMinute.yes, lastMinute.yes + lastMinute.no, 0);
return (
<div style={{ padding: '16px', flexGrow: 1 }}>
<Grid container spacing={2} justify="center" className={styles.grid}>
<Grid
container
spacing={2}
justify="center"
className={styles.grid}
>
<Grid item xs={12} sm={4}>
<Progress
percentage={lastMinutePercent}
@ -83,13 +91,17 @@ export default class MetricComponent extends React.Component {
elseShow={
<p>
<strong>Last minute</strong>
<br /> Yes {lastMinute.yes}, No: {lastMinute.no}
<br /> Yes {lastMinute.yes}, No:{' '}
{lastMinute.no}
</p>
}
/>
</Grid>
<Grid item xs={12} sm={4}>
<Progress percentage={lastHourPercent} isFallback={lastHour.isFallback} />
<Progress
percentage={lastHourPercent}
isFallback={lastHour.isFallback}
/>
<ConditionallyRender
condition={lastHour.isFallback}
show={<p>No metrics available</p>}
@ -111,13 +123,20 @@ export default class MetricComponent extends React.Component {
}
elseShow={
<div>
<Icon className={styles.problemIcon} title="Not used in an app in the last hour">
<Icon
className={styles.problemIcon}
title="Not used in an app in the last hour"
>
report problem
</Icon>
<div>
<small>
<strong>Not used in an app in the last hour. </strong>
This might be due to your client implementation not reporting usage.
<strong>
Not used in an app in the last
hour.{' '}
</strong>
This might be due to your client
implementation not reporting usage.
</small>
</div>
</div>
@ -131,14 +150,20 @@ export default class MetricComponent extends React.Component {
show={
<>
<strong>Created: </strong>
<span>{this.formatFullDateTime(featureToggle.createdAt)}</span>
<span>
{this.formatFullDateTime(
featureToggle.createdAt
)}
</span>
</>
}
/>
<br />
<strong>Last seen: </strong>
<span>{this.renderLastSeen(featureToggle.lastSeenAt)}</span>
<span>
{this.renderLastSeen(featureToggle.lastSeenAt)}
</span>
</div>
</Grid>
</Grid>

View File

@ -2,11 +2,31 @@ import ConditionallyRender from '../../common/ConditionallyRender';
import MainLayout from '../MainLayout/MainLayout';
const LayoutPicker = ({ children, location }) => {
const isLoginPage = location.pathname.includes('login');
const standalonePages = () => {
const isLoginPage = location.pathname.includes('login');
const isNewUserPage = location.pathname.includes('new-user');
const isChangePasswordPage = location.pathname.includes(
'reset-password'
);
const isResetPasswordSuccessPage = location.pathname.includes(
'reset-password-success'
);
const isForgottenPasswordPage = location.pathname.includes(
'forgotten-password'
);
return (
isLoginPage ||
isNewUserPage ||
isChangePasswordPage ||
isResetPasswordSuccessPage ||
isForgottenPasswordPage
);
};
return (
<ConditionallyRender
condition={isLoginPage}
condition={standalonePages()}
show={children}
elseShow={<MainLayout location={location}>{children}</MainLayout>}
/>

View File

@ -104,304 +104,3 @@ Array [
},
]
`;
exports[`returns all defined routes 1`] = `
Array [
Object {
"component": [Function],
"layout": "main",
"parent": "/features",
"path": "/features/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/features",
"path": "/features/copy/:copyToggle",
"title": "Copy",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/features",
"path": "/features/:activeTab/:name",
"title": ":name",
"type": "protected",
},
Object {
"component": [Function],
"icon": "list",
"layout": "main",
"path": "/features",
"title": "Feature Toggles",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/strategies",
"path": "/strategies/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/strategies",
"path": "/strategies/:activeTab/:strategyName",
"title": ":strategyName",
"type": "protected",
},
Object {
"component": [Function],
"icon": "extension",
"layout": "main",
"path": "/strategies",
"title": "Strategies",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/history",
"path": "/history/:toggleName",
"title": ":toggleName",
"type": "protected",
},
Object {
"component": [Function],
"icon": "history",
"layout": "main",
"path": "/history",
"title": "Event History",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/archive",
"path": "/archive/:activeTab/:name",
"title": ":name",
"type": "protected",
},
Object {
"component": [Function],
"icon": "archive",
"layout": "main",
"path": "/archive",
"title": "Archived Toggles",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/applications",
"path": "/applications/:name",
"title": ":name",
"type": "protected",
},
Object {
"component": [Function],
"icon": "apps",
"layout": "main",
"path": "/applications",
"title": "Applications",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/context",
"path": "/context/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/context",
"path": "/context/edit/:name",
"title": ":name",
"type": "protected",
},
Object {
"component": [Function],
"flag": "C",
"icon": "album",
"layout": "main",
"path": "/context",
"title": "Context Fields",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/projects",
"path": "/projects/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/projects",
"path": "/projects/edit/:id",
"title": ":id",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/projects",
"path": "/projects/:id/access",
"title": ":id",
"type": "protected",
},
Object {
"component": [Function],
"flag": "P",
"icon": "folder_open",
"layout": "main",
"path": "/projects",
"title": "Projects",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/tag-types",
"path": "/tag-types/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/tag-types",
"path": "/tag-types/edit/:name",
"title": ":name",
"type": "protected",
},
Object {
"component": [Function],
"icon": "label",
"layout": "main",
"path": "/tag-types",
"title": "Tag types",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/tags",
"path": "/tags/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"hidden": true,
"icon": "label",
"layout": "main",
"path": "/tags",
"title": "Tags",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/addons",
"path": "/addons/create/:provider",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/addons",
"path": "/addons/edit/:id",
"title": "Edit",
"type": "protected",
},
Object {
"component": [Function],
"hidden": false,
"icon": "device_hub",
"layout": "main",
"path": "/addons",
"title": "Addons",
"type": "protected",
},
Object {
"component": [Function],
"icon": "report",
"layout": "main",
"path": "/reporting",
"title": "Reporting",
"type": "protected",
},
Object {
"component": [Function],
"icon": "exit_to_app",
"layout": "main",
"path": "/logout",
"title": "Sign out",
"type": "protected",
},
Object {
"component": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"hidden": true,
"icon": "user",
"layout": "standalone",
"path": "/login",
"title": "Log in",
"type": "unprotected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/admin",
"path": "/admin/api",
"title": "API access",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"parent": "/admin",
"path": "/admin/users",
"title": "Users",
"type": "protected",
},
Object {
"component": Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
"layout": "main",
"parent": "/admin",
"path": "/admin/auth",
"title": "Authentication",
"type": "protected",
},
Object {
"component": [Function],
"hidden": false,
"icon": "album",
"layout": "main",
"path": "/admin",
"title": "Admin",
"type": "protected",
},
]
`;

View File

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

View File

@ -34,6 +34,9 @@ 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 ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
export const routes = [
// Features
@ -307,40 +310,64 @@ export const routes = [
hidden: true,
layout: 'standalone',
},
// Admin
{
path: '/admin/api',
parent: '/admin',
title: 'API access',
component: AdminApi,
type: 'protected',
layout: 'main',
},
{
path: '/admin/users',
parent: '/admin',
title: 'Users',
component: AdminUsers,
type: 'protected',
layout: 'main',
},
{
path: '/admin/auth',
parent: '/admin',
title: 'Authentication',
component: AdminAuth,
type: 'protected',
layout: 'main',
},
{
path: '/admin',
title: 'Admin',
icon: 'album',
component: Admin,
hidden: false,
type: 'protected',
layout: 'main',
},
// Admin
{
path: '/admin/api',
parent: '/admin',
title: 'API access',
component: AdminApi,
type: 'protected',
layout: 'main',
},
{
path: '/admin/users',
parent: '/admin',
title: 'Users',
component: AdminUsers,
type: 'protected',
layout: 'main',
},
{
path: '/admin/auth',
parent: '/admin',
title: 'Authentication',
component: AdminAuth,
type: 'protected',
layout: 'main',
},
{
path: '/admin',
title: 'Admin',
icon: 'album',
component: Admin,
hidden: false,
type: 'protected',
layout: 'main',
},
{
path: '/new-user',
title: 'New user',
hidden: true,
component: NewUser,
type: 'unprotected',
layout: 'standalone',
},
{
path: '/reset-password',
title: 'reset-password',
hidden: true,
component: ResetPassword,
type: 'unprotected',
layout: 'standalone',
},
{
path: '/forgotten-password',
title: 'reset-password',
hidden: true,
component: ForgottenPassword,
type: 'unprotected',
layout: 'standalone',
},
];
export const getRoute = path => routes.find(route => route.path === path);

View File

@ -0,0 +1,14 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles({
container: {
maxWidth: '300px',
},
button: {
width: '150px',
},
email: {
display: 'block',
margin: '0.5rem 0',
},
});

View File

@ -0,0 +1,112 @@
import { Button, TextField, Typography } from '@material-ui/core';
import { AlertTitle, Alert } from '@material-ui/lab';
import classnames from 'classnames';
import { SyntheticEvent, useState } from 'react';
import { useCommonStyles } from '../../../common.styles';
import useLoading from '../../../hooks/useLoading';
import ConditionallyRender from '../../common/ConditionallyRender';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import { useStyles } from './ForgottenPassword.styles';
const ForgottenPassword = () => {
const [email, setEmail] = useState('');
const [attempted, setAttempted] = useState(false);
const [loading, setLoading] = useState(false);
const [attemptedEmail, setAttemptedEmail] = useState('');
const commonStyles = useCommonStyles();
const styles = useStyles();
const ref = useLoading(loading);
const onClick = async (e: SyntheticEvent) => {
e.preventDefault();
setLoading(true);
setAttemptedEmail(email);
await fetch('auth/reset/password-email', {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({ email }),
});
setAttempted(true);
setLoading(false);
};
return (
<StandaloneLayout>
<div
className={classnames(
commonStyles.contentSpacingY,
commonStyles.flexColumn
)}
ref={ref}
>
<Typography
variant="h2"
className={commonStyles.title}
data-loading
>
Forgotten password
</Typography>
<ConditionallyRender
condition={attempted}
show={
<Alert severity="success" data-loading>
<AlertTitle>Attempted to send email</AlertTitle>
We've attempted to send a reset password email to:
<strong className={styles.email}>
{attemptedEmail}
</strong>
If you did not receive an email, please verify that
you typed in the correct email, and contact your
administrator to make sure that you are in the
system.
</Alert>
}
/>
<form
onSubmit={onClick}
className={classnames(
commonStyles.contentSpacingY,
commonStyles.flexColumn,
styles.container
)}
>
<Typography variant="body1" data-loading>
Please provide your email address. If it exists in the
system we'll send a new reset link.
</Typography>
<TextField
variant="outlined"
size="small"
placeholder="email"
type="email"
data-loading
value={email}
onChange={e => {
setEmail(e.target.value);
}}
/>
<Button
variant="contained"
type="submit"
data-loading
color="primary"
className={styles.button}
disabled={loading}
>
<ConditionallyRender
condition={!attempted}
show={<span>Submit</span>}
elseShow={<span>Try again</span>}
/>
</Button>
</form>
</div>
</StandaloneLayout>
);
};
export default ForgottenPassword;

View File

@ -1,62 +1,46 @@
import { useEffect } from 'react';
import classnames from 'classnames';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import { useTheme } from '@material-ui/core/styles';
import { Typography } from '@material-ui/core';
import AuthenticationContainer from '../authentication-container';
import ConditionallyRender from '../../common/ConditionallyRender';
import { ReactComponent as UnleashLogo } from '../../../icons/unleash-logo-inverted.svg';
import { ReactComponent as SwitchesSVG } from '../../../icons/switches.svg';
import { useStyles } from './Login.styles';
import useQueryParams from '../../../hooks/useQueryParams';
import ResetPasswordSuccess from '../common/ResetPasswordSuccess/ResetPasswordSuccess';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
const Login = ({ history, loadInitialData, isUnauthorized, authDetails }) => {
const theme = useTheme();
const styles = useStyles();
const smallScreen = useMediaQuery(theme.breakpoints.up('md'));
const query = useQueryParams();
useEffect(() => {
if (isUnauthorized()) {
loadInitialData();
} else {
}
/* eslint-disable-next-line */
}, []);
useEffect(() => {
if (!isUnauthorized()) {
history.push('features');
}
/* eslint-disable-next-line */
}, [authDetails]);
const resetPassword = query.get('reset') === 'true';
return (
<div className={styles.loginContainer}>
<div className={classnames(styles.container)}>
<div
className={classnames(
styles.contentContainer,
styles.gradient
)}
>
<h1 className={styles.title}>
<UnleashLogo className={styles.logo} /> Unleash
</h1>
<Typography variant="body1" className={styles.subTitle}>
Committed to creating new ways of developing
</Typography>
<ConditionallyRender
condition={smallScreen}
show={
<div className={styles.imageContainer}>
<SwitchesSVG />
</div>
}
/>
</div>
<div className={styles.contentContainer}>
<h2 className={styles.title}>Login</h2>
<div className={styles.loginFormContainer}>
<AuthenticationContainer history={history} />
</div>
<StandaloneLayout>
<div>
<h2 className={styles.title}>Login</h2>
<ConditionallyRender
condition={resetPassword}
show={<ResetPasswordSuccess />}
/>
<div className={styles.loginFormContainer}>
<AuthenticationContainer history={history} />
</div>
</div>
</div>
</StandaloneLayout>
);
};

View File

@ -24,7 +24,7 @@ export const useStyles = makeStyles(theme => ({
color: theme.palette.login.main,
},
title: {
fontSize: '1.5rem',
fontSize: theme.fontSizes.mainHeader,
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',

View File

@ -0,0 +1,29 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
},
roleContainer: {
marginTop: '2rem',
},
innerContainer: {
width: '60%',
minHeight: '100vh',
padding: '4rem 3rem',
},
buttonContainer: {
display: 'flex',
marginTop: '1rem',
},
primaryBtn: {
marginRight: '8px',
},
subtitle: {
marginBottom: '0.5rem',
fontSize: '1.1rem',
},
emailField: {
minWidth: '300px',
},
}));

View File

@ -0,0 +1,94 @@
import useLoading from '../../../hooks/useLoading';
import { TextField, Typography } from '@material-ui/core';
import StandaloneBanner from '../StandaloneBanner/StandaloneBanner';
import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails';
import { useStyles } from './NewUser.styles';
import { useCommonStyles } from '../../../common.styles';
import useResetPassword from '../../../hooks/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
import ConditionallyRender from '../../common/ConditionallyRender';
import InvalidToken from '../common/InvalidToken/InvalidToken';
const NewUser = () => {
const {
token,
data,
loading,
setLoading,
invalidToken,
} = useResetPassword();
const ref = useLoading(loading);
const commonStyles = useCommonStyles();
const styles = useStyles();
return (
<div ref={ref}>
<StandaloneLayout
showMenu={false}
BannerComponent={
<StandaloneBanner showStars title={'Welcome to Unleash'}>
<ConditionallyRender
condition={data?.createdBy}
show={
<Typography variant="body1">
You have been invited by {data?.createdBy}
</Typography>
}
/>
</StandaloneBanner>
}
>
<ConditionallyRender
condition={invalidToken}
show={<InvalidToken />}
elseShow={
<ResetPasswordDetails
token={token}
setLoading={setLoading}
>
<Typography
data-loading
variant="subtitle1"
className={styles.subtitle}
>
Your username is
</Typography>
<TextField
data-loading
value={data?.email}
variant="outlined"
size="small"
className={styles.emailField}
disabled
/>
<div className={styles.roleContainer}>
<Typography
data-loading
variant="subtitle1"
className={styles.subtitle}
>
In Unleash your role is:{' '}
<i>{data?.role?.name}</i>
</Typography>
<Typography variant="body1" data-loading>
{data?.role?.description}
</Typography>
<div
className={commonStyles.largeDivider}
data-loading
/>
<Typography variant="body1" data-loading>
Set a password for your account.
</Typography>
</div>
</ResetPasswordDetails>
}
/>
</StandaloneLayout>
</div>
);
};
export default NewUser;

View File

@ -0,0 +1,13 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
},
innerContainer: { width: '40%', minHeight: '100vh' },
title: {
fontWeight: 'bold',
fontSize: '1.2rem',
marginBottom: '1rem',
},
}));

View File

@ -0,0 +1,43 @@
import useLoading from '../../../hooks/useLoading';
import ResetPasswordDetails from '../common/ResetPasswordDetails/ResetPasswordDetails';
import { useStyles } from './ResetPassword.styles';
import { Typography } from '@material-ui/core';
import ConditionallyRender from '../../common/ConditionallyRender';
import InvalidToken from '../common/InvalidToken/InvalidToken';
import useResetPassword from '../../../hooks/useResetPassword';
import StandaloneLayout from '../common/StandaloneLayout/StandaloneLayout';
const ResetPassword = () => {
const styles = useStyles();
const { token, loading, setLoading, invalidToken } = useResetPassword();
const ref = useLoading(loading);
return (
<div ref={ref}>
<StandaloneLayout>
<ConditionallyRender
condition={invalidToken}
show={<InvalidToken />}
elseShow={
<ResetPasswordDetails
token={token}
setLoading={setLoading}
>
<Typography
variant="h2"
className={styles.title}
data-loading
>
Reset password
</Typography>
</ResetPasswordDetails>
}
/>
</StandaloneLayout>
</div>
);
};
export default ResetPassword;

View File

@ -0,0 +1,49 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
title: {
color: '#fff',
fontSize: '1.2rem',
fontWeight: 'bold',
marginBottom: '1rem',
},
container: {
padding: '4rem 2rem',
color: '#fff',
position: 'relative',
},
switchesContainer: {
position: 'fixed',
bottom: '40px',
display: 'flex',
flexDirection: 'column',
},
switchIcon: {
height: '180px',
},
bottomStar: {
position: 'absolute',
bottom: '-54px',
left: '100px',
},
bottomRightStar: {
position: 'absolute',
bottom: '-100px',
left: '200px',
},
midRightStar: {
position: 'absolute',
bottom: '-80px',
left: '300px',
},
midLeftStar: {
position: 'absolute',
top: '10px',
left: '150px',
},
midLeftStarTwo: {
position: 'absolute',
top: '25px',
left: '350px',
},
}));

View File

@ -0,0 +1,55 @@
import { FC } from 'react';
import { Typography, useTheme } from '@material-ui/core';
import Gradient from '../../common/Gradient/Gradient';
import { ReactComponent as StarIcon } from '../../../icons/star.svg';
import { ReactComponent as RightToggleIcon } from '../../../icons/toggleRight.svg';
import { ReactComponent as LeftToggleIcon } from '../../../icons/toggleLeft.svg';
import { useStyles } from './StandaloneBanner.styles';
import ConditionallyRender from '../../common/ConditionallyRender';
interface IStandaloneBannerProps {
showStars?: boolean;
title: string;
}
const StandaloneBanner: FC<IStandaloneBannerProps> = ({
showStars = false,
title,
children,
}) => {
const theme = useTheme();
const styles = useStyles();
return (
<Gradient from={theme.palette.primary.main} to={'#173341'}>
<div className={styles.container}>
<Typography variant="h1" className={styles.title}>
{title}
</Typography>
{children}
<ConditionallyRender
condition={showStars}
show={
<>
<StarIcon className={styles.midLeftStarTwo} />
<StarIcon className={styles.midLeftStar} />
<StarIcon className={styles.midRightStar} />
<StarIcon className={styles.bottomRightStar} />
<StarIcon className={styles.bottomStar} />
</>
}
/>
</div>
<div className={styles.switchesContainer}>
<RightToggleIcon className={styles.switchIcon} />
<br></br>
<LeftToggleIcon className={styles.switchIcon} />
</div>
</Gradient>
);
};
export default StandaloneBanner;

View File

@ -0,0 +1,29 @@
import { Button, Typography } from '@material-ui/core';
import { Link } from 'react-router-dom';
import { useCommonStyles } from '../../../../common.styles';
const InvalidToken = () => {
const commonStyles = useCommonStyles();
return (
<div className={commonStyles.contentSpacingY}>
<Typography variant="h2" className={commonStyles.title}>
Invalid token
</Typography>
<Typography variant="subtitle1">
Your token has either been used to reset your password, or it
has expired. Please request a new reset password URL in order to
reset your password.
</Typography>
<Button
variant="contained"
color="primary"
component={Link}
to="forgotten-password"
>
Reset password
</Button>
</div>
);
};
export default InvalidToken;

View File

@ -0,0 +1,22 @@
import { FC, Dispatch, SetStateAction } from 'react';
import ResetPasswordForm from '../ResetPasswordForm/ResetPasswordForm';
interface IResetPasswordDetails {
token: string;
setLoading: Dispatch<SetStateAction<boolean>>;
}
const ResetPasswordDetails: FC<IResetPasswordDetails> = ({
children,
token,
setLoading,
}) => {
return (
<div>
{children}
<ResetPasswordForm token={token} setLoading={setLoading} />
</div>
);
};
export default ResetPasswordDetails;

View File

@ -0,0 +1,14 @@
import { Alert, AlertTitle } from '@material-ui/lab';
const ResetPasswordError = () => {
return (
<Alert severity="error" data-loading>
<AlertTitle>Unable to reset password</AlertTitle>
Something went wrong when attempting to update your password. This
could be due to unstable internet connectivity. If retrying the
request does not work, please try again later.
</Alert>
);
};
export default ResetPasswordError;

View File

@ -0,0 +1,43 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
border: '1px solid #f1f1f1',
borderRadius: '3px',
right: '100px',
color: '#44606e',
},
headerContainer: { display: 'flex', padding: '0.5rem' },
divider: {
backgroundColor: theme.palette.borders?.main,
height: '1px',
width: '100%',
},
checkContainer: {
width: '95px',
margin: '0 0.25rem',
display: 'flex',
justifyContent: 'center',
},
statusBarContainer: {
display: 'flex',
padding: '0.5rem',
},
statusBar: {
width: '50px',
borderRadius: '3px',
backgroundColor: 'red',
height: '6px',
},
title: {
marginBottom: '0',
display: 'flex',
alignItems: 'center',
},
statusBarSuccess: {
backgroundColor: theme.palette.primary.main,
},
helpIcon: {
height: '17.5px',
},
}));

View File

@ -0,0 +1,184 @@
import { Tooltip, Typography } from '@material-ui/core';
import classnames from 'classnames';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { BAD_REQUEST, OK } from '../../../../../constants/statusCodes';
import { useStyles } from './PasswordChecker.styles';
import HelpIcon from '@material-ui/icons/Help';
import { useCallback } from 'react';
interface IPasswordCheckerProps {
password: string;
callback: Dispatch<SetStateAction<boolean>>;
}
interface IErrorResponse {
details: IErrorDetails[];
}
interface IErrorDetails {
message: string;
validationErrors: string[];
}
const LENGTH_ERROR = 'The password must be at least 10 characters long.';
const NUMBER_ERROR = 'The password must contain at least one number.';
const SYMBOL_ERROR =
'The password must contain at least one special character.';
const UPPERCASE_ERROR =
'The password must contain at least one uppercase letter';
const LOWERCASE_ERROR =
'The password must contain at least one lowercase letter.';
const PasswordChecker = ({ password, callback }: IPasswordCheckerProps) => {
const styles = useStyles();
const [casingError, setCasingError] = useState(true);
const [numberError, setNumberError] = useState(true);
const [symbolError, setSymbolError] = useState(true);
const [lengthError, setLengthError] = useState(true);
const makeValidatePassReq = useCallback(() => {
return fetch('auth/reset/validate-password', {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({ password }),
});
}, [password]);
const checkPassword = useCallback(async () => {
try {
const res = await makeValidatePassReq();
if (res.status === BAD_REQUEST) {
const data = await res.json();
handleErrorResponse(data);
callback(false);
}
if (res.status === OK) {
clearErrors();
callback(true);
}
} catch (e) {
// ResetPasswordForm handles errors related to submitting the form.
console.log(e);
}
}, [makeValidatePassReq, callback]);
useEffect(() => {
checkPassword();
}, [password, checkPassword]);
const clearErrors = () => {
setCasingError(false);
setNumberError(false);
setSymbolError(false);
setLengthError(false);
};
const handleErrorResponse = (data: IErrorResponse) => {
const errors = data.details[0].validationErrors;
if (errors.includes(NUMBER_ERROR)) {
setNumberError(true);
} else {
setNumberError(false);
}
if (errors.includes(SYMBOL_ERROR)) {
setSymbolError(true);
} else {
setSymbolError(false);
}
if (errors.includes(LENGTH_ERROR)) {
setLengthError(true);
} else {
setLengthError(false);
}
if (
errors.includes(LOWERCASE_ERROR) ||
errors.includes(UPPERCASE_ERROR)
) {
setCasingError(true);
} else {
setCasingError(false);
}
};
const lengthStatusBarClasses = classnames(styles.statusBar, {
[styles.statusBarSuccess]: !lengthError,
});
const numberStatusBarClasses = classnames(styles.statusBar, {
[styles.statusBarSuccess]: !numberError,
});
const symbolStatusBarClasses = classnames(styles.statusBar, {
[styles.statusBarSuccess]: !symbolError,
});
const casingStatusBarClasses = classnames(styles.statusBar, {
[styles.statusBarSuccess]: !casingError,
});
return (
<>
<Tooltip
arrow
title="Your password needs to be at least ten characters long, and include an uppercase letter, a lowercase letter, a number and a symbol to be a valid OWASP password"
>
<Typography
variant="body2"
className={styles.title}
data-loading
>
Please set a strong password
<HelpIcon className={styles.helpIcon} />
</Typography>
</Tooltip>
<div className={styles.container}>
<div className={styles.headerContainer}>
<div className={styles.checkContainer}>
<Typography variant="body2" data-loading>
Length
</Typography>
</div>
<div className={styles.checkContainer}>
<Typography variant="body2" data-loading>
Casing
</Typography>
</div>
<div className={styles.checkContainer}>
<Typography variant="body2" data-loading>
Number
</Typography>
</div>
<div className={styles.checkContainer}>
<Typography variant="body2" data-loading>
Symbol
</Typography>
</div>
</div>
<div className={styles.divider} />
<div className={styles.statusBarContainer}>
<div className={styles.checkContainer}>
<div className={lengthStatusBarClasses} data-loading />
</div>
<div className={styles.checkContainer}>
<div className={casingStatusBarClasses} data-loading />
</div>{' '}
<div className={styles.checkContainer}>
<div className={numberStatusBarClasses} data-loading />
</div>
<div className={styles.checkContainer}>
<div className={symbolStatusBarClasses} data-loading />
</div>
</div>
</div>
</>
);
};
export default PasswordChecker;

View File

@ -0,0 +1,22 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
matcherContainer: {
position: 'relative',
},
matcherIcon: {
marginRight: '5px',
},
matcher: {
position: 'absolute',
bottom: '-8px',
display: 'flex',
alignItems: 'center',
},
matcherError: {
color: theme.palette.error.main,
},
matcherSuccess: {
color: theme.palette.primary.main,
},
}));

View File

@ -0,0 +1,55 @@
import { Typography } from '@material-ui/core';
import ConditionallyRender from '../../../../common/ConditionallyRender';
import classnames from 'classnames';
import CheckIcon from '@material-ui/icons/Check';
import { useStyles } from './PasswordMatcher.styles';
interface IPasswordMatcherProps {
started: boolean;
matchingPasswords: boolean;
}
const PasswordMatcher = ({
started,
matchingPasswords,
}: IPasswordMatcherProps) => {
const styles = useStyles();
return (
<div className={styles.matcherContainer}>
<ConditionallyRender
condition={started}
show={
<ConditionallyRender
condition={matchingPasswords}
show={
<Typography
variant="body2"
data-loading
className={classnames(styles.matcher, {
[styles.matcherSuccess]: matchingPasswords,
})}
>
<CheckIcon className={styles.matcherIcon} />{' '}
Passwords match
</Typography>
}
elseShow={
<Typography
variant="body2"
data-loading
className={classnames(styles.matcher, {
[styles.matcherError]: !matchingPasswords,
})}
>
Passwords do not match
</Typography>
}
/>
}
/>
</div>
);
};
export default PasswordMatcher;

View File

@ -0,0 +1,12 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
flexDirection: 'column',
maxWidth: '300px',
},
button: {
width: '150px',
},
}));

View File

@ -0,0 +1,144 @@
import { Button, TextField } from '@material-ui/core';
import classnames from 'classnames';
import {
SyntheticEvent,
useEffect,
useState,
Dispatch,
SetStateAction,
} from 'react';
import { useHistory } from 'react-router';
import { useCommonStyles } from '../../../../common.styles';
import { OK } from '../../../../constants/statusCodes';
import ConditionallyRender from '../../../common/ConditionallyRender';
import ResetPasswordError from '../ResetPasswordError/ResetPasswordError';
import PasswordChecker from './PasswordChecker/PasswordChecker';
import PasswordMatcher from './PasswordMatcher/PasswordMatcher';
import { useStyles } from './ResetPasswordForm.styles';
import { useCallback } from 'react';
interface IResetPasswordProps {
token: string;
setLoading: Dispatch<SetStateAction<boolean>>;
}
const ResetPasswordForm = ({ token, setLoading }: IResetPasswordProps) => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const [apiError, setApiError] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [matchingPasswords, setMatchingPasswords] = useState(false);
const [validOwaspPassword, setValidOwaspPassword] = useState(false);
const history = useHistory();
const submittable = matchingPasswords && validOwaspPassword;
const setValidOwaspPasswordMemo = useCallback(setValidOwaspPassword, [
setValidOwaspPassword,
]);
useEffect(() => {
if (password === confirmPassword) {
setMatchingPasswords(true);
} else {
setMatchingPasswords(false);
}
}, [password, confirmPassword]);
const makeResetPasswordReq = () => {
return fetch('auth/reset/password', {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
token,
password,
}),
});
};
const submitResetPassword = async () => {
setLoading(true);
try {
const res = await makeResetPasswordReq();
setLoading(false);
if (res.status === OK) {
history.push('login?reset=true');
setApiError(false);
} else {
setApiError(true);
}
} catch (e) {
setApiError(true);
setLoading(false);
}
};
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
if (submittable) {
submitResetPassword();
}
};
const started = Boolean(password && confirmPassword);
return (
<>
<ConditionallyRender
condition={apiError}
show={<ResetPasswordError />}
/>
<form
onSubmit={handleSubmit}
className={classnames(
commonStyles.contentSpacingY,
styles.container
)}
>
<PasswordChecker
password={password}
callback={setValidOwaspPasswordMemo}
/>
<TextField
variant="outlined"
size="small"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
data-loading
/>
<TextField
variant="outlined"
size="small"
type="password"
value={confirmPassword}
placeholder="Confirm password"
onChange={e => setConfirmPassword(e.target.value)}
data-loading
/>
<PasswordMatcher
started={started}
matchingPasswords={matchingPasswords}
/>
<Button
variant="contained"
color="primary"
type="submit"
className={styles.button}
data-loading
disabled={!submittable}
>
Submit
</Button>
</form>
</>
);
};
export default ResetPasswordForm;

View File

@ -0,0 +1,12 @@
import { Alert, AlertTitle } from '@material-ui/lab';
const ResetPasswordSuccess = () => {
return (
<Alert severity="success">
<AlertTitle>Success</AlertTitle>
You successfully reset your password.
</Alert>
);
};
export default ResetPasswordSuccess;

View File

@ -0,0 +1,26 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
},
leftContainer: { width: '40%', minHeight: '100vh' },
rightContainer: { width: '60%', minHeight: '100vh', position: 'relative' },
menu: {
position: 'absolute',
right: '20px',
top: '20px',
'& a': {
textDecoration: 'none',
color: '#000',
},
},
title: {
fontWeight: 'bold',
fontSize: '1.2rem',
marginBottom: '1rem',
},
innerRightContainer: {
padding: '4rem 3rem',
},
}));

View File

@ -0,0 +1,53 @@
import { FC } from 'react';
import StandaloneBanner from '../../StandaloneBanner/StandaloneBanner';
import { Typography } from '@material-ui/core';
import { useStyles } from './StandaloneLayout.styles';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { Link } from 'react-router-dom';
interface IStandaloneLayout {
BannerComponent?: JSX.Element;
showMenu?: boolean;
}
const StandaloneLayout: FC<IStandaloneLayout> = ({
children,
showMenu = true,
BannerComponent,
}) => {
const styles = useStyles();
let banner = (
<StandaloneBanner title="Unleash">
<Typography variant="subtitle1">
Committed to creating new ways of developing.
</Typography>
</StandaloneBanner>
);
if (BannerComponent) {
banner = BannerComponent;
}
return (
<div className={styles.container}>
<div className={styles.leftContainer}>{banner}</div>
<div className={styles.rightContainer}>
<ConditionallyRender
condition={showMenu}
show={
<div className={styles.menu}>
<Link to="/login">Login</Link>
</div>
}
/>
<div className={styles.innerRightContainer}>{children}</div>
</div>
</div>
);
};
export default StandaloneLayout;

View File

@ -26,6 +26,7 @@ export default class ShowUserComponent extends React.Component {
componentDidMount() {
this.props.fetchUser();
// find default locale and add it in choices if not present
const locale = navigator.language || navigator.userLanguage;
let found = this.possibleLocales.find(l => l.value === locale);

View File

@ -0,0 +1,3 @@
export const BAD_REQUEST = 400;
export const OK = 200;
export const NOT_FOUND = 404;

View File

@ -0,0 +1,24 @@
import { useEffect, createRef } from 'react';
type refElement = HTMLDivElement;
const useLoading = (loading: boolean) => {
const ref = createRef<refElement>();
useEffect(() => {
if (ref.current) {
const elements = ref.current.querySelectorAll('[data-loading]');
elements.forEach(element => {
if (loading) {
element.classList.add('skeleton');
} else {
element.classList.remove('skeleton');
}
});
}
}, [loading, ref]);
return ref;
};
export default useLoading;

View File

@ -0,0 +1,7 @@
import { useLocation } from 'react-router';
const useQueryParams = () => {
return new URLSearchParams(useLocation().search);
};
export default useQueryParams;

View File

@ -0,0 +1,38 @@
import useSWR from 'swr';
import useQueryParams from './useQueryParams';
import { useState, useEffect } from 'react';
const getFetcher = (token: string) => () =>
fetch(`auth/reset/validate?token=${token}`, {
method: 'GET',
}).then(res => res.json());
const INVALID_TOKEN_ERROR = 'InvalidTokenError';
const useResetPassword = () => {
const query = useQueryParams();
const initialToken = query.get('token') || '';
const [token, setToken] = useState(initialToken);
const fetcher = getFetcher(token);
const { data, error } = useSWR(
`auth/reset/validate?token=${token}`,
fetcher
);
const [loading, setLoading] = useState(!error && !data);
const retry = () => {
const token = query.get('token') || '';
setToken(token);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
const invalidToken = !loading && data?.name === INVALID_TOKEN_ERROR;
return { token, data, error, loading, setLoading, invalidToken, retry };
};
export default useResetPassword;

View File

@ -1,4 +1,4 @@
<svg width="332" height="229" viewBox="0 0 332 229" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="332" height="239" viewBox="0 0 332 239" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.4999 200C59.4066 200 95 164.407 95 120.5C95 76.5933 59.4066 41 15.4999 41C-28.4068 41 -64 76.5933 -64 120.5C-64 164.407 -28.4068 200 15.4999 200Z" fill="#5D7A88" fill-opacity="0.75"/>
<path d="M212.2 239H-147.2C-178.973 239 -209.445 226.41 -231.911 203.999C-254.378 181.589 -267 151.193 -267 119.5C-267 87.8066 -254.378 57.4112 -231.911 35.0006C-209.445 12.5901 -178.973 0 -147.2 0H212.2C243.973 0 274.445 12.5901 296.911 35.0006C319.378 57.4112 332 87.8066 332 119.5C332 151.193 319.378 181.589 296.911 203.999C274.445 226.41 243.973 239 212.2 239ZM-147.2 15.9333C-174.737 15.9333 -201.145 26.8448 -220.616 46.2673C-240.088 65.6898 -251.027 92.0324 -251.027 119.5C-251.027 146.968 -240.088 173.31 -220.616 192.733C-201.145 212.155 -174.737 223.067 -147.2 223.067H212.2C239.737 223.067 266.145 212.155 285.617 192.733C305.088 173.31 316.027 146.968 316.027 119.5C316.027 92.0324 305.088 65.6898 285.617 46.2673C266.145 26.8448 239.737 15.9333 212.2 15.9333H-147.2Z" fill="#5D7A88" fill-opacity="0.75"/>
<path d="M212.2 239H-147.2C-178.973 239 -209.445 226.41 -231.911 203.999C-254.378 181.589 -267 151.193 -267 119.5C-267 87.8066 -254.378 57.4112 -231.911 35.0006C-209.445 12.5901 -178.973 0 -147.2 0H212.2C243.973 0 274.445 12.5901 296.911 35.0006C319.378 57.4112 332 87.8066 332 119.5C332 151.193 319.378 181.589 296.911 203.999C274.445 226.41 243.973 239 212.2 239ZM-147.2 15.9333C-174.737 15.9333 -201.145 26.8448 -220.616 46.2673C-240.088 65.6898 -251.027 92.0324 -251.027 119.5C-251.027 146.968 -240.088 173.31 -220.616 192.733C-201.145 212.155 -174.737 223.067 -147.2 223.067H212.2C239.737 223.067 266.145 212.155 285.616 192.733C305.088 173.31 316.027 146.968 316.027 119.5C316.027 92.0324 305.088 65.6898 285.616 46.2673C266.145 26.8448 239.737 15.9333 212.2 15.9333H-147.2Z" fill="#5D7A88" fill-opacity="0.75"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

10
frontend/src/interfaces/palette.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import * as createPalette from '@material-ui/core/styles/createPalette';
declare module '@material-ui/core/styles/createPalette' {
interface PaletteOptions {
borders?: PaletteColorOptions;
}
interface Palette {
borders?: PaletteColor;
}
}

View File

@ -11,10 +11,11 @@ const userStore = (state = new $Map(), action) => {
.set('authDetails', undefined);
return state;
case AUTH_REQUIRED:
state = state.set('authDetails', action.error.body).set('showDialog', true);
state = state
.set('authDetails', action.error.body)
.set('showDialog', true);
return state;
case USER_LOGOUT:
console.log("Resetting state due to logout");
return new $Map();
default:
return state;

View File

@ -85,7 +85,8 @@ const theme = createMuiTheme({
radius: { main: '3px' },
},
fontSizes: {
mainHeader: '1.1rem',
mainHeader: '1.2rem',
subHeader: '1.1rem',
},
boxShadows: {
chip: {

View File

@ -1805,6 +1805,11 @@
dependencies:
"@types/node" "*"
"@types/debounce@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192"
integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==
"@types/enzyme-adapter-react-16@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.6.tgz#8aca7ae2fd6c7137d869b6616e696d21bb8b0cec"
@ -4348,6 +4353,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
dequal@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==
des.js@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
@ -11301,6 +11311,13 @@ svgo@^1.0.0, svgo@^1.2.2:
unquote "~1.1.1"
util.promisify "~1.0.0"
swr@^0.5.5:
version "0.5.5"
resolved "https://registry.yarnpkg.com/swr/-/swr-0.5.5.tgz#c72c1615765f33570a16bbb13699e3ac87eaaa3a"
integrity sha512-u4mUorK9Ipt+6LEITvWRWiRWAQjAysI6cHxbMmMV1dIdDzxMnswWo1CyGoyBHXX91CchxcuoqgFZ/ycx+YfhCA==
dependencies:
dequal "2.0.2"
symbol-observable@1.2.0, symbol-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"