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:
committed by
GitHub
parent
ae404d8366
commit
fa2fa9a15c
192
apps/web/app/join/accept/route.ts
Normal file
192
apps/web/app/join/accept/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user