Expired links (#94)

1. Handle expired links on signup
2.Reject invitations when user is already a member
3. Make sure not to display errors due to Next.js redirection during team creation
4. Fix documentation sidebar
This commit is contained in:
Giancarlo Buomprisco
2024-12-12 12:26:50 +01:00
committed by GitHub
parent ae9c33aea4
commit 97d2cf9f85
11 changed files with 268 additions and 71 deletions

View File

@@ -57,11 +57,15 @@ export class InvitationsPageObject {
} }
navigateToMembers() { navigateToMembers() {
return this.page return expect(async () => {
.locator('a', { await this.page
hasText: 'Members', .locator('a', {
}) hasText: 'Members',
.click(); })
.click();
return expect(this.page.url()).toContain('members');
}).toPass()
} }
async openInviteForm() { async openInviteForm() {

View File

@@ -60,6 +60,31 @@ test.describe('Invitations', () => {
'Owner', 'Owner',
); );
}); });
test('user cannot invite a member of the team again', async ({ page }) => {
await invitations.navigateToMembers();
const email = invitations.auth.createRandomEmail();
const invites = [
{
email,
role: 'member',
},
];
await invitations.openInviteForm();
await invitations.inviteMembers(invites);
await expect(invitations.getInvitations()).toHaveCount(1);
// Try to invite the same member again
// This should fail
await invitations.openInviteForm();
await invitations.inviteMembers(invites);
await page.waitForTimeout(500);
await expect(invitations.getInvitations()).toHaveCount(1);
});
}); });
test.describe('Full Invitation Flow', () => { test.describe('Full Invitation Flow', () => {

View File

@@ -92,6 +92,10 @@ function Tree({
)); ));
} }
if (pages.length === 0) {
return null;
}
return ( return (
<SidebarMenuSub> <SidebarMenuSub>
{pages.map((treeNode, index) => ( {pages.map((treeNode, index) => (

View File

@@ -1,6 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation';
import type { AuthError } from '@supabase/supabase-js';
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
@@ -11,41 +13,59 @@ import { withI18n } from '~/lib/i18n/with-i18n';
interface AuthCallbackErrorPageProps { interface AuthCallbackErrorPageProps {
searchParams: Promise<{ searchParams: Promise<{
error: string; error: string;
invite_token: string; callback?: string;
email?: string;
code?: AuthError['code'];
}>; }>;
} }
async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) { async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
const { error, invite_token } = await props.searchParams; const { error, callback, code } = await props.searchParams;
const queryParam = invite_token ? `?invite_token=${invite_token}` : ''; const signInPath = pathsConfig.auth.signIn;
const signInPath = pathsConfig.auth.signIn + queryParam; const redirectPath = callback ?? pathsConfig.auth.callback;
// if there is no error, redirect the user to the sign-in page
if (!error) {
redirect(signInPath);
}
return ( return (
<div className={'flex flex-col space-y-4 py-4'}> <div className={'flex flex-col space-y-4 py-4'}>
<div> <Alert variant={'warning'}>
<Alert variant={'destructive'}> <AlertTitle>
<AlertTitle> <Trans i18nKey={'auth:authenticationErrorAlertHeading'} />
<Trans i18nKey={'auth:authenticationErrorAlertHeading'} /> </AlertTitle>
</AlertTitle>
<AlertDescription> <AlertDescription>
<Trans i18nKey={error} /> <Trans i18nKey={error ?? 'auth:authenticationErrorAlertBody'} />
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div>
<Button asChild> <AuthCallbackForm
<Link href={signInPath}> code={code}
<Trans i18nKey={'auth:signIn'} /> signInPath={signInPath}
</Link> redirectPath={redirectPath}
</Button> />
</div> </div>
); );
} }
function AuthCallbackForm(props: {
signInPath: string;
redirectPath?: string;
code?: AuthError['code'];
}) {
switch (props.code) {
case 'otp_expired':
return <ResendAuthLinkForm redirectPath={props.redirectPath} />;
default:
return <SignInButton signInPath={props.signInPath} />;
}
}
function SignInButton(props: { signInPath: string }) {
return (
<Button className={'w-full'} asChild>
<Link href={props.signInPath}>
<Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
);
}
export default withI18n(AuthCallbackErrorPage); export default withI18n(AuthCallbackErrorPage);

View File

@@ -52,7 +52,8 @@
"emailConfirmationAlertHeading": "We sent you a confirmation email.", "emailConfirmationAlertHeading": "We sent you a confirmation email.",
"emailConfirmationAlertBody": "Welcome! Please check your email and click the link to verify your account.", "emailConfirmationAlertBody": "Welcome! Please check your email and click the link to verify your account.",
"resendLink": "Resend link", "resendLink": "Resend link",
"resendLinkSuccess": "We sent you a new link to your email! Follow the link to sign in.", "resendLinkSuccessDescription": "We sent you a new link to your email! Follow the link to sign in.",
"resendLinkSuccess": "Check your email!",
"authenticationErrorAlertHeading": "Authentication Error", "authenticationErrorAlertHeading": "Authentication Error",
"authenticationErrorAlertBody": "Sorry, we could not authenticate you. Please try again.", "authenticationErrorAlertBody": "Sorry, we could not authenticate you. Please try again.",
"sendEmailCode": "Get code to your Email", "sendEmailCode": "Get code to your Email",
@@ -77,6 +78,7 @@
"minPasswordNumbers": "Password must contain at least one number", "minPasswordNumbers": "Password must contain at least one number",
"minPasswordSpecialChars": "Password must contain at least one special character", "minPasswordSpecialChars": "Password must contain at least one special character",
"uppercasePassword": "Password must contain at least one uppercase letter", "uppercasePassword": "Password must contain at least one uppercase letter",
"insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action" "insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action",
"otp_expired": "The email link has expired. Please try again."
} }
} }

View File

@@ -146,6 +146,11 @@ class KeystaticClient implements CmsClient {
continue; continue;
} }
// If the parent is already set, we don't need to do anything
if (item.entry.parent !== null) {
continue;
}
if (isIndexFile(item.slug)) { if (isIndexFile(item.slug)) {
item.entry.parent = null; item.entry.parent = null;
results[i] = item; results[i] = item;
@@ -167,6 +172,7 @@ class KeystaticClient implements CmsClient {
item.entry.parent = findClosestValidParent(pathParts); item.entry.parent = findClosestValidParent(pathParts);
} }
} }
results[i] = item; results[i] = item;
} }

