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
123 changes: 123 additions & 0 deletions .changeset/clean-days-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
"@bigcommerce/catalyst-core": patch
---

Update /login/token route error handling and messaging

## Migration steps

### 1. Add `invalidToken` translation key to the `Auth.Login` namespace:
```json
"invalidToken": "Your login link is invalid or has expired. Please try logging in again.",
```

### 2. In `core/app/[locale]/(default)/(auth)/login/token/[token]/route.ts`, add a `console.error` in the `catch` block to log the error details:
```typescript
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);

// ...
}
```

### 3. In `core/app/[locale]/(default)/(auth)/login/page.tsx`, add `error` prop to searchParams and pass it down into the `SignInSection` component:
```typescript
export default async function Login({ params, searchParams }: Props) {
const { locale } = await params;
const { redirectTo = '/account/orders', error } = await searchParams;

setRequestLocale(locale);

const t = await getTranslations('Auth.Login');
const vanityUrl = buildConfig.get('urls').vanityUrl;
const redirectUrl = new URL(redirectTo, vanityUrl);
const redirectTarget = redirectUrl.pathname + redirectUrl.search;
const tokenErrorMessage = error === 'InvalidToken' ? t('invalidToken') : undefined;

return (
<>
<ForceRefresh />
<SignInSection
action={login.bind(null, { redirectTo: redirectTarget })}
emailLabel={t('email')}
error={tokenErrorMessage}
...
```


### 4. Update `core/vibes/soul/sections/sign-in-section/index.tsx` and add the `error` prop, and pass it down to `SignInForm`:
```typescript
interface Props {
// ... existing props
error?: string;
}

// ...

export function SignInSection({
// ... existing variables
error,
}: Props) {
// ...
<SignInForm
action={action}
emailLabel={emailLabel}
error={error}
```

### 5. Update `core/vibes/soul/sections/sign-in-section/sign-in-form.tsx` to take the error prop and display it in the form errors:

```typescript
interface Props {
// ... existing props
error?: string;
}

export function SignInForm({
// ... existing variables
error,
}: Props) {
// ...
useEffect(() => {
// If the form errors change when an "error" search param is in the URL,
// the search param should be removed to prevent showing stale errors.
if (form.errors) {
const url = new URL(window.location.href);

if (url.searchParams.has('error')) {
url.searchParams.delete('error');
window.history.replaceState({}, '', url.toString());
}
}
}, [form.errors]);

const formErrors = () => {
// Form errors should take precedence over the error prop that is passed in.
// This ensures that the most recent errors are displayed to avoid confusion.
if (form.errors) {
return form.errors;
}

if (error) {
return [error];
}

return [];
};

return (
<form {...getFormProps(form)} action={formAction} className="flex grow flex-col gap-5">
// ...
<SubmitButton>{submitLabel}</SubmitButton>
{formErrors().map((err, index) => (
<FormStatus key={index} type="error">
{err}
</FormStatus>
))}
</form>
);
}
```

### 6. Copy all changes in the `core/tests` directory
5 changes: 4 additions & 1 deletion core/app/[locale]/(default)/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface Props {
params: Promise<{ locale: string }>;
searchParams: Promise<{
redirectTo?: string;
error?: string;
}>;
}

