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.
This commit is contained in:
committed by
GitHub
parent
698e570545
commit
1032fb7f94
96
apps/e2e/tests/team-accounts/team-invitation-mfa.spec.ts
Normal file
96
apps/e2e/tests/team-accounts/team-invitation-mfa.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { notFound, redirect } from 'next/navigation';
|
|||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
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 { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
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
|
// redirect to the sign up page with the invite token
|
||||||
// so that they will get back to this page after signing up
|
// so that they will get back to this page after signing up
|
||||||
if (auth.error ?? !auth.data) {
|
if (auth.error ?? !auth.data) {
|
||||||
const urlParams = new URLSearchParams({
|
if (auth.error instanceof MultiFactorAuthError) {
|
||||||
invite_token: token,
|
const urlParams = new URLSearchParams({
|
||||||
email: searchParams.email ?? '',
|
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
|
// if the user needs to verify MFA, redirect them to the MFA verification page
|
||||||
redirect(signUpPath);
|
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
|
// get api to interact with team accounts
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { If } from '@kit/ui/if';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ExistingAccountHintProps {
|
interface ExistingAccountHintProps {
|
||||||
signInPath?: string;
|
signInPath?: string;
|
||||||
@@ -35,6 +36,7 @@ export function ExistingAccountHintImpl({
|
|||||||
useLastAuthMethod();
|
useLastAuthMethod();
|
||||||
|
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isInvite = params.get('invite_token');
|
const isInvite = params.get('invite_token');
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ export function ExistingAccountHintImpl({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="auth:existingAccountHint"
|
i18nKey="auth:existingAccountHint"
|
||||||
values={{ method: methodDescription }}
|
values={{ method: t(methodDescription) }}
|
||||||
components={{
|
components={{
|
||||||
method: <span className="font-medium" />,
|
method: <span className="font-medium" />,
|
||||||
signInLink: (
|
signInLink: (
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
|||||||
|
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
|
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
|
||||||
|
|
||||||
<If condition={isOAuth && Boolean(providerName)}>
|
<If condition={isOAuth && Boolean(providerName)}>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="auth:methodOauthWithProvider"
|
i18nKey="auth:methodOauthWithProvider"
|
||||||
@@ -71,6 +72,7 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={!isOAuth || !providerName}>
|
<If condition={!isOAuth || !providerName}>
|
||||||
<span className="text-muted-foreground font-medium">
|
<span className="text-muted-foreground font-medium">
|
||||||
<Trans i18nKey={methodKey} />
|
<Trans i18nKey={methodKey} />
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class AuthenticationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MultiFactorAuthError extends Error {
|
export class MultiFactorAuthError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(`Multi-factor authentication required`);
|
super(`Multi-factor authentication required`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user