Merge pull request #284 from Hestia-Homes/bug/portfolio-invitations

Bug/portfolio invitations
This commit is contained in:
Jun-te Kim 2026-05-27 18:35:10 +01:00 committed by GitHub
commit e3dcc01d88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 11700 additions and 354 deletions

View file

@ -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),

View file

@ -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/<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`;
},
},

View file

@ -0,0 +1,381 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import {
portfolio,
portfolioInvitations,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
recommendationMaterials,
planRecommendations,
plan,
scenario,
} from "@/app/db/schema/recommendations";
import {
propertyTargets,
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
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";
import {
denyIfNotAdmin,
resolvePortfolioPrivilege,
} from "@/app/lib/resolvePortfolioPrivilege";
// Get collaborators (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,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
name: user.firstName,
email: user.email,
})
.from(portfolioUsers)
.leftJoin(user, eq(user.id, portfolioUsers.userId))
.where(eq(portfolioUsers.portfolioId, pId));
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
email: r.email ?? "",
}));
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(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// 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);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const bodySchema = z.object({ portfolioUserId: z.string() });
let body: z.infer<typeof bodySchema>;
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 /collaborators error:", err);
return NextResponse.json(
{ error: "Failed to remove user" },
{ status: 500 },
);
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
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(),
role: z.enum(ROLE_OPTIONS), // adjust to your Role union
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (err) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
// Update role for this portfolioUserId
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, BigInt(body.portfolioUserId)));
return NextResponse.json(
{ success: true, portfolioUserId: body.portfolioUserId, role: body.role },
{ status: 200 }
);
} catch (err) {
console.error("PUT /collaborators error:", err);
return NextResponse.json(
{ error: "Failed to update role" },
{ status: 500 }
);
}
}
// 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;
const bodySchema = z.object({
email: z.string().email(),
role: z.enum(ROLE_OPTIONS),
name: z.string(),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const email = normaliseEmail(body.email);
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const inviterUserId = BigInt(session!.user!.dbId!);
try {
const pId = BigInt(portfolioId);
const [portfolioRow] = await db
.select({ name: portfolio.name })
.from(portfolio)
.where(eq(portfolio.id, pId))
.limit(1);
if (!portfolioRow) {
return NextResponse.json(
{ error: "Portfolio not found" },
{ status: 404 },
);
}
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";
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 /collaborators error:", err);
return NextResponse.json(
{ error: "Failed to invite user" },
{ status: 500 }
);
}
}

View file

@ -1,207 +0,0 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
recommendationMaterials,
planRecommendations,
plan,
scenario,
} from "@/app/db/schema/recommendations";
import {
propertyTargets,
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
import { eq, inArray, Name } from "drizzle-orm";
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
// Get colloborators (users) that have access to the portfolio
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
try {
const rows = await db
.select({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
name: user.firstName,
email: user.email,
})
.from(portfolioUsers)
.leftJoin(user, eq(user.id, portfolioUsers.userId))
.where(eq(portfolioUsers.portfolioId, BigInt(portfolioId)));
// Explicitly normalize BigInts to strings
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
email: r.email ?? "",
}));
return NextResponse.json({ users: collaborators }, { status: 200 });
} catch (err) {
console.error("GET /users error:", err);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
// Validate request body
const bodySchema = z.object({
portfolioUserId: z.string(),
role: z.enum(ROLE_OPTIONS), // adjust to your Role union
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (err) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
// Update role for this portfolioUserId
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, BigInt(body.portfolioUserId)));
return NextResponse.json(
{ success: true, portfolioUserId: body.portfolioUserId, role: body.role },
{ status: 200 }
);
} catch (err) {
console.error("PUT /colloborators error:", err);
return NextResponse.json(
{ error: "Failed to update role" },
{ status: 500 }
);
}
}
// POST: invite a user by email (find-or-create user, then add to portfolio with role)
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),
name: z.string(),
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
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))
.limit(1);
let createdUserId: bigint | null = existing[0]?.id ?? null;
// If not found, create. Prefer Postgres upsert to avoid race.
if (!createdUserId) {
// If youre 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) {
return NextResponse.json(
{ error: "Failed to create portfolio user" },
{ status: 500 }
);
}
const collaborator = {
portfolioUserId: row.portfolioUserId?.toString() ?? null,
userId: row.userId?.toString() ?? null,
role: row.role,
name: body.name ?? null,
email: body.email,
};
// 201 if it was a new link, 200 if it was an update — we cant easily
// tell from .onConflictDoUpdate return, so just use 200 OK.
return NextResponse.json({ user: collaborator }, { status: 200 });
} catch (err) {
console.error("POST /colloborators error:", err);
return NextResponse.json(
{ error: "Failed to invite user" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,104 @@
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";
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
// pending.
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
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);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const bodySchema = z.object({ invitationId: z.string() });
let body: z.infer<typeof bodySchema>;
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 },
);
}
}

View file

@ -0,0 +1,65 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import type { ReactNode } from "react";
// Controlled confirmation dialog. Pass `open` + `onOpenChange` to control
// visibility (so the parent can stash any context needed by onConfirm),
// and `onConfirm` is called when the user clicks the destructive action.
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
destructive = false,
isPending = false,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
isPending?: boolean;
onConfirm: () => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
{cancelLabel}
</Button>
<Button
variant={destructive ? "destructive" : "default"}
className={destructive ? "bg-red-700 hover:bg-red-800" : ""}
onClick={onConfirm}
disabled={isPending}
>
{isPending ? "Working…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,12 @@
CREATE TABLE "portfolioInvitations" (
"id" bigserial PRIMARY KEY NOT NULL,
"portfolio_id" bigint NOT NULL,
"email" text NOT NULL,
"role" "role" NOT NULL,
"invited_by_user_id" bigint NOT NULL,
"created_at" timestamp (6) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "portfolio_invitations_portfolio_email_unique" UNIQUE("portfolio_id","email")
);
--> statement-breakpoint
ALTER TABLE "portfolioInvitations" ADD CONSTRAINT "portfolioInvitations_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolioInvitations" ADD CONSTRAINT "portfolioInvitations_invited_by_user_id_user_id_fk" FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -1485,6 +1485,13 @@
"when": 1779898075572,
"tag": "0211_lovely_sue_storm",
"breakpoints": true
},
{
"idx": 212,
"version": "7",
"when": 1779900843875,
"tag": "0212_sweet_the_anarchist",
"breakpoints": true
}
]
}

View file

@ -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<typeof portfolio, "select">;
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
export type NewPortfolioUsers = InferModel<typeof portfolioUsers, "insert">;
export type PortfolioInvitation = InferModel<
typeof portfolioInvitations,
"select"
>;
export type NewPortfolioInvitation = InferModel<
typeof portfolioInvitations,
"insert"
>;
export type PortfolioCapabilities = InferModel<
typeof portfolioCapabilities,
"select"

View file

@ -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, "&#8203;.");
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 <strong>${portfolioName}</strong> portfolio on Ara. Open it below to start collaborating.`
: `${inviterName} invited you to join the <strong>${portfolioName}</strong> portfolio on Ara. Sign in with this email address to accept the invitation.`;
return `
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0"
style="max-width: 600px; margin: 40px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<tr>
<td align="center" style="background: linear-gradient(90deg, ${brandColor}, ${accentColor}); padding: 12px 8px;">
<img src="${logoUrl}" alt="Domna Logo" width="120" height="auto" style="margin-bottom: 4px;" />
</td>
</tr>
<tr>
<td align="center" style="padding: 28px 24px 12px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin: 0 0 12px;">${heading}</h2>
<p style="font-size: 15px; line-height: 1.5; color: #555; margin: 0 0 24px;">${explainer}</p>
<a href="${linkUrl}" target="_blank"
style="display: inline-block; padding: 12px 24px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">
${ctaLabel}
</a>
<p style="margin-top: 28px; font-size: 13px; color: #777;">
If you weren't expecting this email, you can safely ignore it.
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px; font-size: 12px; color: #999; border-top: 1px solid #eee;">
&copy; ${new Date().getFullYear()} Domna Homes <span style="color: ${accentColor};">${escapedHost}</span>
</td>
</tr>
</table>
</body>
`;
}
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.
`;
}

15
src/app/lib/email.test.ts Normal file
View file

@ -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");
});
});

3
src/app/lib/email.ts Normal file
View file

@ -0,0 +1,3 @@
export function normaliseEmail(email: string): string {
return email.trim().toLowerCase();
}

View file

@ -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);
});
});

View file

@ -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"
);
}

View file

@ -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<bigint>(),
});
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<bigint>([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<bigint>([300n]),
});
expect(plan.memberships).toEqual([
{ portfolioId: 200n, userId: 100n, role: "read" },
{ portfolioId: 400n, userId: 100n, role: "admin" },
]);
expect(plan.invitationsToDelete).toEqual([1n, 2n, 3n]);
});
});

View file

@ -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<bigint>;
}): 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 };
}

View file

@ -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<PortfolioPrivilege> {
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<NextResponse | null> {
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;
}

View file

@ -11,6 +11,10 @@ import {
import { Button } from "@/app/shadcn_components/ui/button";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
COLLABORATORS_QUERY_KEY,
fetchCollaborators,
} from "./collaboratorsClient";
type Capability = "approver" | "contractor";
@ -30,20 +34,6 @@ async function getCapabilities(portfolioId: string): Promise<CapabilityEntry[]>
return res.json();
}
async function getCollaborators(
portfolioId: string,
): Promise<{ userId: string; name: string | null; email: string }[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`);
if (!res.ok) throw new Error("Failed to fetch collaborators");
const json = await res.json();
const users = Array.isArray(json) ? json : json.users ?? [];
return users.map((u: any) => ({
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
}));
}
async function assignCapability(
portfolioId: string,
userId: string,
@ -81,19 +71,20 @@ export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) {
refetchOnWindowFocus: false,
});
const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({
queryKey: ["portfolioUsers", portfolioId],
queryFn: () => getCollaborators(portfolioId),
const { data: collaboratorsResponse, isLoading: loadingCollabs } = useQuery({
queryKey: COLLABORATORS_QUERY_KEY(portfolioId),
queryFn: () => fetchCollaborators(portfolioId),
enabled: !!portfolioId,
refetchOnWindowFocus: false,
});
const collaborators = collaboratorsResponse?.users ?? [];
const isLoading = loadingCaps || loadingCollabs;
// Build a map: userId -> { capabilities: [] }
const capMap: CapabilityMap = {};
for (const c of collaborators) {
capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] };
capMap[c.userId] = { name: c.name ?? null, email: c.email, capabilities: [] };
}
for (const e of entries) {
if (capMap[e.userId]) {

View file

@ -12,37 +12,57 @@ 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";
import {
canAdminister,
type PortfolioPrivilege,
} from "@/app/lib/portfolioAdmin";
import {
COLLABORATORS_QUERY_KEY,
fetchCollaborators,
type CollaboratorsResponse,
} from "./collaboratorsClient";
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
import { useToast } from "@/app/hooks/use-toast";
type PendingInvitation = {
invitationId: string;
email: string;
role: Role | "creator";
createdAt: string;
};
async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
async function getPortfolioInvitations(
portfolioId: string,
): Promise<PendingInvitation[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/invitations`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Failed to fetch users");
if (!res.ok) throw new Error("Failed to fetch invitations");
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,
}))
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<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ portfolioUserId, role }),
@ -57,9 +77,9 @@ async function invitePortfolioUser(
portfolioId: string,
email: string,
role: Role,
name: string
name: string,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role, name }),
@ -70,97 +90,264 @@ async function invitePortfolioUser(
}
}
async function removePortfolioUser(
portfolioId: string,
portfolioUserId: string,
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
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<void> {
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("");
const [inviteRole, setInviteRole] = useState<Role>("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);
const invitationsKey = ["portfolioInvitations", portfolioId];
const {
data: collaborators = [],
data: collaboratorsData,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["portfolioUsers", portfolioId],
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);
},
queryKey: usersKey,
queryFn: () => fetchCollaborators(portfolioId),
enabled: !!portfolioId,
refetchOnWindowFocus: false,
});
const collaborators = collaboratorsData?.users ?? [];
const currentPrivilege: PortfolioPrivilege =
collaboratorsData?.currentUser?.privilege ?? "none";
const isAdmin = canAdminister(currentPrivilege);
const {
data: invitations = [],
isLoading: invitationsLoading,
isFetching: invitationsFetching,
} = useQuery({
queryKey: invitationsKey,
queryFn: () => getPortfolioInvitations(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,
});
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<Collaborator[]>(["portfolioUsers", portfolioId]);
// Optimistically update cache
queryClient.setQueryData<Collaborator[]>(
["portfolioUsers", portfolioId],
(old) =>
(old ?? []).map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c
)
await queryClient.cancelQueries({ queryKey: usersKey });
const previous =
queryClient.getQueryData<CollaboratorsResponse>(usersKey);
queryClient.setQueryData<CollaboratorsResponse>(usersKey, (old) =>
old
? {
...old,
users: old.users.map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c,
),
}
: old,
);
// 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),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] });
mutationFn: ({
email,
role,
name,
}: {
email: string;
role: Role;
name: string;
}) => invitePortfolioUser(portfolioId, email, role, name),
onSuccess: (_data, vars) => {
invalidateBoth();
setInviteEmail("");
setInviteName(""); // clear name after success
// setInviteRole("read");
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",
});
},
});
const removeUserMutation = useMutation({
mutationFn: (portfolioUserId: string) =>
removePortfolioUser(portfolioId, portfolioUserId),
onMutate: async (portfolioUserId) => {
await queryClient.cancelQueries({ queryKey: usersKey });
const previous =
queryClient.getQueryData<CollaboratorsResponse>(usersKey);
queryClient.setQueryData<CollaboratorsResponse>(usersKey, (old) =>
old
? {
...old,
users: old.users.filter(
(c) => c.portfolioUserId !== portfolioUserId,
),
}
: old,
);
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);
},
});
const revokeInvitationMutation = useMutation({
mutationFn: (invitationId: string) =>
revokePortfolioInvitation(portfolioId, invitationId),
onMutate: async (invitationId) => {
await queryClient.cancelQueries({ queryKey: invitationsKey });
const previous =
queryClient.getQueryData<PendingInvitation[]>(invitationsKey);
queryClient.setQueryData<PendingInvitation[]>(invitationsKey, (old) =>
(old ?? []).filter((i) => i.invitationId !== invitationId),
);
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);
},
});
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()
function onRemove(portfolioUserId: string, email: string) {
setPendingRemoval({ portfolioUserId, email });
}
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 (
@ -173,53 +360,69 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<p className="text-xs text-gray-500">Add users and manage roles</p>
</TableHead>
<TableCell className="text-right">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching || isLoading}>
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching || isLoading}
>
{isFetching || isLoading ? "Loading..." : "Refresh Users"}
</Button>
</TableCell>
</TableRow>
{/* Invite row */}
<TableRow>
<TableHead className="text-brandblue">
Add a user
<p className="text-xs text-gray-500">
Invite by email and choose a role
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="text"
placeholder="Full name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<Input
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<div className="min-w-40">
<RoleDropdown value={inviteRole} onChange={setInviteRole} />
</div>
</TableCell>
<TableCell className="text-right">
<Button
className="w-28"
onClick={handleInvite}
disabled={!inviteEmail || !inviteName || inviteUserMutation.isPending}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
</TableCell>
</TableRow>
{/* Invite row — admin-only */}
{isAdmin && (
<TableRow>
<TableHead className="text-brandblue">
Add a user
<p className="text-xs text-gray-500">
Invite by email and choose a role
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="text"
placeholder="Full name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<Input
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<div className="min-w-40">
<RoleDropdown
value={inviteRole}
onChange={setInviteRole}
allowAdminPromotion
/>
</div>
</TableCell>
<TableCell className="text-right">
<Button
className="w-28"
onClick={handleInvite}
disabled={
!inviteEmail ||
!inviteName ||
inviteUserMutation.isPending
}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
</TableCell>
</TableRow>
)}
{/* Current collaborators list */}
<TableRow>
<TableHead className="text-brandblue">
Current users
<p className="text-xs text-gray-500">Update roles or remove access</p>
<p className="text-xs text-gray-500">
Update roles or remove access
</p>
</TableHead>
<TableCell colSpan={2}>
<div className="rounded-md border border-gray-200">
@ -235,7 +438,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">Loading</TableCell>
<TableCell colSpan={4} className="text-sm text-gray-500">
Loading
</TableCell>
</TableRow>
) : collaborators.length === 0 ? (
<TableRow>
@ -249,18 +454,35 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
{c.role === "creator" || c.role === "admin" ? (
{c.role === "creator" || !isAdmin ? (
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
{c.role}
</span>
) : (
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
<RoleDropdown
value={c.role as Role}
onChange={(r) =>
onChangeRole(c.portfolioUserId, r)
}
allowAdminPromotion
/>
)}
</TableCell>
<TableCell className="text-right">
{c.role !== "creator" && (
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
{c.role !== "creator" && isAdmin && (
<Button
variant="destructive"
className="bg-red-700"
onClick={() =>
onRemove(c.portfolioUserId, c.email)
}
disabled={removeUserMutation.isPending}
>
{removeUserMutation.isPending &&
removeUserMutation.variables ===
c.portfolioUserId
? "Removing..."
: "Remove"}
</Button>
)}
</TableCell>
@ -272,8 +494,112 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
</div>
</TableCell>
</TableRow>
{/* Pending invitations list — admin-only */}
{isAdmin && (
<TableRow>
<TableHead className="text-brandblue">
Pending invitations
<p className="text-xs text-gray-500">
Emails invited but not yet signed in
</p>
</TableHead>
<TableCell colSpan={2}>
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Invited</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitationsLoading || invitationsFetching ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
Loading
</TableCell>
</TableRow>
) : invitations.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
No pending invitations.
</TableCell>
</TableRow>
) : (
invitations.map((i) => (
<TableRow key={i.invitationId}>
<TableCell>{i.email}</TableCell>
<TableCell className="capitalize">{i.role}</TableCell>
<TableCell className="text-sm text-gray-500">
{new Date(i.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
className="bg-red-700"
onClick={() =>
onRevokeInvitation(i.invitationId, i.email)
}
disabled={revokeInvitationMutation.isPending}
>
{revokeInvitationMutation.isPending &&
revokeInvitationMutation.variables ===
i.invitationId
? "Revoking..."
: "Revoke"}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<ConfirmDialog
open={pendingRemoval !== null}
onOpenChange={(open) => !open && setPendingRemoval(null)}
title="Remove user from this portfolio?"
description={
pendingRemoval ? (
<>
<span className="font-medium">{pendingRemoval.email}</span> will
immediately lose access. They can be re-invited later.
</>
) : null
}
confirmLabel="Remove"
destructive
isPending={removeUserMutation.isPending}
onConfirm={confirmRemove}
/>
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(open) => !open && setPendingRevoke(null)}
title="Revoke this pending invitation?"
description={
pendingRevoke ? (
<>
<span className="font-medium">{pendingRevoke.email}</span> won't
be able to accept this invitation. You can invite them again
later.
</>
) : null
}
confirmLabel="Revoke"
destructive
isPending={revokeInvitationMutation.isPending}
onConfirm={confirmRevoke}
/>
</div>
);
}
}

View file

@ -0,0 +1,35 @@
import type { Collaborator } from "./roles";
import type { PortfolioPrivilege } from "@/app/lib/portfolioAdmin";
export type CollaboratorsResponse = {
users: Collaborator[];
currentUser?: { privilege: PortfolioPrivilege };
};
// Shared fetcher used by every component that queries the portfolio user
// list. Keeping a single function (and a single response shape) means
// useQuery deduping behaves correctly when the user-access page mounts
// both UsersPermissionsCard and CapabilitiesCard against the same key.
export async function fetchCollaborators(
portfolioId: string,
): Promise<CollaboratorsResponse> {
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`);
if (!res.ok) throw new Error("Failed to fetch collaborators");
const json = await res.json();
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,
email: u.email ?? "",
role: u.role,
}))
: [];
const privilege: PortfolioPrivilege | undefined =
json?.currentUser?.privilege;
return privilege ? { users, currentUser: { privilege } } : { users };
}
export const COLLABORATORS_QUERY_KEY = (portfolioId: string) =>
["portfolioUsers", portfolioId] as const;

