Skip to content

Commit eb9bcab

Browse files
committed
feat: improve invite page handling and user feedback
- Refactored the invite page to enhance user experience by providing clear feedback for invalid or expired invitations. - Introduced new components, `InviteStatusCard` and `InviteNotMatchCard`, to display relevant messages based on invitation status and email mismatches. - Moved the `maskEmail` utility function to a separate file for better organization and reusability. - Streamlined the redirection logic for users with mismatched emails, prompting them to sign out and sign back in with the correct account.
1 parent e3fb7d3 commit eb9bcab

File tree

4 files changed

+129
-49
lines changed

4 files changed

+129
-49
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client';
2+
3+
import { SignOut } from '@/components/sign-out';
4+
import { InviteStatusCard } from './InviteStatusCard';
5+
6+
export function InviteNotMatchCard({
7+
currentEmail,
8+
invitedEmail,
9+
}: {
10+
currentEmail: string;
11+
invitedEmail: string;
12+
}) {
13+
return (
14+
<InviteStatusCard
15+
title="Wrong account"
16+
description="You're signed in with a different email than the one this invite was sent to. Please sign out and sign back in with the invited email."
17+
>
18+
<div className="mx-auto max-w-[42ch] text-muted-foreground leading-relaxed flex flex-col gap-4">
19+
<div className="space-y-2 text-sm">
20+
<p>
21+
You are signed in as
22+
<span className="mx-1 inline-flex items-center rounded-xs border border-muted bg-muted/40 px-2 py-0.5 text-sm">
23+
{currentEmail}
24+
</span>
25+
</p>
26+
<p>
27+
This invite is for
28+
<span className="mx-1 inline-flex items-center rounded-xs border border-muted bg-muted/40 px-2 py-0.5 text-sm">
29+
{invitedEmail}
30+
</span>
31+
</p>
32+
</div>
33+
</div>
34+
<SignOut asButton className="w-full" />
35+
</InviteStatusCard>
36+
);
37+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import { Button } from '@comp/ui/button';
4+
import { Icons } from '@comp/ui/icons';
5+
import Link from 'next/link';
6+
7+
export function InviteStatusCard({
8+
title,
9+
description,
10+
primaryHref,
11+
primaryLabel,
12+
children,
13+
}: {
14+
title: string;
15+
description: string;
16+
primaryHref?: string;
17+
primaryLabel?: string;
18+
children?: React.ReactNode;
19+
}) {
20+
return (
21+
<div className="bg-card relative w-full max-w-[480px] rounded-sm border p-10 shadow-lg">
22+
<div className="flex flex-col items-center gap-6 text-center">
23+
<Icons.Logo />
24+
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
25+
<p className="mx-auto max-w-[48ch] text-base leading-relaxed text-muted-foreground">
26+
{description}
27+
</p>
28+
{children}
29+
{primaryHref && primaryLabel && (
30+
<Link href={primaryHref} className="w-full">
31+
<Button className="w-full" size="sm">
32+
{primaryLabel}
33+
</Button>
34+
</Link>
35+
)}
36+
</div>
37+
</div>
38+
);
39+
}

apps/app/src/app/(app)/invite/[code]/page.tsx

Lines changed: 39 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,30 @@
11
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
2-
import { SignOut } from '@/components/sign-out';
32
import { auth } from '@/utils/auth';
4-
import { Icons } from '@comp/ui/icons';
53
import { db } from '@db';
64
import { headers } from 'next/headers';
7-
import { notFound, redirect } from 'next/navigation';
5+
import { redirect } from 'next/navigation';
86
import { AcceptInvite } from '../../setup/components/accept-invite';
7+
import { InviteNotMatchCard } from './components/InviteNotMatchCard';
8+
import { InviteStatusCard } from './components/InviteStatusCard';
9+
import { maskEmail } from './utils';
910

1011
interface InvitePageProps {
1112
params: Promise<{ code: string }>;
1213
}
1314

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-
2915
export default async function InvitePage({ params }: InvitePageProps) {
3016
const { code } = await params;
3117
const session = await auth.api.getSession({
3218
headers: await headers(),
3319
});
3420

3521
if (!session) {
36-
// Redirect to auth with the invite code
3722
return redirect(`/auth?inviteCode=${code}`);
3823
}
3924

40-
// Load invite by code, then verify email after
4125
const invitation = await db.invitation.findFirst({
4226
where: {
4327
id: code,
44-
status: 'pending',
4528
},
4629
include: {
4730
organization: {
@@ -53,40 +36,47 @@ export default async function InvitePage({ params }: InvitePageProps) {
5336
});
5437

5538
if (!invitation) {
56-
notFound();
39+
return (
40+
<OnboardingLayout variant="setup" currentOrganization={null}>
41+
<div className="flex min-h-[calc(100dvh-80px)] w-full items-center justify-center p-4">
42+
<InviteStatusCard
43+
title="Invite not found"
44+
description="This invitation code does not exist. Please check the link or ask your admin to resend the invite."
45+
primaryHref="/"
46+
primaryLabel="Go home"
47+
/>
48+
</div>
49+
</OnboardingLayout>
50+
);
51+
}
52+
53+
if (invitation.status !== 'pending') {
54+
return (
55+
<OnboardingLayout variant="setup" currentOrganization={null}>
56+
<div className="flex min-h-[calc(100dvh-80px)] w-full items-center justify-center p-4">
57+
<InviteStatusCard
58+
title={invitation.status === 'accepted' ? 'Invite already accepted' : 'Invite expired'}
59+
description={
60+
invitation.status === 'accepted'
61+
? 'This invitation has already been accepted. If you believe this is a mistake, contact your organization admin.'
62+
: 'This invitation has expired. Please ask your organization admin to send a new invite.'
63+
}
64+
primaryHref="/"
65+
primaryLabel="Go home"
66+
/>
67+
</div>
68+
</OnboardingLayout>
69+
);
5770
}
5871

59-
// If signed-in user email doesn't match the invited email, prompt to switch accounts
6072
if (invitation.email !== session.user.email) {
6173
return (
6274
<OnboardingLayout variant="setup" currentOrganization={null}>
6375
<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>
76+
<InviteNotMatchCard
77+
currentEmail={session.user.email}
78+
invitedEmail={maskEmail(invitation.email)}
79+
/>
9080
</div>
9181
</OnboardingLayout>
9282
);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function maskEmail(email: string): string {
2+
const [local, domain] = email.split('@');
3+
if (!domain) return email;
4+
const maskedLocal =
5+
local.length <= 2 ? `${local[0] ?? ''}***` : `${local[0]}***${local.slice(-1)}`;
6+
7+
const domainParts = domain.split('.');
8+
if (domainParts.length === 0) return `${maskedLocal}@***`;
9+
const tld = domainParts[domainParts.length - 1];
10+
const secondLevel = domainParts.length >= 2 ? domainParts[domainParts.length - 2] : '';
11+
const maskedSecondLevel = secondLevel ? `${secondLevel[0]}***` : '***';
12+
13+
return `${maskedLocal}@${maskedSecondLevel}.${tld}`;
14+
}

0 commit comments

Comments
 (0)