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('');