chore: improve invitation flow, update project dependencies and documentation for Next.js 16 (#408)

* chore: update project dependencies and documentation for Next.js 16

- Upgraded Next.js from version 15 to 16 across various documentation files and components.
- Updated references to Next.js 16 in AGENTS.md and CLAUDE.md for consistency.
- Incremented application version to 2.21.0 in package.json.
- Refactored identity setup components to improve user experience and added confirmation dialogs for authentication methods.
- Enhanced invitation flow with new logic for handling user redirection and token generation.

* refactor: streamline invitation flow in e2e tests

- Simplified the invitation flow test by using a predefined email instead of generating a random one.
- Removed unnecessary steps such as clearing cookies and reloading the page before user sign-up.
- Enhanced clarity by eliminating commented-out code related to identity verification and user membership checks.

* refactor: improve code readability in IdentitiesPage and UpdatePasswordForm components

- Enhanced formatting of JSX elements in IdentitiesPage and UpdatePasswordForm for better readability.
- Adjusted indentation and line breaks to maintain consistent coding style across components.

* refactor: enhance LinkAccountsList component with user redirection logic

- Updated the LinkAccountsList component to include a redirectToPath option in the useLinkIdentityWithProvider hook for improved user experience.
- Removed redundant user hook declaration to streamline the code structure.

* refactor: update account setup logic in JoinTeamAccountPage

- Introduced a check for email-only authentication support to streamline account setup requirements.
- Adjusted the conditions for determining if a new account should set up additional authentication methods, enhancing user experience for new users.
This commit is contained in:
Giancarlo Buomprisco
2025-11-05 11:39:08 +07:00
committed by GitHub
parent ae404d8366
commit fa2fa9a15c
23 changed files with 1005 additions and 154 deletions

View File

@@ -62,7 +62,7 @@ export default AdminGuard(AdminPageComponent);
### Async Server Component Pattern
```typescript
// ✅ CORRECT - Next.js 15 pattern
// ✅ CORRECT - Next.js 16 pattern
async function AdminPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // ✅ await params directly

View File

@@ -62,7 +62,7 @@ export default AdminGuard(AdminPageComponent);
### Async Server Component Pattern
```typescript
// ✅ CORRECT - Next.js 15 pattern
// ✅ CORRECT - Next.js 16 pattern
async function AdminPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // ✅ await params directly

View File

@@ -0,0 +1,156 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import type { Provider } from '@supabase/supabase-js';
import { LinkAccountsList } from '@kit/accounts/personal-account-settings';
import { useUser } from '@kit/supabase/hooks/use-user';
import { useUserIdentities } from '@kit/supabase/hooks/use-user-identities';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
interface IdentitiesStepWrapperProps {
nextPath: string;
showPasswordOption: boolean;
showEmailOption: boolean;
enableIdentityLinking: boolean;
oAuthProviders: Provider[];
requiresConfirmation: boolean;
}
export function IdentitiesStepWrapper(props: IdentitiesStepWrapperProps) {
const user = useUser();
const { identities } = useUserIdentities();
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [hasSetPassword, setHasSetPassword] = useState(false);
const [hasLinkedProvider, setHasLinkedProvider] = useState(false);
const initialCountRef = useRef<number | null>(null);
const initialHasPasswordRef = useRef<boolean | null>(null);
// Capture initial state once when data becomes available
// Using refs to avoid re-renders and useEffect to avoid accessing refs during render
useEffect(() => {
if (initialCountRef.current === null && identities.length > 0) {
const nonEmailIdentities = identities.filter(
(identity) => identity.provider !== 'email',
);
initialCountRef.current = nonEmailIdentities.length;
}
}, [identities]);
useEffect(() => {
if (initialHasPasswordRef.current === null && user.data) {
const amr = user.data.amr || [];
const hasPassword = amr.some(
(item: { method: string }) => item.method === 'password',
);
initialHasPasswordRef.current = hasPassword;
}
}, [user.data]);
const handleContinueClick = (e: React.MouseEvent) => {
// Only show confirmation if password or oauth is enabled (requiresConfirmation)
if (!props.requiresConfirmation) {
return;
}
const currentNonEmailIdentities = identities.filter(
(identity) => identity.provider !== 'email',
);
const hasAddedNewIdentity =
currentNonEmailIdentities.length > (initialCountRef.current ?? 0);
// Check if password was added
const amr = user.data?.amr || [];
const currentHasPassword = amr.some(
(item: { method: string }) => item.method === 'password',
);
const hasAddedPassword =
currentHasPassword && !initialHasPasswordRef.current;
// If no new identity was added AND no password was set AND no provider linked, show confirmation dialog
if (
!hasAddedNewIdentity &&
!hasAddedPassword &&
!hasSetPassword &&
!hasLinkedProvider
) {
e.preventDefault();
setShowConfirmDialog(true);
}
};
return (
<>
<div
className={
'animate-in fade-in slide-in-from-bottom-4 mx-auto flex w-full max-w-md flex-col space-y-4 duration-500'
}
data-test="join-step-two"
>
<LinkAccountsList
providers={props.oAuthProviders}
showPasswordOption={props.showPasswordOption}
showEmailOption={props.showEmailOption}
redirectTo={props.nextPath}
enabled={props.enableIdentityLinking}
onPasswordSet={() => setHasSetPassword(true)}
onProviderLinked={() => setHasLinkedProvider(true)}
/>
<Button asChild data-test="continue-button">
<Link href={props.nextPath} onClick={handleContinueClick}>
<Trans i18nKey={'common:continueKey'} />
</Link>
</Button>
</div>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent data-test="no-auth-method-dialog">
<AlertDialogHeader>
<AlertDialogTitle data-test="no-auth-dialog-title">
<Trans i18nKey={'auth:noIdentityLinkedTitle'} />
</AlertDialogTitle>
<AlertDialogDescription data-test="no-auth-dialog-description">
<Trans i18nKey={'auth:noIdentityLinkedDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-test="no-auth-dialog-cancel">
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction asChild data-test="no-auth-dialog-continue">
<Link href={props.nextPath}>
<Trans i18nKey={'common:continueKey'} />
</Link>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -1,15 +1,10 @@
import { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
import { LinkAccountsList } from '@kit/accounts/personal-account-settings';
import { AuthLayoutShell } from '@kit/auth/shared';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
@@ -19,6 +14,8 @@ import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { IdentitiesStepWrapper } from './_components/identities-step-wrapper';
export const meta = async (): Promise<Metadata> => {
const i18n = await createI18nServerInstance();
@@ -42,6 +39,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) {
showEmailOption,
oAuthProviders,
enableIdentityLinking,
requiresConfirmation,
} = await fetchData(props);
return (
@@ -55,24 +53,30 @@ async function IdentitiesPage(props: IdentitiesPageProps) {
}
>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className="text-center">
<Heading
level={4}
className="text-center"
data-test="identities-page-heading"
>
<Trans i18nKey={'auth:linkAccountToSignIn'} />
</Heading>
<Heading
level={6}
className={'text-muted-foreground text-center text-sm'}
data-test="identities-page-description"
>
<Trans i18nKey={'auth:linkAccountToSignInDescription'} />
</Heading>
</div>
<IdentitiesStep
<IdentitiesStepWrapper
nextPath={nextPath}
showPasswordOption={showPasswordOption}
showEmailOption={showEmailOption}
oAuthProviders={oAuthProviders}
enableIdentityLinking={enableIdentityLinking}
requiresConfirmation={requiresConfirmation}
/>
</div>
</AuthLayoutShell>
@@ -81,42 +85,6 @@ async function IdentitiesPage(props: IdentitiesPageProps) {
export default withI18n(IdentitiesPage);
/**
* @name IdentitiesStep
* @description Displays linked accounts and available authentication methods.
* LinkAccountsList component handles all authentication options including OAuth and Email/Password.
*/
function IdentitiesStep(props: {
nextPath: string;
showPasswordOption: boolean;
showEmailOption: boolean;
enableIdentityLinking: boolean;
oAuthProviders: Provider[];
}) {
return (
<div
className={
'animate-in fade-in slide-in-from-bottom-4 mx-auto flex w-full max-w-md flex-col space-y-4 duration-500'
}
data-test="join-step-two"
>
<LinkAccountsList
providers={props.oAuthProviders}
showPasswordOption={props.showPasswordOption}
showEmailOption={props.showEmailOption}
redirectTo={props.nextPath}
enabled={props.enableIdentityLinking}
/>
<Button asChild data-test="skip-identities-button">
<Link href={props.nextPath}>
<Trans i18nKey={'common:continueKey'} />
</Link>
</Button>
</div>
);
}
async function fetchData(props: IdentitiesPageProps) {
const searchParams = await props.searchParams;
const client = getSupabaseServerClient();
@@ -142,11 +110,16 @@ async function fetchData(props: IdentitiesPageProps) {
const oAuthProviders = authConfig.providers.oAuth;
const enableIdentityLinking = authConfig.enableIdentityLinking;
// Only require confirmation if password or oauth providers are enabled
const requiresConfirmation =
authConfig.providers.password || oAuthProviders.length > 0;
return {
nextPath,
showPasswordOption,
showEmailOption,
oAuthProviders,
enableIdentityLinking,
requiresConfirmation,
};
}

View File

@@ -0,0 +1,192 @@
import { NextRequest, NextResponse } from 'next/server';
import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import pathsConfig from '~/config/paths.config';
import { Database } from '~/lib/database.types';
/**
* @name GET
* @description Middleware route that validates team invitation and generates fresh auth link on-demand.
*
* Flow:
* 1. User clicks email link: /join/accept?invite_token=xxx
* 2. Validate invitation exists and not expired (7-day window)
* 3. Generate fresh Supabase auth link (new 24-hour token)
* 4. Redirect to /auth/confirm with fresh token
* 5. User authenticated immediately (token consumed right away)
*/
export async function GET(request: NextRequest) {
const logger = await getLogger();
const { searchParams } = new URL(request.url);
const inviteToken = searchParams.get('invite_token');
const ctx = {
name: 'join.accept',
inviteToken,
};
// Validate invite token is provided
if (!inviteToken) {
logger.warn(ctx, 'Missing invite_token parameter');
return redirectToError('Invalid invitation link');
}
try {
const adminClient = getSupabaseServerAdminClient();
// Query invitation from database
const { data: invitation, error: invitationError } = await adminClient
.from('invitations')
.select('*')
.eq('invite_token', inviteToken)
.gte('expires_at', new Date().toISOString())
.single();
// Handle invitation not found or expired
if (invitationError || !invitation) {
logger.warn(
{
...ctx,
error: invitationError,
},
'Invitation not found or expired',
);
return redirectToError('Invitation not found or expired');
}
logger.info(
{
...ctx,
invitationId: invitation.id,
email: invitation.email,
},
'Valid invitation found. Generating auth link...',
);
// Determine email link type based on user existence
// 'invite' for new users (creates account + authenticates)
// 'magiclink' for existing users (authenticates only)
const emailLinkType = await determineEmailLinkType(
adminClient,
invitation.email,
);
logger.info(
{
...ctx,
emailLinkType,
email: invitation.email,
},
'Determined email link type for invitation',
);
// Generate fresh Supabase auth link
const generateLinkResponse = await adminClient.auth.admin.generateLink({
email: invitation.email,
type: emailLinkType,
});
if (generateLinkResponse.error) {
logger.error(
{
...ctx,
error: generateLinkResponse.error,
},
'Failed to generate auth link',
);
throw generateLinkResponse.error;
}
// Extract token from generated link
const verifyLink = generateLinkResponse.data.properties?.action_link;
const token = new URL(verifyLink).searchParams.get('token');
if (!token) {
logger.error(ctx, 'Token not found in generated link');
throw new Error('Token in verify link from Supabase Auth was not found');
}
// Build redirect URL to auth confirmation with fresh token
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
const authCallbackUrl = new URL('/auth/confirm', siteUrl);
// Add auth parameters
authCallbackUrl.searchParams.set('token_hash', token);
authCallbackUrl.searchParams.set('type', emailLinkType);
// Add next parameter to redirect to join page after auth
const joinUrl = new URL(pathsConfig.app.joinTeam, siteUrl);
joinUrl.searchParams.set('invite_token', inviteToken);
// Mark if this is a new user so /join page can redirect to /identities
if (emailLinkType === 'invite') {
joinUrl.searchParams.set('is_new_user', 'true');
}
authCallbackUrl.searchParams.set('next', joinUrl.href);
logger.info(
{
...ctx,
redirectUrl: authCallbackUrl.pathname,
},
'Redirecting to auth confirmation with fresh token',
);
// Redirect to auth confirmation
return NextResponse.redirect(authCallbackUrl);
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Failed to process invitation acceptance',
);
return redirectToError('An error occurred processing your invitation');
}
}
/**
* @name determineEmailLinkType
* @description Determines whether to use 'invite' or 'magiclink' based on user existence
*/
async function determineEmailLinkType(
adminClient: SupabaseClient<Database>,
email: string,
): Promise<'invite' | 'magiclink'> {
const user = await adminClient
.from('accounts')
.select('*')
.eq('email', email)
.single();
// If user not found, return 'invite' type (allows registration)
if (user.error || !user.data) {
return 'invite';
}
// If user exists, return 'magiclink' type (sign in)
return 'magiclink';
}
/**
* @name redirectToError
* @description Redirects to join page with error message
*/
function redirectToError(message: string): NextResponse {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
const errorUrl = new URL(pathsConfig.app.joinTeam, siteUrl);
errorUrl.searchParams.set('error', message);
return NextResponse.redirect(errorUrl);
}

View File

@@ -24,6 +24,7 @@ interface JoinTeamAccountPageProps {
invite_token?: string;
type?: 'invite' | 'magic-link';
email?: string;
is_new_user?: string;
}>;
}
@@ -131,16 +132,18 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
// Determine if we should show the account setup step (Step 2)
// Decision logic:
// 1. Only show for new accounts (linkType === 'invite')
// 2. Only if we have auth options available (password OR OAuth)
// 1. Only show for new accounts (is_new_user === 'true' or linkType === 'invite')
// 2. Only if we don't support email only auth (magic link or OTP)
// 3. Users can always skip and set up auth later in account settings
const linkType = searchParams.type;
const supportsPasswordSignUp = authConfig.providers.password;
const supportsOAuthProviders = authConfig.providers.oAuth.length > 0;
const isNewAccount = linkType === 'invite';
const isNewUserParam = searchParams.is_new_user === 'true';
const shouldSetupAccount =
isNewAccount && (supportsPasswordSignUp || supportsOAuthProviders);
// if the app supports email only auth, we don't need to setup any other auth methods. In all other cases (passowrd, oauth), we need to setup at least one of them.
const supportsEmailOnlyAuth =
authConfig.providers.magicLink || authConfig.providers.otp;
const isNewAccount = isNewUserParam || linkType === 'invite';
const shouldSetupAccount = isNewAccount && !supportsEmailOnlyAuth;
// Determine redirect destination after joining:
// - If shouldSetupAccount: redirect to /identities with next param (Step 2)