Unify workspace dropdowns; Update layouts (#458)
Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
committed by
GitHub
parent
ca585e09be
commit
4bc8448a1d
@@ -1,193 +0,0 @@
|
||||
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');
|
||||
}
|
||||
|
||||
// Use pathname + search to create a safe relative path for validation
|
||||
authCallbackUrl.searchParams.set('next', joinUrl.pathname + joinUrl.search);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||
import { MultiFactorAuthError, requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
import { AcceptInvitationContainer } from '@kit/team-accounts/components';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import authConfig from '~/config/auth.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface JoinTeamAccountPageProps {
|
||||
searchParams: Promise<{
|
||||
invite_token?: string;
|
||||
type?: 'invite' | 'magic-link';
|
||||
email?: string;
|
||||
is_new_user?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: i18n.t('teams:joinTeamAccount'),
|
||||
};
|
||||
};
|
||||
|
||||
async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const token = searchParams.invite_token;
|
||||
|
||||
// no token, redirect to 404
|
||||
if (!token) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const auth = await requireUser(client);
|
||||
|
||||
// if the user is not logged in or there is an error
|
||||
// redirect to the sign up page with the invite token
|
||||
// so that they will get back to this page after signing up
|
||||
if (auth.error ?? !auth.data) {
|
||||
if (auth.error instanceof MultiFactorAuthError) {
|
||||
const urlParams = new URLSearchParams({
|
||||
next: `${pathsConfig.app.joinTeam}?invite_token=${token}&email=${searchParams.email ?? ''}`,
|
||||
});
|
||||
|
||||
const verifyMfaUrl = `${pathsConfig.auth.verifyMfa}?${urlParams.toString()}`;
|
||||
|
||||
// if the user needs to verify MFA
|
||||
// redirect them to the MFA verification page
|
||||
redirect(verifyMfaUrl);
|
||||
} else {
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: token,
|
||||
});
|
||||
|
||||
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
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
// the user is logged in, we can now check if the token is valid
|
||||
const invitation = await api.getInvitation(adminClient, token);
|
||||
|
||||
if (!invitation) {
|
||||
return (
|
||||
<AuthLayoutShell Logo={AppLogo}>
|
||||
<InviteNotFoundOrExpired />
|
||||
</AuthLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
// the invitation is not found or expired or the email is not the same as the user's email (case insensitive)
|
||||
const isInvitationValid =
|
||||
invitation.email.toLowerCase() === auth.data.email.toLowerCase();
|
||||
|
||||
if (!isInvitationValid) {
|
||||
return (
|
||||
<AuthLayoutShell Logo={AppLogo}>
|
||||
<InviteNotFoundOrExpired />
|
||||
</AuthLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
// we need to verify the user isn't already in the account
|
||||
// we do so by checking if the user can read the account
|
||||
// if the user can read the account, then they are already in the account
|
||||
const { data: isAlreadyTeamMember } = await client.rpc(
|
||||
'is_account_team_member',
|
||||
{
|
||||
target_account_id: invitation.account.id,
|
||||
},
|
||||
);
|
||||
|
||||
// if the user is already in the account redirect to the home page
|
||||
if (isAlreadyTeamMember) {
|
||||
const { getLogger } = await import('@kit/shared/logger');
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
name: 'join-team-account',
|
||||
accountId: invitation.account.id,
|
||||
userId: auth.data.id,
|
||||
},
|
||||
'User is already in the account. Redirecting to account page.',
|
||||
);
|
||||
|
||||
// if the user is already in the account redirect to the home page
|
||||
redirect(pathsConfig.app.home);
|
||||
}
|
||||
|
||||
// if the user decides to sign in with a different account
|
||||
// we redirect them to the sign in page with the invite token
|
||||
const signOutNext = `${pathsConfig.auth.signIn}?invite_token=${token}`;
|
||||
|
||||
// once the user accepts the invitation, we redirect them to the account home page
|
||||
const accountHome = pathsConfig.app.accountHome.replace(
|
||||
'[account]',
|
||||
invitation.account.slug,
|
||||
);
|
||||
|
||||
// Determine if we should show the account setup step (Step 2)
|
||||
// Decision logic:
|
||||
// 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 isNewUserParam = searchParams.is_new_user === 'true';
|
||||
|
||||
// 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)
|
||||
// - Otherwise: redirect directly to team home (skip Step 2)
|
||||
const nextPath = shouldSetupAccount
|
||||
? `/identities?next=${encodeURIComponent(accountHome)}`
|
||||
: accountHome;
|
||||
|
||||
const email = auth.data.email ?? '';
|
||||
|
||||
return (
|
||||
<AuthLayoutShell Logo={AppLogo}>
|
||||
<AcceptInvitationContainer
|
||||
email={email}
|
||||
inviteToken={token}
|
||||
invitation={invitation}
|
||||
paths={{
|
||||
signOutNext,
|
||||
nextPath,
|
||||
}}
|
||||
/>
|
||||
</AuthLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(JoinTeamAccountPage);
|
||||
|
||||
function InviteNotFoundOrExpired() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Heading level={6}>
|
||||
<Trans i18nKey={'teams:inviteNotFoundOrExpired'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'teams:inviteNotFoundOrExpiredDescription'} />
|
||||
</p>
|
||||
|
||||
<Button asChild className={'w-full'} variant={'outline'}>
|
||||
<Link href={pathsConfig.app.home}>
|
||||
<ArrowLeft className={'mr-2 w-4'} />
|
||||
<Trans i18nKey={'teams:backToHome'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user