assessment-model/src/app/api/auth/[...nextauth]/authOptions.ts
Khalim Conn-Kowlessar d70b15e705 Unify invitation flow: explicit accept/decline via profile-menu notifications
Replaces the auto-accept-on-signin behaviour with an explicit accept or
decline step. Every invitee (existing user or brand new) is now treated
the same way: an invitation lands in portfolio_invitations, the email
is a deep-link "Sign in to Ara", and the invitee accepts (or declines)
explicitly from a notifications panel hanging off their profile avatar.

Why: the previous flow silently added existing users to portfolios with
no way to refuse, and gave them no in-app confirmation that the invite
landed. Existing users complained they didn't know which account they
were signed in as either — both gaps are closed by the same panel.

Backend:
  - POST /collaborators no longer creates portfolioUsers directly for
    existing users; it writes a portfolio_invitations row in every case
    except the trivial "already a member of THIS portfolio" path
    (silent role update, no email)
  - New /api/user/invitations endpoint: GET lists pending invitations
    addressed to the current user across all portfolios, joined with
    portfolio + inviter context; POST accepts or declines a single
    invitation, scoped to session.email so users can't act on others'
  - Accept reuses the existing planInvitationApplication helper for
    the "already a member" idempotency check
  - Decline is a silent delete (matches GitHub/Linear/Notion convention;
    no email to inviter, no tombstone)
  - signIn callback no longer auto-applies pending invitations — that
    block is removed entirely along with its now-unused imports
  - Email template subject + body unified, no longer suggests the user
    is "added"; both modes say "invited" and direct them to the profile
    menu

Frontend:
  - ProfileDropDown rewritten as a notifications panel: shows the
    signed-in email at the top (closing the "which account?" gap),
    lists pending invitations with Accept/Decline buttons, displays a
    red count badge on the avatar (max "9+"). Uses TanStack Query with
    optimistic update on accept/decline and toast on outcome. Existing
    Help + Sign Out menu items preserved.
  - No useEffect — pending-count derived from query data, mutations
    handle the rest

Vercel preview test plan:
  - Invite a user already in another portfolio → red badge appears on
    their next page load; Accept lands them in the portfolio
  - Invite a new email → sign-up flow finishes; new account lands on
    home with a badge waiting for Accept (no longer auto-accepted)
  - Existing member of THIS portfolio re-invited → silent role update,
    no email

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:43:13 +00:00