Expand All @@ -28,7 +29,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {

export default async function Login({ params, searchParams }: Props) {
const { locale } = await params;
const { redirectTo = '/account/orders' } = await searchParams;
const { redirectTo = '/account/orders', error } = await searchParams;

setRequestLocale(locale);

Expand All @@ -37,13 +38,15 @@ export default async function Login({ params, searchParams }: Props) {
const vanityUrl = buildConfig.get('urls').vanityUrl;
const redirectUrl = new URL(redirectTo, vanityUrl);
const redirectTarget = redirectUrl.pathname + redirectUrl.search;
const tokenErrorMessage = error === 'InvalidToken' ? t('invalidToken') : undefined;

return (
<>
<ForceRefresh />
<SignInSection
action={login.bind(null, { redirectTo: redirectTarget })}
emailLabel={t('email')}
error={tokenErrorMessage}
forgotPasswordHref="/login/forgot-password"
forgotPasswordLabel={t('forgotPassword')}
passwordLabel={t('password')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ token: str
// and redirect to redirectTo
await signIn('jwt', { jwt: token, cartId, redirectTo });
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);

rethrow(error);

redirect(`/login?error=InvalidToken`);
Expand Down
1 change: 1 addition & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"invalidCredentials": "Your email address or password is incorrect. Try signing in again or reset your password",
"somethingWentWrong": "Something went wrong. Please try again later.",
"passwordResetRequired": "Password reset required. Please check your email for instructions to reset your password.",
"invalidToken": "Your login link is invalid or has expired. Please try logging in again.",
"CreateAccount": {
"title": "New customer?",
"accountBenefits": "Create an account with us and you'll be able to:",
Expand Down
5 changes: 4 additions & 1 deletion core/tests/fixtures/utils/api/customers/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ export const customersHttpClient: CustomersApi = {
throw new Error(`No customer found with the provided ID: ${customerId}`);
}

if (customer.originChannelId !== (testEnv.BIGCOMMERCE_CHANNEL_ID ?? 1)) {
if (
customer.originChannelId !== (testEnv.BIGCOMMERCE_CHANNEL_ID ?? 1) &&
customer.channelIds !== null // if channelIds is null, the customer belongs to all channels
) {
throw new Error(
`Customer ${customerId} is not from the correct channel. Expected ${
testEnv.BIGCOMMERCE_CHANNEL_ID ?? 1
Expand Down
32 changes: 32 additions & 0 deletions core/tests/ui/e2e/auth/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,35 @@ test('JWT login redirects to the specified redirect_to value in the token payloa
await page.waitForURL('/account/addresses/');
await expect(page.getByRole('heading', { name: t('title') })).toBeVisible();
});

test('JWT login with an invalid/expired token shows an error message', async ({ page }) => {
const t = await getTranslations('Auth.Login');

const invalidJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

await page.goto(`/login/token/${invalidJwt}`);
await page.waitForURL('/login/?error=InvalidToken');
await expect(page.getByText(t('invalidToken'))).toBeVisible();
});

test('After invalid JWT login, manually logging in with invalid credentials will replace the error message displayed', async ({
page,
}) => {
const t = await getTranslations('Auth.Login');

const invalidJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

await page.goto(`/login/token/${invalidJwt}`);
await page.waitForURL('/login/?error=InvalidToken');
await expect(page.getByText(t('invalidToken'))).toBeVisible();

await page.getByLabel(t('email')).fill('invalid-email-testing@testing.com');
await page.getByLabel(t('password')).fill('invalid-password');
await page.getByRole('button', { name: t('cta') }).click();

await page.waitForURL('/login/');
await expect(page.getByText(t('invalidToken'))).not.toBeVisible();
await expect(page.getByText(t('invalidCredentials'))).toBeVisible();
});
3 changes: 3 additions & 0 deletions core/vibes/soul/sections/sign-in-section/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface Props {
passwordLabel?: string;
forgotPasswordHref?: string;
forgotPasswordLabel?: string;
error?: string;
}

// eslint-disable-next-line valid-jsdoc
Expand All @@ -35,6 +36,7 @@ export function SignInSection({
passwordLabel,
forgotPasswordHref = '/forgot-password',
forgotPasswordLabel = 'Forgot your password?',
error,
}: Props) {
return (
<div className="@container">
Expand All @@ -46,6 +48,7 @@ export function SignInSection({
<SignInForm
action={action}
emailLabel={emailLabel}
error={error}
passwordLabel={passwordLabel}
submitLabel={submitLabel}
/>
Expand Down
35 changes: 32 additions & 3 deletions core/vibes/soul/sections/sign-in-section/sign-in-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react';
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
import { useActionState } from 'react';
import { useActionState, useEffect } from 'react';
import { useFormStatus } from 'react-dom';

import { FormStatus } from '@/vibes/soul/form/form-status';
Expand All @@ -20,13 +20,15 @@ interface Props {
emailLabel?: string;
passwordLabel?: string;
submitLabel?: string;
error?: string;
}

export function SignInForm({
action,
emailLabel = 'Email',
passwordLabel = 'Password',
submitLabel = 'Sign in',
error,
}: Props) {
const [lastResult, formAction] = useActionState(action, null);
const [form, fields] = useForm({
Expand All @@ -40,6 +42,33 @@ export function SignInForm({
},
});

useEffect(() => {
// If the form errors change when an "error" search param is in the URL,
// the search param should be removed to prevent showing stale errors.
if (form.errors) {
const url = new URL(window.location.href);

if (url.searchParams.has('error')) {
url.searchParams.delete('error');
window.history.replaceState({}, '', url.toString());
}
}
}, [form.errors]);

const formErrors = () => {
// Form errors should take precedence over the error prop that is passed in.
// This ensures that the most recent errors are displayed to avoid confusion.
if (form.errors) {
return form.errors;
}

if (error) {
return [error];
}

return [];
};

return (
<form {...getFormProps(form)} action={formAction} className="flex grow flex-col gap-5">
<Input
Expand All @@ -56,9 +85,9 @@ export function SignInForm({
label={passwordLabel}
/>
<SubmitButton>{submitLabel}</SubmitButton>
{form.errors?.map((error, index) => (
{formErrors().map((err, index) => (
<FormStatus key={index} type="error">
{error}
{err}
</FormStatus>
))}
</form>
Expand Down