Skip to content

Commit e3fb7d3

Browse files
committed
feat: enhance invitation handling and user redirection
- Implemented server-side redirection for users accepting invitations, improving user experience by directing them to the appropriate organization page. - Added logic to check for pending invitations in the layout and root page, ensuring users are redirected to the invite page if applicable. - Updated the invite page to handle email mismatches, prompting users to switch accounts if their signed-in email does not match the invited email. - Refactored the AcceptInvite component to utilize redirection after accepting an invitation, streamlining the flow for users.
1 parent 5c50ccf commit e3fb7d3

File tree

7 files changed

+143
-49
lines changed

7 files changed

+143
-49
lines changed

apps/app/src/actions/organization/accept-invitation.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { db } from '@db';
44
import { revalidatePath, revalidateTag } from 'next/cache';
5+
import { redirect } from 'next/navigation';
56
import { Resend } from 'resend';
67
import { z } from 'zod';
78
import { authActionClientWithoutOrg } from '../safe-action';
@@ -88,13 +89,8 @@ export const completeInvitation = authActionClientWithoutOrg
8889
},
8990
});
9091

91-
return {
92-
success: true,
93-
data: {
94-
accepted: true,
95-
organizationId: invitation.organizationId,
96-
},
97-
};
92+
// Server redirect to the organization's root
93+
redirect(`/${invitation.organizationId}/`);
9894
}
9995

10096
if (!invitation.role) {
@@ -148,13 +144,8 @@ export const completeInvitation = authActionClientWithoutOrg
148144
revalidatePath(`/${invitation.organization.id}/settings/users`);
149145
revalidateTag(`user_${user.id}`);
150146

151-
return {
152-
success: true,
153-
data: {
154-
accepted: true,
155-
organizationId: invitation.organizationId,
156-
},
157-
};
147+
// Server redirect to the organization's root
148+
redirect(`/${invitation.organizationId}/`);
158149
} catch (error) {
159150
console.error('Error accepting invitation:', error);
160151
throw new Error(error as string);
Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { getOrganizations } from '@/data/getOrganizations';
1+
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
2+
import { SignOut } from '@/components/sign-out';
23
import { auth } from '@/utils/auth';
3-
import type { Organization } from '@db';
4+
import { Icons } from '@comp/ui/icons';
45
import { db } from '@db';
56
import { headers } from 'next/headers';
67
import { notFound, redirect } from 'next/navigation';
@@ -10,6 +11,21 @@ interface InvitePageProps {
1011
params: Promise<{ code: string }>;
1112
}
1213

14+
const maskEmail = (email: string) => {
15+
const [local, domain] = email.split('@');
16+
if (!domain) return email;
17+
const maskedLocal =
18+
local.length <= 2 ? `${local[0] ?? ''}***` : `${local[0]}***${local.slice(-1)}`;
19+
20+
const domainParts = domain.split('.');
21+
if (domainParts.length === 0) return `${maskedLocal}@***`;
22+
const tld = domainParts[domainParts.length - 1];
23+
const secondLevel = domainParts.length >= 2 ? domainParts[domainParts.length - 2] : '';
24+
const maskedSecondLevel = secondLevel ? `${secondLevel[0]}***` : '***';
25+
26+
return `${maskedLocal}@${maskedSecondLevel}.${tld}`;
27+
};
28+
1329
export default async function InvitePage({ params }: InvitePageProps) {
1430
const { code } = await params;
1531
const session = await auth.api.getSession({
@@ -21,21 +37,10 @@ export default async function InvitePage({ params }: InvitePageProps) {
2137
return redirect(`/auth?inviteCode=${code}`);
2238
}
2339

24-
// Fetch existing organizations
25-
let organizations: Organization[] = [];
26-
try {
27-
const result = await getOrganizations();
28-
organizations = result.organizations;
29-
} catch (error) {
30-
// If user has no organizations, continue with empty array
31-
console.error('Failed to fetch organizations:', error);
32-
}
33-
34-
// Check if this invitation exists and is valid for this user
40+
// Load invite by code, then verify email after
3541
const invitation = await db.invitation.findFirst({
3642
where: {
3743
id: code,
38-
email: session.user.email,
3944
status: 'pending',
4045
},
4146
include: {
@@ -48,16 +53,53 @@ export default async function InvitePage({ params }: InvitePageProps) {
4853
});
4954

5055
if (!invitation) {
51-
// Either invitation doesn't exist, already accepted, or not for this user
5256
notFound();
5357
}
5458

59+
// If signed-in user email doesn't match the invited email, prompt to switch accounts
60+
if (invitation.email !== session.user.email) {
61+
return (
62+
<OnboardingLayout variant="setup" currentOrganization={null}>
63+
<div className="flex min-h-[calc(100dvh-80px)] w-full items-center justify-center p-4">
64+
<div className="bg-card relative w-full max-w-[480px] rounded-sm border p-10 shadow-lg">
65+
<div className="flex flex-col items-center gap-6 text-center">
66+
<Icons.Logo />
67+
<h1 className="text-2xl font-semibold tracking-tight">Wrong account</h1>
68+
<div className="mx-auto max-w-[42ch] text-muted-foreground leading-relaxed flex flex-col gap-4">
69+
<div className="space-y-2 text-sm">
70+
<p>
71+
You are signed in as
72+
<span className="mx-1 inline-flex items-center rounded-xs border border-muted bg-muted/40 px-2 py-0.5 text-sm">
73+
{session.user.email}
74+
</span>
75+
</p>
76+
<p>
77+
This invite is for
78+
<span className="mx-1 inline-flex items-center rounded-xs border border-muted bg-muted/40 px-2 py-0.5 text-sm">
79+
{maskEmail(invitation.email)}
80+
</span>
81+
</p>
82+
</div>
83+
<p className="text-base font-medium">
84+
To accept, sign out and sign back in with the invited email.
85+
</p>
86+
</div>
87+
<SignOut asButton className="w-full" />
88+
</div>
89+
</div>
90+
</div>
91+
</OnboardingLayout>
92+
);
93+
}
94+
5595
return (
56-
<div className="flex flex-1 items-center justify-center p-4">
57-
<AcceptInvite
58-
inviteCode={invitation.id}
59-
organizationName={invitation.organization.name || ''}
60-
/>
61-
</div>
96+
<OnboardingLayout variant="setup" currentOrganization={null}>
97+
<div className="flex min-h-[calc(100dvh-80px)] w-full items-center justify-center p-4">
98+
<AcceptInvite
99+
inviteCode={invitation.id}
100+
organizationName={invitation.organization.name || ''}
101+
/>
102+
</div>
103+
</OnboardingLayout>
62104
);
63105
}

apps/app/src/app/(app)/layout.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { auth } from '@/utils/auth';
2+
import { db } from '@db';
3+
import { headers } from 'next/headers';
4+
import { redirect } from 'next/navigation';
5+
6+
export default async function Layout({ children }: { children: React.ReactNode }) {
7+
const hdrs = await headers();
8+
const session = await auth.api.getSession({
9+
headers: hdrs,
10+
});
11+
12+
if (!session) {
13+
return redirect('/auth');
14+
}
15+
16+
const pendingInvite = await db.invitation.findFirst({
17+
where: {
18+
email: session.user.email,
19+
status: 'pending',
20+
},
21+
});
22+
23+
if (pendingInvite) {
24+
let path = hdrs.get('x-pathname') || hdrs.get('referer') || '';
25+
// normalize potential locale prefix
26+
path = path.replace(/\/([a-z]{2})\//, '/');
27+
const target = `/invite/${pendingInvite.id}`;
28+
if (!path.startsWith(target)) {
29+
return redirect(target);
30+
}
31+
}
32+
33+
return <>{children}</>;
34+
}

apps/app/src/app/(app)/setup/components/accept-invite.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Icons } from '@comp/ui/icons';
77
import { Loader2 } from 'lucide-react';
88
import { useAction } from 'next-safe-action/hooks';
99
import Link from 'next/link';
10-
import { useRouter } from 'next/navigation';
10+
import { redirect } from 'next/navigation';
1111
import { toast } from 'sonner';
1212

1313
export function AcceptInvite({
@@ -17,7 +17,7 @@ export function AcceptInvite({
1717
inviteCode: string;
1818
organizationName: string;
1919
}) {
20-
const router = useRouter();
20+
// Using next/navigation redirect to avoid showing invite page after accept
2121

2222
const { execute, isPending } = useAction(completeInvitation, {
2323
onSuccess: async (result) => {
@@ -26,36 +26,39 @@ export function AcceptInvite({
2626
await authClient.organization.setActive({
2727
organizationId: result.data.data.organizationId,
2828
});
29-
// Redirect to the organization's root path
30-
router.push(`/${result.data.data.organizationId}/`);
3129
}
3230
},
3331
onError: (error) => {
3432
toast.error('Failed to accept invitation');
3533
},
3634
});
3735

38-
const handleAccept = () => {
39-
execute({ inviteCode });
36+
const handleAccept = async () => {
37+
await execute({ inviteCode });
38+
redirect(`/`);
4039
};
4140

4241
return (
4342
<div className="bg-card relative w-full max-w-[440px] rounded-sm border p-8 shadow-lg">
44-
<div className="mb-8 flex justify-between">
43+
<div className="mb-8 flex justify-center">
4544
<Link href="/">
4645
<Icons.Logo />
4746
</Link>
4847
</div>
4948

50-
<div className="mb-8 space-y-2">
51-
<h1 className="text-2xl font-semibold tracking-tight">You have been invited to join</h1>
52-
<p className="text-xl font-medium line-clamp-1">{organizationName || 'an organization'}</p>
49+
<div className="mb-8 space-y-1.5 text-center">
50+
<h1 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
51+
You have been invited to join
52+
</h1>
53+
<p className="text-2xl font-semibold tracking-tight line-clamp-1">
54+
{organizationName || 'an organization'}
55+
</p>
5356
<p className="text-muted-foreground text-sm">
5457
Please accept the invitation to join the organization.
5558
</p>
5659
</div>
5760

58-
<Button onClick={handleAccept} className="w-full" disabled={isPending}>
61+
<Button onClick={handleAccept} className="w-full" size="sm" disabled={isPending}>
5962
{isPending ? (
6063
<>
6164
<Loader2 className="mr-2 h-4 w-4 animate-spin" />

apps/app/src/app/page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ export default async function RootPage({
3636
const orgId = session.session.activeOrganizationId;
3737

3838
if (!orgId) {
39+
// If the user has no active org, check for pending invitations for this email
40+
const pendingInvite = await db.invitation.findFirst({
41+
where: {
42+
email: session.user.email,
43+
status: 'pending',
44+
},
45+
});
46+
47+
if (pendingInvite) {
48+
return redirect(await buildUrlWithParams(`/invite/${pendingInvite.id}`));
49+
}
50+
3951
return redirect(await buildUrlWithParams('/setup'));
4052
}
4153

apps/app/src/components/onboarding/OnboardingLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function OnboardingLayout({
3939
currentOrganization={currentOrganization}
4040
variant={variant}
4141
/>
42-
<div>{children}</div>
42+
<div className="flex flex-1">{children}</div>
4343
</main>
4444
);
4545
}

apps/app/src/components/sign-out.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ import { DropdownMenuItem } from '@comp/ui/dropdown-menu';
66
import { useRouter } from 'next/navigation';
77
import { useState } from 'react';
88

9-
export function SignOut({ asButton = false }: { asButton?: boolean }) {
9+
export function SignOut({
10+
asButton = false,
11+
className = '',
12+
size = 'sm',
13+
}: {
14+
asButton?: boolean;
15+
className?: string;
16+
size?: 'default' | 'sm' | 'lg' | 'icon';
17+
}) {
1018
const router = useRouter();
1119
const [isLoading, setLoading] = useState(false);
1220

@@ -22,7 +30,11 @@ export function SignOut({ asButton = false }: { asButton?: boolean }) {
2230
};
2331

2432
if (asButton) {
25-
return <Button onClick={handleSignOut}>{isLoading ? 'Loading...' : 'Sign out'}</Button>;
33+
return (
34+
<Button onClick={handleSignOut} className={className} size={size}>
35+
{isLoading ? 'Loading...' : 'Sign out'}
36+
</Button>
37+
);
2638
}
2739

2840
return (

0 commit comments

Comments
 (0)