Skip to content

Commit e383ae9

Browse files
Phase 6 - Enhance Authentication UI with Loading and Error Components
- Add AuthErrorDisplay and AuthLoadingIndicator components for consistent auth UI - Update SignIn, SignUp, and Verification forms to use new components - Improve error handling and loading state management - Add new translations for loading messages and validation - Refactor error display and loading logic across auth forms
1 parent 035c087 commit e383ae9

File tree

9 files changed

+269
-75
lines changed

9 files changed

+269
-75
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.ls-auth-error-display {
2+
&__details {
3+
margin-top: 0.5rem;
4+
padding: 0 0.5rem;
5+
text-align: right;
6+
}
7+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { IonText } from '@ionic/react';
2+
import { useTranslation } from 'react-i18next';
3+
import './AuthErrorDisplay.scss';
4+
import ErrorCard from '../Card/ErrorCard';
5+
import { BaseComponentProps } from '../types';
6+
import { AuthError } from 'common/models/auth';
7+
8+
/**
9+
* Properties for the `AuthErrorDisplay` component.
10+
*/
11+
interface AuthErrorDisplayProps extends BaseComponentProps {
12+
error?: AuthError | string | null;
13+
showDetails?: boolean;
14+
}
15+
16+
/**
17+
* A component that displays authentication errors in a consistent format.
18+
* @param {AuthErrorDisplayProps} props - Component properties.
19+
* @returns {JSX.Element | null} JSX or null if no error
20+
*/
21+
const AuthErrorDisplay = ({
22+
className,
23+
error,
24+
showDetails = false,
25+
testid = 'auth-error-display'
26+
}: AuthErrorDisplayProps): JSX.Element | null => {
27+
const { t } = useTranslation();
28+
29+
if (!error) return null;
30+
31+
// If error is a string, just display it
32+
if (typeof error === 'string') {
33+
return (
34+
<ErrorCard
35+
className={className}
36+
content={error}
37+
testid={testid}
38+
/>
39+
);
40+
}
41+
42+
// If error is an AuthError object
43+
return (
44+
<div className={`ls-auth-error-display ${className || ''}`} data-testid={testid}>
45+
<ErrorCard
46+
content={error.message}
47+
testid={`${testid}-card`}
48+
/>
49+
50+
{showDetails && error.code && (
51+
<div className="ls-auth-error-display__details" data-testid={`${testid}-details`}>
52+
<IonText color="medium">
53+
<small>
54+
{t('error.details', { ns: 'auth' })}: {error.code}
55+
</small>
56+
</IonText>
57+
</div>
58+
)}
59+
</div>
60+
);
61+
};
62+
63+
export default AuthErrorDisplay;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.ls-auth-loading {
2+
position: absolute;
3+
display: flex;
4+
justify-content: center;
5+
align-items: center;
6+
top: 0;
7+
left: 0;
8+
right: 0;
9+
bottom: 0;
10+
background-color: rgba(255, 255, 255, 0.7);
11+
z-index: 1000;
12+
backdrop-filter: blur(2px);
13+
14+
&__content {
15+
padding: 1.5rem;
16+
border-radius: 8px;
17+
background-color: rgba(255, 255, 255, 0.9);
18+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
19+
display: flex;
20+
flex-direction: column;
21+
align-items: center;
22+
text-align: center;
23+
}
24+
25+
&__message {
26+
margin-top: 1rem;
27+
font-weight: 500;
28+
}
29+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { IonSpinner, IonText } from '@ionic/react';
2+
import { useTranslation } from 'react-i18next';
3+
import './AuthLoadingIndicator.scss';
4+
import { BaseComponentProps } from '../types';
5+
6+
/**
7+
* Properties for the `AuthLoadingIndicator` component.
8+
*/
9+
interface AuthLoadingIndicatorProps extends BaseComponentProps {
10+
isLoading: boolean;
11+
message?: string;
12+
}
13+
14+
/**
15+
* A component that displays a loading indicator for authentication actions.
16+
* @param {AuthLoadingIndicatorProps} props - Component properties.
17+
* @returns {JSX.Element | null} JSX or null if not loading
18+
*/
19+
const AuthLoadingIndicator = ({
20+
className,
21+
isLoading,
22+
message,
23+
testid = 'auth-loading'
24+
}: AuthLoadingIndicatorProps): JSX.Element | null => {
25+
const { t } = useTranslation();
26+
27+
if (!isLoading) return null;
28+
29+
return (
30+
<div className={`ls-auth-loading ${className || ''}`} data-testid={testid}>
31+
<div className="ls-auth-loading__content">
32+
<IonSpinner name="circular" />
33+
<IonText>
34+
<p className="ls-auth-loading__message">
35+
{message || t('loading', { ns: 'auth' })}
36+
</p>
37+
</IonText>
38+
</div>
39+
</div>
40+
);
41+
};
42+
43+
export default AuthLoadingIndicator;

frontend/src/common/services/auth/cognito-auth-service.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,9 @@ import { signIn, signUp, confirmSignUp, signOut,
44
import { Amplify } from 'aws-amplify';
55
import { amplifyConfig } from '../../config/aws-config';
66
import { UserTokens } from '../../models/auth';
7-
import { AuthProvider } from '@aws-amplify/auth/dist/esm/types/inputs';
87

9-
// Provider enum for OAuth
10-
enum OAuthProvider {
11-
Google = 'Google',
12-
Apple = 'Apple'
13-
}
8+
// We need to define this type since it's not exported from @aws-amplify/auth
9+
type AuthProvider = 'Google' | 'Apple' | 'Facebook' | 'Amazon';
1410

1511
/**
1612
* Initialize AWS Amplify with the configuration
@@ -174,9 +170,9 @@ export class CognitoAuthService {
174170
static async federatedSignIn(provider: 'Google' | 'SignInWithApple'): Promise<void> {
175171
try {
176172
// Map our provider names to Cognito's provider identifiers
177-
const providerMap: Record<string, string> = {
178-
'Google': OAuthProvider.Google,
179-
'SignInWithApple': OAuthProvider.Apple
173+
const providerMap: Record<string, AuthProvider> = {
174+
'Google': 'Google' as AuthProvider,
175+
'SignInWithApple': 'Apple' as AuthProvider
180176
};
181177

182178
// Get the OAuth provider
@@ -186,7 +182,7 @@ export class CognitoAuthService {
186182
}
187183

188184
// Initiate the OAuth redirect flow
189-
await signInWithRedirect({ provider: oauthProvider as AuthProvider });
185+
await signInWithRedirect({ provider: oauthProvider });
190186

191187
// This function will redirect the browser and not return
192188
// The user will be redirected back to the app after authentication

frontend/src/common/utils/i18n/resources/en/auth.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"error": {
33
"unable-to-verify": "We were unable to verify your credentials. Please try again.",
44
"no-email": "Email address is required for verification.",
5-
"oauth-redirect": "There was an error processing your social login"
5+
"oauth-redirect": "There was an error processing your social login",
6+
"details": "Error details"
67
},
78
"info-username": {
89
"part1": "This example application uses ",
@@ -28,6 +29,10 @@
2829
"signin": "Sign In",
2930
"signup": "Sign Up",
3031
"signout": "Sign Out",
32+
"loading": "Loading...",
33+
"signin.loading": "Signing in...",
34+
"signup.loading": "Creating your account...",
35+
"verification.loading": "Verifying your email...",
3136
"no-account": "Don't have an account?",
3237
"already-have-account": "Already have an account?",
3338
"or-signin-with": "Or sign in with",
@@ -50,6 +55,10 @@
5055
"validation": {
5156
"numeric": "Must contain only numbers",
5257
"exact-length": "Must be exactly {{length}} characters",
53-
"passwords-match": "Passwords must match"
58+
"passwords-match": "Passwords must match",
59+
"email": "Please enter a valid email address",
60+
"required": "This field is required",
61+
"min-length": "Must be at least {{length}} characters",
62+
"max-length": "Must be at most {{length}} characters"
5463
}
5564
}

frontend/src/pages/Auth/SignIn/components/SignInForm.tsx

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,19 @@ import { useTranslation } from 'react-i18next';
1717

1818
import './SignInForm.scss';
1919
import { BaseComponentProps } from 'common/components/types';
20-
import { RememberMe } from 'common/models/auth';
20+
import { AuthError, RememberMe } from 'common/models/auth';
2121
import storage from 'common/utils/storage';
2222
import { StorageKey } from 'common/utils/constants';
2323
import { useSignIn } from 'common/hooks/useAuth';
2424
import { useProgress } from 'common/hooks/useProgress';
2525
import Input from 'common/components/Input/Input';
26-
import ErrorCard from 'common/components/Card/ErrorCard';
2726
import Icon from 'common/components/Icon/Icon';
2827
import HeaderRow from 'common/components/Text/HeaderRow';
2928
import CheckboxInput from 'common/components/Input/CheckboxInput';
3029
import SocialLoginButtons from 'common/components/SocialLogin/SocialLoginButtons';
31-
import { getAuthErrorMessage } from 'common/utils/auth-errors';
30+
import { formatAuthError } from 'common/utils/auth-errors';
31+
import AuthErrorDisplay from 'common/components/Auth/AuthErrorDisplay';
32+
import AuthLoadingIndicator from 'common/components/Auth/AuthLoadingIndicator';
3233

3334
/**
3435
* Properties for the `SignInForm` component.
@@ -53,10 +54,11 @@ interface SignInFormValues {
5354
*/
5455
const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX.Element => {
5556
const focusInput = useRef<HTMLIonInputElement>(null);
56-
const [error, setError] = useState<string>('');
57+
const [error, setError] = useState<AuthError | null>(null);
58+
const [isLoading, setIsLoading] = useState(false);
5759
const { setIsActive: setShowProgress } = useProgress();
5860
const router = useIonRouter();
59-
const { signIn, isLoading } = useSignIn();
61+
const { signIn, isLoading: isSignInLoading } = useSignIn();
6062
const { t } = useTranslation();
6163

6264
/**
@@ -79,13 +81,18 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX
7981

8082
return (
8183
<div className={classNames('ls-signin-form', className)} data-testid={testid}>
82-
{error && (
83-
<ErrorCard
84-
content={`${t('error.unable-to-verify', { ns: 'auth' })} ${error}`}
85-
className="ion-margin-bottom"
86-
testid={`${testid}-error`}
87-
/>
88-
)}
84+
<AuthErrorDisplay
85+
error={error}
86+
showDetails={true}
87+
className="ion-margin-bottom"
88+
testid={`${testid}-error`}
89+
/>
90+
91+
<AuthLoadingIndicator
92+
isLoading={isLoading}
93+
message={t('signin.loading', { ns: 'auth' })}
94+
testid={`${testid}-loading`}
95+
/>
8996

9097
<Formik<SignInFormValues>
9198
enableReinitialize={true}
@@ -96,7 +103,8 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX
96103
}}
97104
onSubmit={async (values, { setSubmitting }) => {
98105
try {
99-
setError('');
106+
setError(null);
107+
setIsLoading(true);
100108
setShowProgress(true);
101109
await signIn(values.email, values.password);
102110

@@ -108,12 +116,15 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX
108116
storage.removeItem(StorageKey.RememberMe);
109117
}
110118

119+
// Show success message before redirecting
120+
setIsLoading(false);
111121
router.push('/tabs', 'forward', 'replace');
112122
} catch (err) {
113-
setError(getAuthErrorMessage(err));
123+
setError(formatAuthError(err));
114124
} finally {
115125
setShowProgress(false);
116126
setSubmitting(false);
127+
setIsLoading(false);
117128
}
118129
}}
119130
validationSchema={validationSchema}
@@ -162,15 +173,15 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX
162173
color="primary"
163174
className="ls-signin-form__button"
164175
expand="block"
165-
disabled={isSubmitting || !dirty || isLoading}
176+
disabled={isSubmitting || !dirty || isSignInLoading || isLoading}
166177
data-testid={`${testid}-button-submit`}
167178
>
168179
{t('signin', { ns: 'auth' })}
169180
</IonButton>
170181

171182
<SocialLoginButtons
172183
className="ls-signin-form__social-buttons"
173-
disabled={isSubmitting}
184+
disabled={isSubmitting || isLoading}
174185
testid={`${testid}-social-buttons`}
175186
/>
176187

0 commit comments

Comments
 (0)