diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index 3828f18e..fcd8955c 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -149,5 +149,35 @@ "studyCreate": "Failed to create the new study", "tagsQuery": "Failed to get tags", "projectQuery": "Failed to find projects" + }, + "Auth": { + "errorUnexpected": "Unknown error. Please contact the administrator", + "email": "Email", + "password": "Password", + "enterUsername": "Enter Username", + "enterPassword": "Enter Password", + "dialogClose": "Close", + "submit": "Submit", + "organization": "Organization", + "login": { + "wrongPassword": "Wrong Password", + "userNotFound": "Username does not exist. Please sign up first", + "login": "Login" + }, + "signup": { + "emailAlreadyInUse": "This email has already used. Please login directly", + "weakPassword": "The password is too weak. Please use a stronger password", + "invalidEmail": "The email address is invalid. Please re-enter", + "passwordNotMatch": "The passwords entered twice do not match", + "success": "Sign Up Successfully. Please login", + "reEnterPassword": "Re-enter Password", + "signup": "Sign Up" + }, + "resetPassword": { + "userNotFound": "No user found with this email address", + "invalidEmail": "The email address is invalid. Please re-enter", + "confirmDialog": "An email for reset password has been sent to your email. Please check and follow the instructions", + "resetPassword": "Reset Password" + } } -} +} \ No newline at end of file diff --git a/packages/client/public/locales/es/translation.json b/packages/client/public/locales/es/translation.json index fcd5d96e..797434ac 100644 --- a/packages/client/public/locales/es/translation.json +++ b/packages/client/public/locales/es/translation.json @@ -29,5 +29,35 @@ "languageSelector": { "selectLanguage": "Seleccione el idioma" } + }, + "Auth": { + "errorUnexpected": "Error desconocido. Por favor, contacta con el administrador", + "email": "Correo electrónico", + "password": "Contraseña", + "enterUsername": "Ingresa tu nombre de usuario", + "enterPassword": "Ingresa tu contraseña", + "dialogClose": "Cerrar", + "submit": "Enviar", + "organization": "Organización", + "login": { + "wrongPassword": "Contraseña incorrecta", + "userNotFound": "El usuario no existe. Por favor, regístrate primero", + "login": "Iniciar sesión" + }, + "signup": { + "emailAlreadyInUse": "Este correo electrónico ya está en uso. Por favor, inicia sesión directamente", + "weakPassword": "La contraseña es demasiado débil. Utilice una contraseña más segura", + "invalidEmail": "La dirección de correo electrónico es inválida. Por favor, ingrésala nuevamente", + "passwordNotMatch": "Las contraseñas ingresadas no coinciden", + "success": "Registro exitoso. Por favor, inicia sesión", + "reEnterPassword": "Reingresa la contraseña", + "signup": "Registrarse" + }, + "resetPassword": { + "userNotFound": "No se encontró ningún usuario con esta dirección de correo electrónico", + "invalidEmail": "La dirección de correo electrónico es inválida. Por favor, ingrésala nuevamente", + "confirmDialog": "Se ha enviado un correo electrónico para restablecer tu contraseña. Por favor, revisa tu correo y sigue las instrucciones", + "resetPassword": "Restablecer la contraseña" + } } } diff --git a/packages/client/src/components/auth/Auth.component.tsx b/packages/client/src/components/auth/Auth.component.tsx new file mode 100644 index 00000000..4ae204d6 --- /dev/null +++ b/packages/client/src/components/auth/Auth.component.tsx @@ -0,0 +1,126 @@ +import { Box, Tabs, Tab, Select, MenuItem, FormControl, Button, Typography, Stack } from '@mui/material'; +import { useState, useEffect } from 'react'; +import { Organization } from '../../graphql/graphql'; +import { SelectChangeEvent } from '@mui/material'; +import * as firebaseui from 'firebaseui'; +import * as firebase from '@firebase/app'; +import * as firebaseauth from '@firebase/auth'; +import { LoginComponent } from './Login.component'; +import { SignUpComponent } from './Signup.component'; +import { ResetPasswordComponent } from './ResetPassword.component'; +import { useGetOrganizationsQuery } from '../../graphql/organization/organization'; +import { useTranslation } from 'react-i18next'; +import { LanguageSelector } from '../LanguageSelector'; + +const firebaseConfig = { + apiKey: import.meta.env.VITE_AUTH_API_KEY, + authDomain: import.meta.env.VITE_AUTH_DOMAIN +}; + +export interface AuthComponentProps { + handleAuthenticated: (token: string) => void; +} + +export const AuthComponent: React.FC = ({ handleAuthenticated }) => { + const [activeTab, setActiveTab] = useState<'login' | 'signup' | 'reset'>('login'); + const [organization, setOrganization] = useState(null); + const [organizationList, setOrganizationList] = useState([]); + const { t } = useTranslation(); + + const getOrganizationResult = useGetOrganizationsQuery(); + + useEffect(() => { + // TODO: Handle multi-organization login + if (getOrganizationResult.data && getOrganizationResult.data.getOrganizations.length > 0) { + setOrganizationList(getOrganizationResult.data.getOrganizations); + setOrganization(getOrganizationResult.data.getOrganizations[0]); + } + }, [getOrganizationResult.data]); + + // Handle switch tab + const handleTabChange = (_event: React.SyntheticEvent, tab: 'login' | 'signup' | 'reset') => { + setActiveTab(tab); + }; + + // Handle organization select + const handleOrganizationSelect = (event: SelectChangeEvent) => { + const selectedOrganization = organizationList.find((org) => org.name === event.target.value); + setOrganization(selectedOrganization || null); + }; + + return ( + + + + + + + + + + {t('Auth.organization')} + + + + {organization && ( + + )} + {activeTab !== 'reset' && ( + + )} + + + ); +}; + +interface FirebaseLoginWrapperProps { + setToken: (token: string) => void; + organization: Organization; + activeTab: 'login' | 'signup' | 'reset'; +} + +const FirebaseLoginWrapper: React.FC = ({ setToken, organization, activeTab }) => { + firebase.initializeApp(firebaseConfig); + + // Handle multi-tenant login + const auth = firebaseauth.getAuth(); + auth.tenantId = organization.tenantID; + const ui = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(auth); + + const signInSuccess = async (authResult: any) => { + setToken(await authResult.user.getIdToken()); + }; + + useEffect(() => { + ui.start('#firebaseui-auth-container', { + callbacks: { + signInSuccessWithAuthResult: (authResult, _redirectUrl) => { + signInSuccess(authResult); + return true; + } + }, + signInOptions: [firebaseauth.GoogleAuthProvider.PROVIDER_ID] + }); + }, []); + + return ( + + {activeTab === 'login' && } + {activeTab === 'signup' && } + {activeTab === 'reset' && } + + + ); +}; diff --git a/packages/client/src/components/auth/Login.component.tsx b/packages/client/src/components/auth/Login.component.tsx new file mode 100644 index 00000000..8634403b --- /dev/null +++ b/packages/client/src/components/auth/Login.component.tsx @@ -0,0 +1,88 @@ +import { useState, FC } from 'react'; +import * as firebaseauth from '@firebase/auth'; +import { TextField, Button, Typography, Dialog, DialogTitle, DialogActions, Stack } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { FirebaseError } from '@firebase/util'; + +interface LoginComponentProps { + onLoginSuccess: (token: string) => void; + auth: firebaseauth.Auth; +} + +// Login Page Component +export const LoginComponent: FC = ({ auth, onLoginSuccess }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [openDialog, setOpenDialog] = useState(false); + const [dialogMessage, setDialogMessage] = useState(''); + const { t } = useTranslation(); + + // Handle Login + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const userCredential = await firebaseauth.signInWithEmailAndPassword(auth, email, password); + const token = await userCredential.user.getIdToken(); + onLoginSuccess(token); + } catch (error: unknown) { + let errorMessage = ''; + if (error instanceof FirebaseError) { + switch (error.code) { + case 'auth/wrong-password': + errorMessage = t('Auth.login.wrongPassword'); + break; + case 'auth/user-not-found': + errorMessage = t('Auth.login.userNotFound'); + break; + default: + errorMessage = t('Auth.errorUnexpected'); + break; + } + } else { + errorMessage = t('Auth.errorUnexpected'); + } + setDialogMessage(errorMessage); + setOpenDialog(true); + setPassword(''); + } + }; + + const handleClose = () => { + setOpenDialog(false); + }; + + return ( + + {t('Auth.enterUsername')} + setEmail(e.target.value)} + placeholder={t('Auth.enterUsername')} + required + /> + {t('Auth.enterPassword')} + setPassword(e.target.value)} + placeholder={t('Auth.enterPassword')} + required + /> + + + + {dialogMessage} + + + + + + ); +}; diff --git a/packages/client/src/components/auth/ResetPassword.component.tsx b/packages/client/src/components/auth/ResetPassword.component.tsx new file mode 100644 index 00000000..a0bb13d6 --- /dev/null +++ b/packages/client/src/components/auth/ResetPassword.component.tsx @@ -0,0 +1,74 @@ +import { useState, FC } from 'react'; +import * as firebaseauth from '@firebase/auth'; +import { TextField, Button, Typography, Dialog, DialogTitle, DialogActions, Stack } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { FirebaseError } from '@firebase/util'; + +interface ResetPasswordComponentProps { + auth: firebaseauth.Auth; +} + +// Reset-Password Page Component +export const ResetPasswordComponent: FC = ({ auth }) => { + const [email, setEmail] = useState(''); + const [openDialog, setOpenDialog] = useState(false); + const [dialogMessage, setDialogMessage] = useState(''); + const { t } = useTranslation(); + + // Handle Reset-Password + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await firebaseauth.sendPasswordResetEmail(auth, email); + setDialogMessage(t('Auth.resetPassword.confirmDialog')); + setOpenDialog(true); + } catch (error) { + let errorMessage = ''; + if (error instanceof FirebaseError) { + switch (error.code) { + case 'auth/user-not-found': + errorMessage = t('Auth.resetPassword.userNotFound'); + break; + case 'auth/invalid-email': + errorMessage = t('Auth.resetPassword.invalidEmail'); + break; + default: + errorMessage = t('Auth.errorUnexpected'); + break; + } + } else { + errorMessage = t('Auth.errorUnexpected'); + } + setDialogMessage(errorMessage); + setOpenDialog(true); + } + }; + + const handleClose = () => { + setOpenDialog(false); + }; + + return ( + + {t('Auth.enterUsername')} + setEmail(e.target.value)} + placeholder={t('Auth.enterUsername')} + required + /> + + + {dialogMessage} + + + + + + ); +}; diff --git a/packages/client/src/components/auth/Signup.component.tsx b/packages/client/src/components/auth/Signup.component.tsx new file mode 100644 index 00000000..ec9b6063 --- /dev/null +++ b/packages/client/src/components/auth/Signup.component.tsx @@ -0,0 +1,108 @@ +import { useState, FC } from 'react'; +import * as firebaseauth from '@firebase/auth'; +import { TextField, Button, Typography, Dialog, DialogTitle, DialogActions, Stack } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { FirebaseError } from '@firebase/util'; + +interface SignUpComponentProps { + auth: firebaseauth.Auth; +} + +// SignUp Page Component +export const SignUpComponent: FC = ({ auth }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [openDialog, setOpenDialog] = useState(false); + const [dialogMessage, setDialogMessage] = useState(''); + const { t } = useTranslation(); + + // Handle Sign Up + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + // Check if passwords match + if (password !== confirmPassword) { + setDialogMessage(t('Auth.signup.passwordNotMatch')); + setOpenDialog(true); + return; + } + try { + await firebaseauth.createUserWithEmailAndPassword(auth, email, password); + setDialogMessage(t('Auth.signup.success')); + setOpenDialog(true); + setPassword(''); + setConfirmPassword(''); + } catch (error) { + let errorMessage = ''; + if (error instanceof FirebaseError) { + switch (error.code) { + case 'auth/email-already-in-use': + errorMessage = t('Auth.signup.emailAlreadyInUse'); + break; + case 'auth/weak-password': + errorMessage = t('Auth.signup.weakPassword'); + break; + case 'auth/invalid-email': + errorMessage = t('Auth.signup.invalidEmail'); + break; + default: + errorMessage = t('Auth.errorUnexpected'); + break; + } + } else { + errorMessage = t('Auth.errorUnexpected'); + } + setDialogMessage(errorMessage); + setOpenDialog(true); + } + }; + + const handleClose = () => { + setOpenDialog(false); + }; + + return ( + + {t('Auth.enterUsername')} + setEmail(e.target.value)} + placeholder={t('Auth.enterUsername')} + required + /> + {t('Auth.enterPassword')} + setPassword(e.target.value)} + placeholder={t('Auth.enterPassword')} + required + /> + {t('Auth.signup.reEnterPassword')} + setConfirmPassword(e.target.value)} + placeholder={t('Auth.signup.reEnterPassword')} + required + /> + + + + {dialogMessage} + + + + + + ); +}; diff --git a/packages/client/src/context/Auth.context.tsx b/packages/client/src/context/Auth.context.tsx index 1c0a4ebf..c8910831 100644 --- a/packages/client/src/context/Auth.context.tsx +++ b/packages/client/src/context/Auth.context.tsx @@ -1,15 +1,6 @@ import { createContext, FC, useContext, useEffect, useState, ReactNode } from 'react'; import jwt_decode from 'jwt-decode'; -import * as firebaseui from 'firebaseui'; -import * as firebase from '@firebase/app'; -import * as firebaseauth from '@firebase/auth'; -import { Organization } from '../graphql/graphql'; -import { useGetOrganizationsQuery } from '../graphql/organization/organization'; - -const firebaseConfig = { - apiKey: import.meta.env.VITE_AUTH_API_KEY, - authDomain: import.meta.env.VITE_AUTH_DOMAIN -}; +import { AuthComponent } from '../components/auth/Auth.component'; export const AUTH_TOKEN_STR = 'token'; @@ -51,16 +42,6 @@ export const AuthProvider: FC = ({ children }) => { const [token, setToken] = useState(localStorage.getItem(AUTH_TOKEN_STR)); const [authenticated, setAuthenticated] = useState(true); const [decodedToken, setDecodedToken] = useState(null); - const [organization, setOrganization] = useState(null); - - const getOrganizationResult = useGetOrganizationsQuery(); - - useEffect(() => { - // TODO: Handle multi-organization login - if (getOrganizationResult.data && getOrganizationResult.data.getOrganizations.length > 0) { - setOrganization(getOrganizationResult.data.getOrganizations[0]); - } - }, [getOrganizationResult.data]); const handleUnauthenticated = () => { // Clear the token and authenticated state @@ -107,46 +88,14 @@ export const AuthProvider: FC = ({ children }) => { }; return ( - - {!authenticated && organization && ( - + <> + {authenticated ? ( + {children} + ) : ( + )} - {authenticated && children} - + ); }; -interface FirebaseLoginWrapperProps { - setToken: (token: string) => void; - organization: Organization; -} - -const FirebaseLoginWrapper: FC = ({ setToken, organization }) => { - firebase.initializeApp(firebaseConfig); - - // Handle multi-tenant login - const auth = firebaseauth.getAuth(); - auth.tenantId = organization.tenantID; - - const ui = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(auth); - - const signInSuccess = async (authResult: any) => { - setToken(await authResult.user.getIdToken()); - }; - - useEffect(() => { - ui.start('#firebaseui-auth-container', { - callbacks: { - signInSuccessWithAuthResult: (authResult, _redirectUrl) => { - signInSuccess(authResult); - return true; - } - }, - signInOptions: [firebaseauth.GoogleAuthProvider.PROVIDER_ID, firebaseauth.EmailAuthProvider.PROVIDER_ID] - }); - }, []); - - return
; -}; - export const useAuth = () => useContext(AuthContext);