mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #284 from Hestia-Homes/bug/portfolio-invitations
Bug/portfolio invitations
This commit is contained in:
commit
e3dcc01d88
22 changed files with 11700 additions and 354 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
381
src/app/api/portfolio/[portfolioId]/collaborators/route.ts
Normal file
381
src/app/api/portfolio/[portfolioId]/collaborators/route.ts
Normal 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 collaborator’s 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 collaborator’s 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 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) {
|
||||
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 can’t 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
src/app/api/portfolio/[portfolioId]/invitations/route.ts
Normal file
104
src/app/api/portfolio/[portfolioId]/invitations/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
65
src/app/components/ConfirmDialog.tsx
Normal file
65
src/app/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/db/migrations/0212_sweet_the_anarchist.sql
Normal file
12
src/app/db/migrations/0212_sweet_the_anarchist.sql
Normal 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;
|
||||
10119
src/app/db/migrations/meta/0212_snapshot.json
Normal file
10119
src/app/db/migrations/meta/0212_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
179
src/app/email_templates/portfolio_invitation.ts
Normal file
179
src/app/email_templates/portfolio_invitation.ts
Normal 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, "​.");
|
||||
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;">
|
||||
© ${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
15
src/app/lib/email.test.ts
Normal 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
3
src/app/lib/email.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function normaliseEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
35
src/app/lib/portfolioAdmin.test.ts
Normal file
35
src/app/lib/portfolioAdmin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
src/app/lib/portfolioAdmin.ts
Normal file
19
src/app/lib/portfolioAdmin.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
50
src/app/lib/portfolioInvitations.test.ts
Normal file
50
src/app/lib/portfolioInvitations.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
42
src/app/lib/portfolioInvitations.ts
Normal file
42
src/app/lib/portfolioInvitations.ts
Normal 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 };
|
||||
}
|
||||
65
src/app/lib/resolvePortfolioPrivilege.ts
Normal file
65
src/app/lib/resolvePortfolioPrivilege.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue