From c921db7d9c1fbd25dcfae590fb76d4e2b938cd4e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 16:18:21 +0000 Subject: [PATCH 1/7] initial implementation for portfolio invitations. A user can send an invitation to a user and they will receive an invitation email --- .../auth/[...nextauth]/DrizzleEmailAdapter.ts | 3 +- src/app/api/auth/[...nextauth]/authOptions.ts | 76 +++++- .../[portfolioId]/colloborators/route.ts | 236 ++++++++++++------ src/app/db/schema/portfolio.ts | 34 +++ .../email_templates/portfolio_invitation.ts | 179 +++++++++++++ src/app/lib/portfolioInvitations.test.ts | 50 ++++ src/app/lib/portfolioInvitations.ts | 42 ++++ 7 files changed, 534 insertions(+), 86 deletions(-) create mode 100644 src/app/email_templates/portfolio_invitation.ts create mode 100644 src/app/lib/portfolioInvitations.test.ts create mode 100644 src/app/lib/portfolioInvitations.ts diff --git a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts index 0e7cbab0..da2c4adf 100644 --- a/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts +++ b/src/app/api/auth/[...nextauth]/DrizzleEmailAdapter.ts @@ -12,6 +12,7 @@ import { sessions as sessionsTable, verificationTokens as verificationTokensTable, } from "@/app/db/schema/users"; +import { normaliseEmail } from "@/app/lib/email"; /** * Custom Drizzle adapter for NextAuth v4 @@ -48,8 +49,6 @@ export default function DrizzleEmailAdapter( //---------------------------------------------------------------------- // Helpers //---------------------------------------------------------------------- - const normaliseEmail = (email: string) => email.trim().toLowerCase(); - const toAdapterUser = (u: any): AdapterUser => ({ id: String(u.id), dbId: String(u.id), diff --git a/src/app/api/auth/[...nextauth]/authOptions.ts b/src/app/api/auth/[...nextauth]/authOptions.ts index b7a450b4..4adeaaed 100644 --- a/src/app/api/auth/[...nextauth]/authOptions.ts +++ b/src/app/api/auth/[...nextauth]/authOptions.ts @@ -19,6 +19,12 @@ import { authRateLimits, verificationTokens, } from "@/app/db/schema/users"; +import { + portfolioInvitations, + portfolioUsers, +} from "@/app/db/schema/portfolio"; +import { planInvitationApplication } from "@/app/lib/portfolioInvitations"; +import { normaliseEmail } from "@/app/lib/email"; import { eq, and, ne } from "drizzle-orm"; // ------------------------------------------------------------------ @@ -409,6 +415,52 @@ export const AuthOptions: NextAuthOptions = { .set({ lastLogin: new Date() }) .where(eq(users.id, dbUser.id)); + // Apply any pending portfolio invitations addressed to this email. + // Idempotent: runs every sign-in; no-op when there are no pending rows. + const pending = await db + .select({ + id: portfolioInvitations.id, + portfolioId: portfolioInvitations.portfolioId, + role: portfolioInvitations.role, + }) + .from(portfolioInvitations) + .where(eq(portfolioInvitations.email, normalisedEmail)); + + if (pending.length > 0) { + const existing = await db + .select({ portfolioId: portfolioUsers.portfolioId }) + .from(portfolioUsers) + .where(eq(portfolioUsers.userId, dbUser.id)); + + const plan = planInvitationApplication({ + userId: dbUser.id, + invitations: pending.map((p) => ({ + id: p.id, + portfolioId: p.portfolioId, + role: p.role as "creator" | "admin" | "read" | "write", + })), + existingPortfolioIds: new Set(existing.map((m) => m.portfolioId)), + }); + + await db.transaction(async (tx) => { + if (plan.memberships.length > 0) { + await tx.insert(portfolioUsers).values(plan.memberships); + } + for (const id of plan.invitationsToDelete) { + await tx + .delete(portfolioInvitations) + .where(eq(portfolioInvitations.id, id)); + } + }); + + console.log("APPLIED_PENDING_INVITATIONS", { + email: normalisedEmail, + userId: dbUser.id.toString(), + count: plan.memberships.length, + staleDeleted: plan.invitationsToDelete.length - plan.memberships.length, + }); + } + // Pass bigint ID into NextAuth session/jwt user.dbId = dbUser.id.toString(); user.onboarded = dbUser.onboarded ?? false; @@ -452,11 +504,18 @@ export const AuthOptions: NextAuthOptions = { }, /** - * Attach dbId to session.user + * 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 && token.dbId) { - session.user.dbId = token.dbId; + if (session.user) { + if (session.user.email) { + session.user.email = normaliseEmail(session.user.email); + } + if (token.dbId) { + session.user.dbId = token.dbId; + } } return session; }, @@ -465,13 +524,10 @@ export const AuthOptions: NextAuthOptions = { * Redirect users after login */ async redirect({ url, baseUrl }) { - // If the user has not onboarded, send them to onboarding - // This logging is too noisy - // console.log("Redirect triggered:", { - // from: url, - // to: `${baseUrl}/home`, - // timestamp: new Date().toISOString(), - // }); + // Respect internal callbackUrl so e.g. invitation emails can deep-link + // to /portfolio/ 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`; }, }, diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index 309fee66..5420c4ca 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -1,6 +1,10 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; -import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio"; +import { + portfolio, + portfolioInvitations, + portfolioUsers, +} from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; import { recommendation, @@ -14,9 +18,13 @@ import { propertyDetailsEpc, property, } from "@/app/db/schema/property"; -import { eq, inArray, Name } from "drizzle-orm"; +import { and, eq, inArray, Name } from "drizzle-orm"; import { z } from "zod"; import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; +import { normaliseEmail } from "@/app/lib/email"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { PortfolioInvitationEmail } from "@/app/email_templates/portfolio_invitation"; // Get colloborators (users) that have access to the portfolio export async function GET( @@ -97,14 +105,17 @@ export async function PUT( } } -// POST: invite a user by email (find-or-create user, then add to portfolio with role) +// POST: invite a user by email. +// +// If the email already corresponds to a user, link them to the portfolio +// directly (existing user case). Otherwise create a pending invitation that +// gets consumed by the signIn callback the first time the invitee signs in. export async function POST( req: NextRequest, props: { params: Promise<{ portfolioId: string }> } ) { const { portfolioId } = await props.params; - // 1) Validate payload const bodySchema = z.object({ email: z.string().email(), role: z.enum(ROLE_OPTIONS), @@ -118,85 +129,162 @@ export async function POST( return NextResponse.json({ error: "Invalid body" }, { status: 400 }); } + const email = normaliseEmail(body.email); + + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + const inviterUserId = BigInt(session.user.dbId); + try { const pId = BigInt(portfolioId); - // 2) Find or create the user by email - // Try to find existing user - let existing = await db - .select({ id: user.id, firstName: user.firstName, email: user.email }) - .from(user) - .where(eq(user.email, body.email)) + const [portfolioRow] = await db + .select({ name: portfolio.name }) + .from(portfolio) + .where(eq(portfolio.id, pId)) .limit(1); - - let createdUserId: bigint | null = existing[0]?.id ?? null; - - // If not found, create. Prefer Postgres upsert to avoid race. - if (!createdUserId) { - // If you’re on Postgres, this is ideal: - const inserted = await db - .insert(user) - .values({ - email: body.email, - firstName: body.name, - oauthProvider: "credentials", - }) - .onConflictDoNothing() // relies on a UNIQUE(email) constraint - .returning({ id: user.id }); - - if (inserted.length > 0) { - createdUserId = inserted[0].id; - } else { - // Someone else created the user concurrently; fetch it - const fetched = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, body.email)) - .limit(1); - if (!fetched[0]) { - return NextResponse.json( - { error: "Failed to create or fetch user" }, - { status: 500 } - ); - } - createdUserId = fetched[0].id; - } - } - - // 3) Link user to portfolio with role (upsert) - // Assumes a UNIQUE index on (portfolioId, userId) in portfolioUsers. - const linkResult = await db - .insert(portfolioUsers) - .values({ - portfolioId: pId, - userId: createdUserId!, - role: body.role, - }) - .returning({ - portfolioUserId: portfolioUsers.id, - userId: portfolioUsers.userId, - role: portfolioUsers.role, - }); - - const row = linkResult[0]; - if (!row) { + if (!portfolioRow) { return NextResponse.json( - { error: "Failed to create portfolio user" }, - { status: 500 } + { error: "Portfolio not found" }, + { status: 404 }, ); } - const collaborator = { - portfolioUserId: row.portfolioUserId?.toString() ?? null, - userId: row.userId?.toString() ?? null, - role: row.role, - name: body.name ?? null, - email: body.email, - }; + const [inviterRow] = await db + .select({ firstName: user.firstName, email: user.email }) + .from(user) + .where(eq(user.id, inviterUserId)) + .limit(1); + const inviterName = + inviterRow?.firstName ?? inviterRow?.email ?? "Someone at Domna"; - // 201 if it was a new link, 200 if it was an update — we can’t easily - // tell from .onConflictDoUpdate return, so just use 200 OK. - return NextResponse.json({ user: collaborator }, { status: 200 }); + const appOrigin = + process.env.NEXTAUTH_URL ?? `https://${req.headers.get("host")}`; + + const [existingUser] = await db + .select({ id: user.id, firstName: user.firstName, email: user.email }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + + if (existingUser) { + const [existingMembership] = await db + .select({ id: portfolioUsers.id, role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, pId), + eq(portfolioUsers.userId, existingUser.id), + ), + ) + .limit(1); + + let portfolioUserId: bigint; + if (existingMembership) { + if (existingMembership.role !== body.role) { + await db + .update(portfolioUsers) + .set({ role: body.role }) + .where(eq(portfolioUsers.id, existingMembership.id)); + } + portfolioUserId = existingMembership.id; + } else { + const [inserted] = await db + .insert(portfolioUsers) + .values({ + portfolioId: pId, + userId: existingUser.id, + role: body.role, + }) + .returning({ id: portfolioUsers.id }); + portfolioUserId = inserted.id; + } + + try { + await PortfolioInvitationEmail({ + identifier: email, + portfolioName: portfolioRow.name, + inviterName, + linkUrl: `${appOrigin}/portfolio/${pId.toString()}`, + mode: "existing-user", + }); + } catch (mailErr) { + console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", { + email, + error: mailErr instanceof Error ? mailErr.message : String(mailErr), + }); + // The membership write succeeded — surface the email failure to the + // client but don't roll back the membership. + } + + return NextResponse.json( + { + user: { + portfolioUserId: portfolioUserId.toString(), + userId: existingUser.id.toString(), + role: body.role, + name: existingUser.firstName ?? body.name ?? null, + email, + kind: "member" as const, + }, + }, + { status: 200 }, + ); + } + + // No user with this email yet — record a pending invitation. The signIn + // callback applies it the first time the invitee signs in. + const [invitation] = await db + .insert(portfolioInvitations) + .values({ + portfolioId: pId, + email, + role: body.role, + invitedByUserId: inviterUserId, + }) + .onConflictDoUpdate({ + target: [ + portfolioInvitations.portfolioId, + portfolioInvitations.email, + ], + set: { role: body.role }, + }) + .returning({ + id: portfolioInvitations.id, + role: portfolioInvitations.role, + }); + + try { + await PortfolioInvitationEmail({ + identifier: email, + portfolioName: portfolioRow.name, + inviterName, + linkUrl: appOrigin, + mode: "new-user", + }); + } catch (mailErr) { + console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", { + email, + error: mailErr instanceof Error ? mailErr.message : String(mailErr), + }); + } + + return NextResponse.json( + { + user: { + portfolioUserId: null, + userId: null, + invitationId: invitation.id.toString(), + role: invitation.role, + name: body.name ?? null, + email, + kind: "invitation" as const, + }, + }, + { status: 200 }, + ); } catch (err) { console.error("POST /colloborators error:", err); return NextResponse.json( diff --git a/src/app/db/schema/portfolio.ts b/src/app/db/schema/portfolio.ts index 8e231bc8..85469a27 100644 --- a/src/app/db/schema/portfolio.ts +++ b/src/app/db/schema/portfolio.ts @@ -125,6 +125,32 @@ export const portfolioUsers = pgTable("portfolioUsers", { .notNull(), }); +// Pending invitations to portfolios for emails that don't yet correspond to a +// user. Once the invitee signs in, the signIn callback consumes the row and +// creates a portfolioUsers entry atomically. Existing users skip this table and +// get a portfolioUsers row written at invite time. +export const portfolioInvitations = pgTable( + "portfolioInvitations", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + portfolioId: bigint("portfolio_id", { mode: "bigint" }) + .notNull() + .references(() => portfolio.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: roleEnum("role").notNull(), + invitedByUserId: bigint("invited_by_user_id", { mode: "bigint" }) + .notNull() + .references(() => user.id), + createdAt: timestamp("created_at", { + precision: 6, + withTimezone: true, + }) + .defaultNow() + .notNull(), + }, + (t) => [unique("portfolio_invitations_portfolio_email_unique").on(t.portfolioId, t.email)], +); + export const PortfolioCapability: [string, ...string[]] = [ "approver", "contractor", @@ -161,6 +187,14 @@ export type Portfolio = InferModel; export type NewPortfolio = InferModel; export type PortfolioUsers = InferModel; export type NewPortfolioUsers = InferModel; +export type PortfolioInvitation = InferModel< + typeof portfolioInvitations, + "select" +>; +export type NewPortfolioInvitation = InferModel< + typeof portfolioInvitations, + "insert" +>; export type PortfolioCapabilities = InferModel< typeof portfolioCapabilities, "select" diff --git a/src/app/email_templates/portfolio_invitation.ts b/src/app/email_templates/portfolio_invitation.ts new file mode 100644 index 00000000..738e7d8a --- /dev/null +++ b/src/app/email_templates/portfolio_invitation.ts @@ -0,0 +1,179 @@ +// Notification email sent when a user is invited to a portfolio. +// +// Two modes: +// "existing-user" — recipient already has an Ara account; the membership was +// written directly, the email is just an FYI with a link to the portfolio. +// "new-user" — recipient has no account; a pending portfolio_invitations row +// was written. They need to sign in (which creates the user and triggers the +// signIn callback that applies the invitation). + +import { createTransport } from "nodemailer"; +import { buildMailHeaders } from "./buildMailHeaders"; + +export type InvitationMode = "existing-user" | "new-user"; + +export async function PortfolioInvitationEmail({ + identifier, + portfolioName, + inviterName, + linkUrl, + mode, +}: { + identifier: string; + portfolioName: string; + inviterName: string; + linkUrl: string; + mode: InvitationMode; +}): Promise<{ messageId: string }> { + const from = process.env.EMAIL_FROM!; + const transport = createTransport({ + host: process.env.EMAIL_SERVER_HOST, + port: Number(process.env.EMAIL_SERVER_PORT), + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, + }); + + const parsed = new URL(linkUrl); + const host = parsed.host; + const logoUrl = `${parsed.origin}/domna-email-logo.png`; + + const subject = + mode === "existing-user" + ? `You've been added to ${portfolioName} on Ara` + : `${inviterName} invited you to join ${portfolioName} on Ara`; + + const ctaLabel = + mode === "existing-user" ? "Open portfolio" : "Sign in to Ara"; + + const result = await transport.sendMail({ + to: identifier, + from, + subject, + text: plainText({ + portfolioName, + inviterName, + linkUrl, + ctaLabel, + mode, + }), + html: domnaHtml({ + portfolioName, + inviterName, + linkUrl, + ctaLabel, + mode, + logoUrl, + host, + }), + headers: buildMailHeaders({ + fromAddress: from, + sesConfigurationSet: process.env.SES_CONFIGURATION_SET, + }), + }); + + const failed = result.rejected.filter(Boolean); + if (failed.length) { + throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`); + } + + return { messageId: result.messageId }; +} + +function domnaHtml({ + portfolioName, + inviterName, + linkUrl, + ctaLabel, + mode, + logoUrl, + host, +}: { + portfolioName: string; + inviterName: string; + linkUrl: string; + ctaLabel: string; + mode: InvitationMode; + logoUrl: string; + host: string; +}) { + const escapedHost = host.replace(/\./g, "​."); + const brandColor = "#14163d"; + const accentColor = "#2d348f"; + const brown = "#c4a47c"; + const background = "#F9F9F9"; + + const heading = + mode === "existing-user" + ? `You've been added to ${portfolioName}` + : `${inviterName} invited you to ${portfolioName}`; + + const explainer = + mode === "existing-user" + ? `${inviterName} added you to the ${portfolioName} portfolio on Ara. Open it below to start collaborating.` + : `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to accept the invitation.`; + + return ` + + + + + + + + + + + +
+ Domna Logo +
+

${heading}

+

${explainer}

+ + ${ctaLabel} + +

+ If you weren't expecting this email, you can safely ignore it. +

+
+ © ${new Date().getFullYear()} Domna Homes • ${escapedHost} +
+ + `; +} + +function plainText({ + portfolioName, + inviterName, + linkUrl, + ctaLabel, + mode, +}: { + portfolioName: string; + inviterName: string; + linkUrl: string; + ctaLabel: string; + mode: InvitationMode; +}) { + const heading = + mode === "existing-user" + ? `You've been added to ${portfolioName}` + : `${inviterName} invited you to ${portfolioName}`; + const explainer = + mode === "existing-user" + ? `${inviterName} added you to the ${portfolioName} portfolio on Ara.` + : `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email address to accept.`; + + return `${heading} + +${explainer} + +${ctaLabel}: ${linkUrl} + +If you weren't expecting this email, you can safely ignore it. +`; +} diff --git a/src/app/lib/portfolioInvitations.test.ts b/src/app/lib/portfolioInvitations.test.ts new file mode 100644 index 00000000..f5dc9511 --- /dev/null +++ b/src/app/lib/portfolioInvitations.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { planInvitationApplication } from "./portfolioInvitations"; + +describe("planInvitationApplication", () => { + it("translates a single pending invitation into one membership insert + invitation delete", () => { + const plan = planInvitationApplication({ + userId: 100n, + invitations: [ + { id: 1n, portfolioId: 200n, role: "read" }, + ], + existingPortfolioIds: new Set(), + }); + + expect(plan.memberships).toEqual([ + { portfolioId: 200n, userId: 100n, role: "read" }, + ]); + expect(plan.invitationsToDelete).toEqual([1n]); + }); + + it("skips the membership insert if the user is already a member of that portfolio, but still deletes the stale invitation", () => { + const plan = planInvitationApplication({ + userId: 100n, + invitations: [ + { id: 1n, portfolioId: 200n, role: "read" }, + ], + existingPortfolioIds: new Set([200n]), + }); + + expect(plan.memberships).toEqual([]); + expect(plan.invitationsToDelete).toEqual([1n]); + }); + + it("handles invitations to several portfolios in one application", () => { + const plan = planInvitationApplication({ + userId: 100n, + invitations: [ + { id: 1n, portfolioId: 200n, role: "read" }, + { id: 2n, portfolioId: 300n, role: "write" }, + { id: 3n, portfolioId: 400n, role: "admin" }, + ], + existingPortfolioIds: new Set([300n]), + }); + + expect(plan.memberships).toEqual([ + { portfolioId: 200n, userId: 100n, role: "read" }, + { portfolioId: 400n, userId: 100n, role: "admin" }, + ]); + expect(plan.invitationsToDelete).toEqual([1n, 2n, 3n]); + }); +}); diff --git a/src/app/lib/portfolioInvitations.ts b/src/app/lib/portfolioInvitations.ts new file mode 100644 index 00000000..92919536 --- /dev/null +++ b/src/app/lib/portfolioInvitations.ts @@ -0,0 +1,42 @@ +export type InvitationRecord = { + id: bigint; + portfolioId: bigint; + role: "creator" | "admin" | "read" | "write"; +}; + +export type MembershipPayload = { + portfolioId: bigint; + userId: bigint; + role: "creator" | "admin" | "read" | "write"; +}; + +export type InvitationApplicationPlan = { + memberships: MembershipPayload[]; + invitationsToDelete: bigint[]; +}; + +export function planInvitationApplication({ + userId, + invitations, + existingPortfolioIds, +}: { + userId: bigint; + invitations: InvitationRecord[]; + existingPortfolioIds: Set; +}): InvitationApplicationPlan { + const memberships: MembershipPayload[] = []; + const invitationsToDelete: bigint[] = []; + + for (const inv of invitations) { + invitationsToDelete.push(inv.id); + if (!existingPortfolioIds.has(inv.portfolioId)) { + memberships.push({ + portfolioId: inv.portfolioId, + userId, + role: inv.role, + }); + } + } + + return { memberships, invitationsToDelete }; +} From 7e9193313bb57d5d9fdb8caf2e5295b7ec50ec75 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 16:19:14 +0000 Subject: [PATCH 2/7] added missing email and email test files --- .../[portfolioId]/colloborators/route.ts | 63 +++++++++++++++++++ src/app/lib/email.test.ts | 15 +++++ src/app/lib/email.ts | 3 + 3 files changed, 81 insertions(+) create mode 100644 src/app/lib/email.test.ts create mode 100644 src/app/lib/email.ts diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index 5420c4ca..b48c61f3 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -65,6 +65,69 @@ export async function GET( } } +// DELETE: remove a collaborator from this portfolio. +export async function DELETE( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> } +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const bodySchema = z.object({ portfolioUserId: z.string() }); + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + const pId = BigInt(portfolioId); + const puId = BigInt(body.portfolioUserId); + + // Refuse to remove the creator — they own the portfolio. + const [target] = await db + .select({ id: portfolioUsers.id, role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.id, puId), + eq(portfolioUsers.portfolioId, pId), + ), + ) + .limit(1); + if (!target) { + return NextResponse.json( + { error: "Membership not found in this portfolio" }, + { status: 404 }, + ); + } + if (target.role === "creator") { + return NextResponse.json( + { error: "Cannot remove the portfolio creator" }, + { status: 400 }, + ); + } + + await db.delete(portfolioUsers).where(eq(portfolioUsers.id, puId)); + + return NextResponse.json( + { success: true, portfolioUserId: body.portfolioUserId }, + { status: 200 }, + ); + } catch (err) { + console.error("DELETE /colloborators error:", err); + return NextResponse.json( + { error: "Failed to remove user" }, + { status: 500 }, + ); + } +} + // PUT: update a collaborator’s role export async function PUT( req: NextRequest, diff --git a/src/app/lib/email.test.ts b/src/app/lib/email.test.ts new file mode 100644 index 00000000..e387a574 --- /dev/null +++ b/src/app/lib/email.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { normaliseEmail } from "./email"; + +describe("normaliseEmail", () => { + it("lowercases mixed-case addresses", () => { + expect(normaliseEmail("Craig.Williams@Example.com")).toBe( + "craig.williams@example.com", + ); + }); + + it("trims surrounding whitespace (common from copy-paste into invite forms)", () => { + expect(normaliseEmail(" user@example.com ")).toBe("user@example.com"); + expect(normaliseEmail("\tuser@example.com\n")).toBe("user@example.com"); + }); +}); diff --git a/src/app/lib/email.ts b/src/app/lib/email.ts new file mode 100644 index 00000000..4cb5d732 --- /dev/null +++ b/src/app/lib/email.ts @@ -0,0 +1,3 @@ +export function normaliseEmail(email: string): string { + return email.trim().toLowerCase(); +} From 07acf4d93d18891a5b481d49d58cf5857a7ec92e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 16:27:41 +0000 Subject: [PATCH 3/7] Add pending-invitations admin UI and wire member removal Adds two pieces of user-access management on the portfolio settings page that were previously stubbed or missing: - The existing "Remove" button was wired to console.log only. It now calls a DELETE on /colloborators with optimistic cache update and rollback on failure. The route refuses to remove the portfolio creator and 404s if the membership isn't in the URL's portfolio. - A new "Pending invitations" section in UsersPermissionsCard lists invitees who haven't signed in yet, backed by a new /api/portfolio/[id]/invitations endpoint (GET + DELETE). Admins can revoke a pending invitation; revoking deletes the row so the invitee no longer auto-joins on sign-in. Inviting a new email shows up here immediately because the invite mutation invalidates both query keys. Both new mutations use optimistic updates with rollback, and disable only the in-flight row (mutation.variables === currentId) so the rest of the table stays interactive. No useEffect, TanStack Query throughout. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portfolioId]/invitations/route.ts | 105 ++++++ .../settings/UsersPermissionsCard.tsx | 343 ++++++++++++++---- 2 files changed, 382 insertions(+), 66 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/invitations/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/invitations/route.ts b/src/app/api/portfolio/[portfolioId]/invitations/route.ts new file mode 100644 index 00000000..33ccdba0 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/invitations/route.ts @@ -0,0 +1,105 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { portfolioInvitations } from "@/app/db/schema/portfolio"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +// GET: list pending invitations for a portfolio. Invitations are consumed +// (deleted) when the invitee signs in, so anything returned here is still +// pending. +export async function GET( + _req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + const pId = BigInt(portfolioId); + const rows = await db + .select({ + id: portfolioInvitations.id, + email: portfolioInvitations.email, + role: portfolioInvitations.role, + createdAt: portfolioInvitations.createdAt, + }) + .from(portfolioInvitations) + .where(eq(portfolioInvitations.portfolioId, pId)); + + const invitations = rows.map((r) => ({ + invitationId: r.id.toString(), + email: r.email, + role: r.role, + createdAt: r.createdAt.toISOString(), + })); + + return NextResponse.json({ invitations }, { status: 200 }); + } catch (err) { + console.error("GET /invitations error:", err); + return NextResponse.json( + { error: "Failed to fetch invitations" }, + { status: 500 }, + ); + } +} + +// DELETE: revoke a pending invitation. Idempotent — 404 if it's already +// been consumed or revoked. +export async function DELETE( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const bodySchema = z.object({ invitationId: z.string() }); + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + const pId = BigInt(portfolioId); + const invId = BigInt(body.invitationId); + + const result = await db + .delete(portfolioInvitations) + .where( + and( + eq(portfolioInvitations.id, invId), + eq(portfolioInvitations.portfolioId, pId), + ), + ) + .returning({ id: portfolioInvitations.id }); + + if (result.length === 0) { + return NextResponse.json( + { error: "Invitation not found in this portfolio" }, + { status: 404 }, + ); + } + + return NextResponse.json( + { success: true, invitationId: body.invitationId }, + { status: 200 }, + ); + } catch (err) { + console.error("DELETE /invitations error:", err); + return NextResponse.json( + { error: "Failed to revoke invitation" }, + { status: 500 }, + ); + } +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index c0e97ea9..323ffefe 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -12,10 +12,19 @@ import { Input } from "@/app/shadcn_components/ui/input"; import { Button } from "@/app/shadcn_components/ui/button"; import { useState } from "react"; -import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; +import { + Role, + RoleDropdown, + Collaborator, +} from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; - +type PendingInvitation = { + invitationId: string; + email: string; + role: Role | "creator" | "admin"; + createdAt: string; +}; async function getPortfolioUsers(portfolioId: string): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { @@ -25,22 +34,41 @@ async function getPortfolioUsers(portfolioId: string): Promise { if (!res.ok) throw new Error("Failed to fetch users"); const json = await res.json(); const users = Array.isArray(json) ? json : json.users; // support both shapes - // Guard + shape to Collaborator[] return Array.isArray(users) ? users.map((u: any) => ({ - portfolioUserId: String(u.portfolioUserId), - userId: String(u.userId), - name: u.name ?? null, - email: u.email ?? "", - role: u.role, - })) + portfolioUserId: String(u.portfolioUserId), + userId: String(u.userId), + name: u.name ?? null, + email: u.email ?? "", + role: u.role, + })) + : []; +} + +async function getPortfolioInvitations( + portfolioId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error("Failed to fetch invitations"); + const json = await res.json(); + const invitations = json?.invitations ?? []; + return Array.isArray(invitations) + ? invitations.map((i: any) => ({ + invitationId: String(i.invitationId), + email: i.email ?? "", + role: i.role, + createdAt: i.createdAt, + })) : []; } async function updatePortfolioUserRole( portfolioId: string, portfolioUserId: string, - role: Role + role: Role, ): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { method: "PUT", @@ -57,7 +85,7 @@ async function invitePortfolioUser( portfolioId: string, email: string, role: Role, - name: string + name: string, ): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { method: "POST", @@ -70,6 +98,35 @@ async function invitePortfolioUser( } } +async function removePortfolioUser( + portfolioId: string, + portfolioUserId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ portfolioUserId }), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || "Failed to remove user"); + } +} + +async function revokePortfolioInvitation( + portfolioId: string, + invitationId: string, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ invitationId }), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || "Failed to revoke invitation"); + } +} export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); @@ -78,89 +135,154 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const queryClient = useQueryClient(); + const usersKey = ["portfolioUsers", portfolioId]; + const invitationsKey = ["portfolioInvitations", portfolioId]; + const { data: collaborators = [], isLoading, isFetching, refetch, } = useQuery({ - queryKey: ["portfolioUsers", portfolioId], + queryKey: usersKey, queryFn: () => getPortfolioUsers(portfolioId), - enabled: !!portfolioId, // only run when id is present - refetchOnWindowFocus: false, // optional: avoid surprise refetch logs - onSuccess: (data) => { - console.log("Fetched users for portfolio:", data); - }, - onError: (err) => { - console.error("Error fetching users:", err); - }, + enabled: !!portfolioId, + refetchOnWindowFocus: false, }); + const { + data: invitations = [], + isLoading: invitationsLoading, + isFetching: invitationsFetching, + } = useQuery({ + queryKey: invitationsKey, + queryFn: () => getPortfolioInvitations(portfolioId), + enabled: !!portfolioId, + refetchOnWindowFocus: false, + }); + + const invalidateBoth = () => { + queryClient.invalidateQueries({ queryKey: usersKey }); + queryClient.invalidateQueries({ queryKey: invitationsKey }); + }; + const changeRoleMutation = useMutation({ - mutationFn: ({ portfolioUserId, role }: { portfolioUserId: string; role: Role }) => - updatePortfolioUserRole(portfolioId, portfolioUserId, role), + mutationFn: ({ + portfolioUserId, + role, + }: { + portfolioUserId: string; + role: Role; + }) => updatePortfolioUserRole(portfolioId, portfolioUserId, role), - // Optimistic update onMutate: async ({ portfolioUserId, role }) => { - await queryClient.cancelQueries({ queryKey: ["portfolioUsers", portfolioId] }); - const previous = queryClient.getQueryData(["portfolioUsers", portfolioId]); - - // Optimistically update cache - queryClient.setQueryData( - ["portfolioUsers", portfolioId], - (old) => - (old ?? []).map((c) => - c.portfolioUserId === portfolioUserId ? { ...c, role } : c - ) + await queryClient.cancelQueries({ queryKey: usersKey }); + const previous = queryClient.getQueryData(usersKey); + queryClient.setQueryData(usersKey, (old) => + (old ?? []).map((c) => + c.portfolioUserId === portfolioUserId ? { ...c, role } : c, + ), ); - - // Return context to rollback on error return { previous }; }, - - // Rollback on error onError: (err, _vars, context) => { if (context?.previous) { - queryClient.setQueryData(["portfolioUsers", portfolioId], context.previous); + queryClient.setQueryData(usersKey, context.previous); } console.error("Failed to update role:", err); }, - - // Always revalidate after success/error onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); + queryClient.invalidateQueries({ queryKey: usersKey }); }, }); - // ADD: mutation for inviting a user const inviteUserMutation = useMutation({ - mutationFn: ({ email, role, name }: { email: string; role: Role; name: string }) => - invitePortfolioUser(portfolioId, email, role, name), + mutationFn: ({ + email, + role, + name, + }: { + email: string; + role: Role; + name: string; + }) => invitePortfolioUser(portfolioId, email, role, name), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] }); + invalidateBoth(); setInviteEmail(""); - setInviteName(""); // clear name after success - // setInviteRole("read"); + setInviteName(""); }, onError: (err) => { console.error("Invite failed:", err); }, }); + const removeUserMutation = useMutation({ + mutationFn: (portfolioUserId: string) => + removePortfolioUser(portfolioId, portfolioUserId), + + onMutate: async (portfolioUserId) => { + await queryClient.cancelQueries({ queryKey: usersKey }); + const previous = queryClient.getQueryData(usersKey); + queryClient.setQueryData(usersKey, (old) => + (old ?? []).filter((c) => c.portfolioUserId !== portfolioUserId), + ); + return { previous }; + }, + onError: (err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(usersKey, context.previous); + } + console.error("Failed to remove user:", err); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: usersKey }); + }, + }); + + const revokeInvitationMutation = useMutation({ + mutationFn: (invitationId: string) => + revokePortfolioInvitation(portfolioId, invitationId), + + onMutate: async (invitationId) => { + await queryClient.cancelQueries({ queryKey: invitationsKey }); + const previous = + queryClient.getQueryData(invitationsKey); + queryClient.setQueryData(invitationsKey, (old) => + (old ?? []).filter((i) => i.invitationId !== invitationId), + ); + return { previous }; + }, + onError: (err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(invitationsKey, context.previous); + } + console.error("Failed to revoke invitation:", err); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: invitationsKey }); + }, + }); function handleInvite() { - inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName }); + inviteUserMutation.mutate({ + email: inviteEmail, + role: inviteRole, + name: inviteName, + }); } function onChangeRole(portfolioUserId: string, role: Role) { - console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`); changeRoleMutation.mutate({ portfolioUserId, role }); } function onRemove(portfolioUserId: string) { - console.log(`This button will delete the row portoflioUserId ${portfolioUserId}`); - console.log("This was not implemented as Jun-te wanted to avoid Delete via drizzle before Database integrirty") - // TODO: DELETE user -> then refetch() + if (!confirm("Remove this user from the portfolio?")) return; + removeUserMutation.mutate(portfolioUserId); + } + + function onRevokeInvitation(invitationId: string) { + if (!confirm("Revoke this pending invitation?")) return; + revokeInvitationMutation.mutate(invitationId); } return ( @@ -173,7 +295,11 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {

Add users and manage roles

- @@ -205,13 +331,15 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { - + @@ -219,7 +347,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { Current users -

Update roles or remove access

+

+ Update roles or remove access +

@@ -235,7 +365,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {isLoading ? ( - Loading… + + Loading… + ) : collaborators.length === 0 ? ( @@ -254,13 +386,27 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {c.role} ) : ( - onChangeRole(c.portfolioUserId, r)} /> + + onChangeRole(c.portfolioUserId, r) + } + /> )} {c.role !== "creator" && ( - )} @@ -272,8 +418,73 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
+ + {/* Pending invitations list */} + + + Pending invitations +

+ Emails invited but not yet signed in +

+
+ +
+ + + + Email + Role + Invited + Actions + + + + {invitationsLoading || invitationsFetching ? ( + + + Loading… + + + ) : invitations.length === 0 ? ( + + + No pending invitations. + + + ) : ( + invitations.map((i) => ( + + {i.email} + {i.role} + + {new Date(i.createdAt).toLocaleDateString()} + + + + + + )) + )} + +
+
+
+
); -} \ No newline at end of file +} From 616302a5c7c21d5a34c8d344131c861b421df484 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 16:40:34 +0000 Subject: [PATCH 4/7] Gate user-access page behind admin privilege; allow admin role assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a portfolio-privilege concept (creator > admin > domna employee > write > read > none) and gates all user-access mutations + the pending- invitations view behind it. Plus opens the role dropdown to include "admin" so creators/admins/Domna can promote and demote. Privilege model: - Portfolio creator: full admin powers (cannot be removed/demoted) - Portfolio admin: full admin powers via explicit membership role - Domna employee (email ends @domna.homes, case-insensitive): implicit admin across all portfolios, even if not a member — intended for customer-support / internal-tooling needs - Anyone else (read/write/none): no admin powers Backend: - New pure-function helpers in src/app/lib/portfolioAdmin.ts — isDomnaEmail() and canAdminister(privilege), with 6 tests covering case-insensitivity and look-alike domain rejection - New server helper resolvePortfolioPrivilege() that reads portfolioUsers + checks the email, returning the highest privilege - New denyIfNotAdmin(portfolioId, session) one-liner that returns a 401/403 NextResponse or null; used at the top of every mutating route handler to keep the guard out of the way - POST/PUT/DELETE on /colloborators and GET/DELETE on /invitations are now all gated. Non-admin callers get 403. - GET /colloborators now requires auth and returns `{ users, currentUser: { privilege } }` so the UI knows which actions to expose without an extra round-trip Frontend: - ROLE_OPTIONS extended to ["read", "write", "admin"]. RoleDropdown takes allowAdminPromotion?: boolean to keep the basic dropdown unchanged where promotion isn't allowed. - UsersPermissionsCard derives isAdmin = canAdminister(privilege) from the API response. Invite section, role-change dropdown, Remove button, and the entire Pending Invitations section are now rendered only when isAdmin. Non-admins see a read-only members table. - The invitations useQuery is disabled when !isAdmin, avoiding guaranteed-403 network calls. Defensive note: the UI gating is for UX; the backend guard is the security boundary. A non-admin who hand-crafts a POST still gets 403. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portfolioId]/colloborators/route.ts | 45 +++-- .../[portfolioId]/invitations/route.ts | 11 +- src/app/lib/portfolioAdmin.test.ts | 35 ++++ src/app/lib/portfolioAdmin.ts | 19 +++ src/app/lib/resolvePortfolioPrivilege.ts | 65 +++++++ .../settings/UsersPermissionsCard.tsx | 158 +++++++++++------- .../[slug]/(portfolio)/settings/roles.tsx | 34 +++- 7 files changed, 285 insertions(+), 82 deletions(-) create mode 100644 src/app/lib/portfolioAdmin.test.ts create mode 100644 src/app/lib/portfolioAdmin.ts create mode 100644 src/app/lib/resolvePortfolioPrivilege.ts diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index b48c61f3..3fc72965 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -25,15 +25,28 @@ import { normaliseEmail } from "@/app/lib/email"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { PortfolioInvitationEmail } from "@/app/email_templates/portfolio_invitation"; +import { + denyIfNotAdmin, + resolvePortfolioPrivilege, +} from "@/app/lib/resolvePortfolioPrivilege"; -// Get colloborators (users) that have access to the portfolio +// Get colloborators (users) that have access to the portfolio, plus the +// effective privilege of the requesting user (so the UI knows which actions +// to expose). export async function GET( _req: NextRequest, props: { params: Promise<{ portfolioId: string }> } ) { const { portfolioId } = await props.params; + const session = await getServerSession(AuthOptions); + if (!session?.user?.dbId || !session.user.email) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + try { + const pId = BigInt(portfolioId); + const rows = await db .select({ portfolioUserId: portfolioUsers.id, @@ -44,9 +57,8 @@ export async function GET( }) .from(portfolioUsers) .leftJoin(user, eq(user.id, portfolioUsers.userId)) - .where(eq(portfolioUsers.portfolioId, BigInt(portfolioId))); + .where(eq(portfolioUsers.portfolioId, pId)); - // Explicitly normalize BigInts to strings const collaborators = rows.map((r) => ({ portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null, userId: r.userId ? r.userId.toString() : null, @@ -55,7 +67,16 @@ export async function GET( email: r.email ?? "", })); - return NextResponse.json({ users: collaborators }, { status: 200 }); + const privilege = await resolvePortfolioPrivilege({ + portfolioId: pId, + userId: BigInt(session.user.dbId), + userEmail: session.user.email, + }); + + return NextResponse.json( + { users: collaborators, currentUser: { privilege } }, + { status: 200 }, + ); } catch (err) { console.error("GET /users error:", err); return NextResponse.json( @@ -73,9 +94,8 @@ export async function DELETE( const { portfolioId } = await props.params; const session = await getServerSession(AuthOptions); - if (!session?.user?.dbId) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const denied = await denyIfNotAdmin(BigInt(portfolioId), session); + if (denied) return denied; const bodySchema = z.object({ portfolioUserId: z.string() }); let body: z.infer; @@ -135,6 +155,10 @@ export async function PUT( ) { const { portfolioId } = await props.params; + const session = await getServerSession(AuthOptions); + const denied = await denyIfNotAdmin(BigInt(portfolioId), session); + if (denied) return denied; + // Validate request body const bodySchema = z.object({ portfolioUserId: z.string(), @@ -195,10 +219,9 @@ export async function POST( const email = normaliseEmail(body.email); const session = await getServerSession(AuthOptions); - if (!session?.user?.dbId) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } - const inviterUserId = BigInt(session.user.dbId); + const denied = await denyIfNotAdmin(BigInt(portfolioId), session); + if (denied) return denied; + const inviterUserId = BigInt(session!.user!.dbId!); try { const pId = BigInt(portfolioId); diff --git a/src/app/api/portfolio/[portfolioId]/invitations/route.ts b/src/app/api/portfolio/[portfolioId]/invitations/route.ts index 33ccdba0..6409dad0 100644 --- a/src/app/api/portfolio/[portfolioId]/invitations/route.ts +++ b/src/app/api/portfolio/[portfolioId]/invitations/route.ts @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { denyIfNotAdmin } from "@/app/lib/resolvePortfolioPrivilege"; // GET: list pending invitations for a portfolio. Invitations are consumed // (deleted) when the invitee signs in, so anything returned here is still @@ -16,9 +17,8 @@ export async function GET( const { portfolioId } = await props.params; const session = await getServerSession(AuthOptions); - if (!session?.user?.dbId) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const denied = await denyIfNotAdmin(BigInt(portfolioId), session); + if (denied) return denied; try { const pId = BigInt(portfolioId); @@ -58,9 +58,8 @@ export async function DELETE( const { portfolioId } = await props.params; const session = await getServerSession(AuthOptions); - if (!session?.user?.dbId) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const denied = await denyIfNotAdmin(BigInt(portfolioId), session); + if (denied) return denied; const bodySchema = z.object({ invitationId: z.string() }); let body: z.infer; diff --git a/src/app/lib/portfolioAdmin.test.ts b/src/app/lib/portfolioAdmin.test.ts new file mode 100644 index 00000000..23afc564 --- /dev/null +++ b/src/app/lib/portfolioAdmin.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { canAdminister, isDomnaEmail } from "./portfolioAdmin"; + +describe("isDomnaEmail", () => { + it("identifies @domna.homes addresses as internal", () => { + expect(isDomnaEmail("khalim@domna.homes")).toBe(true); + }); + + it("is case-insensitive on the domain", () => { + expect(isDomnaEmail("Khalim@Domna.Homes")).toBe(true); + }); + + it("rejects look-alike domains and prefixes", () => { + expect(isDomnaEmail("user@example.com")).toBe(false); + expect(isDomnaEmail("user@domna.homes.attacker.com")).toBe(false); + expect(isDomnaEmail("user@notdomna.homes")).toBe(false); + }); +}); + +describe("canAdminister", () => { + it("grants admin powers to portfolio creator", () => { + expect(canAdminister("creator")).toBe(true); + }); + + it("grants admin powers to portfolio admins and Domna employees", () => { + expect(canAdminister("admin")).toBe(true); + expect(canAdminister("domna")).toBe(true); + }); + + it("denies admin powers to read/write members and non-members", () => { + expect(canAdminister("write")).toBe(false); + expect(canAdminister("read")).toBe(false); + expect(canAdminister("none")).toBe(false); + }); +}); diff --git a/src/app/lib/portfolioAdmin.ts b/src/app/lib/portfolioAdmin.ts new file mode 100644 index 00000000..6798ecfd --- /dev/null +++ b/src/app/lib/portfolioAdmin.ts @@ -0,0 +1,19 @@ +export type PortfolioPrivilege = + | "creator" + | "admin" + | "domna" + | "write" + | "read" + | "none"; + +export function isDomnaEmail(email: string): boolean { + return email.toLowerCase().endsWith("@domna.homes"); +} + +export function canAdminister(privilege: PortfolioPrivilege): boolean { + return ( + privilege === "creator" || + privilege === "admin" || + privilege === "domna" + ); +} diff --git a/src/app/lib/resolvePortfolioPrivilege.ts b/src/app/lib/resolvePortfolioPrivilege.ts new file mode 100644 index 00000000..4a23a15a --- /dev/null +++ b/src/app/lib/resolvePortfolioPrivilege.ts @@ -0,0 +1,65 @@ +import { db } from "@/app/db/db"; +import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import type { Session } from "next-auth"; +import { + canAdminister, + isDomnaEmail, + type PortfolioPrivilege, +} from "./portfolioAdmin"; + +// Resolves the effective privilege a session has on a given portfolio. +// Highest-wins: an explicit "creator" or "admin" membership ranks above the +// implicit "domna" employee privilege; otherwise Domna employees get admin +// powers without being a member; otherwise the membership role is returned. +export async function resolvePortfolioPrivilege({ + portfolioId, + userId, + userEmail, +}: { + portfolioId: bigint; + userId: bigint; + userEmail: string; +}): Promise { + const [membership] = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, portfolioId), + eq(portfolioUsers.userId, userId), + ), + ) + .limit(1); + + if (membership?.role === "creator") return "creator"; + if (membership?.role === "admin") return "admin"; + if (isDomnaEmail(userEmail)) return "domna"; + if (membership?.role === "write") return "write"; + if (membership?.role === "read") return "read"; + return "none"; +} + +// Convenience: returns an HTTP response if the session can't administer the +// portfolio, otherwise null. Use at the top of mutating route handlers: +// +// const denied = await denyIfNotAdmin(portfolioId, session); +// if (denied) return denied; +export async function denyIfNotAdmin( + portfolioId: bigint, + session: Session | null, +): Promise { + if (!session?.user?.dbId || !session.user.email) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + const privilege = await resolvePortfolioPrivilege({ + portfolioId, + userId: BigInt(session.user.dbId), + userEmail: session.user.email, + }); + if (!canAdminister(privilege)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return null; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 323ffefe..6e6d58d9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -18,24 +18,35 @@ import { Collaborator, } from "@/app/portfolio/[slug]/(portfolio)/settings/roles"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + canAdminister, + type PortfolioPrivilege, +} from "@/app/lib/portfolioAdmin"; type PendingInvitation = { invitationId: string; email: string; - role: Role | "creator" | "admin"; + role: Role | "creator"; createdAt: string; }; -async function getPortfolioUsers(portfolioId: string): Promise { +type CollaboratorsResponse = { + users: Collaborator[]; + currentUser?: { privilege: PortfolioPrivilege }; +}; + +async function getPortfolioUsers( + portfolioId: string, +): Promise { const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, { method: "GET", headers: { "Content-Type": "application/json" }, }); if (!res.ok) throw new Error("Failed to fetch users"); const json = await res.json(); - const users = Array.isArray(json) ? json : json.users; // support both shapes - return Array.isArray(users) - ? users.map((u: any) => ({ + const rawUsers = Array.isArray(json) ? json : json.users; + const users: Collaborator[] = Array.isArray(rawUsers) + ? rawUsers.map((u: any) => ({ portfolioUserId: String(u.portfolioUserId), userId: String(u.userId), name: u.name ?? null, @@ -43,6 +54,9 @@ async function getPortfolioUsers(portfolioId: string): Promise { role: u.role, })) : []; + const privilege: PortfolioPrivilege | undefined = + json?.currentUser?.privilege; + return privilege ? { users, currentUser: { privilege } } : { users }; } async function getPortfolioInvitations( @@ -139,7 +153,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const invitationsKey = ["portfolioInvitations", portfolioId]; const { - data: collaborators = [], + data: collaboratorsData, isLoading, isFetching, refetch, @@ -150,6 +164,11 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { refetchOnWindowFocus: false, }); + const collaborators = collaboratorsData?.users ?? []; + const currentPrivilege: PortfolioPrivilege = + collaboratorsData?.currentUser?.privilege ?? "none"; + const isAdmin = canAdminister(currentPrivilege); + const { data: invitations = [], isLoading: invitationsLoading, @@ -157,7 +176,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { } = useQuery({ queryKey: invitationsKey, queryFn: () => getPortfolioInvitations(portfolioId), - enabled: !!portfolioId, + // Only admins can see pending invitations — the GET endpoint also enforces + // this; gating here avoids the unauthorised network request. + enabled: !!portfolioId && isAdmin, refetchOnWindowFocus: false, }); @@ -177,11 +198,17 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { onMutate: async ({ portfolioUserId, role }) => { await queryClient.cancelQueries({ queryKey: usersKey }); - const previous = queryClient.getQueryData(usersKey); - queryClient.setQueryData(usersKey, (old) => - (old ?? []).map((c) => - c.portfolioUserId === portfolioUserId ? { ...c, role } : c, - ), + const previous = + queryClient.getQueryData(usersKey); + queryClient.setQueryData(usersKey, (old) => + old + ? { + ...old, + users: old.users.map((c) => + c.portfolioUserId === portfolioUserId ? { ...c, role } : c, + ), + } + : old, ); return { previous }; }, @@ -222,9 +249,17 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { onMutate: async (portfolioUserId) => { await queryClient.cancelQueries({ queryKey: usersKey }); - const previous = queryClient.getQueryData(usersKey); - queryClient.setQueryData(usersKey, (old) => - (old ?? []).filter((c) => c.portfolioUserId !== portfolioUserId), + const previous = + queryClient.getQueryData(usersKey); + queryClient.setQueryData(usersKey, (old) => + old + ? { + ...old, + users: old.users.filter( + (c) => c.portfolioUserId !== portfolioUserId, + ), + } + : old, ); return { previous }; }, @@ -305,43 +340,51 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { - {/* Invite row */} - - - Add a user -