View File

@@ -15,7 +15,8 @@
"./shared": "./src/shared.ts", "./shared": "./src/shared.ts",
"./mfa": "./src/mfa.ts", "./mfa": "./src/mfa.ts",
"./captcha/client": "./src/captcha/client/index.ts", "./captcha/client": "./src/captcha/client/index.ts",
"./captcha/server": "./src/captcha/server/index.ts" "./captcha/server": "./src/captcha/server/index.ts",
"./resend-email-link": "./src/components/resend-auth-link-form.tsx"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",

View File

@@ -1,60 +1,105 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Alert, AlertDescription } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
function ResendAuthLinkForm() { import { useCaptchaToken } from '../captcha/client';
export function ResendAuthLinkForm(props: {
redirectPath?: string;
}) {
const resendLink = useResendLink(); const resendLink = useResendLink();
const form = useForm({
resolver: zodResolver(z.object({ email: z.string().email() })),
defaultValues: {
email: '',
},
});
if (resendLink.data && !resendLink.isPending) { if (resendLink.data && !resendLink.isPending) {
return ( return (
<Alert variant={'success'}> <Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'auth:resendLinkSuccess'} />
</AlertTitle>
<AlertDescription> <AlertDescription>
<Trans i18nKey={'auth:resendLinkSuccess'} defaults={'Success!'} /> <Trans
i18nKey={'auth:resendLinkSuccessDescription'}
defaults={'Success!'}
/>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
} }
return ( return (
<form <Form {...form}>
className={'flex flex-col space-y-2'} <form
onSubmit={(data) => { className={'flex flex-col space-y-2'}
data.preventDefault(); onSubmit={form.handleSubmit((data) => {
return resendLink.mutate({
email: data.email,
redirectPath: props.redirectPath,
});
})}
>
<FormField
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:emailAddress'} />
</FormLabel>
const email = new FormData(data.currentTarget).get('email') as string; <FormControl>
<Input type="email" required {...field} />
</FormControl>
</FormItem>
);
}}
name={'email'}
/>
return resendLink.mutateAsync(email); <Button disabled={resendLink.isPending}>
}} <Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
> </Button>
<Label> </form>
<Trans i18nKey={'common:emailAddress'} /> </Form>
<Input name={'email'} required placeholder={''} />
</Label>
<Button disabled={resendLink.isPending}>
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
</Button>
</form>
); );
} }
export default ResendAuthLinkForm;
function useResendLink() { function useResendLink() {
const supabase = useSupabase(); const supabase = useSupabase();
const { captchaToken } = useCaptchaToken();
const mutationKey = ['resend-link']; const mutationFn = async (props: {
const mutationFn = async (email: string) => { email: string;
redirectPath?: string;
}) => {
const response = await supabase.auth.resend({ const response = await supabase.auth.resend({
email, email: props.email,
type: 'signup', type: 'signup',
options: {
emailRedirectTo: props.redirectPath,
captchaToken,
},
}); });
if (response.error) { if (response.error) {
@@ -65,7 +110,6 @@ function useResendLink() {
}; };
return useMutation({ return useMutation({
mutationKey,
mutationFn, mutationFn,
}); });
} }

View File

@@ -2,6 +2,8 @@
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -76,10 +78,16 @@ function CreateOrganizationAccountForm(props: { onClose: () => void }) {
data-test={'create-team-form'} data-test={'create-team-form'}
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
startTransition(async () => { startTransition(async () => {
const { error } = await createTeamAccountAction(data); try {
const { error } = await createTeamAccountAction(data);
if (error) { if (error) {
setError(true); setError(true);
}
} catch (error) {
if (!isRedirectError(error)) {
setError(true);
}
} }
}); });
})} })}

View File

@@ -101,6 +101,30 @@ class AccountInvitationsService {
return data; return data;
} }
async validateInvitation(
invitation: z.infer<typeof InviteMembersSchema>['invitations'][number],
accountSlug: string,
) {
const { data: members, error } = await this.client.rpc(
'get_account_members',
{
account_slug: accountSlug,
},
);
if (error) {
throw error;
}
const isUserAlreadyMember = members.find((member) => {
return member.email === invitation.email;
});
if (isUserAlreadyMember) {
throw new Error('User already member of the team');
}
}
/** /**
* @name sendInvitations * @name sendInvitations
* @description Sends invitations to join a team. * @description Sends invitations to join a team.
@@ -123,6 +147,24 @@ class AccountInvitationsService {
logger.info(ctx, 'Storing invitations...'); logger.info(ctx, 'Storing invitations...');
try {
await Promise.all(
invitations.map((invitation) =>
this.validateInvitation(invitation, accountSlug),
),
);
} catch (error) {
logger.error(
{
...ctx,
error: (error as Error).message,
},
'Error validating invitations',
);
throw error;
}
const accountResponse = await this.client const accountResponse = await this.client
.from('accounts') .from('accounts')
.select('name') .select('name')

View File

@@ -1,6 +1,9 @@
import 'server-only'; import 'server-only';
import { type EmailOtpType, SupabaseClient } from '@supabase/supabase-js';
import {AuthError, type EmailOtpType, SupabaseClient} from '@supabase/supabase-js';
/** /**
* @name createAuthCallbackService * @name createAuthCallbackService
@@ -105,6 +108,14 @@ class AuthCallbackService {
if (!error) { if (!error) {
return url; return url;
} }
if (error.code) {
url.searchParams.set('code', error.code);
}
const errorMessage = getAuthErrorMessage({ error: error.message, code: error.code });
url.searchParams.set('error', errorMessage);
} }
// return the user to an error page with some instructions // return the user to an error page with some instructions
@@ -163,6 +174,7 @@ class AuthCallbackService {
// if we have an error, we redirect to the error page // if we have an error, we redirect to the error page
if (error) { if (error) {
return onError({ return onError({
code: error.code,
error: error.message, error: error.message,
path: errorPath, path: errorPath,
}); });
@@ -179,6 +191,7 @@ class AuthCallbackService {
const message = error instanceof Error ? error.message : error; const message = error instanceof Error ? error.message : error;
return onError({ return onError({
code: (error as AuthError)?.code,
error: message as string, error: message as string,
path: errorPath, path: errorPath,
}); });
@@ -198,8 +211,16 @@ class AuthCallbackService {
} }
} }
function onError({ error, path }: { error: string; path: string }) { function onError({
const errorMessage = getAuthErrorMessage(error); error,
path,
code,
}: {
error: string;
path: string;
code?: string;
}) {
const errorMessage = getAuthErrorMessage({ error, code });
console.error( console.error(
{ {
@@ -209,7 +230,12 @@ function onError({ error, path }: { error: string; path: string }) {
`An error occurred while signing user in`, `An error occurred while signing user in`,
); );
const nextPath = `${path}?error=${errorMessage}`; const searchParams = new URLSearchParams({
error: errorMessage,
code: code ?? '',
});
const nextPath = `${path}?${searchParams.toString()}`;
return { return {
nextPath, nextPath,
@@ -227,8 +253,23 @@ function isVerifierError(error: string) {
return error.includes('both auth code and code verifier should be non-empty'); return error.includes('both auth code and code verifier should be non-empty');
} }
function getAuthErrorMessage(error: string) { function getAuthErrorMessage(params: {
return isVerifierError(error) error: string;
? `auth:errors.codeVerifierMismatch` code?: string;
: `auth:authenticationErrorAlertBody`; }) {
// this error arises when the user tries to sign in with an expired email link
if (params.code) {
if (params.code === 'otp_expired') {
return 'auth:errors.otp_expired';
}
}
// this error arises when the user is trying to sign in with a different
// browser than the one they used to request the sign in link
if (isVerifierError(params.error)) {
return 'auth:errors.codeVerifierMismatch';
}
// fallback to the default error message
return `auth:authenticationErrorAlertBody`;
} }