diff --git a/frontend/src/common/api/reportService.ts b/frontend/src/common/api/reportService.ts index 1ce21ff4..3356ccbd 100644 --- a/frontend/src/common/api/reportService.ts +++ b/frontend/src/common/api/reportService.ts @@ -165,9 +165,15 @@ const determineCategory = (filename: string): ReportCategory => { * @returns Promise with the latest reports */ export const fetchLatestReports = async (limit = 3): Promise => { + + const headers = await getAuthConfig(); + console.log('headers', JSON.stringify(headers)); + console.log('API_URL', `${API_URL}/api/reports/latest?limit=${limit}`); + try { - const response = await axios.get(`${API_URL}/api/reports/latest?limit=${limit}`, await getAuthConfig()); + const response = await axios.get(`${API_URL}/api/reports/latest?limit=${limit}`, headers); console.log('response', response.data); + console.log('response headers', response.headers); console.log('API_URL', API_URL); return response.data; } catch (error) { diff --git a/frontend/src/common/providers/AxiosProvider.tsx b/frontend/src/common/providers/AxiosProvider.tsx index effda424..d29c02df 100644 --- a/frontend/src/common/providers/AxiosProvider.tsx +++ b/frontend/src/common/providers/AxiosProvider.tsx @@ -1,6 +1,8 @@ import { PropsWithChildren, useEffect, useState } from 'react'; +import { InternalAxiosRequestConfig } from 'axios'; import { AxiosContext, customAxios } from './AxiosContext'; +import CognitoAuthService from 'common/services/auth/cognito-auth-service'; /** * The `AxiosProvider` React component creates, maintains, and provides @@ -12,12 +14,36 @@ const AxiosProvider = ({ children }: PropsWithChildren): JSX.Element => { const [isReady, setIsReady] = useState(false); useEffect(() => { - // use axios interceptors + // Add request interceptor to include auth token + const requestInterceptor = customAxios.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + try { + // Get tokens from Cognito + const tokens = await CognitoAuthService.getUserTokens(); + + // If tokens exist, add Authorization header + if (tokens?.access_token) { + // Make sure headers exists + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${tokens.access_token}`; + } + + return config; + } catch (error) { + console.error('Error adding auth token to request:', error); + return config; + } + }, + (error) => { + return Promise.reject(error); + } + ); setIsReady(true); return () => { - // eject axios interceptors + // Eject axios interceptors when component unmounts + customAxios.interceptors.request.eject(requestInterceptor); }; }, []); diff --git a/frontend/src/common/utils/i18n/resources/en/auth.json b/frontend/src/common/utils/i18n/resources/en/auth.json index 2caf65e8..c503749f 100644 --- a/frontend/src/common/utils/i18n/resources/en/auth.json +++ b/frontend/src/common/utils/i18n/resources/en/auth.json @@ -21,6 +21,7 @@ "remember-me": "Remember me", "username": "Username", "email": "Email Address", + "email_address": "Email Address", "first-name": "First Name", "last-name": "Last Name", "confirm-password": "Confirm Password", @@ -42,18 +43,24 @@ "confirm": "Confirm", "resend-code": "Resend Code", "forgot-password": "Forgot Password?", + "back.to.signin": "← Back to Log in", "password-recovery": { - "title": "Password Recovery", - "message": "Enter your email address and we'll send you instructions to reset your password.", + "title": "Forgot your Password?", + "message": "Enter your account email, and we'll send you reset instructions.", "success": "Password reset instructions sent to your email.", "email-sent": "We've sent a verification code to your email.", "enter-code": "Enter the verification code and your new password below." }, "password-reset": { "title": "Reset Password", + "message": "Set a new password for your account.", "success": "Password reset successful!", "button": "Reset Password" }, + "account-not-found": { + "title": "Account not found", + "message": "An account with this email address doesn't exist." + }, "password-requirements": "Password Requirements:", "email-verification": { "title": "Email Verification", diff --git a/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.scss b/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.scss index 0925a4cc..3bd08c00 100644 --- a/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.scss +++ b/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.scss @@ -1,9 +1,69 @@ .ls-forgot-password-page { - &__container { + &__content { + --background: var(--ion-color-background); + } + + &__background { + width: 100%; + height: 100%; display: flex; flex-direction: column; - height: 100%; + align-items: center; + padding: 2rem 1.5rem; + } + + &__logo-container { + display: flex; + align-items: center; justify-content: center; + margin-bottom: 2.5rem; + } + + &__logo { + width: 3.5rem; + height: auto; + margin-right: 0.75rem; + } + + &__logo-text { + color: white; + font-size: 1.75rem; + font-weight: 600; + } + + &__container { + display: flex; + flex-direction: column; + width: 100%; + max-width: 30rem; + } + + &__card { + background-color: #ffffff; + border-radius: 1.5rem; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15); + padding: 2.5rem 2rem; + width: 100%; + max-width: 30rem; + margin: 0 auto; + } + + &__header { + margin-bottom: 2.5rem; + + h1 { + font-size: 2rem; + font-weight: 600; + color: #333; + margin-bottom: 0.5rem; + margin-top: 0; + } + + p { + font-size: 1.125rem; + color: #666; + margin: 0; + } } &__form { diff --git a/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.tsx b/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.tsx index f2365990..9dbf0590 100644 --- a/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.tsx +++ b/frontend/src/pages/Auth/ForgotPassword/ForgotPasswordPage.tsx @@ -1,12 +1,12 @@ -import { IonContent, IonPage } from '@ionic/react'; +import { IonContent, IonPage, IonImg } from '@ionic/react'; import { useTranslation } from 'react-i18next'; import './ForgotPasswordPage.scss'; import { PropsWithTestId } from 'common/components/types'; import ProgressProvider from 'common/providers/ProgressProvider'; -import Header from 'common/components/Header/Header'; import ForgotPasswordForm from './components/ForgotPasswordForm'; import Container from 'common/components/Content/Container'; +import logo from 'assets/logo_ls.png'; /** * Properties for the `ForgotPasswordPage` component. @@ -24,12 +24,19 @@ const ForgotPasswordPage = ({ testid = 'page-forgot-password' }: ForgotPasswordP return ( -
- - - - - + +
+
+ + {t('app.name', { ns: 'common' })} +
+ + +
+ +
+
+
diff --git a/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.scss b/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.scss index 39b006d9..4876e83a 100644 --- a/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.scss +++ b/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.scss @@ -1,20 +1,149 @@ .ls-forgot-password-form { padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + &__icon-container { + display: flex; + justify-content: center; + margin-bottom: 2rem; + } + + &__icon-circle { + width: 8.5rem; + height: 8.5rem; + border-radius: 50%; + background-color: rgba(252, 123, 244, 0.05); + display: flex; + justify-content: center; + align-items: center; + position: relative; + + &::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + border: 1px solid rgba(252, 123, 244, 0.2); + box-sizing: border-box; + } + } + + &__icon { + width: 3rem; + height: 3rem; + color: #fc4b95; + } + + &__title { + font-size: 1.25rem; + font-weight: 700; + color: #31343f; + margin-bottom: 1rem; + text-align: center; + } + + &__field { + margin-bottom: 16px; + } + + &__label { + display: block; + margin-bottom: 8px; + font-weight: 500; + } &__input { margin-bottom: 1rem; + width: 100%; + + border: 1px solid #ccc; + border-radius: 0.75rem; + --padding-start: 1rem; + --padding-end: 1rem; + --padding-top: 0.875rem; + --padding-bottom: 0.875rem; + --highlight-color: var(--ion-color-primary); + font-size: 1rem; + height: 3.25rem; + + &::part(native) { + padding: 0; + } } &__button { - margin-top: 1rem; + margin-top: 2rem; + width: 100%; + height: 3.75rem; + font-size: 1.125rem; + font-weight: 600; + --border-radius: 0.625rem; + --box-shadow: 0 0.5rem 0.75rem rgba(67, 96, 240, 0.08); + --background: var(--ion-color-primary); + --color: #ffffff; } &__message { - margin-bottom: 1rem; + margin-bottom: 2.5rem; + text-align: center; + color: #31343f; + max-width: 20rem; + font-size: 0.875rem; + line-height: 1.5; + opacity: 0.8; } &__success { margin-bottom: 1rem; text-align: center; } + + &__error { + margin-bottom: 1rem; + width: 100%; + } + + &__custom-error { + margin-bottom: 1rem; + width: 100%; + border-radius: 0.5rem; + background-color: #f9edf0; + padding: 1rem; + } + + &__error-title { + font-weight: 700; + font-size: 0.875rem; + color: #b01b3f; + margin-bottom: 0.25rem; + } + + &__error-message { + font-size: 0.875rem; + color: #2d2d2d; + } + + &__back-link { + margin-top: 2rem; + text-align: center; + cursor: pointer; + font-size: 0.875rem; + + ion-text { + color: #666666; + + .login-text { + color: #4360f0; + font-weight: 600; + } + + &:hover { + text-decoration: underline; + } + } + } } \ No newline at end of file diff --git a/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.tsx b/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.tsx index 6c68236f..deff1c9d 100644 --- a/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.tsx +++ b/frontend/src/pages/Auth/ForgotPassword/components/ForgotPasswordForm.tsx @@ -3,14 +3,14 @@ import { useIonRouter, useIonViewDidEnter, IonText, - IonRow, - IonCol, + IonIcon, } from '@ionic/react'; import { useRef, useState } from 'react'; import classNames from 'classnames'; import { Form, Formik } from 'formik'; import { object, string } from 'yup'; import { useTranslation } from 'react-i18next'; +import { lockClosed } from 'ionicons/icons'; import './ForgotPasswordForm.scss'; import { BaseComponentProps } from 'common/components/types'; @@ -18,7 +18,6 @@ import { AuthError } from 'common/models/auth'; import { useAuth } from 'common/hooks/useAuth'; import { useProgress } from 'common/hooks/useProgress'; import Input from 'common/components/Input/Input'; -import HeaderRow from 'common/components/Text/HeaderRow'; import { formatAuthError } from 'common/utils/auth-errors'; import AuthErrorDisplay from 'common/components/Auth/AuthErrorDisplay'; import AuthLoadingIndicator from 'common/components/Auth/AuthLoadingIndicator'; @@ -63,14 +62,44 @@ const ForgotPasswordForm = ({ className, testid = 'form-forgot-password' }: Forg focusInput.current?.setFocus(); }); + const handleBackToLogin = () => { + router.push('/auth/signin', 'back'); + }; + + const renderErrorContent = () => { + if (!error) return null; + + // Check if it's a specific error like account not found + if (error.code === 'UserNotFoundException') { + return ( +
+
+ {t('account-not-found.title', { ns: 'auth' })} +
+
+ {t('account-not-found.message', { ns: 'auth' })} +
+
+ ); + } + + return null; + }; + return (
- + {error && ( +
+ {renderErrorContent() || ( + + )} +
+ )} { try { setError(null); @@ -115,13 +147,18 @@ const ForgotPasswordForm = ({ className, testid = 'form-forgot-password' }: Forg setIsLoading(false); } }} - validationSchema={validationSchema} > - {({ dirty, isSubmitting }) => ( + {({ dirty, isSubmitting, isValid }) => (
- -
{t('password-recovery.title', { ns: 'auth' })}
-
+
+
+ +
+
+ +

+ {t('password-recovery.title', { ns: 'auth' })} +

@@ -129,36 +166,34 @@ const ForgotPasswordForm = ({ className, testid = 'form-forgot-password' }: Forg
- +
+ + +
{t('submit', { ns: 'auth' })} - - - - {t('signin', { ns: 'auth' })} - - - +
+ ← Back to Log in +
)} diff --git a/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.scss b/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.scss index 34cdb2e5..cb94fe01 100644 --- a/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.scss +++ b/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.scss @@ -1,14 +1,50 @@ .ls-reset-password-page { - &__container { + --ion-background-color: var(--ion-color-background); + + &__background { + width: 100%; + height: 100%; display: flex; flex-direction: column; - height: 100%; + align-items: center; + padding: 2rem 1.5rem; + background: var(--ion-color-background); + } + + &__logo-container { + display: flex; + align-items: center; justify-content: center; + margin-bottom: 2.5rem; + } + + &__logo { + width: 3.5rem; + height: auto; + margin-right: 0.75rem; + } + + &__logo-text { + color: #435FF0; + font-size: 1.75rem; + font-weight: 600; + } + + &__card { + width: 100%; + max-width: 30rem; + background-color: white; + border-radius: 1.5rem; + padding: 2.5rem 2rem; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15); } &__form { width: 100%; - max-width: 31.25rem; - margin: 0 auto; + margin: 0; + } + + ion-content { + --background: transparent; } } \ No newline at end of file diff --git a/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.tsx b/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.tsx index f7bf94ef..49c4ea17 100644 --- a/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.tsx +++ b/frontend/src/pages/Auth/ResetPassword/ResetPasswordPage.tsx @@ -1,17 +1,15 @@ -import { IonContent, IonPage } from '@ionic/react'; +import { IonPage, IonContent, IonImg } from '@ionic/react'; import { useTranslation } from 'react-i18next'; import './ResetPasswordPage.scss'; -import { PropsWithTestId } from 'common/components/types'; +import { BaseComponentProps } from 'common/components/types'; +import logo from 'assets/logo_ls.png'; import ProgressProvider from 'common/providers/ProgressProvider'; -import Header from 'common/components/Header/Header'; import ResetPasswordForm from './components/ResetPasswordForm'; -import Container from 'common/components/Content/Container'; - /** * Properties for the `ResetPasswordPage` component. */ -interface ResetPasswordPageProps extends PropsWithTestId {} +interface ResetPasswordPageProps extends BaseComponentProps {} /** * The `ResetPasswordPage` renders the layout for resetting a password. @@ -20,16 +18,21 @@ interface ResetPasswordPageProps extends PropsWithTestId {} */ const ResetPasswordPage = ({ testid = 'page-reset-password' }: ResetPasswordPageProps): JSX.Element => { const { t } = useTranslation(); - + return ( -
- - - - - + +
+
+ + {t('app.name', { ns: 'common' })} +
+ +
+ +
+
diff --git a/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.scss b/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.scss index efdde78f..da1af668 100644 --- a/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.scss +++ b/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.scss @@ -1,27 +1,121 @@ .ls-reset-password-form { - padding: 1rem; + width: 100%; + + &__title { + font-size: 1.75rem; + font-weight: 600; + color: #313e4c; + text-align: center; + margin-bottom: 0.5rem; + } + + &__subtitle { + font-size: 1rem; + color: #5c6d80; + text-align: center; + margin-bottom: 2rem; + } + + &__field { + margin-bottom: 1.25rem; + } + + &__label { + display: block; + font-size: 0.875rem; + color: #313e4c; + margin-bottom: 0.5rem; + font-weight: 500; + } &__input { - margin-bottom: 1rem; + width: 100%; + border: 1px solid #838b94; + border-radius: 0.75rem; + --padding-start: 1rem; + --padding-end: 1rem; + --padding-top: 0.75rem; + --padding-bottom: 0.75rem; + --highlight-color: var(--ion-color-primary); + font-size: 0.875rem; + height: 3rem; + + &::part(native) { + padding: 0; + } } &__button { - margin-top: 1rem; + margin-top: 1.5rem; + --border-radius: 0.75rem; + --padding-top: 1.125rem; + --padding-bottom: 1.125rem; + font-weight: 600; + --background: #435ff0; + --background-activated: #3a56d4; + --background-hover: #3a56d4; + font-size: 1.125rem; + height: 3.5rem; + letter-spacing: 0.36px; + box-shadow: 0 0.5rem 1rem rgba(67, 95, 240, 0.12); + text-transform: none; + + &:disabled { + --background: #d7dbec; + --color: #abbccd; + } } + + &__signup-login { + margin-top: 1.75rem; + text-align: center; + font-size: 0.875rem; + color: #000000; + font-weight: 600; - &__message { - margin-bottom: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; - } + a { + color: #435ff0; + text-decoration: none; + font-weight: 600; - &__email { - color: var(--ion-color-dark); + &:hover { + text-decoration: underline; + } + } } &__success { - margin-bottom: 1rem; + background-color: rgba(54, 166, 86, 0.08); + border: 1px solid #36a656; + border-radius: 0.5rem; + padding: 1rem; + display: flex; + align-items: flex-start; + margin: 1rem 0; + text-align: center; + } + + &__back-link { text-align: center; + margin-top: 2rem; + + a { + color: #313e4c; + text-decoration: none; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + + ion-icon { + margin-right: 0.25rem; + font-size: 1rem; + } + + &:hover { + text-decoration: underline; + } + } } } \ No newline at end of file diff --git a/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.tsx b/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.tsx index f7bc8052..82355e8d 100644 --- a/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.tsx +++ b/frontend/src/pages/Auth/ResetPassword/components/ResetPasswordForm.tsx @@ -4,14 +4,13 @@ import { useIonRouter, useIonViewDidEnter, IonText, - IonRow, - IonCol, } from '@ionic/react'; import { useRef, useState, useEffect } from 'react'; import classNames from 'classnames'; import { Form, Formik } from 'formik'; import { object, string, ref } from 'yup'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import './ResetPasswordForm.scss'; import { BaseComponentProps } from 'common/components/types'; @@ -19,7 +18,6 @@ import { AuthError } from 'common/models/auth'; import { useAuth } from 'common/hooks/useAuth'; import { useProgress } from 'common/hooks/useProgress'; import Input from 'common/components/Input/Input'; -import HeaderRow from 'common/components/Text/HeaderRow'; import { formatAuthError } from 'common/utils/auth-errors'; import AuthErrorDisplay from 'common/components/Auth/AuthErrorDisplay'; import AuthLoadingIndicator from 'common/components/Auth/AuthLoadingIndicator'; @@ -152,91 +150,84 @@ const ResetPasswordForm = ({ className, testid = 'form-reset-password' }: ResetP > {({ dirty, isSubmitting }) => (
- -
{t('password-reset.title', { ns: 'auth' })}
-
- -
- - {t('password-recovery.enter-code', { ns: 'auth' })} - - {email && ( - - {email} - - )} +
+ {t('password-reset.title', { ns: 'auth', defaultValue: 'Reset Password' })} +
+ +
+ {t('password-reset.subtitle', { ns: 'auth', defaultValue: 'Set a new password for your account.' })}
{!email && ( +
+ + +
+ )} + +
+ - )} +
- - - - - - - - - +
+ + + + +
+ +
+ + + + +
- {t('password-reset.button', { ns: 'auth' })} + {t('password-reset.button', { ns: 'auth', defaultValue: 'Reset Password' })} - - - - {t('signin', { ns: 'auth' })} - - - +
+ {t('already-have-account', { ns: 'auth', defaultValue: 'Already have an account?' })} + {t('signin', { ns: 'auth', defaultValue: 'Login' })} +
)} diff --git a/frontend/src/pages/Auth/SignIn/SignInPage.scss b/frontend/src/pages/Auth/SignIn/SignInPage.scss index 4810006d..ab322102 100644 --- a/frontend/src/pages/Auth/SignIn/SignInPage.scss +++ b/frontend/src/pages/Auth/SignIn/SignInPage.scss @@ -1,5 +1,5 @@ .ls-signin-page { - --ion-background-color: #1a233f; + --ion-background-color: var(--ion-color-background); &__background { width: 100%; diff --git a/frontend/src/pages/Auth/SignIn/components/SignInForm.tsx b/frontend/src/pages/Auth/SignIn/components/SignInForm.tsx index 910100eb..6dc5c93f 100644 --- a/frontend/src/pages/Auth/SignIn/components/SignInForm.tsx +++ b/frontend/src/pages/Auth/SignIn/components/SignInForm.tsx @@ -4,7 +4,7 @@ import { useIonRouter, useIonViewDidEnter, } from '@ionic/react'; -import { useRef, useState, useEffect } from 'react'; +import { useRef, useState } from 'react'; import classNames from 'classnames'; import { Form, Formik } from 'formik'; import { boolean, object, string } from 'yup'; @@ -15,7 +15,7 @@ import { BaseComponentProps } from 'common/components/types'; import { AuthError, RememberMe } from 'common/models/auth'; import storage from 'common/utils/storage'; import { StorageKey } from 'common/utils/constants'; -import { useSignIn, useCurrentUser } from 'common/hooks/useAuth'; +import { useSignIn } from 'common/hooks/useAuth'; import { useProgress } from 'common/hooks/useProgress'; import Input from 'common/components/Input/Input'; import CheckboxInput from 'common/components/Input/CheckboxInput'; @@ -50,14 +50,11 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX const focusInput = useRef(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [shouldRedirect, setShouldRedirect] = useState(false); - const [isSignInComplete, setIsSignInComplete] = useState(false); const { setIsActive: setShowProgress } = useProgress(); const router = useIonRouter(); const { signIn, isLoading: isSignInLoading } = useSignIn(); const { t } = useTranslation(); - const currentUser = useCurrentUser(); - const { isSuccess: hasTokens, refetch: refetchTokens } = useGetUserTokens(); + const { refetch: refetchTokens } = useGetUserTokens(); /** * Sign in form validation schema. @@ -77,15 +74,6 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX focusInput.current?.setFocus(); }); - // Effect to handle redirection after sign-in is complete and user data is available - useEffect(() => { - if (isSignInComplete && shouldRedirect && currentUser && hasTokens) { - console.log('User data loaded, redirecting to home'); - setIsLoading(false); - router.push('/tabs/home', 'forward', 'replace'); - } - }, [isSignInComplete, shouldRedirect, currentUser, hasTokens, router]); - return (
return ( -
- - - - +
+
+ + {t('app.name', { ns: 'common' })} +
+ +
+ +
+
diff --git a/frontend/src/pages/Auth/SignUp/components/PasswordGuidelines.scss b/frontend/src/pages/Auth/SignUp/components/PasswordGuidelines.scss new file mode 100644 index 00000000..e11161a0 --- /dev/null +++ b/frontend/src/pages/Auth/SignUp/components/PasswordGuidelines.scss @@ -0,0 +1,42 @@ +.ls-signup-form { + &__password-guidelines { + margin-top: 8px; + margin-bottom: 16px; + padding: 12px 16px; + background-color: var(--ion-color-light); + border-radius: 8px; + + &-header { + font-weight: 600; + margin-bottom: 8px; + color: var(--ion-color-dark); + } + + &-item { + display: flex; + align-items: center; + margin-bottom: 4px; + font-size: 14px; + + &-icon { + margin-right: 8px; + } + + &-valid { + color: var(--ion-color-success); + + .ls-signup-form__password-guidelines-item-icon { + color: var(--ion-color-success); + } + } + + &-invalid { + color: var(--ion-color-medium); + + .ls-signup-form__password-guidelines-item-icon { + color: var(--ion-color-danger); + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignUp/components/PasswordGuidelines.tsx b/frontend/src/pages/Auth/SignUp/components/PasswordGuidelines.tsx new file mode 100644 index 00000000..49026f39 --- /dev/null +++ b/frontend/src/pages/Auth/SignUp/components/PasswordGuidelines.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { IonIcon } from '@ionic/react'; +import { checkmarkCircle, closeCircle } from 'ionicons/icons'; +import { useTranslation } from 'react-i18next'; +import './PasswordGuidelines.scss'; + +interface PasswordGuidelinesProps { + password: string; +} + +/** + * Password guidelines component + */ +const PasswordGuidelines: React.FC = ({ password }) => { + const { t } = useTranslation(); + + const hasMinLength = password.length >= 8; + const hasUppercase = /[A-Z]/.test(password); + const hasLowercase = /[a-z]/.test(password); + const hasNumbers = /\d/.test(password); + const hasSpecialChars = /[!@#$%^&*(),.?":{}|<>]/.test(password); + + const renderGuideline = (isValid: boolean, text: string) => { + const statusClass = isValid ? 'valid' : 'invalid'; + const icon = isValid ? checkmarkCircle : closeCircle; + + return ( +
+ + {text} +
+ ); + }; + + return ( +
+
+ {t('password-requirements', { ns: 'auth', defaultValue: 'Password Requirements:' })} +
+ {renderGuideline( + hasMinLength, + t('validation.min-length', { length: 8, ns: 'auth', defaultValue: 'At least 8 characters long' }) + )} + {renderGuideline( + hasUppercase, + t('validation.uppercase', { ns: 'auth', defaultValue: 'At least one uppercase letter' }) + )} + {renderGuideline( + hasLowercase, + t('validation.lowercase', { ns: 'auth', defaultValue: 'At least one lowercase letter' }) + )} + {renderGuideline( + hasNumbers, + t('validation.number', { ns: 'auth', defaultValue: 'At least one number' }) + )} + {renderGuideline( + hasSpecialChars, + t('validation.special-char', { ns: 'auth', defaultValue: 'At least one special character' }) + )} +
+ ); +}; + +export default PasswordGuidelines; \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignUp/components/SignUpForm.scss b/frontend/src/pages/Auth/SignUp/components/SignUpForm.scss index fe855270..2a7b01f4 100644 --- a/frontend/src/pages/Auth/SignUp/components/SignUpForm.scss +++ b/frontend/src/pages/Auth/SignUp/components/SignUpForm.scss @@ -1,40 +1,191 @@ .ls-signup-form { - padding: 1rem; + width: 100%; - .ls-signup-form__input { - margin-bottom: 1rem; + &__header { + margin-bottom: 1.5rem; + + h1 { + font-size: 1.25rem; + font-weight: 600; + color: #313e4c; + margin-bottom: 0.5rem; + margin-top: 0; + } + + p { + font-size: 0.875rem; + color: #5c6d80; + margin: 0; + font-family: 'Merriweather', serif; + letter-spacing: 0.28px; + line-height: 1.5; + } } - .ls-signup-form__button { - margin-top: 1rem; + &__divider { + height: 1px; + background-color: #ebeef8; + margin: 1.25rem 0 1rem 0; + width: 100%; + } + + &__field { + margin-bottom: 2rem; + } + + &__label { + display: block; + font-size: 0.8125rem; + color: #313e4c; + margin-bottom: 0.5rem; + font-weight: 600; + letter-spacing: 0.26px; + } + + &__input { + width: 100%; + border: 1px solid #838b94; + border-radius: 0.75rem; + --padding-start: 1rem; + --padding-end: 1rem; + --padding-top: 0.75rem; + --padding-bottom: 0.75rem; + --highlight-color: var(--ion-color-primary); + margin-bottom: 0.75rem; + font-size: 0.875rem; + height: 3rem; + + &::part(native) { + padding: 0; + } + } + + &__checkbox { + margin: 0.5rem 0 1.25rem 0; + display: flex; + align-items: center; + + &::part(container) { + border-radius: 0.125rem; + border: 1px solid #838b94; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.5rem; + background-color: #435ff0; + } + + &::part(label) { + color: #5c6d80; + font-size: 0.8125rem; + } } &__password-guidelines { - margin-top: -0.5rem; - margin-bottom: 1rem; - font-size: 0.85rem; + margin-top: 0.25rem; + margin-bottom: 1.5rem; + font-size: 0.8125rem; &-header { margin-bottom: 0.25rem; font-weight: 500; + color: #313e4c; } &-item { display: flex; align-items: center; margin-bottom: 0.125rem; + color: #5c6d80; + line-height: 1.5; &-icon { margin-right: 0.375rem; + min-width: 1.5rem; } &-valid { - color: var(--ion-color-success); + color: #313e4c; + + .ls-signup-form__password-guidelines-item-icon { + color: #36a656; + } } &-invalid { - color: var(--ion-color-medium); + color: #5c6d80; + + .ls-signup-form__password-guidelines-item-icon { + color: #5c6d80; + } + } + } + } + + &__button { + margin-top: 1.5rem; + --border-radius: 0.6875rem; + --padding-top: 1.125rem; + --padding-bottom: 1.125rem; + font-weight: 600; + --background: #435ff0; + --background-activated: #3a56d4; + --background-hover: #3a56d4; + font-size: 1.125rem; + height: 3.75rem; + letter-spacing: 0.36px; + box-shadow: 0 0.5rem 1rem rgba(67, 95, 240, 0.12); + text-transform: none; + + &:disabled { + --background: #d7dbec; + --color: #abbccd; + } + } + + &__signup-login { + margin-top: 1.75rem; + text-align: center; + font-size: 0.875rem; + color: #000000; + font-weight: 600; + + a { + color: #435ff0; + text-decoration: none; + font-weight: 600; + + &:hover { + text-decoration: underline; } } } + + &__toast { + background-color: rgba(54, 166, 86, 0.08); + border: 1px solid #36a656; + border-radius: 0.5rem; + padding: 1rem; + display: flex; + align-items: flex-start; + margin: 1rem 0; + + &-icon { + color: #36a656; + margin-right: 0.75rem; + min-width: 1.5rem; + } + + &-text { + color: #5c6d80; + font-size: 0.8125rem; + line-height: 1.5; + } + } + + &__error { + color: var(--ion-color-danger); + font-size: 0.8125rem; + margin-top: 0.375rem; + margin-bottom: 1rem; + } } \ No newline at end of file diff --git a/frontend/src/pages/Auth/SignUp/components/SignUpForm.tsx b/frontend/src/pages/Auth/SignUp/components/SignUpForm.tsx index 9ef045f1..a09e2898 100644 --- a/frontend/src/pages/Auth/SignUp/components/SignUpForm.tsx +++ b/frontend/src/pages/Auth/SignUp/components/SignUpForm.tsx @@ -3,17 +3,15 @@ import { IonInputPasswordToggle, useIonRouter, useIonViewDidEnter, - IonText, - IonRow, - IonCol, IonIcon, } from '@ionic/react'; import { useRef, useState } from 'react'; import classNames from 'classnames'; -import { Form, Formik, useFormikContext } from 'formik'; +import { Form, Formik } from 'formik'; import { object, string, ref } from 'yup'; import { useTranslation } from 'react-i18next'; -import { checkmarkCircle, closeCircle } from 'ionicons/icons'; +import { checkmarkOutline } from 'ionicons/icons'; +import { Link } from 'react-router-dom'; import './SignUpForm.scss'; import { BaseComponentProps } from 'common/components/types'; @@ -21,10 +19,10 @@ import { AuthError } from 'common/models/auth'; import { useSignUp } from 'common/hooks/useAuth'; import { useProgress } from 'common/hooks/useProgress'; import Input from 'common/components/Input/Input'; -import HeaderRow from 'common/components/Text/HeaderRow'; import { formatAuthError } from 'common/utils/auth-errors'; import AuthErrorDisplay from 'common/components/Auth/AuthErrorDisplay'; import AuthLoadingIndicator from 'common/components/Auth/AuthLoadingIndicator'; +import PasswordGuidelines from './PasswordGuidelines'; /** * Properties for the `SignUpForm` component. @@ -42,44 +40,6 @@ interface SignUpFormValues { confirmPassword: string; } -/** - * Password guidelines component - */ -const PasswordGuidelines = () => { - const { values } = useFormikContext(); - const password = values.password || ''; - const { t } = useTranslation(); - - const hasMinLength = password.length >= 8; - const hasUppercase = /[A-Z]/.test(password); - const hasNumber = /[0-9]/.test(password); - const hasSpecialChar = /[^A-Za-z0-9]/.test(password); - - const renderGuideline = (isValid: boolean, text: string) => { - return ( -
- - {text} -
- ); - }; - - return ( -
-
- {t('password-requirements', { ns: 'auth' })} -
- {renderGuideline(hasMinLength, t('validation.min-length', { length: 8, ns: 'auth' }))} - {renderGuideline(hasUppercase, t('validation.uppercase', { ns: 'auth' }))} - {renderGuideline(hasNumber, t('validation.number', { ns: 'auth' }))} - {renderGuideline(hasSpecialChar, t('validation.special-char', { ns: 'auth' }))} -
- ); -}; - /** * The `SignUpForm` component renders a form for user registration. * @param {SignUpFormProps} props - Component properties. @@ -89,10 +49,13 @@ const SignUpForm = ({ className, testid = 'form-signup' }: SignUpFormProps): JSX const focusInput = useRef(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [registrationSuccess, setRegistrationSuccess] = useState(false); const { setIsActive: setShowProgress } = useProgress(); const router = useIonRouter(); const { signUp, isLoading: isSignUpLoading } = useSignUp(); const { t } = useTranslation(); + const [toastMessage, setToastMessage] = useState(''); + const [showToast, setShowToast] = useState(false); /** * Sign up form validation schema. @@ -124,6 +87,40 @@ const SignUpForm = ({ className, testid = 'form-signup' }: SignUpFormProps): JSX focusInput.current?.setFocus(); }); + const onSubmit = async (values: SignUpFormValues) => { + try { + setError(null); + setIsLoading(true); + setShowProgress(true); + await signUp(values.email, values.password, values.firstName, values.lastName); + + // Store the email in sessionStorage for the verification page + sessionStorage.setItem('verification_email', values.email); + + // Show success briefly before redirecting + setIsLoading(false); + setRegistrationSuccess(true); + setToastMessage(t('signup.success', { ns: 'auth', defaultValue: 'Registration successful!' })); + setShowToast(true); + + // Navigate to verification page after a short delay + setTimeout(() => { + router.push('/auth/verify', 'forward', 'replace'); + }, 2000); + } catch (error: unknown) { + setError(formatAuthError(error)); + setToastMessage( + error instanceof Error + ? error.message + : t('signup.error', { ns: 'auth', defaultValue: 'Registration failed' }) + ); + setShowToast(true); + } finally { + setShowProgress(false); + setIsLoading(false); + } + }; + return (
{ - try { - setError(null); - setIsLoading(true); - setShowProgress(true); - await signUp(values.email, values.password, values.firstName, values.lastName); - - // Store the email in sessionStorage for the verification page - sessionStorage.setItem('verification_email', values.email); - - // Show success briefly before redirecting - setIsLoading(false); - - // Navigate to verification page - router.push('/auth/verify', 'forward', 'replace'); - } catch (err) { - setError(formatAuthError(err)); - } finally { - setShowProgress(false); - setSubmitting(false); - setIsLoading(false); - } - }} validationSchema={validationSchema} + onSubmit={onSubmit} > - {({ dirty, isSubmitting }) => ( + {({ dirty, isSubmitting, isValid, values }) => (
- -
{t('signup', { ns: 'auth' })}
-
- - - - - - - - - - - - - - - - +
+

{t('signup', { ns: 'auth' })}

+

{t('signup.subtitle', { ns: 'auth', defaultValue: 'Please fill in your personal details' })}

+
+ +
+ + {showToast && registrationSuccess && ( +
+ +
+ {toastMessage} +
+
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+ +
+ + + + +
+ + - {t('signup', { ns: 'auth' })} + {isLoading ? t('signing-up', { ns: 'auth' }) : t('signup', { ns: 'auth' })} - - - - {t('already-have-account', { ns: 'auth' })}{' '} - {t('signin', { ns: 'auth' })} - - - +
+ {t('already-have-account', { ns: 'auth' })} + {t('signin', { ns: 'auth' })} +
)} diff --git a/frontend/src/theme/components/inputs.scss b/frontend/src/theme/components/inputs.scss new file mode 100644 index 00000000..12af9e6d --- /dev/null +++ b/frontend/src/theme/components/inputs.scss @@ -0,0 +1,23 @@ +/* + * Global input component styles + * Used by various form components throughout the application + */ + +/* Error state styling for inputs */ +.ls-input, +.ls-textarea, +.ls-signin-form__input, +.ls-signup-form__input, +.ls-forgot-password-form__input, +.ls-profile-form__input, +.ls-user-form__input, +.ls-reset-password-form__input { + &.ion-invalid { + border-color: var(--ion-color-danger); + --highlight-color-focused: transparent; + + .input-bottom { + border-top: none; + } + } +} \ No newline at end of file diff --git a/frontend/src/theme/main.css b/frontend/src/theme/main.css index 6aaf1d9e..4ef921ac 100644 --- a/frontend/src/theme/main.css +++ b/frontend/src/theme/main.css @@ -38,3 +38,5 @@ @import './typography.css'; /* flexbox and grid */ @import './grid.css'; +/* global component styles */ +@import './components/inputs.scss'; diff --git a/frontend/src/theme/variables.css b/frontend/src/theme/variables.css index e4e62eab..66aa7e2e 100644 --- a/frontend/src/theme/variables.css +++ b/frontend/src/theme/variables.css @@ -9,6 +9,8 @@ http://ionicframework.com/docs/theming/ */ --ls-breakpoint-lg: 992px; --ls-breakpoint-xl: 1200px; + --ion-color-background: #0c0f22; + --ion-color-primary: #0054e9; --ion-color-primary-rgb: 0, 84, 233; --ion-color-primary-contrast: #ffffff; diff --git a/package-lock.json b/package-lock.json index 29f72055..ed3b8e68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,32 +5,10 @@ "packages": { "": { "name": "medical-reports-explainer", - "dependencies": { - "@capacitor/filesystem": "^7.0.0" - }, "devDependencies": { "husky": "^9.1.7" } }, - "node_modules/@capacitor/core": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.1.0.tgz", - "integrity": "sha512-I0a4C8gux5sx+HDamJjCiWHEWRdJU3hejwURFOSwJjUmAMkfkrm4hOsI0dgd+S0eCkKKKYKz9WNm7DAIvhm2zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@capacitor/filesystem": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-7.0.0.tgz", - "integrity": "sha512-xMzLq+ZaqYBAincYOKF1eNy/3UWwx1XM6TuvWBTVQTHeRsURzqwwbqBKtfkhbRzk5wnXEprWZz5k5iFo2s2BXw==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" - } - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -46,13 +24,6 @@ "funding": { "url": "https://github.com/sponsors/typicode" } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true } } }