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:
Giancarlo Buomprisco
2026-03-11 14:45:42 +08:00
committed by GitHub
parent ca585e09be
commit 4bc8448a1d
530 changed files with 14398 additions and 11198 deletions

View File

@@ -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);
}

View File

@@ -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>
);
}