mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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:
		
							parent
							
								
									3cca2513fb
								
							
						
					
					
						commit
						524936912d
					
				| @ -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": { | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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', | ||||
|     }, | ||||
| })); | ||||
|  | ||||
| @ -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} | ||||
|                     /> | ||||
|                 )} | ||||
|             /> | ||||
|  | ||||
| @ -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; | ||||
| @ -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; | ||||
							
								
								
									
										26
									
								
								frontend/src/component/common/Gradient/Gradient.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/component/common/Gradient/Gradient.tsx
									
									
									
									
									
										Normal 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; | ||||
| @ -152,7 +152,6 @@ const FeatureToggleList = ({ | ||||
|                                                 <Button | ||||
|                                                     to="/features/create" | ||||
|                                                     data-test="add-feature-btn" | ||||
|                                                     size="large" | ||||
|                                                     color="secondary" | ||||
|                                                     variant="contained" | ||||
|                                                     component={Link} | ||||
|  | ||||
| @ -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} </span> | ||||
|                 <Link | ||||
|                     to={featureUrl} | ||||
|                     className={classnames( | ||||
|                         commonStyles.listLink, | ||||
|                         commonStyles.truncate | ||||
|                     )} | ||||
|                 > | ||||
|                     <span className={commonStyles.toggleName}> | ||||
|                         {name}  | ||||
|                     </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> | ||||
|  | ||||
| @ -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]} | ||||
|  | ||||
| @ -74,7 +74,7 @@ const FeatureView = ({ | ||||
|             } | ||||
|         } | ||||
|         // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|     }, [features]); | ||||
|     }, []); | ||||
| 
 | ||||
|     const getTabComponent = key => { | ||||
|         switch (key) { | ||||
|  | ||||
							
								
								
									
										153
									
								
								frontend/src/component/feature/ProgressWheel.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								frontend/src/component/feature/ProgressWheel.jsx
									
									
									
									
									
										Normal 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; | ||||
| @ -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(); | ||||
| }); | ||||
|  | ||||
| @ -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; | ||||
| @ -1,11 +1,11 @@ | ||||
| .path { | ||||
|     stroke: currentColor; | ||||
|     stroke: #5eb89b; | ||||
|     stroke-linecap: round; | ||||
|     transition: stroke-dashoffset 5s ease 0s; | ||||
| } | ||||
| 
 | ||||
| .trail { | ||||
|     stroke: currentColor; | ||||
|     stroke: #888888; | ||||
| } | ||||
| 
 | ||||
| .text { | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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>} | ||||
|         /> | ||||
|  | ||||
| @ -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", | ||||
|   }, | ||||
| ] | ||||
| `; | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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', | ||||
|     }, | ||||
| }); | ||||
| @ -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; | ||||
| @ -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> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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', | ||||
|  | ||||
							
								
								
									
										29
									
								
								frontend/src/component/user/NewUser/NewUser.styles.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/component/user/NewUser/NewUser.styles.ts
									
									
									
									
									
										Normal 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', | ||||
|     }, | ||||
| })); | ||||
							
								
								
									
										94
									
								
								frontend/src/component/user/NewUser/NewUser.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								frontend/src/component/user/NewUser/NewUser.tsx
									
									
									
									
									
										Normal 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; | ||||
| @ -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', | ||||
|     }, | ||||
| })); | ||||
							
								
								
									
										43
									
								
								frontend/src/component/user/ResetPassword/ResetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/component/user/ResetPassword/ResetPassword.tsx
									
									
									
									
									
										Normal 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; | ||||
| @ -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', | ||||
|     }, | ||||
| })); | ||||
| @ -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; | ||||
| @ -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; | ||||
| @ -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; | ||||
| @ -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; | ||||
| @ -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', | ||||
|     }, | ||||
| })); | ||||
| @ -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; | ||||
| @ -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, | ||||
|     }, | ||||
| })); | ||||
| @ -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; | ||||
| @ -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', | ||||
|     }, | ||||
| })); | ||||
| @ -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; | ||||
| @ -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; | ||||
| @ -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', | ||||
|     }, | ||||
| })); | ||||
| @ -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; | ||||
| @ -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); | ||||
|  | ||||
							
								
								
									
										3
									
								
								frontend/src/constants/statusCodes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								frontend/src/constants/statusCodes.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| export const BAD_REQUEST = 400; | ||||
| export const OK = 200; | ||||
| export const NOT_FOUND = 404; | ||||
							
								
								
									
										24
									
								
								frontend/src/hooks/useLoading.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/hooks/useLoading.ts
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										7
									
								
								frontend/src/hooks/useQueryParams.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/hooks/useQueryParams.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| import { useLocation } from 'react-router'; | ||||
| 
 | ||||
| const useQueryParams = () => { | ||||
|     return new URLSearchParams(useLocation().search); | ||||
| }; | ||||
| 
 | ||||
| export default useQueryParams; | ||||
							
								
								
									
										38
									
								
								frontend/src/hooks/useResetPassword.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/hooks/useResetPassword.ts
									
									
									
									
									
										Normal 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; | ||||
| @ -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
									
								
							
							
						
						
									
										10
									
								
								frontend/src/interfaces/palette.d.ts
									
									
									
									
										vendored
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
| @ -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; | ||||
|  | ||||
| @ -85,7 +85,8 @@ const theme = createMuiTheme({ | ||||
|         radius: { main: '3px' }, | ||||
|     }, | ||||
|     fontSizes: { | ||||
|         mainHeader: '1.1rem', | ||||
|         mainHeader: '1.2rem', | ||||
|         subHeader: '1.1rem', | ||||
|     }, | ||||
|     boxShadows: { | ||||
|         chip: { | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user