498 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
import EmailProvider from "next-auth/providers/email";
import CredentialsProvider from "next-auth/providers/credentials";
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
import {
evaluateCodeAttempt,
generateCode,
hashCode,
} from "@/app/lib/verificationCode";
import { createHash } from "crypto";
import { db } from "@/app/db/db";
import {
user as users,
accounts,
authRateLimits,
verificationTokens,
} from "@/app/db/schema/users";
import { normaliseEmail } from "@/app/lib/email";
import { eq, and, ne } from "drizzle-orm";
// ------------------------------------------------------------------
// Environment variables
// ------------------------------------------------------------------
const {
GOOGLE_CLIENT_ID = "",
GOOGLE_CLIENT_SECRET = "",
AZURE_AD_B2C_TENANT_NAME = "",
AZURE_AD_B2C_CLIENT_ID = "",
AZURE_AD_B2C_CLIENT_SECRET = "",
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
EMAIL_SERVER_HOST = "",
EMAIL_SERVER_PORT = "",
EMAIL_SERVER_USER = "",
EMAIL_SERVER_PASSWORD = "",
EMAIL_FROM = "",
} = process.env;
type OauthProvider = "google" | "azure-ad-b2c";
// ------------------------------------------------------------------
// NextAuth configuration
// ------------------------------------------------------------------
export const AuthOptions: NextAuthOptions = {
adapter: DrizzleEmailAdapter(db, {
user: users,
accounts,
verificationTokens,
}),
providers: [
// ------------------ Google ------------------
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
authorization: {
params: {
access_type: "offline",
prompt: "consent",
response_type: "code",
},
},
}),
// ------------------ Azure AD B2C ------------------
AzureADB2CProvider({
tenantId: AZURE_AD_B2C_TENANT_NAME,
clientId: AZURE_AD_B2C_CLIENT_ID,
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
authorization: {
params: {
scope: "openid profile offline_access",
prompt: "login",
},
},
}),
// ------------------ Email (SES Magic Link) ------------------
EmailProvider({
server: {
host: EMAIL_SERVER_HOST,
port: Number(EMAIL_SERVER_PORT),
auth: {
user: EMAIL_SERVER_USER,
pass: EMAIL_SERVER_PASSWORD,
},
},
from: EMAIL_FROM,
maxAge: 60 * 10, // code and link share a 10-minute window
sendVerificationRequest: async ({ identifier, url, provider, token }) => {
const secret = process.env.NEXTAUTH_SECRET!;
const hashedToken = createHash("sha256")
.update(`${token}${secret}`)
.digest("hex");
const now = new Date();
const oneHourMs = 60 * 60 * 1000;
const SEND_LIMIT = 5;
// Per-email send rate limit, fixed 1-hour window.
const limited = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(authRateLimits)
.where(
and(
eq(authRateLimits.scope, "send"),
eq(authRateLimits.key, identifier),
),
);
const inWindow =
existing &&
now.getTime() - existing.windowStart.getTime() < oneHourMs;
if (inWindow) {
if (existing.count >= SEND_LIMIT) return true;
await tx
.update(authRateLimits)
.set({ count: existing.count + 1 })
.where(
and(
eq(authRateLimits.scope, "send"),
eq(authRateLimits.key, identifier),
),
);
return false;
}
await tx
.insert(authRateLimits)
.values({
scope: "send",
key: identifier,
count: 1,
windowStart: now,
})
.onConflictDoUpdate({
target: [authRateLimits.scope, authRateLimits.key],
set: { count: 1, windowStart: now },
});
return false;
});
if (limited) {
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, identifier),
eq(verificationTokens.token, hashedToken),
),
);
console.warn("EMAIL_RATE_LIMIT_EXCEEDED", {
email: identifier,
timestamp: now.toISOString(),
});
throw new Error(
"Too many sign-in attempts. Please wait an hour before requesting another code.",
);
}
// Generate code, attach to the just-created row, replace any older rows
// for this identifier so only the latest send is live.
const code = generateCode();
const codeHash = hashCode(code, secret);
await db.transaction(async (tx) => {
await tx
.update(verificationTokens)
.set({ codeHash, attempts: 0 })
.where(
and(
eq(verificationTokens.identifier, identifier),
eq(verificationTokens.token, hashedToken),
),
);
await tx
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, identifier),
ne(verificationTokens.token, hashedToken),
),
);
});
try {
const { messageId } = await MagicLinksEmail({
identifier,
url,
provider,
code,
});
console.log("EMAIL_MAGIC_LINK_SUCCESS", {
email: identifier,
messageId,
timestamp: new Date().toISOString(),
});
} catch (err) {
console.error("EMAIL_MAGIC_LINK_FAILURE", {
email: identifier,
error: err instanceof Error ? err.message : String(err),
timestamp: new Date().toISOString(),
});
throw err;
}
},
}),
// ------------------ Email code (typed at /auth/verify-code) ------------------
CredentialsProvider({
id: "email-code",
name: "Email Code",
credentials: {
email: { label: "Email", type: "email" },
code: { label: "Code", type: "text" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.code) return null;
const email = credentials.email.trim().toLowerCase();
const submitted = credentials.code.trim();
if (!/^\d{6}$/.test(submitted)) return null;
const secret = process.env.NEXTAUTH_SECRET!;
const submittedCodeHash = hashCode(submitted, secret);
const now = new Date();
return await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(verificationTokens)
.where(eq(verificationTokens.identifier, email))
.limit(1);
const outcome = evaluateCodeAttempt({
submittedCodeHash,
row: row
? {
codeHash: row.codeHash,
attempts: row.attempts,
expires: row.expires,
}
: null,
now,
});
if (outcome.outcome === "ok") {
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, email));
let [dbUser] = await tx
.select()
.from(users)
.where(eq(users.email, email));
if (!dbUser) {
[dbUser] = await tx
.insert(users)
.values({ email, emailVerified: now })
.returning();
} else if (!dbUser.emailVerified) {
await tx
.update(users)
.set({ emailVerified: now })
.where(eq(users.id, dbUser.id));
}
console.log("EMAIL_CODE_SIGN_IN_SUCCESS", {
email,
userId: String(dbUser.id),
timestamp: now.toISOString(),
});
return {
id: String(dbUser.id),
email: dbUser.email,
name: dbUser.firstName ?? null,
dbId: String(dbUser.id),
onboarded: dbUser.onboarded ?? false,
};
}
if (outcome.outcome === "wrong") {
await tx
.update(verificationTokens)
.set({ attempts: outcome.newAttempts })
.where(eq(verificationTokens.identifier, email));
return null;
}
if (
outcome.outcome === "locked-out" ||
outcome.outcome === "expired"
) {
await tx
.delete(verificationTokens)
.where(eq(verificationTokens.identifier, email));
return null;
}
return null;
});
},
}),
],
// ------------------------------------------------------------------
// Pages
// ------------------------------------------------------------------
pages: {
signIn: "/",
verifyRequest: "/auth/verify-code",
error: "/api/auth/error",
},
// ------------------------------------------------------------------
// Callbacks
// ------------------------------------------------------------------
callbacks: {
/**
* Sign in callback — ensures user exists and links OAuth provider if needed
*/
async signIn({ user, account, profile }) {
try {
if (!user?.email) return false;
const normalisedEmail = user.email.toLowerCase();
console.log("SignIn attempt:", {
email: normalisedEmail,
provider: account?.provider,
type: account?.type,
timestamp: new Date().toISOString(),
});
// Fetch the user (NextAuth will have created them already if new)
const [dbUser] = await db
.select()
.from(users)
.where(eq(users.email, normalisedEmail));
// New user - next auth will handle
if (!dbUser) {
console.log("New user created through sign-in:", {
email: normalisedEmail,
provider: account?.provider,
});
return true;
}
// Auto-link provider if same verified email but account not linked yet
if (account?.provider && account.type === "oauth") {
const existingLink = await db
.select()
.from(accounts)
.where(
and(
eq(accounts.userId, dbUser.id),
eq(accounts.provider, account.provider),
),
);
const emailVerified =
(profile as any)?.email_verified ?? account.provider === "google";
if (existingLink.length === 0 && emailVerified) {
// This handles the case where we had not set up accounts but
// signed up users with oauth
console.log(
`Linking ${account.provider} account for user ${normalisedEmail}`,
);
await db
.insert(accounts)
.values({
userId: dbUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
id_token: account.id_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
})
.onConflictDoNothing();
}
}
// Link OAuth ID if missing (helps for older accounts). Only for OAuth
// providers — the email-code credentials flow has no provider identity.
if (account?.type === "oauth" && !dbUser.oauthId) {
console.log("Backfilling OAuth ID:", {
email: normalisedEmail,
provider: account.provider,
});
const provider = account.provider as OauthProvider;
await db
.update(users)
.set({ oauthId: user.id, oauthProvider: provider })
.where(eq(users.email, normalisedEmail));
}
// Always update last login timestamp
await db
.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Pending portfolio invitations are NOT auto-applied here anymore.
// The invitee accepts/declines explicitly via the profile-menu
// notifications panel (POST /api/user/invitations).
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();
user.onboarded = dbUser.onboarded ?? false;
return true;
} catch (error) {
console.error("Error during sign-in:", error);
return false;
}
},
/**
* Persist dbId in the JWT so its available in sessions
*/
async jwt({ token, user: userFromLogin }) {
// Initial sign-in: attach DB fields
if (userFromLogin) {
console.log("JWT initial login:", {
email: userFromLogin.email,
timestamp: new Date().toISOString(),
});
const existing = await db.query.user.findFirst({
where: eq(users.email, userFromLogin.email!),
});
if (existing) {
token.onboarded = existing.onboarded;
}
} else if (token.email) {
// On subsequent calls, keep token synced from DB
const existing = await db.query.user.findFirst({
where: eq(users.email, token.email),
});
if (existing) {
token.onboarded = existing.onboarded;
}
}
if (userFromLogin?.dbId) {
token.dbId = userFromLogin.dbId;
}
return token;
},
/**
* Attach dbId to session.user, and normalise the email so downstream
* lookups against `user.email` are case-insensitive without each call site
* remembering to lowercase.
*/
async session({ session, token }) {
if (session.user) {
if (session.user.email) {
session.user.email = normaliseEmail(session.user.email);
}
if (token.dbId) {
session.user.dbId = token.dbId;
}
}
return session;
},
/**
* Redirect users after login
*/
async redirect({ url, baseUrl }) {
// Respect internal callbackUrl so e.g. invitation emails can deep-link
// to /portfolio/<id> after sign-in. Default to /home for bare sign-ins.
if (url.startsWith("/")) return `${baseUrl}${url}`;
if (url.startsWith(baseUrl)) return url;
return `${baseUrl}/home`;
},
},
// ------------------------------------------------------------------
// General options
// ------------------------------------------------------------------
session: {
strategy: "jwt", // lightweight, avoids DB session writes
},
jwt: {
maxAge: 60 * 60 * 24 * 30, // 30 days
},
debug: false,
};