Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/client/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
30 changes: 30 additions & 0 deletions packages/client/public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
126 changes: 126 additions & 0 deletions packages/client/src/components/auth/Auth.component.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthComponentProps> = ({ handleAuthenticated }) => {
const [activeTab, setActiveTab] = useState<'login' | 'signup' | 'reset'>('login');
const [organization, setOrganization] = useState<Organization | null>(null);
const [organizationList, setOrganizationList] = useState<Organization[]>([]);
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<string>) => {
const selectedOrganization = organizationList.find((org) => org.name === event.target.value);
setOrganization(selectedOrganization || null);
};

return (
<Box sx={{ width: '100%', alignItems: 'center', justifyContent: 'center', display: 'flex' }}>
<Stack sx={{ justifyContent: 'center', maxWidth: 450 }}>
<LanguageSelector />
<Box sx={{ borderBottom: 1 }}>
<Tabs value={activeTab} onChange={handleTabChange} aria-label="login signup tabs" variant="fullWidth">
<Tab label={t('Auth.login.login')} value="login" />
<Tab label={t('Auth.signup.signup')} value="signup" />
</Tabs>
</Box>
<Typography variant="h5">{t('Auth.organization')}</Typography>
<FormControl fullWidth>
<Select value={organization ? organization.name : ''} onChange={handleOrganizationSelect}>
{organizationList.map((organization, index) => (
<MenuItem key={index} value={organization.name}>
{organization.name}
</MenuItem>
))}
</Select>
</FormControl>
{organization && (
<FirebaseLoginWrapper setToken={handleAuthenticated} organization={organization} activeTab={activeTab} />
)}
{activeTab !== 'reset' && (
<Button
onClick={(event) => handleTabChange(event, 'reset')}
variant="text"
sx={{ color: 'blue', textTransform: 'none' }}
>
{t('Auth.resetPassword.resetPassword')}
</Button>
)}
</Stack>
</Box>
);
};

interface FirebaseLoginWrapperProps {
setToken: (token: string) => void;
organization: Organization;
activeTab: 'login' | 'signup' | 'reset';
}

const FirebaseLoginWrapper: React.FC<FirebaseLoginWrapperProps> = ({ 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 (
<Box>
{activeTab === 'login' && <LoginComponent onLoginSuccess={setToken} auth={auth} />}
{activeTab === 'signup' && <SignUpComponent auth={auth} />}
{activeTab === 'reset' && <ResetPasswordComponent auth={auth} />}
<Box id="firebaseui-auth-container" style={{ display: activeTab === 'reset' ? 'none' : 'block' }} />
</Box>
);
};
88 changes: 88 additions & 0 deletions packages/client/src/components/auth/Login.component.tsx
Original file line number Diff line number Diff line change
@@ -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<LoginComponentProps> = ({ auth, onLoginSuccess }) => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [openDialog, setOpenDialog] = useState(false);
const [dialogMessage, setDialogMessage] = useState('');
const { t } = useTranslation();

// Handle Login
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<Stack component="form" onSubmit={handleLogin} spacing={1}>
<Typography variant="h5">{t('Auth.enterUsername')}</Typography>
<TextField
label={t('Auth.email')}
type="email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Auth.enterUsername')}
required
/>
<Typography variant="h5">{t('Auth.enterPassword')}</Typography>
<TextField
label={t('Auth.password')}
type="password"
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('Auth.enterPassword')}
required
/>
<Button type="submit" variant="contained">
{t('Auth.login.login')}
</Button>

<Dialog open={openDialog} onClose={handleClose}>
<DialogTitle>{dialogMessage}</DialogTitle>
<DialogActions>
<Button onClick={handleClose}>{t('Auth.dialogClose')}</Button>
</DialogActions>
</Dialog>
</Stack>
);
};
74 changes: 74 additions & 0 deletions packages/client/src/components/auth/ResetPassword.component.tsx
Original file line number Diff line number Diff line change
@@ -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<ResetPasswordComponentProps> = ({ auth }) => {
const [email, setEmail] = useState<string>('');
const [openDialog, setOpenDialog] = useState(false);
const [dialogMessage, setDialogMessage] = useState('');
const { t } = useTranslation();

// Handle Reset-Password
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<Stack component="form" onSubmit={handleResetPassword} spacing={1}>
<Typography variant="h5">{t('Auth.enterUsername')}</Typography>
<TextField
label={t('Auth.email')}
type="email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Auth.enterUsername')}
required
/>
<Button type="submit" variant="contained">
{t('Auth.submit')}
</Button>
<Dialog open={openDialog} onClose={handleClose}>
<DialogTitle>{dialogMessage}</DialogTitle>
<DialogActions>
<Button onClick={handleClose}>{t('Auth.dialogClose')}</Button>
</DialogActions>
</Dialog>
</Stack>
);
};
Loading