diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 764e861..d66f193 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -66,12 +66,18 @@ export async function POST(req: NextRequest) { 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({ data: { email, displayName, hashedPassword, role: "viewer", + emailVerified: isInviteSignup ? new Date() : null, }, }); @@ -119,14 +125,20 @@ export async function POST(req: NextRequest) { } } - try { - const baseUrl = new URL(req.url).origin; - await sendVerificationEmail(email, baseUrl); - } catch (emailErr) { - console.warn("[register] Verification email failed (non-blocking):", emailErr); + if (!isInviteSignup) { + try { + const baseUrl = new URL(req.url).origin; + await sendVerificationEmail(email, baseUrl); + } 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) { console.error("[register] Error:", error); return NextResponse.json( diff --git a/src/app/api/invitations/verify/route.ts b/src/app/api/invitations/verify/route.ts index ee24083..3780b30 100644 --- a/src/app/api/invitations/verify/route.ts +++ b/src/app/api/invitations/verify/route.ts @@ -1,10 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; 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 { + 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) { const token = req.nextUrl.searchParams.get("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({ @@ -12,8 +38,31 @@ export async function GET(req: NextRequest) { include: { organization: { select: { name: true } } }, }); - if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) { - return NextResponse.json({ valid: false }); + if (!invitation) { + 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({ @@ -21,11 +70,13 @@ export async function GET(req: NextRequest) { select: { id: true }, }); - return NextResponse.json({ + const body: VerifySuccess = { valid: true, email: invitation.email, role: invitation.role, orgName: invitation.organization.name, + inviterName, hasExistingAccount: !!existingUser, - }); + }; + return NextResponse.json(body); }