Refactor registration and login and improve routing
Changes:
- Improve registration and login pages
- Extract Home component from App.tsx file into its own
- Make Home component display registration or login
- Extract routes from App component and refactor routing
GitLab: #12
Change-Id: I68b01890781308282072b6dcf5e6df0d54837b4a
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
new file mode 100644
index 0000000..8a5e3d6
--- /dev/null
+++ b/client/src/pages/Home.tsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { Box, Grid, Paper, useMediaQuery } from '@mui/material';
+import { Theme, useTheme } from '@mui/material/styles';
+import { useState } from 'react';
+
+import JamiWelcomeLogo from '../components/JamiWelcomeLogo';
+import JamiLogin from './JamiLogin';
+import JamiRegistration from './JamiRegistration';
+
+const borderRadius = 30;
+
+export default function Home() {
+ const theme: Theme = useTheme();
+ const [isRegistrationDisplayed, setIsRegistrationDisplayed] = useState<boolean>(false);
+
+ const child = !isRegistrationDisplayed ? (
+ <JamiLogin register={() => setIsRegistrationDisplayed(true)} />
+ ) : (
+ <JamiRegistration login={() => setIsRegistrationDisplayed(false)} />
+ );
+
+ const isDesktopOrLaptop: boolean = useMediaQuery(theme.breakpoints.up('md'));
+ const isMobile: boolean = useMediaQuery(theme.breakpoints.only('xs'));
+
+ return (
+ <Box
+ sx={{
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ backgroundColor: `${isDesktopOrLaptop ? theme.palette.primary.dark : 'white'}`,
+ }}
+ >
+ <Paper
+ elevation={10}
+ sx={{
+ width: '100%',
+ backgroundColor: 'white',
+ margin: `${isDesktopOrLaptop ? theme.typography.pxToRem(100) : 0}`,
+ borderRadius: `${isDesktopOrLaptop ? theme.typography.pxToRem(borderRadius) : 0}`,
+ }}
+ >
+ <Grid container spacing={0} sx={{ height: '100%' }}>
+ {!isMobile && (
+ <Grid
+ item
+ xs={6}
+ id="logo"
+ sx={{
+ height: '100%',
+ backgroundColor: theme.palette.secondary.main,
+ borderRadius: `${
+ isDesktopOrLaptop
+ ? `${theme.typography.pxToRem(borderRadius)} 0 0 ${theme.typography.pxToRem(borderRadius)}`
+ : 0
+ }`, // To follow paper border-radius
+ }}
+ >
+ <JamiWelcomeLogo logoWidth="90%" sx={{ height: '100%' }} />
+ </Grid>
+ )}
+ <Grid item xs={isMobile ? 12 : 6} sx={{ height: '100%' }}>
+ {isMobile && (
+ <JamiWelcomeLogo
+ logoWidth={theme.typography.pxToRem(100)}
+ logoHeight={theme.typography.pxToRem(100)}
+ sx={{ mt: theme.typography.pxToRem(30), mb: theme.typography.pxToRem(20) }}
+ />
+ )}
+ <Box className="home-child" sx={{ height: `${isMobile ? 'auto' : '100%'}` }}>
+ {child}
+ </Box>
+ </Grid>
+ </Grid>
+ </Paper>
+ </Box>
+ );
+}
diff --git a/client/src/pages/JamiAccountCreation.tsx b/client/src/pages/JamiAccountCreation.tsx
deleted file mode 100644
index da80533..0000000
--- a/client/src/pages/JamiAccountCreation.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program. If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { AddRounded } from '@mui/icons-material';
-import { Box, Card, CardActions, CardContent, Container, Fab, Typography } from '@mui/material';
-import { FormEvent, useState } from 'react';
-import { useNavigate } from 'react-router';
-
-import authManager from '../AuthManager';
-import UsernameChooser from '../components/UsernameChooser';
-
-export default function JamiAccountDialog() {
- const [name, setName] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(false);
- const navigate = useNavigate();
-
- const onSubmit = async (event: FormEvent) => {
- event.preventDefault();
- setLoading(true);
- const result = await authManager
- .fetch('/api/accounts', {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ registerName: name }),
- })
- .then((res) => res.json())
- .catch((error) => {
- setLoading(false);
- setError(error);
- });
- console.log(result);
- if (result && result.accountId) navigate(`/account/${result.accountId}/settings`);
- };
-
- return (
- <Container>
- <Card component="form" onSubmit={onSubmit}>
- <CardContent>
- <Typography gutterBottom variant="h5" component="h2">
- Create Jami account
- </Typography>
- <Typography variant="body2" color="textSecondary" component="p">
- Welcome to the Jami web node setup.
- <br />
- Let's start by creating a new administrator account to control access to the server configuration.
- </Typography>
-
- <Box>
- <UsernameChooser disabled={loading} setName={setName} />
- </Box>
- </CardContent>
- <CardActions>
- {error && <Typography color="error">Error: {JSON.stringify(error)}</Typography>}
- <Fab color="primary" type="submit" variant="extended" disabled={!name || loading}>
- <AddRounded />
- Register name
- </Fab>
- </CardActions>
- </Card>
- </Container>
- );
-}
diff --git a/client/src/pages/JamiLogin.tsx b/client/src/pages/JamiLogin.tsx
new file mode 100644
index 0000000..66fcf3e
--- /dev/null
+++ b/client/src/pages/JamiLogin.tsx
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { Box, Button, Stack, Typography, useMediaQuery } from '@mui/material';
+import { Theme, useTheme } from '@mui/material/styles';
+import { ChangeEvent, FormEvent, MouseEvent, useState } from 'react';
+import { Form } from 'react-router-dom';
+
+import { PasswordInput, UsernameInput } from '../components/Input';
+import ProcessingRequest from '../components/ProcessingRequest';
+import { inputWidth } from '../utils/constants';
+
+type JamiLoginProps = {
+ register: () => void;
+};
+
+export default function JamiLogin(props: JamiLoginProps) {
+ const theme: Theme = useTheme();
+ const [username, setUsername] = useState<string>('');
+ const [password, setPassword] = useState<string>('');
+ const [isLoggingInUser, setIsLoggingInUser] = useState<boolean>(false);
+
+ const handleUsername = (event: ChangeEvent<HTMLInputElement>) => {
+ setUsername(event.target.value);
+ };
+
+ const handlePassword = (event: ChangeEvent<HTMLInputElement>) => {
+ setPassword(event.target.value);
+ };
+
+ const register = (event: MouseEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ props.register();
+ };
+
+ const authenticateUser = async (event: FormEvent) => {
+ event.preventDefault();
+ if (username.length > 0 && password.length > 0) {
+ setIsLoggingInUser(true);
+
+ // TODO: Replace with login logic (https://git.jami.net/savoirfairelinux/jami-web/-/issues/75).
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ console.log('Login');
+ setIsLoggingInUser(false);
+ }
+ };
+
+ const isMobile: boolean = useMediaQuery(theme.breakpoints.only('xs'));
+
+ return (
+ <>
+ <ProcessingRequest open={isLoggingInUser} />
+
+ <Stack
+ sx={{
+ minHeight: `${isMobile ? 'auto' : '100%'}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+ <Box sx={{ mt: theme.typography.pxToRem(50), mb: theme.typography.pxToRem(20) }}>
+ <Typography component={'span'} variant="h2">
+ LOGIN
+ </Typography>
+ </Box>
+
+ <Form method="post" id="login-form">
+ <div>
+ <UsernameInput
+ onChange={handleUsername}
+ tooltipTitle={'The username you registered with'}
+ sx={{ width: theme.typography.pxToRem(inputWidth) }}
+ />
+ </div>
+ <div>
+ <PasswordInput
+ onChange={handlePassword}
+ tooltipTitle={'The password you registered with'}
+ sx={{ width: theme.typography.pxToRem(inputWidth) }}
+ />
+ </div>
+
+ <Button
+ variant="contained"
+ type="submit"
+ onClick={authenticateUser}
+ sx={{ width: theme.typography.pxToRem(inputWidth), mt: theme.typography.pxToRem(20) }}
+ >
+ LOG IN
+ </Button>
+ </Form>
+
+ <Box sx={{ mt: theme.typography.pxToRem(50), mb: theme.typography.pxToRem(50) }}>
+ <Typography variant="body1">
+ Need an account ?
+ <a href="" onClick={register}>
+ REGISTER
+ </a>
+ </Typography>
+ </Box>
+ </Stack>
+ </>
+ );
+}
diff --git a/client/src/pages/JamiRegistration.tsx b/client/src/pages/JamiRegistration.tsx
new file mode 100644
index 0000000..ebfc5fd
--- /dev/null
+++ b/client/src/pages/JamiRegistration.tsx
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { Box, Button, Stack, Typography, useMediaQuery } from '@mui/material';
+import { Theme, useTheme } from '@mui/material/styles';
+import { ChangeEvent, FormEvent, MouseEvent, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form } from 'react-router-dom';
+
+import { PasswordInput, UsernameInput } from '../components/Input';
+import ProcessingRequest from '../components/ProcessingRequest';
+import { checkPasswordStrength, isNameRegistered, StrengthValueCode } from '../utils/auth';
+import { inputWidth, jamiUsernamePattern } from '../utils/constants';
+
+const usernameTooltipTitle =
+ 'Choose a password hard to guess for others but easy to remember for you, ' +
+ 'it must be at least 8 characters. ' +
+ "Your account won't be recovered if you forget it!\n\n" +
+ 'Click for more details';
+
+const passwordTooltipTitle =
+ 'Username may be from 3 to 32 chraracters long and contain a-z, A-Z, -, _\n\n' + 'Click for more details';
+
+type NameStatus = 'default' | 'success' | 'taken' | 'invalid' | 'registration_failed';
+type PasswordStatus = StrengthValueCode | 'registration_failed';
+
+type JamiRegistrationProps = {
+ login: () => void;
+};
+
+export default function JamiRegistration(props: JamiRegistrationProps) {
+ const theme: Theme = useTheme();
+ const { t } = useTranslation();
+
+ const [isCreatingUser, setIsCreatingUser] = useState(false);
+ const [usernameValue, setUsernameValue] = useState('');
+ const [passwordValue, setPasswordValue] = useState('');
+ const [usernameStatus, setUsernameStatus] = useState<NameStatus>('default');
+ const [passwordStatus, setPasswordStatus] = useState<PasswordStatus>('default');
+
+ const usernameError = usernameStatus !== 'success' && usernameStatus !== 'default';
+ const usernameSuccess = usernameStatus === 'success';
+ const passwordError = passwordStatus !== 'strong' && passwordStatus !== 'default';
+ const passwordSuccess = passwordStatus === 'strong';
+
+ useEffect(() => {
+ // To prevent lookup if field is empty, in error state or lookup already done
+ if (usernameValue.length > 0 && usernameStatus === 'default') {
+ const validateUsername = async () => {
+ if (await isNameRegistered(usernameValue)) {
+ setUsernameStatus('taken');
+ } else {
+ setUsernameStatus('success');
+ }
+ };
+ const timeout = setTimeout(validateUsername, 1000);
+
+ return () => clearTimeout(timeout);
+ }
+ }, [usernameValue, usernameStatus]);
+
+ const handleUsername = async (event: ChangeEvent<HTMLInputElement>) => {
+ const username: string = event.target.value;
+ setUsernameValue(username);
+
+ if (username.length > 0 && !jamiUsernamePattern.test(username)) {
+ setUsernameStatus('invalid');
+ } else {
+ setUsernameStatus('default');
+ }
+ };
+
+ const handlePassword = (event: ChangeEvent<HTMLInputElement>) => {
+ const password: string = event.target.value;
+ setPasswordValue(password);
+
+ if (password.length > 0) {
+ const checkResult = checkPasswordStrength(password);
+ setPasswordStatus(checkResult.valueCode);
+ } else {
+ setPasswordStatus('default');
+ }
+ };
+
+ const login = (event: MouseEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ props.login();
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ const canCreate = usernameSuccess && passwordSuccess;
+
+ if (canCreate) {
+ setIsCreatingUser(true);
+ // TODO: Replace with registration logic (https://git.jami.net/savoirfairelinux/jami-web/-/issues/75).
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ console.log('Account created');
+ setIsCreatingUser(false);
+ } else {
+ if (usernameError || usernameValue.length === 0) {
+ setUsernameStatus('registration_failed');
+ }
+ if (!passwordSuccess) {
+ setPasswordStatus('registration_failed');
+ }
+ }
+ };
+
+ const isMobile: boolean = useMediaQuery(theme.breakpoints.only('xs'));
+
+ return (
+ <>
+ <ProcessingRequest open={isCreatingUser} />
+
+ <Stack
+ sx={{
+ minHeight: `${isMobile ? 'auto' : '100%'}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+ <Box sx={{ mt: theme.typography.pxToRem(50), mb: theme.typography.pxToRem(20) }}>
+ <Typography component={'span'} variant="h2">
+ REGISTRATION
+ </Typography>
+ </Box>
+
+ <Form method="post" id="register-form">
+ <div>
+ <UsernameInput
+ value={usernameValue}
+ onChange={handleUsername}
+ error={usernameError}
+ success={usernameSuccess}
+ helperText={t(`username_input_${usernameStatus}_helper_text`)}
+ sx={{ width: theme.typography.pxToRem(inputWidth) }}
+ tooltipTitle={usernameTooltipTitle}
+ />
+ </div>
+ <div>
+ <PasswordInput
+ value={passwordValue}
+ onChange={handlePassword}
+ error={passwordError}
+ success={passwordSuccess}
+ helperText={t(`password_input_${passwordStatus}_helper_text`)}
+ sx={{ width: theme.typography.pxToRem(inputWidth) }}
+ tooltipTitle={passwordTooltipTitle}
+ />
+ </div>
+
+ <Button
+ variant="contained"
+ type="submit"
+ onClick={handleSubmit}
+ sx={{ width: theme.typography.pxToRem(inputWidth), mt: theme.typography.pxToRem(20) }}
+ >
+ REGISTER
+ </Button>
+ </Form>
+
+ <Box sx={{ mt: theme.typography.pxToRem(50), mb: theme.typography.pxToRem(50) }}>
+ <Typography variant="body1">
+ Already have an account ?
+ <a href="" onClick={login}>
+ LOG IN
+ </a>
+ </Typography>
+ </Box>
+ </Stack>
+ </>
+ );
+}
diff --git a/client/src/pages/LoginDialog.tsx b/client/src/pages/LoginDialog.tsx
deleted file mode 100644
index ee990bc..0000000
--- a/client/src/pages/LoginDialog.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program. If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import Button from '@mui/material/Button';
-import Checkbox from '@mui/material/Checkbox';
-import Dialog from '@mui/material/Dialog';
-import DialogActions from '@mui/material/DialogActions';
-import DialogContent from '@mui/material/DialogContent';
-import DialogTitle from '@mui/material/DialogTitle';
-import FormControlLabel from '@mui/material/FormControlLabel';
-import TextField from '@mui/material/TextField';
-import { ChangeEvent, Component, MouseEvent } from 'react';
-
-import authManager from '../AuthManager';
-
-type SignInPageProps = {
- open: boolean;
-};
-type SignInPageState = {
- username?: string;
- password?: string;
- submitted?: boolean;
- loading?: boolean;
- redirect?: boolean;
- error?: boolean;
- open?: boolean;
- errorMessage?: string;
-};
-
-class SignInPage extends Component<SignInPageProps, SignInPageState> {
- constructor(props: SignInPageProps) {
- console.log('SignInPage ' + props.open);
- super(props);
- this.state = {
- submitted: false,
- loading: false,
- };
- this.handleSubmit = this.handleSubmit.bind(this);
- this.localLogin = this.localLogin.bind(this);
- }
-
- handleusername(text: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
- this.setState({ username: text.target.value });
- }
-
- handlePassword(text: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
- this.setState({ password: text.target.value });
- }
-
- localLogin() {
- this.setState({
- submitted: true,
- loading: true,
- });
- authManager.authenticate('admin', 'admin');
- /*fetch('/api/localLogin?username=none&password=none', {
- header: { "Content-Type": "application/json" },
- method: "POST",
- credentials: 'same-origin'
- //body: JSON.stringify({ obj })
- })
- .then((res) => {
- if (res.status === '200') {
- this.setState({
- redirect: true
- });
- } else if (res.status === '401') {
- this.setState({
- loading: false,
- error: true,
- open: true,
- errorMessage: "Wrong credentials! Your are not allowed to connect"
- })
- }
- //this.setState({ session: res });
- }).catch((e) => {
- this.setState({
- loading: false,
- error: true,
- open: true,
- errorMessage: e.toString()
- })
- })*/
- }
-
- handleSubmit(event: MouseEvent<HTMLButtonElement>) {
- event.preventDefault();
- const obj = {
- username: this.state.username,
- password: this.state.password,
- };
-
- this.setState({
- submitted: true,
- loading: true,
- });
-
- fetch('/api/login?username=' + obj.username + '&password=' + obj.password, {
- headers: {
- 'Content-Type': 'application/json',
- },
- method: 'POST',
- credentials: 'same-origin',
- //body: JSON.stringify({ obj })
- })
- .then((res) => {
- if (res.status === 200) {
- this.setState({
- redirect: true,
- });
- } else if (res.status === 401) {
- this.setState({
- loading: false,
- error: true,
- open: true,
- errorMessage: 'Wrong credentials! Your are not allowed to connect',
- });
- }
- //this.setState({ session: res });
- })
- .catch((e) => {
- this.setState({
- loading: false,
- error: true,
- open: true,
- errorMessage: e.toString(),
- });
- });
- }
-
- render() {
- console.log('SignInPage render ' + this.props.open);
- return (
- <Dialog open={this.props.open}>
- <DialogTitle>Se connecter</DialogTitle>
- <DialogContent>
- <Button
- type="submit"
- fullWidth
- variant="contained"
- color="primary"
- className="" /*{classes.submit}*/
- onClick={() => {
- this.localLogin();
- }}
- >
- Compte local
- </Button>
- <TextField
- variant="outlined"
- margin="normal"
- required
- fullWidth
- id="username"
- label="LDAP Savoir-faire Linux"
- name="username"
- autoComplete="email"
- autoFocus
- onChange={(text) => {
- this.handleusername(text);
- }}
- />
- <TextField
- variant="outlined"
- margin="normal"
- required
- fullWidth
- name="password"
- label="Mot de passe"
- type="password"
- id="password"
- autoComplete="current-password"
- onChange={(text) => {
- this.handlePassword(text);
- }}
- />
- <FormControlLabel control={<Checkbox value="remember" color="primary" />} label="Se rapeller de moi" />
- </DialogContent>
-
- <DialogActions>
- <Button type="submit" size="medium" onClick={this.handleSubmit}>
- Se connecter
- </Button>
- </DialogActions>
- </Dialog>
- );
- }
-}
-
-export default SignInPage;