View file

@ -7,34 +7,50 @@ import {
SelectItem,
} from "@/app/shadcn_components/ui/select";
// Roles you support in your app (adjust as needed)
export const ROLE_OPTIONS = ["read", "write"] as const;
export type Role = typeof ROLE_OPTIONS[number];
// Roles a portfolio admin can assign via the UI. "creator" is set on portfolio
// creation only and is not assignable.
export const ROLE_OPTIONS = ["read", "write", "admin"] as const;
export type Role = (typeof ROLE_OPTIONS)[number];
// Roles a non-admin viewer would see in the assignable dropdown — not used
// for backend validation, just shapes the dropdown when promotion isn't
// permitted.
const BASIC_ROLE_OPTIONS = ["read", "write"] as const;
export type Collaborator = {
portfolioUserId: string;
userId: string;
userId: string;
name?: string | null;
email: string;
role: Role | "creator" | "admin";
role: Role | "creator";
};
// Small role dropdown using shadcn Select
// Small role dropdown using shadcn Select. Pass `allowAdminPromotion` when the
// viewer can promote/demote to/from "admin".
export function RoleDropdown({
value,
onChange,
allowAdminPromotion = false,
disabled = false,
}: {
value: Role;
onChange: (role: Role) => void;
allowAdminPromotion?: boolean;
disabled?: boolean;
}) {
const options = allowAdminPromotion ? ROLE_OPTIONS : BASIC_ROLE_OPTIONS;
return (
<Select value={value} onValueChange={(v) => onChange(v as Role)}>
<Select
value={value}
onValueChange={(v) => onChange(v as Role)}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={value} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLE_OPTIONS.map((r) => (
{options.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
@ -43,4 +59,4 @@ export function RoleDropdown({
</SelectContent>
</Select>
);
}
}