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/.env.development b/client/.env.development
index 2accbba..6d453cd 100644
--- a/client/.env.development
+++ b/client/.env.development
@@ -1 +1,2 @@
ESLINT_NO_DEV_ERRORS=true
+VITE_API_URL=http://localhost:5000
\ No newline at end of file
diff --git a/client/src/components/AccountPreferences.tsx b/client/src/components/AccountPreferences.tsx
index db3e62d..2eedc97 100644
--- a/client/src/components/AccountPreferences.tsx
+++ b/client/src/components/AccountPreferences.tsx
@@ -58,7 +58,6 @@
type AccountPreferencesProps = {
account: Account;
- onAccountChanged?: (account: Account) => void;
};
export default function AccountPreferences({ account }: AccountPreferencesProps) {
diff --git a/client/src/components/AlertSnackbar.tsx b/client/src/components/AlertSnackbar.tsx
new file mode 100644
index 0000000..c32eca8
--- /dev/null
+++ b/client/src/components/AlertSnackbar.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { Alert, AlertColor, AlertProps, AlertTitle, Snackbar, SnackbarProps } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+
+type AlertSnackbarProps = AlertProps & {
+ severity: AlertColor;
+ open?: boolean;
+ snackBarProps?: Partial<SnackbarProps>;
+};
+
+export function AlertSnackbar({ severity, open, snackBarProps, children, ...alertProps }: AlertSnackbarProps) {
+ const { t } = useTranslation();
+
+ return (
+ <Snackbar
+ open={open}
+ {...snackBarProps}
+ anchorOrigin={{
+ vertical: 'top',
+ horizontal: 'center',
+ ...snackBarProps?.anchorOrigin,
+ }}
+ >
+ <Alert severity={severity} {...alertProps}>
+ <AlertTitle>{t(`severity_${severity}`)}</AlertTitle>
+ {children}
+ </Alert>
+ </Snackbar>
+ );
+}
diff --git a/client/src/components/ContactList.jsx b/client/src/components/ContactList.jsx
index 2aee98b..56f5f9c 100644
--- a/client/src/components/ContactList.jsx
+++ b/client/src/components/ContactList.jsx
@@ -37,7 +37,7 @@
};
export default function ContactList() {
- const { accountId, accountObject } = useAppSelector((state) => state.app);
+ const { accountId, account } = useAppSelector((state) => state.userInfo);
const dispatch = useAppDispatch();
const [contacts, setContacts] = useState([]);
diff --git a/client/src/components/ConversationList.tsx b/client/src/components/ConversationList.tsx
index 9ad47f6..316792b 100644
--- a/client/src/components/ConversationList.tsx
+++ b/client/src/components/ConversationList.tsx
@@ -31,7 +31,7 @@
search?: Conversation;
};
export default function ConversationList(props: ConversationListProps) {
- const { refresh } = useAppSelector((state) => state.app);
+ const { refresh } = useAppSelector((state) => state.userInfo);
useEffect(() => {
console.log('refresh list');
diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx
index 303fc67..f2a0c37 100644
--- a/client/src/components/Header.tsx
+++ b/client/src/components/Header.tsx
@@ -19,7 +19,7 @@
import { MouseEvent, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
-import authManager from '../AuthManager';
+import { setAccessToken } from '../utils/auth';
export default function Header() {
const navigate = useNavigate();
@@ -28,20 +28,23 @@
const handleClose = () => setAnchorEl(null);
const params = useParams();
- const goToAccountSelection = () => navigate(`/account`);
- const goToContacts = () => navigate(`/Contacts`);
+ const goToContacts = () => navigate(`/contacts`);
const goToAccountSettings = () => navigate(`/account/${params.accountId}/settings`);
+ const logout = () => {
+ setAccessToken('');
+ navigate('/', { replace: true });
+ };
+
return (
<Box>
<Button aria-controls="simple-menu" aria-haspopup="true" onClick={handleClick}>
Menu
</Button>
<Menu id="simple-menu" anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleClose}>
- <MenuItem onClick={goToAccountSelection}>Change account</MenuItem>
<MenuItem onClick={goToContacts}>Contacts</MenuItem>
{params.accountId && <MenuItem onClick={goToAccountSettings}>Account settings</MenuItem>}
- <MenuItem onClick={() => authManager.disconnect()}>Log out</MenuItem>
+ <MenuItem onClick={logout}>Log out</MenuItem>
</Menu>
</Box>
);
diff --git a/client/src/components/Input.tsx b/client/src/components/Input.tsx
index ae640cf..063b350 100644
--- a/client/src/components/Input.tsx
+++ b/client/src/components/Input.tsx
@@ -53,7 +53,13 @@
tooltipTitle: string;
};
-export const UsernameInput = ({ infoButtonProps, onChange: _onChange, tooltipTitle, ...props }: InputProps) => {
+export const UsernameInput = ({
+ infoButtonProps,
+ onChange: _onChange,
+ success,
+ tooltipTitle,
+ ...props
+}: InputProps) => {
const [isSelected, setIsSelected] = useState(false);
const [input, setInput] = useState(props.defaultValue);
const [startAdornment, setStartAdornment] = useState<ReactElement | undefined>();
@@ -73,13 +79,13 @@
let visibility = 'visible';
if (props.error) {
Icon = StyledRoundSaltireIconError;
- } else if (props.success) {
+ } else if (success) {
Icon = StyledCheckedIconSuccess;
} else if (!isSelected && !input) {
visibility = 'hidden'; // keep icon's space so text does not move
}
setStartAdornment(<Icon sx={{ visibility }} />);
- }, [props.error, props.success, isSelected, input]);
+ }, [props.error, success, isSelected, input]);
return (
<>
@@ -88,7 +94,7 @@
</RulesDialog>
<TextField
{...props}
- color={inputColor(props.error, props.success)}
+ color={inputColor(props.error, success)}
label={'Choose an identifier'}
variant="standard"
InputLabelProps={{ shrink: !!(isSelected || input) }}
@@ -106,7 +112,13 @@
);
};
-export const PasswordInput = ({ infoButtonProps, onChange: _onChange, tooltipTitle, ...props }: InputProps) => {
+export const PasswordInput = ({
+ infoButtonProps,
+ onChange: _onChange,
+ success,
+ tooltipTitle,
+ ...props
+}: InputProps) => {
const [showPassword, setShowPassword] = useState(false);
const [isSelected, setIsSelected] = useState(false);
const [input, setInput] = useState(props.defaultValue);
@@ -131,13 +143,13 @@
let visibility = 'visible';
if (props.error) {
Icon = StyledRoundSaltireIconError;
- } else if (props.success) {
+ } else if (success) {
Icon = StyledCheckedIconSuccess;
} else if (!isSelected && !input) {
visibility = 'hidden'; // keep icon's space so text does not move
}
setStartAdornment(<Icon sx={{ visibility }} />);
- }, [props.error, props.success, isSelected, input]);
+ }, [props.error, success, isSelected, input]);
return (
<>
@@ -146,7 +158,7 @@
</RulesDialog>
<TextField
{...props}
- color={inputColor(props.error, props.success)}
+ color={inputColor(props.error, success)}
label="Password"
type={showPassword ? 'text' : 'password'}
variant="standard"
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index b2a2935..4a1dfa8 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -32,5 +32,10 @@
"password_input_weak_helper_text": "Weak",
"password_input_medium_helper_text": "Medium",
"password_input_strong_helper_text": "Strong",
- "password_input_registration_failed_helper_text": "Choose another password!"
+ "password_input_registration_failed_helper_text": "Choose another password!",
+ "login_username_not_found": "Username not found",
+ "login_invalid_password": "Incorrect password",
+ "severity_error": "Error",
+ "severity_success": "Success",
+ "registration_success": "You've successfully registered! — Logging you in..."
}
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('');
diff --git a/client/src/redux/appSlice.ts b/client/src/redux/appSlice.ts
index d5c01ee..b04d5db 100644
--- a/client/src/redux/appSlice.ts
+++ b/client/src/redux/appSlice.ts
@@ -19,29 +19,32 @@
import { Account } from 'jami-web-common';
// Define a type for the slice state
-export interface appState {
+export interface AppState {
+ // TODO: Remove accountId when account endpoints available.
+ // Left for backwards compatibility, not necessary, included in token.
accountId: string;
- accountObject: Account | null;
+ account?: Account;
+ // TODO : Evaluate need for this when WebSocket will be available.
refresh: boolean;
}
// Define the initial state using that type
-const initialState: appState = {
+const initialState: AppState = {
accountId: '',
- accountObject: null,
+ account: undefined,
refresh: true,
};
-export const appSlice = createSlice({
- name: 'app',
+export const userInfoSlice = createSlice({
+ name: 'userInfo',
// `createSlice` will infer the state type from the `initialState` argument
initialState,
reducers: {
setAccountId: (state, action: PayloadAction<string>) => {
state.accountId = action.payload;
},
- setAccountObject: (state, action: PayloadAction<Account>) => {
- state.accountObject = action.payload;
+ setAccount: (state, action: PayloadAction<Account | undefined>) => {
+ state.account = action.payload;
},
setRefreshFromSlice: (state) => {
state.refresh = !state.refresh;
@@ -49,9 +52,9 @@
},
});
-export const { setAccountId, setAccountObject, setRefreshFromSlice } = appSlice.actions;
+export const { setAccountId, setAccount, setRefreshFromSlice } = userInfoSlice.actions;
// Other code such as selectors can use the imported `RootState` type
// export const selectCount = (state: RootState) => state.app.value;
-export default appSlice.reducer;
+export default userInfoSlice.reducer;
diff --git a/client/src/redux/store.ts b/client/src/redux/store.ts
index be2b4a5..d0dbd89 100644
--- a/client/src/redux/store.ts
+++ b/client/src/redux/store.ts
@@ -21,7 +21,7 @@
export const store = configureStore({
reducer: {
- app: appReducer,
+ userInfo: appReducer,
},
});
diff --git a/client/src/utils/auth.ts b/client/src/utils/auth.ts
index 98ecdba..8404eee 100644
--- a/client/src/utils/auth.ts
+++ b/client/src/utils/auth.ts
@@ -16,9 +16,11 @@
* <https://www.gnu.org/licenses/>.
*/
import { passwordStrength } from 'check-password-strength';
-import { HttpStatusCode, LookupResolveValue } from 'jami-web-common';
+import { HttpStatusCode } from 'jami-web-common';
import { PasswordStrength } from '../enums/password-strength';
+import { apiUrl } from './constants';
+import { InvalidPassword, UsernameNotFound } from './errors';
interface PasswordStrengthResult {
id: number;
@@ -36,21 +38,17 @@
const idToStrengthValueCode: StrengthValueCode[] = ['too_weak', 'weak', 'medium', 'strong'];
-// TODO: Find a way to do it differently or remove this check from account creation.
-// It doesn't work if the server has secured this path, so I tweaked the server for test.
-// The tweak is to remove secured of apiRouter middleware in the server (app.ts).
export async function isNameRegistered(name: string): Promise<boolean> {
- try {
- const response: Response = await fetch(`api/ns/name/${name}`);
- if (response.status === HttpStatusCode.Ok) {
- const data: LookupResolveValue = await response.json();
- return data.name === name;
- } else if (response.status === HttpStatusCode.NotFound) {
+ const url = new URL(`/ns/username/${name}`, apiUrl);
+ const response = await fetch(url);
+
+ switch (response.status) {
+ case HttpStatusCode.Ok:
+ return true;
+ case HttpStatusCode.NotFound:
return false;
- }
- return true;
- } catch (err) {
- return true;
+ default:
+ throw new Error(await response.text());
}
}
@@ -64,3 +62,52 @@
return checkResult;
}
+
+export async function registerUser(username: string, password: string): Promise<void> {
+ const url = new URL('/auth/new-account', apiUrl);
+ const response: Response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ username, password }),
+ });
+
+ if (response.status !== HttpStatusCode.Created) {
+ throw new Error(await response.text());
+ }
+}
+
+export async function loginUser(username: string, password: string): Promise<string> {
+ const url = new URL('/auth/login', apiUrl);
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ username, password }),
+ });
+
+ switch (response.status) {
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.NotFound:
+ throw new UsernameNotFound();
+ case HttpStatusCode.Unauthorized:
+ throw new InvalidPassword();
+ default:
+ throw new Error(await response.text());
+ }
+
+ const data: { accessToken: string } = await response.json();
+ return data.accessToken;
+}
+
+export function getAccessToken(): string {
+ const accessToken: string | null = localStorage.getItem('accessToken');
+ return accessToken ?? '';
+}
+
+export function setAccessToken(accessToken: string): void {
+ localStorage.setItem('accessToken', accessToken);
+}
diff --git a/client/src/utils/constants.ts b/client/src/utils/constants.ts
index 341622f..f5c5423 100644
--- a/client/src/utils/constants.ts
+++ b/client/src/utils/constants.ts
@@ -20,3 +20,5 @@
export const inputWidth = 260;
export const jamiLogoDefaultSize = '512px';
+
+export const apiUrl = new URL(import.meta.env.VITE_API_URL);
diff --git a/client/src/utils/errors.ts b/client/src/utils/errors.ts
new file mode 100644
index 0000000..30ec7aa
--- /dev/null
+++ b/client/src/utils/errors.ts
@@ -0,0 +1,20 @@
+/*
+ * 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/>.
+ */
+export class UsernameNotFound extends Error {}
+
+export class InvalidPassword extends Error {}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index f62be3c..e984a81 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -24,7 +24,7 @@
host: '0.0.0.0',
port: 3000,
proxy: {
- '^/(api)|^/(auth)|^/(setup)': {
+ '^/(api)|^/(setup)': {
target: 'http://localhost:3001',
secure: false,
},
diff --git a/package-lock.json b/package-lock.json
index 91233da..1acd8d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17683,6 +17683,7 @@
"name": "jami-web-server",
"dependencies": {
"argon2": "^0.29.1",
+ "cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
@@ -25551,6 +25552,7 @@
"@types/whatwg-url": "^11.0.0",
"@types/ws": "^8.5.3",
"argon2": "^0.29.1",
+ "cors": "^2.8.5",
"dotenv": "^16.0.3",
"dotenv-cli": "^6.0.0",
"express": "^4.18.2",
diff --git a/server/package.json b/server/package.json
index b11c92d..bfcca37 100644
--- a/server/package.json
+++ b/server/package.json
@@ -27,6 +27,7 @@
},
"dependencies": {
"argon2": "^0.29.1",
+ "cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
diff --git a/server/src/app.ts b/server/src/app.ts
index 634256a..4c30a6c 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -15,6 +15,7 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
+import cors from 'cors';
import express, { json, NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { HttpStatusCode } from 'jami-web-common';
@@ -33,6 +34,7 @@
// Setup middleware
app.use(helmet());
+ app.use(cors());
app.use(json());
// Setup routing