Enrich invitation verify API and auto-verify invited signups

- /api/invitations/verify now returns inviterName and a structured
  failure reason ("not_found" | "expired" | "accepted") plus orgName,
  so the invite page can show contextual error messages and know who
  to blame for an expired link.
- /api/auth/register marks emailVerified immediately and skips the
  verification email when an inviteToken is present, since clicking
  the tokenized invite link already proves email ownership.

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-19 10:45:41 -05:00
parent 4431778c0a
commit 47da94fc95
2 changed files with 74 additions and 11 deletions

View file

@ -66,12 +66,18 @@ export async function POST(req: NextRequest) {
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Invite-based signups have already proven email ownership by clicking the
// tokenized link we sent to that exact address, so we mark the email as
// verified up-front and skip the post-registration verification email.
const isInviteSignup = !!inviteOrgId;
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email, email,
displayName, displayName,
hashedPassword, hashedPassword,
role: "viewer", role: "viewer",
emailVerified: isInviteSignup ? new Date() : null,
}, },
}); });
@ -119,14 +125,20 @@ export async function POST(req: NextRequest) {
} }
} }
try { if (!isInviteSignup) {
const baseUrl = new URL(req.url).origin; try {
await sendVerificationEmail(email, baseUrl); const baseUrl = new URL(req.url).origin;
} catch (emailErr) { await sendVerificationEmail(email, baseUrl);
console.warn("[register] Verification email failed (non-blocking):", emailErr); } catch (emailErr) {
console.warn("[register] Verification email failed (non-blocking):", emailErr);
}
} }
return NextResponse.json({ success: true, userId: user.id }); return NextResponse.json({
success: true,
userId: user.id,
emailVerified: isInviteSignup,
});
} catch (error) { } catch (error) {
console.error("[register] Error:", error); console.error("[register] Error:", error);
return NextResponse.json( return NextResponse.json(

View file

@ -1,10 +1,36 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
type VerifyFailure = {
valid: false;
reason: "not_found" | "expired" | "accepted";
orgName?: string;
inviterName?: string;
};
type VerifySuccess = {
valid: true;
email: string;
role: string;
orgName: string;
inviterName: string | null;
hasExistingAccount: boolean;
};
async function resolveInviterName(invitedById: string): Promise<string | null> {
const inviter = await prisma.user.findUnique({
where: { id: invitedById },
select: { displayName: true, username: true, email: true },
});
if (!inviter) return null;
return inviter.displayName || inviter.username || inviter.email || null;
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token"); const token = req.nextUrl.searchParams.get("token");
if (!token) { if (!token) {
return NextResponse.json({ valid: false }); const body: VerifyFailure = { valid: false, reason: "not_found" };
return NextResponse.json(body);
} }
const invitation = await prisma.invitation.findUnique({ const invitation = await prisma.invitation.findUnique({
@ -12,8 +38,31 @@ export async function GET(req: NextRequest) {
include: { organization: { select: { name: true } } }, include: { organization: { select: { name: true } } },
}); });
if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) { if (!invitation) {
return NextResponse.json({ valid: false }); const body: VerifyFailure = { valid: false, reason: "not_found" };
return NextResponse.json(body);
}
const inviterName = await resolveInviterName(invitation.invitedById);
if (invitation.acceptedAt) {
const body: VerifyFailure = {
valid: false,
reason: "accepted",
orgName: invitation.organization.name,
inviterName: inviterName ?? undefined,
};
return NextResponse.json(body);
}
if (invitation.expiresAt < new Date()) {
const body: VerifyFailure = {
valid: false,
reason: "expired",
orgName: invitation.organization.name,
inviterName: inviterName ?? undefined,
};
return NextResponse.json(body);
} }
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
@ -21,11 +70,13 @@ export async function GET(req: NextRequest) {
select: { id: true }, select: { id: true },
}); });
return NextResponse.json({ const body: VerifySuccess = {
valid: true, valid: true,
email: invitation.email, email: invitation.email,
role: invitation.role, role: invitation.role,
orgName: invitation.organization.name, orgName: invitation.organization.name,
inviterName,
hasExistingAccount: !!existingUser, hasExistingAccount: !!existingUser,
}); };
return NextResponse.json(body);
} }