diff options
author | Anton Tananaev <anton@traccar.org> | 2022-05-08 11:37:30 -0700 |
---|---|---|
committer | Anton Tananaev <anton@traccar.org> | 2022-05-08 11:37:30 -0700 |
commit | 044733ff543156d76437daae8edb66850d785ac9 (patch) | |
tree | 7507e469449b5ffc95c7a77016e0299e07c932cc /modern/src/login | |
parent | 934d9fa416d30a24dc038e5a1e12ef3f7eaec160 (diff) | |
download | trackermap-web-044733ff543156d76437daae8edb66850d785ac9.tar.gz trackermap-web-044733ff543156d76437daae8edb66850d785ac9.tar.bz2 trackermap-web-044733ff543156d76437daae8edb66850d785ac9.zip |
Reorganize login pages
Diffstat (limited to 'modern/src/login')
-rw-r--r-- | modern/src/login/LoginLayout.js | 78 | ||||
-rw-r--r-- | modern/src/login/LoginPage.js | 170 | ||||
-rw-r--r-- | modern/src/login/RegisterPage.js | 126 | ||||
-rw-r--r-- | modern/src/login/ResetPasswordPage.js | 129 |
4 files changed, 503 insertions, 0 deletions
diff --git a/modern/src/login/LoginLayout.js b/modern/src/login/LoginLayout.js new file mode 100644 index 00000000..4a2bf43a --- /dev/null +++ b/modern/src/login/LoginLayout.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { useMediaQuery, makeStyles, Paper } from '@material-ui/core'; +import { useTheme } from '@material-ui/core/styles'; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + height: '100%', + }, + sidebar: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: theme.palette.secondary.contrastText, + background: theme.palette.primary.main, + paddingBottom: theme.spacing(5), + width: theme.dimensions.sidebarWidth, + [theme.breakpoints.down('md')]: { + width: theme.dimensions.sidebarWidthTablet, + }, + [theme.breakpoints.down('xs')]: { + width: '0px', + }, + }, + paper: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + flex: 1, + boxShadow: '-2px 0px 16px rgba(0, 0, 0, 0.25)', + [theme.breakpoints.up('lg')]: { + padding: theme.spacing(0, 25, 0, 0), + }, + }, + form: { + maxWidth: theme.spacing(52), + padding: theme.spacing(5), + width: '100%', + }, + attribution: { + position: 'absolute', + bottom: theme.spacing(1), + right: theme.spacing(1.5), + fontSize: 'x-small', + }, +})); + +const LoginLayout = ({ children }) => { + const classes = useStyles(); + const theme = useTheme(); + + return ( + <> + <main className={classes.root}> + <div className={classes.sidebar}> + {!useMediaQuery(theme.breakpoints.down('md')) + && ( + <svg height="64" width="240"> + <use xlinkHref="/logo.svg#img" /> + </svg> + )} + </div> + <Paper className={classes.paper}> + <form className={classes.form}> + { children } + </form> + </Paper> + </main> + <div className={classes.attribution}> + Powered by + <a href="https://www.traccar.org/">Traccar GPS Tracking System</a> + </div> + </> + ); +}; + +export default LoginLayout; diff --git a/modern/src/login/LoginPage.js b/modern/src/login/LoginPage.js new file mode 100644 index 00000000..64570b24 --- /dev/null +++ b/modern/src/login/LoginPage.js @@ -0,0 +1,170 @@ +import React, { useState } from 'react'; +import { + Grid, useMediaQuery, makeStyles, InputLabel, Select, MenuItem, FormControl, Button, TextField, Link, Snackbar, IconButton, Tooltip, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import CachedIcon from '@material-ui/icons/Cached'; +import { useTheme } from '@material-ui/core/styles'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { sessionActions } from '../store'; +import { useLocalization, useTranslation } from '../LocalizationProvider'; +import LoginLayout from './LoginLayout'; +import usePersistedState from '../common/usePersistedState'; + +const useStyles = makeStyles((theme) => ({ + legacy: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + }, + logoContainer: { + textAlign: 'center', + color: theme.palette.primary.main, + }, + resetPassword: { + cursor: 'pointer', + }, +})); + +const LoginPage = () => { + const classes = useStyles(); + const dispatch = useDispatch(); + const history = useHistory(); + const theme = useTheme(); + const t = useTranslation(); + + const { languages, language, setLanguage } = useLocalization(); + const languageList = Object.entries(languages).map((values) => ({ code: values[0], name: values[1].name })); + + const [failed, setFailed] = useState(false); + + const [email, setEmail] = usePersistedState('loginEmail', ''); + const [password, setPassword] = useState(''); + + const registrationEnabled = useSelector((state) => state.session.server?.registration); + const emailEnabled = useSelector((state) => state.session.server?.emailEnabled); + + const [announcementShown, setAnnouncementShown] = useState(false); + const announcement = useSelector((state) => state.session.server?.announcement); + + const handleSubmit = async (event) => { + event.preventDefault(); + const response = await fetch('/api/session', { + method: 'POST', + body: new URLSearchParams(`email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`), + }); + if (response.ok) { + const user = await response.json(); + dispatch(sessionActions.updateUser(user)); + history.push('/'); + } else { + setFailed(true); + setPassword(''); + } + }; + + const handleSpecialKey = (e) => { + if (e.keyCode === 13 && email && password) { + handleSubmit(e); + } + }; + + return ( + <LoginLayout> + <Tooltip title="Switch to Legacy App" className={classes.legacy}> + <IconButton onClick={() => window.localStorage.setItem('legacyApp', true) || window.location.replace('/')}> + <CachedIcon /> + </IconButton> + </Tooltip> + <Grid container direction="column" spacing={2}> + {useMediaQuery(theme.breakpoints.down('md')) + && ( + <Grid item className={classes.logoContainer}> + <svg height="64" width="240"> + <use xlinkHref="/logo.svg#img" /> + </svg> + </Grid> + )} + <Grid item> + <TextField + required + fullWidth + error={failed} + label={t('userEmail')} + name="email" + value={email} + autoComplete="email" + autoFocus={!email} + onChange={(e) => setEmail(e.target.value)} + onKeyUp={handleSpecialKey} + helperText={failed && 'Invalid username or password'} + variant="filled" + /> + </Grid> + <Grid item> + <TextField + required + fullWidth + error={failed} + label={t('userPassword')} + name="password" + value={password} + type="password" + autoComplete="current-password" + autoFocus={!!email} + onChange={(e) => setPassword(e.target.value)} + onKeyUp={handleSpecialKey} + variant="filled" + /> + </Grid> + <Grid item> + <Button + onClick={handleSubmit} + onKeyUp={handleSpecialKey} + variant="contained" + color="secondary" + disabled={!email || !password} + fullWidth + > + {t('loginLogin')} + </Button> + </Grid> + <Grid item container spacing={2}> + <Grid item> + <Button onClick={() => history.push('/register')} disabled={!registrationEnabled} color="secondary"> + {t('loginRegister')} + </Button> + </Grid> + <Grid item xs> + <FormControl variant="filled" fullWidth> + <InputLabel>{t('loginLanguage')}</InputLabel> + <Select value={language} onChange={(e) => setLanguage(e.target.value)}> + {languageList.map((it) => <MenuItem key={it.code} value={it.code}>{it.name}</MenuItem>)} + </Select> + </FormControl> + </Grid> + </Grid> + {emailEnabled && ( + <Grid item container justifyContent="flex-end"> + <Grid item> + <Link onClick={() => history.push('/reset-password')} className={classes.resetPassword} underline="none">{t('loginReset')}</Link> + </Grid> + </Grid> + )} + <Snackbar + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + open={!!announcement && !announcementShown} + message={announcement} + action={( + <IconButton size="small" color="inherit" onClick={() => setAnnouncementShown(true)}> + <CloseIcon fontSize="small" /> + </IconButton> + )} + /> + </Grid> + </LoginLayout> + ); +}; + +export default LoginPage; diff --git a/modern/src/login/RegisterPage.js b/modern/src/login/RegisterPage.js new file mode 100644 index 00000000..f622f679 --- /dev/null +++ b/modern/src/login/RegisterPage.js @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { + Grid, Button, TextField, Typography, Link, makeStyles, Snackbar, +} from '@material-ui/core'; +import { useHistory } from 'react-router-dom'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import LoginLayout from './LoginLayout'; +import { useTranslation } from '../LocalizationProvider'; +import { snackBarDurationShortMs } from '../common/duration'; + +const useStyles = makeStyles((theme) => ({ + title: { + fontSize: theme.spacing(3), + fontWeight: 500, + marginLeft: theme.spacing(2), + textTransform: 'uppercase', + }, + link: { + fontSize: theme.spacing(3), + fontWeight: 500, + marginTop: theme.spacing(0.5), + cursor: 'pointer', + }, +})); + +const RegisterPage = () => { + const classes = useStyles(); + const history = useHistory(); + const t = useTranslation(); + + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const handleSubmit = async () => { + const response = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }), + }); + if (response.ok) { + setSnackbarOpen(true); + } + }; + + return ( + <LoginLayout> + <Snackbar + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + open={snackbarOpen} + onClose={() => history.push('/login')} + autoHideDuration={snackBarDurationShortMs} + message={t('loginCreated')} + /> + <Grid container direction="column" spacing={2}> + <Grid container item> + <Grid item> + <Typography className={classes.link} color="primary"> + <Link onClick={() => history.push('/login')}> + <ArrowBackIcon /> + </Link> + </Typography> + </Grid> + <Grid item xs> + <Typography className={classes.title} color="primary"> + {t('loginRegister')} + </Typography> + </Grid> + </Grid> + <Grid item> + <TextField + required + fullWidth + label={t('sharedName')} + name="name" + value={name} + autoComplete="name" + autoFocus + onChange={(event) => setName(event.target.value)} + variant="filled" + /> + </Grid> + <Grid item> + <TextField + required + fullWidth + type="email" + label={t('userEmail')} + name="email" + value={email} + autoComplete="email" + onChange={(event) => setEmail(event.target.value)} + variant="filled" + /> + </Grid> + <Grid item> + <TextField + required + fullWidth + label={t('userPassword')} + name="password" + value={password} + type="password" + autoComplete="current-password" + onChange={(event) => setPassword(event.target.value)} + variant="filled" + /> + </Grid> + <Grid item> + <Button + variant="contained" + color="secondary" + onClick={handleSubmit} + disabled={!name || !/(.+)@(.+)\.(.{2,})/.test(email) || !password} + fullWidth + > + {t('loginRegister')} + </Button> + </Grid> + </Grid> + </LoginLayout> + ); +}; + +export default RegisterPage; diff --git a/modern/src/login/ResetPasswordPage.js b/modern/src/login/ResetPasswordPage.js new file mode 100644 index 00000000..27c30763 --- /dev/null +++ b/modern/src/login/ResetPasswordPage.js @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { + Grid, Button, TextField, Typography, Link, makeStyles, Snackbar, +} from '@material-ui/core'; +import { useHistory } from 'react-router-dom'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import LoginLayout from './LoginLayout'; +import { useTranslation } from '../LocalizationProvider'; +import useQuery from '../common/useQuery'; +import { snackBarDurationShortMs } from '../common/duration'; + +const useStyles = makeStyles((theme) => ({ + title: { + fontSize: theme.spacing(3), + fontWeight: 500, + marginLeft: theme.spacing(2), + textTransform: 'uppercase', + }, + link: { + fontSize: theme.spacing(3), + fontWeight: 500, + marginTop: theme.spacing(0.5), + cursor: 'pointer', + }, +})); + +const ResetPasswordPage = () => { + const classes = useStyles(); + const history = useHistory(); + const t = useTranslation(); + const query = useQuery(); + + const token = query.get('passwordReset'); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + let response; + if (!token) { + response = await fetch('/api/password/reset', { + method: 'POST', + body: new URLSearchParams(`email=${encodeURIComponent(email)}`), + }); + } else { + response = await fetch('/api/password/update', { + method: 'POST', + body: new URLSearchParams(`token=${encodeURIComponent(token)}&password=${encodeURIComponent(password)}`), + }); + } + if (response.ok) { + setSnackbarOpen(true); + } + }; + + return ( + <LoginLayout> + <Snackbar + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + open={snackbarOpen} + onClose={() => history.push('/login')} + autoHideDuration={snackBarDurationShortMs} + message={!token ? t('loginResetSuccess') : t('loginUpdateSuccess')} + /> + <Grid container direction="column" spacing={2}> + <Grid container item> + <Grid item> + <Typography className={classes.link} color="primary"> + <Link onClick={() => history.push('/login')}> + <ArrowBackIcon /> + </Link> + </Typography> + </Grid> + <Grid item xs> + <Typography className={classes.title} color="primary"> + {t('loginReset')} + </Typography> + </Grid> + </Grid> + {!token + ? ( + <Grid item> + <TextField + required + fullWidth + type="email" + label={t('userEmail')} + name="email" + value={email} + autoComplete="email" + onChange={(event) => setEmail(event.target.value)} + variant="filled" + /> + </Grid> + ) + : ( + <Grid item> + <TextField + required + fullWidth + label={t('userPassword')} + name="password" + value={password} + type="password" + autoComplete="current-password" + onChange={(event) => setPassword(event.target.value)} + variant="filled" + /> + </Grid> + )} + <Grid item> + <Button + variant="contained" + color="secondary" + onClick={handleSubmit} + disabled={!/(.+)@(.+)\.(.{2,})/.test(email) && !password} + fullWidth + > + {t('loginReset')} + </Button> + </Grid> + </Grid> + </LoginLayout> + ); +}; + +export default ResetPasswordPage; |