* 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.
193 lines
5.3 KiB
TypeScript
193 lines
5.3 KiB
TypeScript
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);
|
|
}
|