Rewrite login and register related queries using React Query
Change-Id: Ifb462a30b3100043b29aaa3db7c321950937cad1
diff --git a/client/src/App.tsx b/client/src/App.tsx
index ebebd81..ffc98bd 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -22,7 +22,7 @@
import { json, LoaderFunctionArgs, Outlet, redirect } from 'react-router-dom';
import WelcomeAnimation from './components/WelcomeAnimation';
-import { getAccessToken } from './utils/auth';
+import { getAccessToken } from './services/authQueries';
import { apiUrl } from './utils/constants';
export async function checkSetupStatus(): Promise<boolean> {
diff --git a/client/src/components/Input.tsx b/client/src/components/Input.tsx
index 20da5e7..b0e041a 100644
--- a/client/src/components/Input.tsx
+++ b/client/src/components/Input.tsx
@@ -22,7 +22,7 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { StrengthValueCode } from '../utils/auth';
+import { StrengthValueCode } from '../services/authQueries';
import { InfoButton, ToggleVisibilityButton } from './Button';
import { DialogContentList, InfosDialog, useDialogHandler } from './Dialog';
import { CheckedIcon, LockIcon, PenIcon, PersonIcon, RoundSaltireIcon } from './SvgIcon';
diff --git a/client/src/components/WithAuthUI.tsx b/client/src/components/WithAuthUI.tsx
index db6e692..2168da5 100644
--- a/client/src/components/WithAuthUI.tsx
+++ b/client/src/components/WithAuthUI.tsx
@@ -20,7 +20,7 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
-import { getAccessToken } from '../utils/auth';
+import { getAccessToken } from '../services/authQueries';
import JamiWelcomeLogo from './JamiWelcomeLogo';
export default function withAuthUI(WrappedComponent: React.FunctionComponent): React.FunctionComponent {
diff --git a/client/src/contexts/AlertSnackbarProvider.tsx b/client/src/contexts/AlertSnackbarProvider.tsx
index e5da7be..ac93c6b 100644
--- a/client/src/contexts/AlertSnackbarProvider.tsx
+++ b/client/src/contexts/AlertSnackbarProvider.tsx
@@ -57,6 +57,7 @@
type IAlertSnackbarContext = {
alertContent: AlertContent;
setAlertContent: SetState<AlertContent>;
+ closeAlert: () => void;
};
const defaultAlertSnackbarContext: IAlertSnackbarContext = {
@@ -67,6 +68,7 @@
alertOpen: false,
},
setAlertContent: () => {},
+ closeAlert: () => {},
};
type AlertMessageKeys =
@@ -118,6 +120,7 @@
const value = {
alertContent,
setAlertContent,
+ closeAlert,
};
return (
diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx
index f865bb8..9cd2175 100644
--- a/client/src/pages/Login.tsx
+++ b/client/src/pages/Login.tsx
@@ -27,29 +27,29 @@
useMediaQuery,
} from '@mui/material';
import { Theme, useTheme } from '@mui/material/styles';
-import { HttpStatusCode } from 'jami-web-common';
import { ChangeEvent, FormEvent, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Form, Link, useNavigate } from 'react-router-dom';
+import { Form, Link } from 'react-router-dom';
import { PasswordInput, UsernameInput } from '../components/Input';
import ProcessingRequest from '../components/ProcessingRequest';
import withAuthUI from '../components/WithAuthUI';
import { AlertSnackbarContext } from '../contexts/AlertSnackbarProvider';
-import { loginUser, setAccessToken } from '../utils/auth';
+import { useLoginMutation } from '../services/authQueries';
import { inputWidth } from '../utils/constants';
function LoginForm() {
const theme: Theme = useTheme();
- const navigate = useNavigate();
const { t } = useTranslation();
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [isJams, setIsJams] = useState<boolean>(false);
- const [loading, setLoading] = useState<boolean>(false);
const { setAlertContent } = useContext(AlertSnackbarContext);
+ const loginMutation = useLoginMutation();
+ const { isLoading } = loginMutation;
+
const isMobile: boolean = useMediaQuery(theme.breakpoints.only('xs'));
const handleUsername = (event: ChangeEvent<HTMLInputElement>) => {
@@ -66,51 +66,21 @@
const login = (event: FormEvent) => {
event.preventDefault();
-
if (username === '') {
setAlertContent({ messageI18nKey: 'username_input_helper_text_empty', severity: 'error', alertOpen: true });
return;
}
-
if (password === '') {
setAlertContent({ messageI18nKey: 'password_input_helper_text_empty', severity: 'error', alertOpen: true });
return;
}
- setLoading(true);
-
- loginUser(username, password, isJams)
- .then((response) => {
- if (response.status === HttpStatusCode.Ok) {
- setAccessToken(response.data.accessToken);
- navigate('/conversation', { replace: true });
- }
- })
- .catch((e) => {
- console.log(e);
- const { status } = e.response;
- if (status === HttpStatusCode.BadRequest) {
- //TODO: the only bad request response defined in the server is missing credentials. add the response message to the locale.
- console.log(e.response.data);
- // setErrorAlertContent(t('unknown_error_alert'));
- } else if (status === HttpStatusCode.NotFound) {
- //TODO: there are two different not found responses that could be returned by the server, use message to differentiate them?
- console.log(e.response.data);
- // setErrorAlertContent(t('login_username_not_found'));
- } else if (status === HttpStatusCode.Unauthorized) {
- setAlertContent({ messageI18nKey: 'login_invalid_password', severity: 'error', alertOpen: true });
- } else {
- setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true });
- }
- })
- .finally(() => {
- setLoading(false);
- });
+ loginMutation.mutate({ username, password, isJams });
};
return (
<>
- <ProcessingRequest open={loading} />
+ <ProcessingRequest open={isLoading} />
<Stack
sx={{
diff --git a/client/src/pages/Registration.tsx b/client/src/pages/Registration.tsx
index 1b63d33..0955a4a 100644
--- a/client/src/pages/Registration.tsx
+++ b/client/src/pages/Registration.tsx
@@ -15,146 +15,76 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import {
- Box,
- Button,
- FormControl,
- FormControlLabel,
- Radio,
- RadioGroup,
- Stack,
- Typography,
- useMediaQuery,
-} from '@mui/material';
+import { Box, Button, Stack, Typography, useMediaQuery } from '@mui/material';
import { Theme, useTheme } from '@mui/material/styles';
import { HttpStatusCode } from 'jami-web-common';
-import { ChangeEvent, FormEvent, useContext, useEffect, useState } from 'react';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Form, Link, useNavigate } from 'react-router-dom';
+import { Form, Link } from 'react-router-dom';
import { NameStatus, PasswordInput, PasswordStatus, UsernameInput } from '../components/Input';
import ProcessingRequest from '../components/ProcessingRequest';
import withAuthUI from '../components/WithAuthUI';
-import { AlertSnackbarContext } from '../contexts/AlertSnackbarProvider';
import {
- checkIfUserameIsRegistered,
checkPasswordStrength,
- loginUser,
- registerUser,
- setAccessToken,
-} from '../utils/auth';
+ useCheckIfUsernameIsRegisteredQuery,
+ useLoginMutation,
+ useRegisterMutation,
+} from '../services/authQueries';
import { inputWidth, jamiUsernamePattern } from '../utils/constants';
function RegistrationForm() {
const theme: Theme = useTheme();
- const navigate = useNavigate();
const { t } = useTranslation();
- const [loading, setLoading] = useState(false);
-
const [username, setUsername] = useState<string>('');
+ const [debouncedUsername, setDebouncedUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
- const [isJams, setIsJams] = useState<boolean>(false);
const [usernameStatus, setUsernameStatus] = useState<NameStatus>('default');
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus>('default');
- const { setAlertContent } = useContext(AlertSnackbarContext);
+ const registerMutation = useRegisterMutation();
+ const loginMutation = useLoginMutation();
+
+ const { isLoading: isRegisterLoading } = registerMutation;
+ const { isLoading: isLoginLoading } = loginMutation;
+
+ const isLoading = isRegisterLoading || isLoginLoading;
const isMobile: boolean = useMediaQuery(theme.breakpoints.only('xs'));
+ const { data: response, isError } = useCheckIfUsernameIsRegisteredQuery(debouncedUsername);
+
useEffect(() => {
- let timetoutId: ReturnType<typeof setTimeout>;
- if (usernameStatus === 'valid') {
- timetoutId = setTimeout(() => {
- // useCheckIfUsernameIsRegisteredMutation.mutate(username);
- checkIfUserameIsRegistered(username)
- .then((response) => {
- const { status, data } = response;
- if (status === HttpStatusCode.Ok) {
- if (data === 'taken') {
- setUsernameStatus('taken');
- } else {
- setUsernameStatus('success');
- }
- }
- })
- .catch((e) => {
- const { status } = e.response;
- if (status === HttpStatusCode.BadRequest) {
- setUsernameStatus('invalid');
- } else {
- setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true });
- }
- });
- }, 1000);
+ if (response !== undefined) {
+ const { responseMessage } = response;
+ setUsernameStatus(responseMessage);
}
+ if (isError) {
+ setUsernameStatus('invalid');
+ }
+ }, [response, isError]);
+ useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ setDebouncedUsername(username);
+ }, 500);
return () => {
- clearTimeout(timetoutId);
+ clearTimeout(timeoutId);
};
- }, [t, username, usernameStatus, setAlertContent]);
-
- const login = () => {
- setLoading(true);
-
- loginUser(username, password, isJams)
- .then((response) => {
- if (response.status === HttpStatusCode.Ok) {
- setAccessToken(response.data.accessToken);
- navigate('/conversation', { replace: true });
- }
- })
- .catch((e) => {
- console.log(e);
- const { status } = e.response;
- if (status === HttpStatusCode.BadRequest) {
- //TODO: the only bad request response defined in the server is missing credentials. add the response message to the locale.
- console.log(e.response.data);
- // setAlertContent(t('unknown_error_alert'));
- } else if (status === HttpStatusCode.NotFound) {
- //TODO: there are two different not found responses that could be returned by the server, use message to differentiate them?
- console.log(e.response.data);
- // setAlertContent(t('login_username_not_found'));
- } else if (status === HttpStatusCode.Unauthorized) {
- setAlertContent({ messageI18nKey: 'login_invalid_password', severity: 'error', alertOpen: true });
- } else {
- setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true });
- }
- })
- .finally(() => {
- setLoading(false);
- });
- };
+ }, [username]);
const createAccount = () => {
- setLoading(true);
- registerUser(username, password, isJams)
- .then((response) => {
- if (response.status === HttpStatusCode.Created) {
- setAlertContent({ messageI18nKey: 'registration_success', severity: 'success', alertOpen: true });
- login();
- }
- })
- .catch((e) => {
- console.log(e);
- const { status } = e.response;
- if (status === HttpStatusCode.BadRequest) {
- //TODO: more than one bad request response defined in the server is missing credentials. add the response message to the locale.
- console.log(e.response.data);
- // setAlertContent(t('unknown_error_alert'));
- } else if (status === HttpStatusCode.Conflict) {
- //TODO: there are two different conflict responses that could be returned by the server, use message to differentiate them?
- console.log(e.response.data);
- // setAlertContent(t('login_username_not_found'));
- } else if (status === HttpStatusCode.Unauthorized) {
- //TODO: this is a response for JAMS, add message to the locale
- } else {
- setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true });
- }
- })
- .finally(() => {
- setLoading(false);
- });
+ registerMutation.mutate(
+ { username, password },
+ {
+ onSuccess: (response) => {
+ if (response.status === HttpStatusCode.Created) {
+ loginMutation.mutate({ username, password, isJams: false });
+ }
+ },
+ }
+ );
};
const handleUsername = async (event: ChangeEvent<HTMLInputElement>) => {
@@ -186,10 +116,6 @@
}
};
- const handleIsJams = (event: ChangeEvent<HTMLInputElement>) => {
- setIsJams(event.target.value === 'jams');
- };
-
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const usernameOk = usernameStatus === 'success';
@@ -211,7 +137,7 @@
return (
<>
- <ProcessingRequest open={loading} />
+ <ProcessingRequest open={isLoading} />
<Stack
sx={{
@@ -246,20 +172,6 @@
sx={{ width: theme.typography.pxToRem(inputWidth) }}
/>
</div>
- <div>
- <FormControl
- sx={{
- width: theme.typography.pxToRem(inputWidth),
- alignItems: 'center',
- justifyContent: 'space-between',
- }}
- >
- <RadioGroup row onChange={handleIsJams} defaultValue="jami">
- <FormControlLabel value="jami" control={<Radio />} label={t('jami')} />
- <FormControlLabel value="jams" control={<Radio />} label={t('jams')} />
- </RadioGroup>
- </FormControl>
- </div>
<Button
variant="contained"
diff --git a/client/src/services/authQueries.ts b/client/src/services/authQueries.ts
new file mode 100644
index 0000000..45f78dd
--- /dev/null
+++ b/client/src/services/authQueries.ts
@@ -0,0 +1,157 @@
+/*
+ * 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 { useMutation, useQuery } from '@tanstack/react-query';
+import axios from 'axios';
+import { passwordStrength } from 'check-password-strength';
+import { AccessToken } from 'jami-web-common';
+import { HttpStatusCode } from 'jami-web-common';
+import { useContext } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { NameStatus } from '../components/Input';
+import { AlertSnackbarContext } from '../contexts/AlertSnackbarProvider';
+import { PasswordStrength } from '../enums/passwordStrength';
+import { apiUrl } from '../utils/constants';
+import { jamiUsernamePattern } from '../utils/constants';
+interface PasswordStrengthResult {
+ id: number;
+ value: string;
+ contains: string[];
+ length: number;
+}
+
+interface LoginData {
+ username: string;
+ password: string;
+ isJams: boolean;
+}
+
+interface RegisterData {
+ username: string;
+ password: string;
+}
+
+export interface PasswordCheckResult {
+ strong: boolean;
+ valueCode: StrengthValueCode;
+}
+
+export type StrengthValueCode = 'default' | 'too_weak' | 'weak' | 'medium' | 'strong';
+export type LoginMethod = 'Jami' | 'JAMS';
+
+const idToStrengthValueCode: StrengthValueCode[] = ['too_weak', 'weak', 'medium', 'strong'];
+
+export const useCheckIfUsernameIsRegisteredQuery = (username: string) => {
+ return useQuery({
+ queryKey: ['username', username],
+ queryFn: async () => {
+ const res = await axios.get<NameStatus>(`/ns/username/availability/${username}`, {
+ baseURL: apiUrl,
+ });
+ return { responseMessage: res.data, statusCode: res.status };
+ },
+ enabled: jamiUsernamePattern.test(username),
+ });
+};
+
+export function checkPasswordStrength(password: string): PasswordCheckResult {
+ const strengthResult: PasswordStrengthResult = passwordStrength(password);
+
+ return {
+ strong: strengthResult.id === PasswordStrength.Strong.valueOf(),
+ valueCode: idToStrengthValueCode[strengthResult.id] ?? 'default',
+ };
+}
+
+export const useRegisterMutation = () => {
+ const { setAlertContent } = useContext(AlertSnackbarContext);
+ return useMutation({
+ mutationKey: ['user', 'register'],
+ mutationFn: async (registerData: RegisterData) => {
+ const response = await axios.post(
+ '/auth/new-account',
+ { username: registerData.username, password: registerData.password },
+ { baseURL: apiUrl }
+ );
+ return response;
+ },
+ onSuccess: (response) => {
+ if (response.status === HttpStatusCode.Created) {
+ setAlertContent({ messageI18nKey: 'registration_success', severity: 'success', alertOpen: true });
+ }
+ },
+ onError: (e: any) => {
+ const { status } = e.response;
+ if (status === HttpStatusCode.BadRequest) {
+ //TODO: more than one bad request response defined in the server is missing credentials. add the response message to the locale.
+ } else if (status === HttpStatusCode.Conflict) {
+ //TODO: there are two different conflict responses that could be returned by the server, use message to differentiate them?
+ } else if (status === HttpStatusCode.Unauthorized) {
+ //TODO: this is a response for JAMS, add message to the locale
+ } else {
+ setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true });
+ }
+ },
+ });
+};
+
+export const useLoginMutation = () => {
+ const navigate = useNavigate();
+ const { setAlertContent, closeAlert } = useContext(AlertSnackbarContext);
+ return useMutation({
+ mutationKey: ['user', 'login'],
+ mutationFn: async (loginData: LoginData) => {
+ const { data } = await axios.post<AccessToken>(
+ '/auth/login',
+ { username: loginData.username, password: loginData.password, isJams: loginData.isJams },
+ { baseURL: apiUrl }
+ );
+ return data;
+ },
+ onMutate: () => {
+ closeAlert();
+ },
+ onSuccess: (response) => {
+ setAccessToken(response.accessToken);
+ navigate('/conversation', { replace: true });
+ },
+ onError: (e: any) => {
+ //e: any is a simple workaround for type check since the error is of type unknown and we can't reference a property on an unknown
+ const { status } = e.response;
+ if (status === HttpStatusCode.BadRequest) {
+ //TODO: the only bad request response defined in the server is missing credentials. add the response message to the locale.
+ //continue when the auth flow is clear
+ } else if (status === HttpStatusCode.NotFound) {
+ //TODO: there are two different not found responses that could be returned by the server, use message to differentiate them?
+ //continue when the auth flow is clear
+ } else if (status === HttpStatusCode.Unauthorized) {
+ setAlertContent({ messageI18nKey: 'login_invalid_password', severity: 'error', alertOpen: true });
+ } else {
+ setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true });
+ }
+ },
+ });
+};
+
+export function getAccessToken(): string | undefined {
+ return localStorage.getItem('accessToken') ?? undefined;
+}
+
+export function setAccessToken(accessToken: string): void {
+ localStorage.setItem('accessToken', accessToken);
+}
diff --git a/client/src/utils/auth.ts b/client/src/utils/auth.ts
deleted file mode 100644
index 7336639..0000000
--- a/client/src/utils/auth.ts
+++ /dev/null
@@ -1,69 +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 axios from 'axios';
-import { passwordStrength } from 'check-password-strength';
-import { AccessToken } from 'jami-web-common';
-
-import { PasswordStrength } from '../enums/passwordStrength';
-import { apiUrl } from './constants';
-
-interface PasswordStrengthResult {
- id: number;
- value: string;
- contains: string[];
- length: number;
-}
-
-export interface PasswordCheckResult {
- strong: boolean;
- valueCode: StrengthValueCode;
-}
-
-export type StrengthValueCode = 'default' | 'too_weak' | 'weak' | 'medium' | 'strong';
-export type LoginMethod = 'Jami' | 'JAMS';
-
-const idToStrengthValueCode: StrengthValueCode[] = ['too_weak', 'weak', 'medium', 'strong'];
-
-export function checkIfUserameIsRegistered(username: string) {
- return axios.get(`/ns/username/availability/${username}`, { baseURL: apiUrl });
-}
-
-export function checkPasswordStrength(password: string): PasswordCheckResult {
- const strengthResult: PasswordStrengthResult = passwordStrength(password);
-
- return {
- strong: strengthResult.id === PasswordStrength.Strong.valueOf(),
- valueCode: idToStrengthValueCode[strengthResult.id] ?? 'default',
- };
-}
-
-export async function registerUser(username: string, password: string, isJams: boolean) {
- return axios.post('/auth/new-account', { username, password, isJams }, { baseURL: apiUrl });
-}
-
-export async function loginUser(username: string, password: string, isJams: boolean) {
- return axios.post<AccessToken>('/auth/login', { username, password, isJams }, { baseURL: apiUrl });
-}
-
-export function getAccessToken(): string | undefined {
- return localStorage.getItem('accessToken') ?? undefined;
-}
-
-export function setAccessToken(accessToken: string): void {
- localStorage.setItem('accessToken', accessToken);
-}
diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts
index 53b8a02..d432c56 100644
--- a/client/src/utils/utils.ts
+++ b/client/src/utils/utils.ts
@@ -45,3 +45,13 @@
};
export type Listener = () => void;
+
+export function debounce<T extends any[]>(fn: (...args: T) => void, timeout = 1000) {
+ let timeoutId: ReturnType<typeof setTimeout>;
+ return (...args: T) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ fn(...args);
+ }, timeout);
+ };
+}