Skip to content

Commit 198559a

Browse files
authored
Merge pull request #1331 from trycompai/mariano/docker
[dev] [Marfuen] mariano/docker
2 parents 62fbd4f + eb9bcab commit 198559a

File tree

10 files changed

+227
-53
lines changed

10 files changed

+227
-53
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: 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+
}
Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { getOrganizations } from '@/data/getOrganizations';
1+
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
22
import { auth } from '@/utils/auth';
3-
import type { Organization } from '@db';
43
import { db } from '@db';
54
import { headers } from 'next/headers';
6-
import { notFound, redirect } from 'next/navigation';
5+
import { redirect } from 'next/navigation';
76
import { AcceptInvite } from '../../setup/components/accept-invite';
7+
import { InviteNotMatchCard } from './components/InviteNotMatchCard';
8+
import { InviteStatusCard } from './components/InviteStatusCard';
9+
import { maskEmail } from './utils';
810

911
interface InvitePageProps {
1012
params: Promise<{ code: string }>;
@@ -17,26 +19,12 @@ export default async function InvitePage({ params }: InvitePageProps) {
1719
});
1820

1921
if (!session) {
20-
// Redirect to auth with the invite code
2122
return redirect(`/auth?inviteCode=${code}`);
2223
}
2324

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
3525
const invitation = await db.invitation.findFirst({
3626
where: {
3727
id: code,
38-
email: session.user.email,
39-
status: 'pending',
4028
},
4129
include: {
4230
organization: {
@@ -48,16 +36,60 @@ export default async function InvitePage({ params }: InvitePageProps) {
4836
});
4937

5038
if (!invitation) {
51-
// Either invitation doesn't exist, already accepted, or not for this user
52-
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+
);
70+
}
71+
72+
if (invitation.email !== session.user.email) {
73+
return (
74+
<OnboardingLayout variant="setup" currentOrganization={null}>
75+
<div className="flex min-h-[calc(100dvh-80px)] w-full items-center justify-center p-4">
76+
<InviteNotMatchCard
77+
currentEmail={session.user.email}
78+
invitedEmail={maskEmail(invitation.email)}
79+
/>
80+
</div>
81+
</OnboardingLayout>
82+
);
5383
}
5484

5585
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>
86+
<OnboardingLayout variant="setup" currentOrganization={null}>
87+
<div className="flex min-h-[calc(100dvh-80px)] w-full items-center justify-center p-4">
88+
<AcceptInvite
89+
inviteCode={invitation.id}
90+
organizationName={invitation.organization.name || ''}
91+
/>
92+
</div>
93+
</OnboardingLayout>
6294
);
6395
}
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+
}

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
}

0 commit comments

Comments
 (0)