Skip to content

Commit 11f0fed

Browse files
#301: Restyle auth pages and add invitation acceptance page
Shared components/auth (AuthLayout, text/password fields with show/hide, inline errors, form message area) on theme tokens, replacing the layout duplicated across login, signup, forgot and reset password and every alert(). Validation follows the server's real password policy, which lib/auth.ts now reads from the same constants. Invitation emails linked to /accept-invitation/<id>, which 404'd. The new server page loads the invitation through an org-scoped InvitationService and covers not found, expired/used, create account (email locked), log in and come back, wrong account (sign out), accept, and joined. The old afterUserCreated invite path called BetterAuth's acceptInvitation, which needs a session cookie that doesn't exist while the session is being created, so invitees always became join requests. Sign-up from the invitation page now sends x-invitation-id and the hook accepts that invitation only if it's open, on the host's org and addressed to the new user. A plain sign-up with the invited email no longer can claim it. Signed-in invitees accept through POST /api/invitations/:id/accept. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b3b0b02 commit 11f0fed

39 files changed

Lines changed: 2400 additions & 671 deletions

‎api/invitations.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Hono } from "hono";
2+
import { HTTPException } from "hono/http-exception";
3+
import { zValidator } from "@hono/zod-validator";
4+
import z from "zod";
5+
import { requireAuth } from "@/lib/authUtils";
6+
import { getSlugFromHost } from "@/lib/clientAuthUtils";
7+
import {
8+
AcceptInvitationResult,
9+
InvitationService,
10+
} from "@/lib/services/InvitationService";
11+
import { OrganizationsService } from "@/lib/services/OrganizationService";
12+
13+
const paramsSchema = z.object({ id: z.string().min(1) });
14+
15+
/** Status and message for each way accepting can fail. */
16+
const ACCEPT_FAILURE = {
17+
[AcceptInvitationResult.NotFound]: {
18+
status: 404,
19+
message: "Invitation not found",
20+
},
21+
[AcceptInvitationResult.Closed]: {
22+
status: 410,
23+
message: "This invitation has expired or was already used",
24+
},
25+
[AcceptInvitationResult.WrongRecipient]: {
26+
status: 403,
27+
message: "This invitation is for a different email",
28+
},
29+
[AcceptInvitationResult.AlreadyMember]: {
30+
status: 409,
31+
message: "You're already a member of this organization",
32+
},
33+
} as const satisfies Record<
34+
Exclude<AcceptInvitationResult, typeof AcceptInvitationResult.Accepted>,
35+
{ status: 403 | 404 | 409 | 410; message: string }
36+
>;
37+
38+
const app = new Hono().post(
39+
"/:id/accept",
40+
zValidator("param", paramsSchema),
41+
async (c) => {
42+
const session = await requireAuth(c);
43+
const { id } = c.req.valid("param");
44+
45+
// The invitation must belong to the org on this host, not whichever org
46+
// happens to be active on the session.
47+
const organization = await OrganizationsService.findBySlug(
48+
getSlugFromHost(c.req.header("host")),
49+
);
50+
if (!organization) {
51+
throw new HTTPException(404, { message: "Organization not found" });
52+
}
53+
54+
const result = await InvitationService.acceptForUser(
55+
id,
56+
organization.id,
57+
session.user,
58+
);
59+
if (result !== AcceptInvitationResult.Accepted) {
60+
const { status, message } = ACCEPT_FAILURE[result];
61+
throw new HTTPException(status, { message });
62+
}
63+
64+
return c.json({ organizationId: organization.id });
65+
},
66+
);
67+
68+
export default app;
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { Metadata } from "next";
2+
import { headers } from "next/headers";
3+
import AuthLayout from "@/components/auth/AuthLayout";
4+
import BackToLoginLink from "@/components/auth/BackToLoginLink";
5+
import SignupForm from "@/components/auth/SignupForm";
6+
import {
7+
AcceptInvitationButton,
8+
ContinueLink,
9+
LogInToAcceptLink,
10+
SignOutButton,
11+
} from "@/components/auth/AcceptInvitationActions";
12+
import { auth } from "@/lib/auth";
13+
import { getSlugFromHost } from "@/lib/clientAuthUtils";
14+
import {
15+
InvitationService,
16+
type Invitation,
17+
} from "@/lib/services/InvitationService";
18+
import { MembersService } from "@/lib/services/MemberService";
19+
import { OrganizationsService } from "@/lib/services/OrganizationService";
20+
import { UserService } from "@/lib/services/UserService";
21+
22+
export const metadata: Metadata = { title: "Accept Invitation" };
23+
24+
type InvitationView =
25+
| { kind: "not-found" }
26+
| { kind: "closed" }
27+
| { kind: "joined"; invitation: Invitation }
28+
| { kind: "create-account"; invitation: Invitation }
29+
| { kind: "log-in"; invitation: Invitation }
30+
| { kind: "wrong-account"; invitation: Invitation; signedInEmail: string }
31+
| { kind: "accept"; invitation: Invitation };
32+
33+
const ROLE_LABEL: Partial<Record<string, string>> = {
34+
owner: "an owner",
35+
admin: "an admin",
36+
member: "a member",
37+
};
38+
39+
function roleLabel(role: string) {
40+
return ROLE_LABEL[role] ?? role;
41+
}
42+
43+
function sameEmail(a: string, b: string) {
44+
return a.toLowerCase() === b.toLowerCase();
45+
}
46+
47+
/** Decides what the invitee sees, from the invitation and who's signed in. */
48+
async function resolveInvitationView(
49+
invitationId: string,
50+
requestHeaders: Headers,
51+
): Promise<InvitationView> {
52+
const organization = await OrganizationsService.findBySlug(
53+
getSlugFromHost(requestHeaders.get("host") ?? undefined),
54+
);
55+
const invitation =
56+
organization &&
57+
(await InvitationService.findByIdAndOrganization(
58+
invitationId,
59+
organization.id,
60+
));
61+
if (!invitation) return { kind: "not-found" };
62+
63+
const session = await auth.api.getSession({ headers: requestHeaders });
64+
const signedInUser = session?.user;
65+
const isInvitee =
66+
signedInUser !== undefined &&
67+
sameEmail(signedInUser.email, invitation.email);
68+
const membership =
69+
isInvitee &&
70+
(await MembersService.findByUserAndOrganization(
71+
signedInUser.id,
72+
invitation.organizationId,
73+
));
74+
75+
// Signing up from this page accepts the invitation, so the new invitee
76+
// lands here already a member if they come back.
77+
if (membership) return { kind: "joined", invitation };
78+
if (!InvitationService.isOpen(invitation)) return { kind: "closed" };
79+
if (signedInUser && !isInvitee) {
80+
return {
81+
kind: "wrong-account",
82+
invitation,
83+
signedInEmail: signedInUser.email,
84+
};
85+
}
86+
if (isInvitee) return { kind: "accept", invitation };
87+
88+
const account = await UserService.findByEmailAndOrganization(
89+
invitation.email.toLowerCase(),
90+
invitation.organizationId,
91+
);
92+
return account
93+
? { kind: "log-in", invitation }
94+
: { kind: "create-account", invitation };
95+
}
96+
97+
function invitedTitle(invitation: Invitation) {
98+
return `You've been invited to join ${invitation.organizationName} as ${roleLabel(invitation.role)}`;
99+
}
100+
101+
function InvitationContent({
102+
view,
103+
invitationId,
104+
}: {
105+
view: InvitationView;
106+
invitationId: string;
107+
}) {
108+
const loginHref = `/login?redirect=${encodeURIComponent(
109+
`/accept-invitation/${invitationId}`,
110+
)}`;
111+
112+
switch (view.kind) {
113+
case "not-found":
114+
return (
115+
<AuthLayout
116+
title="Invitation not found"
117+
description="This link doesn't match an invitation here. Check that you opened the whole link from your email."
118+
>
119+
<BackToLoginLink />
120+
</AuthLayout>
121+
);
122+
case "closed":
123+
return (
124+
<AuthLayout
125+
title="This invitation is no longer valid"
126+
description="It has expired or was already used. Ask the person who invited you to send a new one."
127+
>
128+
<BackToLoginLink />
129+
</AuthLayout>
130+
);
131+
case "joined":
132+
return (
133+
<AuthLayout
134+
title={`You've joined ${view.invitation.organizationName}`}
135+
description={`You're in as ${roleLabel(view.invitation.role)}.`}
136+
>
137+
<ContinueLink />
138+
</AuthLayout>
139+
);
140+
case "create-account":
141+
return (
142+
<AuthLayout
143+
title={invitedTitle(view.invitation)}
144+
description="Create your account to accept."
145+
>
146+
<SignupForm invitation={view.invitation} loginHref={loginHref} />
147+
</AuthLayout>
148+
);
149+
case "log-in":
150+
return (
151+
<AuthLayout
152+
title={invitedTitle(view.invitation)}
153+
description={`You already have an account with ${view.invitation.email}. Log in to accept.`}
154+
>
155+
<LogInToAcceptLink href={loginHref} />
156+
</AuthLayout>
157+
);
158+
case "wrong-account":
159+
return (
160+
<AuthLayout
161+
title={invitedTitle(view.invitation)}
162+
description={`This invitation is for ${view.invitation.email}, but you're signed in as ${view.signedInEmail}. Sign out, then open the link again.`}
163+
>
164+
<SignOutButton />
165+
</AuthLayout>
166+
);
167+
case "accept":
168+
return (
169+
<AuthLayout
170+
title={invitedTitle(view.invitation)}
171+
description={`Signed in as ${view.invitation.email}.`}
172+
>
173+
<AcceptInvitationButton invitationId={invitationId} />
174+
</AuthLayout>
175+
);
176+
default: {
177+
const leftover: never = view;
178+
return leftover;
179+
}
180+
}
181+
}
182+
183+
export default async function AcceptInvitationPage({
184+
params,
185+
}: {
186+
params: Promise<{ id: string }>;
187+
}) {
188+
const { id } = await params;
189+
const view = await resolveInvitationView(id, await headers());
190+
return <InvitationContent view={view} invitationId={id} />;
191+
}

‎app/api/[[...route]]/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import messages from "@/api/messages";
1111
import emails from "@/api/emails";
1212
import profile from "@/api/profile";
1313
import tags from "@/api/tags";
14+
import invitations from "@/api/invitations";
1415
import { Hono } from "hono";
1516
import { handle } from "hono/netlify";
1617

@@ -28,7 +29,8 @@ const app = new Hono()
2829
.route("/messages", messages)
2930
.route("/emails", emails)
3031
.route("/profile", profile)
31-
.route("/tags", tags);
32+
.route("/tags", tags)
33+
.route("/invitations", invitations);
3234

3335
export const GET = handle(app);
3436
export const POST = handle(app);

0 commit comments

Comments
 (0)