diff --git a/frontend/package.json b/frontend/package.json index d702412521..fec0e13bd3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/app.css b/frontend/src/app.css index e2ddd4ad8c..74a2fb9e6b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } diff --git a/frontend/src/common.styles.js b/frontend/src/common.styles.js index ce0e58be24..cd3bcf0b82 100644 --- a/frontend/src/common.styles.js +++ b/frontend/src/common.styles.js @@ -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', + }, })); diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx index 5431bd8d60..45156449a4 100644 --- a/frontend/src/component/App.tsx +++ b/frontend/src/component/App.tsx @@ -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) => { )} /> diff --git a/frontend/src/component/common/ConditionallyRender/ConditionallyRender.jsx b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.jsx deleted file mode 100644 index 8e74dec4a0..0000000000 --- a/frontend/src/component/common/ConditionallyRender/ConditionallyRender.jsx +++ /dev/null @@ -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; diff --git a/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx new file mode 100644 index 0000000000..88399c9c29 --- /dev/null +++ b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx @@ -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; diff --git a/frontend/src/component/common/Gradient/Gradient.tsx b/frontend/src/component/common/Gradient/Gradient.tsx new file mode 100644 index 0000000000..136570d741 --- /dev/null +++ b/frontend/src/component/common/Gradient/Gradient.tsx @@ -0,0 +1,26 @@ +interface IGradientProps { + from: string; + to: string; +} + +const Gradient: React.FC = ({ + children, + from, + to, + ...rest +}) => { + return ( +
+ {children} +
+ ); +}; + +export default Gradient; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx index e83f9e688a..d3e7d7fabf 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleList.jsx @@ -152,7 +152,6 @@ const FeatureToggleList = ({ diff --git a/frontend/src/component/feature/view/metric-component.jsx b/frontend/src/component/feature/view/metric-component.jsx index 04d8b361ed..08ae96543a 100644 --- a/frontend/src/component/feature/view/metric-component.jsx +++ b/frontend/src/component/feature/view/metric-component.jsx @@ -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 (
- + Last minute -
Yes {lastMinute.yes}, No: {lastMinute.no} +
Yes {lastMinute.yes}, No:{' '} + {lastMinute.no}

} />
- + No metrics available

} @@ -111,13 +123,20 @@ export default class MetricComponent extends React.Component { } elseShow={
- + report problem
- Not used in an app in the last hour. - This might be due to your client implementation not reporting usage. + + Not used in an app in the last + hour.{' '} + + This might be due to your client + implementation not reporting usage.
@@ -131,14 +150,20 @@ export default class MetricComponent extends React.Component { show={ <> Created: - {this.formatFullDateTime(featureToggle.createdAt)} + + {this.formatFullDateTime( + featureToggle.createdAt + )} + } />
Last seen: - {this.renderLastSeen(featureToggle.lastSeenAt)} + + {this.renderLastSeen(featureToggle.lastSeenAt)} +
diff --git a/frontend/src/component/layout/LayoutPicker/LayoutPicker.jsx b/frontend/src/component/layout/LayoutPicker/LayoutPicker.jsx index 7a9517ef6f..00b51aeb67 100644 --- a/frontend/src/component/layout/LayoutPicker/LayoutPicker.jsx +++ b/frontend/src/component/layout/LayoutPicker/LayoutPicker.jsx @@ -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 ( {children}} /> diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap index 5cf0b5802c..71f71dd650 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes-test.jsx.snap @@ -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", - }, -] -`; diff --git a/frontend/src/component/menu/__tests__/routes-test.jsx b/frontend/src/component/menu/__tests__/routes-test.jsx index 44effad700..560e08691c 100644 --- a/frontend/src/component/menu/__tests__/routes-test.jsx +++ b/frontend/src/component/menu/__tests__/routes-test.jsx @@ -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); diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js index 9bf18d5928..554b3ff6db 100644 --- a/frontend/src/component/menu/routes.js +++ b/frontend/src/component/menu/routes.js @@ -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); diff --git a/frontend/src/component/user/ForgottenPassword/ForgottenPassword.styles.ts b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.styles.ts new file mode 100644 index 0000000000..19f7e3d5d8 --- /dev/null +++ b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.styles.ts @@ -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', + }, +}); diff --git a/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx new file mode 100644 index 0000000000..d2027c58b4 --- /dev/null +++ b/frontend/src/component/user/ForgottenPassword/ForgottenPassword.tsx @@ -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 ( + +
+ + Forgotten password + + + Attempted to send email + We've attempted to send a reset password email to: + + {attemptedEmail} + + 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. + + } + /> +
+ + Please provide your email address. If it exists in the + system we'll send a new reset link. + + { + setEmail(e.target.value); + }} + /> + + +
+
+ ); +}; + +export default ForgottenPassword; diff --git a/frontend/src/component/user/Login/Login.jsx b/frontend/src/component/user/Login/Login.jsx index c473e8c551..1a1dff1be9 100644 --- a/frontend/src/component/user/Login/Login.jsx +++ b/frontend/src/component/user/Login/Login.jsx @@ -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 ( -
-
-
-

- Unleash -

- - Committed to creating new ways of developing - - - -
- } - /> -
-
-

Login

-
- -
+ +
+

Login

+ } + /> +
+
-
+ ); }; diff --git a/frontend/src/component/user/Login/Login.styles.js b/frontend/src/component/user/Login/Login.styles.js index 8db086628f..39068eb550 100644 --- a/frontend/src/component/user/Login/Login.styles.js +++ b/frontend/src/component/user/Login/Login.styles.js @@ -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', diff --git a/frontend/src/component/user/NewUser/NewUser.styles.ts b/frontend/src/component/user/NewUser/NewUser.styles.ts new file mode 100644 index 0000000000..9b9f1d8c56 --- /dev/null +++ b/frontend/src/component/user/NewUser/NewUser.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/user/NewUser/NewUser.tsx b/frontend/src/component/user/NewUser/NewUser.tsx new file mode 100644 index 0000000000..8b2596c340 --- /dev/null +++ b/frontend/src/component/user/NewUser/NewUser.tsx @@ -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 ( +
+ + + You have been invited by {data?.createdBy} + + } + /> + + } + > + } + elseShow={ + + + Your username is + + +
+ + In Unleash your role is:{' '} + {data?.role?.name} + + + {data?.role?.description} + +
+ + Set a password for your account. + +
+ + } + /> + +
+ ); +}; + +export default NewUser; diff --git a/frontend/src/component/user/ResetPassword/ResetPassword.styles.ts b/frontend/src/component/user/ResetPassword/ResetPassword.styles.ts new file mode 100644 index 0000000000..11c61655fd --- /dev/null +++ b/frontend/src/component/user/ResetPassword/ResetPassword.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/user/ResetPassword/ResetPassword.tsx b/frontend/src/component/user/ResetPassword/ResetPassword.tsx new file mode 100644 index 0000000000..2e2ed35950 --- /dev/null +++ b/frontend/src/component/user/ResetPassword/ResetPassword.tsx @@ -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 ( +
+ + } + elseShow={ + + + Reset password + + + } + /> + +
+ ); +}; + +export default ResetPassword; diff --git a/frontend/src/component/user/StandaloneBanner/StandaloneBanner.styles.ts b/frontend/src/component/user/StandaloneBanner/StandaloneBanner.styles.ts new file mode 100644 index 0000000000..ec44c01a72 --- /dev/null +++ b/frontend/src/component/user/StandaloneBanner/StandaloneBanner.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/user/StandaloneBanner/StandaloneBanner.tsx b/frontend/src/component/user/StandaloneBanner/StandaloneBanner.tsx new file mode 100644 index 0000000000..23a8bed3d4 --- /dev/null +++ b/frontend/src/component/user/StandaloneBanner/StandaloneBanner.tsx @@ -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 = ({ + showStars = false, + title, + children, +}) => { + const theme = useTheme(); + const styles = useStyles(); + return ( + +
+ + {title} + + {children} + + + + + + + + + } + /> +
+ +
+ +

+ +
+
+ ); +}; + +export default StandaloneBanner; diff --git a/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx b/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx new file mode 100644 index 0000000000..712a30c0af --- /dev/null +++ b/frontend/src/component/user/common/InvalidToken/InvalidToken.tsx @@ -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 ( +
+ + Invalid token + + + 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. + + +
+ ); +}; + +export default InvalidToken; diff --git a/frontend/src/component/user/common/ResetPasswordDetails/ResetPasswordDetails.tsx b/frontend/src/component/user/common/ResetPasswordDetails/ResetPasswordDetails.tsx new file mode 100644 index 0000000000..aee5a111da --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordDetails/ResetPasswordDetails.tsx @@ -0,0 +1,22 @@ +import { FC, Dispatch, SetStateAction } from 'react'; +import ResetPasswordForm from '../ResetPasswordForm/ResetPasswordForm'; + +interface IResetPasswordDetails { + token: string; + setLoading: Dispatch>; +} + +const ResetPasswordDetails: FC = ({ + children, + token, + setLoading, +}) => { + return ( +
+ {children} + +
+ ); +}; + +export default ResetPasswordDetails; diff --git a/frontend/src/component/user/common/ResetPasswordError/ResetPasswordError.tsx b/frontend/src/component/user/common/ResetPasswordError/ResetPasswordError.tsx new file mode 100644 index 0000000000..876498f242 --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordError/ResetPasswordError.tsx @@ -0,0 +1,14 @@ +import { Alert, AlertTitle } from '@material-ui/lab'; + +const ResetPasswordError = () => { + return ( + + Unable to reset password + 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. + + ); +}; + +export default ResetPasswordError; diff --git a/frontend/src/component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker.styles.ts b/frontend/src/component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker.styles.ts new file mode 100644 index 0000000000..736bbd3290 --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker.tsx b/frontend/src/component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker.tsx new file mode 100644 index 0000000000..82fb61fab7 --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker.tsx @@ -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>; +} + +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 ( + <> + + + Please set a strong password + + + +
+
+
+ + Length + +
+
+ + Casing + +
+
+ + Number + +
+
+ + Symbol + +
+
+
+
+
+
+
+
+
+
{' '} +
+
+
+
+
+
+
+
+ + ); +}; + +export default PasswordChecker; diff --git a/frontend/src/component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher.styles.ts b/frontend/src/component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher.styles.ts new file mode 100644 index 0000000000..0b112d46dd --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher.styles.ts @@ -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, + }, +})); diff --git a/frontend/src/component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher.tsx b/frontend/src/component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher.tsx new file mode 100644 index 0000000000..82f0de09a8 --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher.tsx @@ -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 ( +
+ + {' '} + Passwords match + + } + elseShow={ + + Passwords do not match + + } + /> + } + /> +
+ ); +}; + +export default PasswordMatcher; diff --git a/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.styles.ts b/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.styles.ts new file mode 100644 index 0000000000..919ddf58c8 --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx b/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx new file mode 100644 index 0000000000..474e313fdc --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordForm/ResetPasswordForm.tsx @@ -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>; +} + +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 ( + <> + } + /> +
+ + setPassword(e.target.value)} + data-loading + /> + setConfirmPassword(e.target.value)} + data-loading + /> + + + + + ); +}; + +export default ResetPasswordForm; diff --git a/frontend/src/component/user/common/ResetPasswordSuccess/ResetPasswordSuccess.tsx b/frontend/src/component/user/common/ResetPasswordSuccess/ResetPasswordSuccess.tsx new file mode 100644 index 0000000000..0202b8c8c0 --- /dev/null +++ b/frontend/src/component/user/common/ResetPasswordSuccess/ResetPasswordSuccess.tsx @@ -0,0 +1,12 @@ +import { Alert, AlertTitle } from '@material-ui/lab'; + +const ResetPasswordSuccess = () => { + return ( + + Success + You successfully reset your password. + + ); +}; + +export default ResetPasswordSuccess; diff --git a/frontend/src/component/user/common/StandaloneLayout/StandaloneLayout.styles.ts b/frontend/src/component/user/common/StandaloneLayout/StandaloneLayout.styles.ts new file mode 100644 index 0000000000..78e96e5304 --- /dev/null +++ b/frontend/src/component/user/common/StandaloneLayout/StandaloneLayout.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/user/common/StandaloneLayout/StandaloneLayout.tsx b/frontend/src/component/user/common/StandaloneLayout/StandaloneLayout.tsx new file mode 100644 index 0000000000..14938b8300 --- /dev/null +++ b/frontend/src/component/user/common/StandaloneLayout/StandaloneLayout.tsx @@ -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 = ({ + children, + showMenu = true, + BannerComponent, +}) => { + const styles = useStyles(); + + let banner = ( + + + Committed to creating new ways of developing. + + + ); + + if (BannerComponent) { + banner = BannerComponent; + } + + return ( +
+
{banner}
+
+ + Login +
+ } + /> + +
{children}
+
+
+ ); +}; + +export default StandaloneLayout; diff --git a/frontend/src/component/user/show-user-component.jsx b/frontend/src/component/user/show-user-component.jsx index 0f9872f9f4..223fafafcc 100644 --- a/frontend/src/component/user/show-user-component.jsx +++ b/frontend/src/component/user/show-user-component.jsx @@ -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); diff --git a/frontend/src/constants/statusCodes.ts b/frontend/src/constants/statusCodes.ts new file mode 100644 index 0000000000..aeb843466f --- /dev/null +++ b/frontend/src/constants/statusCodes.ts @@ -0,0 +1,3 @@ +export const BAD_REQUEST = 400; +export const OK = 200; +export const NOT_FOUND = 404; diff --git a/frontend/src/hooks/useLoading.ts b/frontend/src/hooks/useLoading.ts new file mode 100644 index 0000000000..ba76b95753 --- /dev/null +++ b/frontend/src/hooks/useLoading.ts @@ -0,0 +1,24 @@ +import { useEffect, createRef } from 'react'; + +type refElement = HTMLDivElement; + +const useLoading = (loading: boolean) => { + const ref = createRef(); + 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; diff --git a/frontend/src/hooks/useQueryParams.ts b/frontend/src/hooks/useQueryParams.ts new file mode 100644 index 0000000000..bfda0bf79f --- /dev/null +++ b/frontend/src/hooks/useQueryParams.ts @@ -0,0 +1,7 @@ +import { useLocation } from 'react-router'; + +const useQueryParams = () => { + return new URLSearchParams(useLocation().search); +}; + +export default useQueryParams; diff --git a/frontend/src/hooks/useResetPassword.ts b/frontend/src/hooks/useResetPassword.ts new file mode 100644 index 0000000000..88c1b9c854 --- /dev/null +++ b/frontend/src/hooks/useResetPassword.ts @@ -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; diff --git a/frontend/src/icons/toggleLeft.svg b/frontend/src/icons/toggleLeft.svg index a882d3603a..d4e9d2ae49 100644 --- a/frontend/src/icons/toggleLeft.svg +++ b/frontend/src/icons/toggleLeft.svg @@ -1,4 +1,4 @@ - + - + diff --git a/frontend/src/interfaces/palette.d.ts b/frontend/src/interfaces/palette.d.ts new file mode 100644 index 0000000000..f173071c66 --- /dev/null +++ b/frontend/src/interfaces/palette.d.ts @@ -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; + } +} diff --git a/frontend/src/store/user/index.js b/frontend/src/store/user/index.js index 7276e53c29..b1f3ceb7e5 100644 --- a/frontend/src/store/user/index.js +++ b/frontend/src/store/user/index.js @@ -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; diff --git a/frontend/src/themes/main-theme.js b/frontend/src/themes/main-theme.js index 9132cc285b..756b660817 100644 --- a/frontend/src/themes/main-theme.js +++ b/frontend/src/themes/main-theme.js @@ -85,7 +85,8 @@ const theme = createMuiTheme({ radius: { main: '3px' }, }, fontSizes: { - mainHeader: '1.1rem', + mainHeader: '1.2rem', + subHeader: '1.1rem', }, boxShadows: { chip: { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5830272ce9..bbc64f221d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"