Integrate new server authentication to client
Changes:
- Use server authentication REST API
- Log in automatically after registration
- Store token in localStorage
- Give feedback to user if registration or login fails
GitLab: #75
Change-Id: Ib90e5b911621567c6825af5e275920d703cdfe88
diff --git a/client/src/pages/AccountSettings.tsx b/client/src/pages/AccountSettings.tsx
index fe98277..b09fb65 100644
--- a/client/src/pages/AccountSettings.tsx
+++ b/client/src/pages/AccountSettings.tsx
@@ -15,62 +15,68 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { CircularProgress, Container } from '@mui/material';
-import { Account } from 'jami-web-common';
-import { useEffect, useState } from 'react';
-import { useParams } from 'react-router';
+import { Container } from '@mui/material';
+import { Account, HttpStatusCode } from 'jami-web-common';
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
-import authManager from '../AuthManager';
import AccountPreferences from '../components/AccountPreferences';
import Header from '../components/Header';
-import { setAccountId, setAccountObject } from '../redux/appSlice';
-import { useAppDispatch } from '../redux/hooks';
+import ProcessingRequest from '../components/ProcessingRequest';
+import { setAccount } from '../redux/appSlice';
+import { useAppDispatch, useAppSelector } from '../redux/hooks';
+import { getAccessToken, setAccessToken } from '../utils/auth';
+import { apiUrl } from '../utils/constants';
-type AccountSettingsProps = {
- accountId?: string;
- account?: Account;
-};
-
-const AccountSettings = (props: AccountSettingsProps) => {
- console.log('ACCOUNT SETTINGS', props.account);
- const params = useParams();
- const accountId = props.accountId || params.accountId;
-
- if (accountId == null) {
- throw new Error('Missing accountId');
- }
-
+export default function AccountSettings() {
const dispatch = useAppDispatch();
+ const navigate = useNavigate();
- const [account, setAccount] = useState<Account | null>(null);
+ const { account } = useAppSelector((state) => state.userInfo);
+ const accessToken = getAccessToken();
useEffect(() => {
- dispatch(setAccountId(accountId));
+ if (accessToken) {
+ const getAccount = async () => {
+ const url = new URL('/account', apiUrl);
+ let response: Response;
- const controller = new AbortController();
- authManager
- .fetch(`/api/accounts/${accountId}`, { signal: controller.signal })
- .then((res) => res.json())
- .then((result) => {
- const account = Account.from(result);
- account.setDevices(result.devices);
- dispatch(setAccountObject(account));
- setAccount(account);
- })
- .catch((e) => console.log(e));
- // return () => controller.abort() // crash on React18
- }, [accountId, dispatch]);
+ try {
+ response = await fetch(url, {
+ method: 'GET',
+ mode: 'cors',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ referrerPolicy: 'no-referrer',
+ });
+ } catch (err) {
+ setAccessToken('');
+ dispatch(setAccount(undefined));
+ navigate('/', { replace: true });
+ return;
+ }
+ if (response.status === HttpStatusCode.Ok) {
+ const serializedAccount = await response.json();
+ const account = Account.from(serializedAccount);
+ dispatch(setAccount(account));
+ } else if (response.status === HttpStatusCode.Unauthorized) {
+ setAccessToken('');
+ dispatch(setAccount(undefined));
+ navigate('/', { replace: true });
+ }
+ };
+
+ getAccount();
+ }
+ }, [accessToken, dispatch, navigate]);
+
+ // TODO: Improve component and sub-components UI.
return (
<Container maxWidth="sm">
<Header />
- {account != null ? (
- <AccountPreferences account={account} onAccountChanged={(account: Account) => setAccount(account)} />
- ) : (
- <CircularProgress />
- )}
+ {account ? <AccountPreferences account={account} /> : <ProcessingRequest open={true} />}
</Container>
);
-};
-
-export default AccountSettings;
+}
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
index 8a5e3d6..cdebb42 100644
--- a/client/src/pages/Home.tsx
+++ b/client/src/pages/Home.tsx
@@ -18,8 +18,10 @@
import { Box, Grid, Paper, useMediaQuery } from '@mui/material';
import { Theme, useTheme } from '@mui/material/styles';
import { useState } from 'react';
+import { Navigate } from 'react-router-dom';
import JamiWelcomeLogo from '../components/JamiWelcomeLogo';
+import { getAccessToken } from '../utils/auth';
import JamiLogin from './JamiLogin';
import JamiRegistration from './JamiRegistration';
@@ -38,6 +40,11 @@
const isDesktopOrLaptop: boolean = useMediaQuery(theme.breakpoints.up('md'));
const isMobile: boolean = useMediaQuery(theme.breakpoints.only('xs'));
+ const accessToken = getAccessToken();
+
+ if (accessToken) {
+ return <Navigate to="/settings" replace />;
+ }
return (
<Box
sx={{
diff --git a/client/src/pages/JamiLogin.tsx b/client/src/pages/JamiLogin.tsx
index 66fcf3e..06a607b 100644
--- a/client/src/pages/JamiLogin.tsx
+++ b/client/src/pages/JamiLogin.tsx
@@ -17,12 +17,16 @@
*/
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 { ChangeEvent, FormEvent, MouseEvent, ReactNode, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form, useNavigate } from 'react-router-dom';
+import { AlertSnackbar } from '../components/AlertSnackbar';
import { PasswordInput, UsernameInput } from '../components/Input';
import ProcessingRequest from '../components/ProcessingRequest';
+import { loginUser, setAccessToken } from '../utils/auth';
import { inputWidth } from '../utils/constants';
+import { InvalidPassword, UsernameNotFound } from '../utils/errors';
type JamiLoginProps = {
register: () => void;
@@ -30,9 +34,13 @@
export default function JamiLogin(props: JamiLoginProps) {
const theme: Theme = useTheme();
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [isLoggingInUser, setIsLoggingInUser] = useState<boolean>(false);
+ const [errorAlertContent, setErrorAlertContent] = useState<ReactNode>(undefined);
const handleUsername = (event: ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value);
@@ -52,10 +60,20 @@
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);
+ try {
+ const accessToken = await loginUser(username, password);
+ setAccessToken(accessToken);
+ navigate('/settings', { replace: true });
+ } catch (err) {
+ setIsLoggingInUser(false);
+ if (err instanceof UsernameNotFound) {
+ setErrorAlertContent(t('login_username_not_found'));
+ } else if (err instanceof InvalidPassword) {
+ setErrorAlertContent(t('login_invalid_password'));
+ } else {
+ throw err;
+ }
+ }
}
};
@@ -65,6 +83,10 @@
<>
<ProcessingRequest open={isLoggingInUser} />
+ <AlertSnackbar severity={'error'} open={!!errorAlertContent} onClose={() => setErrorAlertContent(undefined)}>
+ {errorAlertContent}
+ </AlertSnackbar>
+
<Stack
sx={{
minHeight: `${isMobile ? 'auto' : '100%'}`,
diff --git a/client/src/pages/JamiRegistration.tsx b/client/src/pages/JamiRegistration.tsx
index ebfc5fd..2e8d23d 100644
--- a/client/src/pages/JamiRegistration.tsx
+++ b/client/src/pages/JamiRegistration.tsx
@@ -17,14 +17,23 @@
*/
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 { ChangeEvent, FormEvent, MouseEvent, ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Form } from 'react-router-dom';
+import { Form, useNavigate } from 'react-router-dom';
+import { AlertSnackbar } from '../components/AlertSnackbar';
import { PasswordInput, UsernameInput } from '../components/Input';
import ProcessingRequest from '../components/ProcessingRequest';
-import { checkPasswordStrength, isNameRegistered, StrengthValueCode } from '../utils/auth';
+import {
+ checkPasswordStrength,
+ isNameRegistered,
+ loginUser,
+ registerUser,
+ setAccessToken,
+ StrengthValueCode,
+} from '../utils/auth';
import { inputWidth, jamiUsernamePattern } from '../utils/constants';
+import { InvalidPassword, UsernameNotFound } from '../utils/errors';
const usernameTooltipTitle =
'Choose a password hard to guess for others but easy to remember for you, ' +
@@ -44,14 +53,20 @@
export default function JamiRegistration(props: JamiRegistrationProps) {
const theme: Theme = useTheme();
+ const navigate = useNavigate();
const { t } = useTranslation();
const [isCreatingUser, setIsCreatingUser] = useState(false);
- const [usernameValue, setUsernameValue] = useState('');
- const [passwordValue, setPasswordValue] = useState('');
+
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+
const [usernameStatus, setUsernameStatus] = useState<NameStatus>('default');
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus>('default');
+ const [errorAlertContent, setErrorAlertContent] = useState<ReactNode>(undefined);
+ const [successAlertContent, setSuccessAlertContent] = useState<ReactNode>(undefined);
+
const usernameError = usernameStatus !== 'success' && usernameStatus !== 'default';
const usernameSuccess = usernameStatus === 'success';
const passwordError = passwordStatus !== 'strong' && passwordStatus !== 'default';
@@ -59,9 +74,9 @@
useEffect(() => {
// To prevent lookup if field is empty, in error state or lookup already done
- if (usernameValue.length > 0 && usernameStatus === 'default') {
+ if (username.length > 0 && usernameStatus === 'default') {
const validateUsername = async () => {
- if (await isNameRegistered(usernameValue)) {
+ if (await isNameRegistered(username)) {
setUsernameStatus('taken');
} else {
setUsernameStatus('success');
@@ -71,13 +86,36 @@
return () => clearTimeout(timeout);
}
- }, [usernameValue, usernameStatus]);
+ }, [username, usernameStatus]);
+
+ const firstUserLogin = async () => {
+ try {
+ const accessToken = await loginUser(username, password);
+ setAccessToken(accessToken);
+ navigate('/settings', { replace: true });
+ } catch (err) {
+ setIsCreatingUser(false);
+ if (err instanceof UsernameNotFound) {
+ setErrorAlertContent(t('login_username_not_found'));
+ } else if (err instanceof InvalidPassword) {
+ setErrorAlertContent(t('login_invalid_password'));
+ } else {
+ throw err;
+ }
+ }
+ };
+
+ const createAccount = async () => {
+ await registerUser(username, password);
+ setSuccessAlertContent(t('registration_success'));
+ await firstUserLogin();
+ };
const handleUsername = async (event: ChangeEvent<HTMLInputElement>) => {
- const username: string = event.target.value;
- setUsernameValue(username);
+ const usernameValue: string = event.target.value;
+ setUsername(usernameValue);
- if (username.length > 0 && !jamiUsernamePattern.test(username)) {
+ if (usernameValue.length > 0 && !jamiUsernamePattern.test(usernameValue)) {
setUsernameStatus('invalid');
} else {
setUsernameStatus('default');
@@ -85,11 +123,11 @@
};
const handlePassword = (event: ChangeEvent<HTMLInputElement>) => {
- const password: string = event.target.value;
- setPasswordValue(password);
+ const passwordValue: string = event.target.value;
+ setPassword(passwordValue);
- if (password.length > 0) {
- const checkResult = checkPasswordStrength(password);
+ if (passwordValue.length > 0) {
+ const checkResult = checkPasswordStrength(passwordValue);
setPasswordStatus(checkResult.valueCode);
} else {
setPasswordStatus('default');
@@ -107,12 +145,9 @@
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);
+ createAccount();
} else {
- if (usernameError || usernameValue.length === 0) {
+ if (usernameError || username.length === 0) {
setUsernameStatus('registration_failed');
}
if (!passwordSuccess) {
@@ -127,6 +162,18 @@
<>
<ProcessingRequest open={isCreatingUser} />
+ <AlertSnackbar
+ severity={'success'}
+ open={!!successAlertContent}
+ onClose={() => setSuccessAlertContent(undefined)}
+ >
+ {successAlertContent}
+ </AlertSnackbar>
+
+ <AlertSnackbar severity={'error'} open={!!errorAlertContent} onClose={() => setErrorAlertContent(undefined)}>
+ {errorAlertContent}
+ </AlertSnackbar>
+
<Stack
sx={{
minHeight: `${isMobile ? 'auto' : '100%'}`,
@@ -144,7 +191,7 @@
<Form method="post" id="register-form">
<div>
<UsernameInput
- value={usernameValue}
+ value={username}
onChange={handleUsername}
error={usernameError}
success={usernameSuccess}
@@ -155,7 +202,7 @@
</div>
<div>
<PasswordInput
- value={passwordValue}
+ value={password}
onChange={handlePassword}
error={passwordError}
success={passwordSuccess}
diff --git a/client/src/pages/Messenger.tsx b/client/src/pages/Messenger.tsx
index 6aeb840..0d13d5a 100644
--- a/client/src/pages/Messenger.tsx
+++ b/client/src/pages/Messenger.tsx
@@ -37,7 +37,7 @@
};
const Messenger = (props: MessengerProps) => {
- const { refresh } = useAppSelector((state) => state.app);
+ const { refresh } = useAppSelector((state) => state.userInfo);
const [conversations, setConversations] = useState<Conversation[] | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState('');