From 1032fb7f94fef3f198f7a15318dc6590d8e6a5f9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Tue, 17 Jun 2025 07:25:01 +0700 Subject: [PATCH] feat(auth): add MFA handling in team invitations flow (#285) - Export `MultiFactorAuthError` from `require-user` for reuse. - Implement MFA handling during team invitations' sign-in flow. - Add E2E test for team invitation flow with MFA. - Update components to improve i18n translation handling. --- .../team-accounts/team-invitation-mfa.spec.ts | 96 +++++++++++++++++++ apps/web/app/join/page.tsx | 27 ++++-- .../src/components/existing-account-hint.tsx | 4 +- .../src/components/last-auth-method-hint.tsx | 2 + packages/supabase/src/require-user.ts | 2 +- 5 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 apps/e2e/tests/team-accounts/team-invitation-mfa.spec.ts diff --git a/apps/e2e/tests/team-accounts/team-invitation-mfa.spec.ts b/apps/e2e/tests/team-accounts/team-invitation-mfa.spec.ts new file mode 100644 index 000000000..d2622db08 --- /dev/null +++ b/apps/e2e/tests/team-accounts/team-invitation-mfa.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; + +import { AuthPageObject } from '../authentication/auth.po'; +import { InvitationsPageObject } from '../invitations/invitations.po'; +import { TeamAccountsPageObject } from './team-accounts.po'; + +const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE'; + +test.describe('Team Invitation with MFA Flow', () => { + test('complete flow: test@makerkit.dev creates team, invites super-admin@makerkit.dev who accepts after MFA', async ({ + page, + }) => { + const auth = new AuthPageObject(page); + const teamAccounts = new TeamAccountsPageObject(page); + const invitations = new InvitationsPageObject(page); + + const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`; + const teamSlug = teamName.toLowerCase().replace(/ /g, '-'); + + // Step 1: test@makerkit.dev creates a team and sends invitation + await page.goto('/auth/sign-in'); + + await auth.signIn({ + email: 'test@makerkit.dev', + password: 'testingpassword', + }); + + await page.waitForURL('/home'); + + // Create a new team + await teamAccounts.createTeam({ + teamName, + slug: teamSlug, + }); + + // Navigate to members section and invite super-admin + await invitations.navigateToMembers(); + await invitations.openInviteForm(); + + await invitations.inviteMembers([ + { + email: 'super-admin@makerkit.dev', + role: 'member', + }, + ]); + + // Verify invitation was sent + await expect(invitations.getInvitations()).toHaveCount(1); + const invitationRow = invitations.getInvitationRow( + 'super-admin@makerkit.dev', + ); + await expect(invitationRow).toBeVisible(); + + // Sign out test@makerkit.dev + await auth.signOut(); + await page.waitForURL('/'); + + // Step 2: super-admin@makerkit.dev signs in with MFA + await page.context().clearCookies(); + + await auth.visitConfirmEmailLink('super-admin@makerkit.dev'); + await page + .locator('[data-test="existing-account-hint"]') + .getByRole('link', { name: 'Already have an account?' }) + .click(); + + await auth.signIn({ + email: 'super-admin@makerkit.dev', + password: 'testingpassword', + }); + + // Complete MFA verification + await expect(async () => { + await auth.submitMFAVerification(MFA_KEY); + }).toPass({ + intervals: [ + 500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000, + 40_000, 45_000, 50_000, + ], + }); + + // Step 3: Verify team invitation is visible and accept it + // Accept the team invitation + await invitations.acceptInvitation(); + + // Should be redirected to the team dashboard + await page.waitForURL(`/home/${teamSlug}`); + + // Step 4: Verify membership was successful + // Open account selector to verify team is available + await teamAccounts.openAccountsSelector(); + const team = teamAccounts.getTeamFromSelector(teamName); + + await expect(team).toBeVisible(); + }); +}); diff --git a/apps/web/app/join/page.tsx b/apps/web/app/join/page.tsx index 6fa5c7760..d3c476906 100644 --- a/apps/web/app/join/page.tsx +++ b/apps/web/app/join/page.tsx @@ -4,7 +4,7 @@ import { notFound, redirect } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { AuthLayoutShell } from '@kit/auth/shared'; -import { requireUser } from '@kit/supabase/require-user'; +import { MultiFactorAuthError, requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createTeamAccountsApi } from '@kit/team-accounts/api'; @@ -49,15 +49,26 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) { // redirect to the sign up page with the invite token // so that they will get back to this page after signing up if (auth.error ?? !auth.data) { - const urlParams = new URLSearchParams({ - invite_token: token, - email: searchParams.email ?? '', - }); + if (auth.error instanceof MultiFactorAuthError) { + const urlParams = new URLSearchParams({ + next: `${pathsConfig.app.joinTeam}?invite_token=${token}&email=${searchParams.email ?? ''}`, + }) - const signUpPath = `${pathsConfig.auth.signUp}?${urlParams.toString()}`; + const verifyMfaUrl = `${pathsConfig.auth.verifyMfa}?${urlParams.toString()}`; - // redirect to the sign up page with the invite token - redirect(signUpPath); + // if the user needs to verify MFA, redirect them to the MFA verification page + redirect(verifyMfaUrl); + } else { + const urlParams = new URLSearchParams({ + invite_token: token, + email: searchParams.email ?? '', + }); + + const nextUrl = `${pathsConfig.auth.signUp}?${urlParams.toString()}`; + + // redirect to the sign up page with the invite token + redirect(nextUrl); + } } // get api to interact with team accounts diff --git a/packages/features/auth/src/components/existing-account-hint.tsx b/packages/features/auth/src/components/existing-account-hint.tsx index 281b94636..5b7d0f877 100644 --- a/packages/features/auth/src/components/existing-account-hint.tsx +++ b/packages/features/auth/src/components/existing-account-hint.tsx @@ -13,6 +13,7 @@ import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import { useLastAuthMethod } from '../hooks/use-last-auth-method'; +import { useTranslation } from 'react-i18next'; interface ExistingAccountHintProps { signInPath?: string; @@ -35,6 +36,7 @@ export function ExistingAccountHintImpl({ useLastAuthMethod(); const params = useSearchParams(); + const { t } = useTranslation(); const isInvite = params.get('invite_token'); @@ -78,7 +80,7 @@ export function ExistingAccountHintImpl({ , signInLink: ( diff --git a/packages/features/auth/src/components/last-auth-method-hint.tsx b/packages/features/auth/src/components/last-auth-method-hint.tsx index 1b6a9595d..43be53029 100644 --- a/packages/features/auth/src/components/last-auth-method-hint.tsx +++ b/packages/features/auth/src/components/last-auth-method-hint.tsx @@ -62,6 +62,7 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) { {' '} + + diff --git a/packages/supabase/src/require-user.ts b/packages/supabase/src/require-user.ts index 5d240fff5..edfa5eb86 100644 --- a/packages/supabase/src/require-user.ts +++ b/packages/supabase/src/require-user.ts @@ -62,7 +62,7 @@ class AuthenticationError extends Error { } } -class MultiFactorAuthError extends Error { +export class MultiFactorAuthError extends Error { constructor() { super(`Multi-factor authentication required`); }