- Invite by email and choose a role -

-
- - setInviteName(e.target.value)} - /> - setInviteEmail(e.target.value)} - /> -
- -
-
- - - -
+ {/* Invite row — admin-only */} + {isAdmin && ( + + + Add a user +

+ Invite by email and choose a role +

+
+ + setInviteName(e.target.value)} + /> + setInviteEmail(e.target.value)} + /> +
+ +
+
+ + + +
+ )} {/* Current collaborators list */} @@ -381,21 +424,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {c.name || "—"} {c.email} - {c.role === "creator" || c.role === "admin" ? ( + {c.role === "creator" || !isAdmin ? ( {c.role} ) : ( onChangeRole(c.portfolioUserId, r) } + allowAdminPromotion /> )} - {c.role !== "creator" && ( + {c.role !== "creator" && isAdmin && ( + + + + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index a5f61cf3..c6a07a5f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -27,6 +27,8 @@ import { fetchCollaborators, type CollaboratorsResponse, } from "./collaboratorsClient"; +import { ConfirmDialog } from "@/app/components/ConfirmDialog"; +import { useToast } from "@/app/hooks/use-toast"; type PendingInvitation = { invitationId: string; @@ -122,7 +124,12 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("read"); const [inviteName, setInviteName] = useState(""); + const [pendingRemoval, setPendingRemoval] = + useState<{ portfolioUserId: string; email: string } | null>(null); + const [pendingRevoke, setPendingRevoke] = + useState<{ invitationId: string; email: string } | null>(null); + const { toast } = useToast(); const queryClient = useQueryClient(); const usersKey = COLLABORATORS_QUERY_KEY(portfolioId); @@ -209,13 +216,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { role: Role; name: string; }) => invitePortfolioUser(portfolioId, email, role, name), - onSuccess: () => { + onSuccess: (_data, vars) => { invalidateBoth(); setInviteEmail(""); setInviteName(""); + toast({ + title: "Invitation sent", + description: `We've emailed ${vars.email} an invitation to this portfolio.`, + }); }, onError: (err) => { console.error("Invite failed:", err); + toast({ + title: "Couldn't send invitation", + description: err instanceof Error ? err.message : "Please try again.", + variant: "destructive", + }); }, }); @@ -239,14 +255,29 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { ); return { previous }; }, + onSuccess: (_data, _portfolioUserId) => { + const email = pendingRemoval?.email; + toast({ + title: "User removed", + description: email + ? `${email} no longer has access to this portfolio.` + : "User no longer has access to this portfolio.", + }); + }, onError: (err, _vars, context) => { if (context?.previous) { queryClient.setQueryData(usersKey, context.previous); } console.error("Failed to remove user:", err); + toast({ + title: "Couldn't remove user", + description: err instanceof Error ? err.message : "Please try again.", + variant: "destructive", + }); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: usersKey }); + setPendingRemoval(null); }, }); @@ -263,14 +294,29 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { ); return { previous }; }, + onSuccess: () => { + const email = pendingRevoke?.email; + toast({ + title: "Invitation revoked", + description: email + ? `${email}'s invitation has been cancelled.` + : "The invitation has been cancelled.", + }); + }, onError: (err, _vars, context) => { if (context?.previous) { queryClient.setQueryData(invitationsKey, context.previous); } console.error("Failed to revoke invitation:", err); + toast({ + title: "Couldn't revoke invitation", + description: err instanceof Error ? err.message : "Please try again.", + variant: "destructive", + }); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: invitationsKey }); + setPendingRevoke(null); }, }); @@ -286,14 +332,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { changeRoleMutation.mutate({ portfolioUserId, role }); } - function onRemove(portfolioUserId: string) { - if (!confirm("Remove this user from the portfolio?")) return; - removeUserMutation.mutate(portfolioUserId); + function onRemove(portfolioUserId: string, email: string) { + setPendingRemoval({ portfolioUserId, email }); } - function onRevokeInvitation(invitationId: string) { - if (!confirm("Revoke this pending invitation?")) return; - revokeInvitationMutation.mutate(invitationId); + function onRevokeInvitation(invitationId: string, email: string) { + setPendingRevoke({ invitationId, email }); + } + + function confirmRemove() { + if (!pendingRemoval) return; + removeUserMutation.mutate(pendingRemoval.portfolioUserId); + } + + function confirmRevoke() { + if (!pendingRevoke) return; + revokeInvitationMutation.mutate(pendingRevoke.invitationId); } return ( @@ -419,7 +473,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {