mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #287 from Hestia-Homes/bug/portfolio-invitations
Bug/portfolio invitations
This commit is contained in:
commit
ec10a53aeb
11 changed files with 574 additions and 165 deletions
|
|
@ -19,11 +19,6 @@ 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";
|
||||
|
||||
|
|
@ -415,51 +410,9 @@ 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,
|
||||
});
|
||||
}
|
||||
// Pending portfolio invitations are NOT auto-applied here anymore.
|
||||
// The invitee accepts/declines explicitly via the profile-menu
|
||||
// notifications panel (POST /api/user/invitations).
|
||||
|
||||
// Pass bigint ID into NextAuth session/jwt
|
||||
user.dbId = dbUser.id.toString();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inviteRequestSchema } from "./inviteRequestSchema";
|
||||
|
||||
describe("inviteRequestSchema", () => {
|
||||
it("accepts an invite request with just email and role (no name)", () => {
|
||||
const result = inviteRequestSchema.parse({
|
||||
email: "alice@example.com",
|
||||
role: "read",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
email: "alice@example.com",
|
||||
role: "read",
|
||||
});
|
||||
});
|
||||
|
||||
it("silently drops an unknown name field (in-flight clients still parse)", () => {
|
||||
const result = inviteRequestSchema.parse({
|
||||
email: "alice@example.com",
|
||||
role: "write",
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
email: "alice@example.com",
|
||||
role: "write",
|
||||
});
|
||||
expect(result).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("rejects an invite request with a malformed email", () => {
|
||||
expect(() =>
|
||||
inviteRequestSchema.parse({ email: "not-an-email", role: "read" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an invite request with a role outside the enum", () => {
|
||||
expect(() =>
|
||||
inviteRequestSchema.parse({
|
||||
email: "alice@example.com",
|
||||
role: "superuser",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from "zod";
|
||||
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
|
||||
|
||||
// Body schema for POST /api/portfolio/[portfolioId]/collaborators. Lives in
|
||||
// its own file because Next.js 15 rejects non-handler named exports from
|
||||
// route.ts. See route.test.ts for the contract tests.
|
||||
export const inviteRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(ROLE_OPTIONS),
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
denyIfNotAdmin,
|
||||
resolvePortfolioPrivilege,
|
||||
} from "@/app/lib/resolvePortfolioPrivilege";
|
||||
import { inviteRequestSchema } from "./inviteRequestSchema";
|
||||
|
||||
// Get collaborators (users) that have access to the portfolio, plus the
|
||||
// effective privilege of the requesting user (so the UI knows which actions
|
||||
|
|
@ -194,24 +195,19 @@ export async function PUT(
|
|||
|
||||
// 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.
|
||||
// Unified flow: in nearly every case we write a pending portfolio_invitations
|
||||
// row, and the invitee accepts/declines explicitly via the in-app dropdown.
|
||||
// The only fast-path is when the invitee is *already* a member of this
|
||||
// portfolio — then it's just a role update with no email or invitation.
|
||||
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>;
|
||||
let body: z.infer<typeof inviteRequestSchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await req.json());
|
||||
body = inviteRequestSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
|
@ -255,6 +251,8 @@ export async function POST(
|
|||
.where(eq(user.email, email))
|
||||
.limit(1);
|
||||
|
||||
// Fast path: invitee is already a member of this portfolio. Just adjust
|
||||
// their role if it changed — no invitation, no email.
|
||||
if (existingUser) {
|
||||
const [existingMembership] = await db
|
||||
.select({ id: portfolioUsers.id, role: portfolioUsers.role })
|
||||
|
|
@ -267,7 +265,6 @@ export async function POST(
|
|||
)
|
||||
.limit(1);
|
||||
|
||||
let portfolioUserId: bigint;
|
||||
if (existingMembership) {
|
||||
if (existingMembership.role !== body.role) {
|
||||
await db
|
||||
|
|
@ -275,53 +272,24 @@ export async function POST(
|
|||
.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,
|
||||
return NextResponse.json(
|
||||
{
|
||||
user: {
|
||||
portfolioUserId: existingMembership.id.toString(),
|
||||
userId: existingUser.id.toString(),
|
||||
role: body.role,
|
||||
name: existingUser.firstName ?? null,
|
||||
email,
|
||||
kind: "member" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No user with this email yet — record a pending invitation. The signIn
|
||||
// callback applies it the first time the invitee signs in.
|
||||
// Standard path (whether or not the user already exists): write a pending
|
||||
// invitation. The invitee accepts/declines via their in-app dropdown.
|
||||
const [invitation] = await db
|
||||
.insert(portfolioInvitations)
|
||||
.values({
|
||||
|
|
@ -348,7 +316,7 @@ export async function POST(
|
|||
portfolioName: portfolioRow.name,
|
||||
inviterName,
|
||||
linkUrl: appOrigin,
|
||||
mode: "new-user",
|
||||
mode: existingUser ? "existing-user" : "new-user",
|
||||
});
|
||||
} catch (mailErr) {
|
||||
console.error("PORTFOLIO_INVITATION_EMAIL_FAILURE", {
|
||||
|
|
@ -364,7 +332,7 @@ export async function POST(
|
|||
userId: null,
|
||||
invitationId: invitation.id.toString(),
|
||||
role: invitation.role,
|
||||
name: body.name ?? null,
|
||||
name: null,
|
||||
email,
|
||||
kind: "invitation" as const,
|
||||
},
|
||||
|
|
|
|||
190
src/app/api/user/invitations/route.ts
Normal file
190
src/app/api/user/invitations/route.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
portfolio,
|
||||
portfolioInvitations,
|
||||
portfolioUsers,
|
||||
} from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { normaliseEmail } from "@/app/lib/email";
|
||||
import { planInvitationApplication } from "@/app/lib/portfolioInvitations";
|
||||
|
||||
// GET: list pending portfolio invitations addressed to the current user's
|
||||
// email, across all portfolios. Used by the profile-menu notifications panel.
|
||||
export async function GET() {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.dbId || !session.user.email) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const email = normaliseEmail(session.user.email);
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: portfolioInvitations.id,
|
||||
portfolioId: portfolioInvitations.portfolioId,
|
||||
portfolioName: portfolio.name,
|
||||
role: portfolioInvitations.role,
|
||||
invitedByName: user.firstName,
|
||||
invitedByEmail: user.email,
|
||||
createdAt: portfolioInvitations.createdAt,
|
||||
})
|
||||
.from(portfolioInvitations)
|
||||
.innerJoin(portfolio, eq(portfolio.id, portfolioInvitations.portfolioId))
|
||||
.leftJoin(user, eq(user.id, portfolioInvitations.invitedByUserId))
|
||||
.where(eq(portfolioInvitations.email, email));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
invitations: rows.map((r) => ({
|
||||
invitationId: r.id.toString(),
|
||||
portfolioId: r.portfolioId.toString(),
|
||||
portfolioName: r.portfolioName,
|
||||
role: r.role,
|
||||
invitedByName: r.invitedByName ?? r.invitedByEmail ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("GET /user/invitations error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch invitations" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: accept or decline an invitation addressed to the current user.
|
||||
// { invitationId, action: "accept" | "decline" }
|
||||
//
|
||||
// Accept: writes the portfolioUsers row (skipped if already a member) and
|
||||
// deletes the invitation atomically.
|
||||
// Decline: deletes the invitation. Silent — no inviter notification.
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.dbId || !session.user.email) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const sessionEmail = normaliseEmail(session.user.email);
|
||||
const sessionUserId = BigInt(session.user.dbId);
|
||||
|
||||
const bodySchema = z.object({
|
||||
invitationId: z.string(),
|
||||
action: z.enum(["accept", "decline"]),
|
||||
});
|
||||
let body: z.infer<typeof bodySchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const invId = BigInt(body.invitationId);
|
||||
|
||||
const [invitation] = await db
|
||||
.select({
|
||||
id: portfolioInvitations.id,
|
||||
portfolioId: portfolioInvitations.portfolioId,
|
||||
email: portfolioInvitations.email,
|
||||
role: portfolioInvitations.role,
|
||||
})
|
||||
.from(portfolioInvitations)
|
||||
.where(eq(portfolioInvitations.id, invId))
|
||||
.limit(1);
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invitation not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (invitation.email !== sessionEmail) {
|
||||
// Either someone else's invitation or address mismatch — treat as
|
||||
// not-found so we don't leak existence of other users' invitations.
|
||||
return NextResponse.json(
|
||||
{ error: "Invitation not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (body.action === "decline") {
|
||||
await db
|
||||
.delete(portfolioInvitations)
|
||||
.where(eq(portfolioInvitations.id, invId));
|
||||
console.log("INVITATION_DECLINED", {
|
||||
email: sessionEmail,
|
||||
invitationId: body.invitationId,
|
||||
portfolioId: invitation.portfolioId.toString(),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ success: true, action: "declined" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// Accept: load existing memberships so we don't double-insert, then
|
||||
// delegate to the shared planning function.
|
||||
const existing = await db
|
||||
.select({ portfolioId: portfolioUsers.portfolioId })
|
||||
.from(portfolioUsers)
|
||||
.where(eq(portfolioUsers.userId, sessionUserId));
|
||||
|
||||
const plan = planInvitationApplication({
|
||||
userId: sessionUserId,
|
||||
invitations: [
|
||||
{
|
||||
id: invitation.id,
|
||||
portfolioId: invitation.portfolioId,
|
||||
role: invitation.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("INVITATION_ACCEPTED", {
|
||||
email: sessionEmail,
|
||||
invitationId: body.invitationId,
|
||||
portfolioId: invitation.portfolioId.toString(),
|
||||
newMembership: plan.memberships.length > 0,
|
||||
});
|
||||
|
||||
// /home renders the user's portfolio list from the DB in a server
|
||||
// component; invalidate so the next navigation there picks up the new
|
||||
// membership. router.refresh() handles the in-place case client-side.
|
||||
revalidatePath("/home");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
action: "accepted",
|
||||
portfolioId: invitation.portfolioId.toString(),
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("POST /user/invitations error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update invitation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/app/hooks/use-toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
|
||||
type PendingInvitation = {
|
||||
invitationId: string;
|
||||
portfolioId: string;
|
||||
portfolioName: string;
|
||||
role: string;
|
||||
invitedByName: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
async function fetchPendingInvitations(): Promise<PendingInvitation[]> {
|
||||
const res = await fetch("/api/user/invitations");
|
||||
if (!res.ok) throw new Error("Failed to fetch invitations");
|
||||
const json = await res.json();
|
||||
const invitations = json?.invitations ?? [];
|
||||
return Array.isArray(invitations) ? invitations : [];
|
||||
}
|
||||
|
||||
async function respondToInvitation(
|
||||
invitationId: string,
|
||||
action: "accept" | "decline",
|
||||
): Promise<{ portfolioId?: string }> {
|
||||
const res = await fetch("/api/user/invitations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ invitationId, action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => "");
|
||||
throw new Error(msg || `Failed to ${action} invitation`);
|
||||
}
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
const INVITATIONS_KEY = ["userInvitations"] as const;
|
||||
|
||||
function ProfileDropDown({ userImage }: { userImage: string }) {
|
||||
const { data: session } = useSession();
|
||||
const email = session?.user?.email ?? null;
|
||||
const isAuthenticated = !!session?.user;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const [acceptedInfo, setAcceptedInfo] = useState<{
|
||||
portfolioId: string;
|
||||
portfolioName: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: invitations = [], isLoading } = useQuery({
|
||||
queryKey: INVITATIONS_KEY,
|
||||
queryFn: fetchPendingInvitations,
|
||||
enabled: isAuthenticated,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const pendingCount = invitations.length;
|
||||
|
||||
const respondMutation = useMutation({
|
||||
mutationFn: ({
|
||||
invitationId,
|
||||
action,
|
||||
}: {
|
||||
invitationId: string;
|
||||
action: "accept" | "decline";
|
||||
}) => respondToInvitation(invitationId, action),
|
||||
|
||||
onMutate: async ({ invitationId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: INVITATIONS_KEY });
|
||||
const previous =
|
||||
queryClient.getQueryData<PendingInvitation[]>(INVITATIONS_KEY);
|
||||
queryClient.setQueryData<PendingInvitation[]>(INVITATIONS_KEY, (old) =>
|
||||
(old ?? []).filter((i) => i.invitationId !== invitationId),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(INVITATIONS_KEY, context.previous);
|
||||
}
|
||||
toast({
|
||||
title: `Couldn't ${vars.action} invitation`,
|
||||
description: err instanceof Error ? err.message : "Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
const inv = invitations.find((i) => i.invitationId === vars.invitationId);
|
||||
if (vars.action === "accept") {
|
||||
const portfolioId = data?.portfolioId ?? inv?.portfolioId;
|
||||
const portfolioName = inv?.portfolioName ?? "the portfolio";
|
||||
// /home's server-rendered portfolio list won't pick up the new
|
||||
// membership without an explicit refresh; the API handler also calls
|
||||
// revalidatePath("/home") so any later navigation is fresh too.
|
||||
router.refresh();
|
||||
if (portfolioId) {
|
||||
setAcceptedInfo({ portfolioId, portfolioName });
|
||||
} else {
|
||||
toast({
|
||||
title: "Joined portfolio",
|
||||
description: `You now have access to ${portfolioName}.`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const portfolioLabel = inv ? `the ${inv.portfolioName} portfolio` : "the portfolio";
|
||||
toast({
|
||||
title: "Invitation declined",
|
||||
description: `You've declined the invitation to ${portfolioLabel}.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: INVITATIONS_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="rounded-full">
|
||||
<Menu.Button className="rounded-full relative">
|
||||
{userImage ? (
|
||||
<Image
|
||||
src={userImage}
|
||||
|
|
@ -28,25 +156,157 @@ function ProfileDropDown({ userImage }: { userImage: string }) {
|
|||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[1.25rem] h-5 px-1 inline-flex items-center justify-center rounded-full bg-red-600 text-white text-[10px] font-bold ring-2 ring-brandblue"
|
||||
aria-label={`${pendingCount} pending invitation${pendingCount === 1 ? "" : "s"}`}
|
||||
>
|
||||
{pendingCount > 9 ? "9+" : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
<Menu.Items className="z-[100] absolute right-0 mt-2 w-48 origin-top-right overflow-hidden rounded-md border bg-white shadow-lg focus:outline-none">
|
||||
|
||||
<Menu.Items className="z-[100] absolute right-0 mt-2 w-80 origin-top-right overflow-hidden rounded-md border bg-white shadow-lg focus:outline-none">
|
||||
{/* Signed-in identity */}
|
||||
{email && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-400 font-medium">
|
||||
Signed in as
|
||||
</p>
|
||||
<p className="text-sm text-gray-800 truncate" title={email}>
|
||||
{email}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending invitations */}
|
||||
{isAuthenticated && (
|
||||
<div className="border-b border-gray-100">
|
||||
<p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-wider text-gray-400 font-medium">
|
||||
Pending invitations
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<p className="px-4 py-2 text-sm text-gray-500">Loading…</p>
|
||||
) : invitations.length === 0 ? (
|
||||
<p className="px-4 py-2 text-sm text-gray-400">
|
||||
No pending invitations.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-72 overflow-y-auto">
|
||||
{invitations.map((inv) => (
|
||||
<li
|
||||
key={inv.invitationId}
|
||||
className="px-4 py-3 border-t border-gray-50 first:border-t-0"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
{inv.portfolioName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{inv.invitedByName
|
||||
? `Invited by ${inv.invitedByName}`
|
||||
: "Invited"}{" "}
|
||||
· <span className="capitalize">{inv.role}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
respondMutation.mutate({
|
||||
invitationId: inv.invitationId,
|
||||
action: "accept",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
respondMutation.isPending &&
|
||||
respondMutation.variables?.invitationId ===
|
||||
inv.invitationId
|
||||
}
|
||||
className="flex-1 px-3 py-1.5 rounded-md bg-brandblue text-white text-xs font-medium hover:bg-hoverblue disabled:opacity-50"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
respondMutation.mutate({
|
||||
invitationId: inv.invitationId,
|
||||
action: "decline",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
respondMutation.isPending &&
|
||||
respondMutation.variables?.invitationId ===
|
||||
inv.invitationId
|
||||
}
|
||||
className="flex-1 px-3 py-1.5 rounded-md border border-gray-200 text-gray-600 text-xs font-medium hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Menu.Item>
|
||||
<Link href="/help" className="flex px-4 py-2 text-sm text-gray-700">
|
||||
Help
|
||||
</Link>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
href="/help"
|
||||
className={`flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
|
||||
>
|
||||
Help
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<a>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className="flex px-4 py-2 text-sm text-gray-700"
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
className={`w-full text-left flex px-4 py-2 text-sm text-gray-700 ${active ? "bg-gray-50" : ""}`}
|
||||
>
|
||||
Sign Out
|
||||
Sign out
|
||||
</button>
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
|
||||
<Dialog
|
||||
open={!!acceptedInfo}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAcceptedInfo(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
You've joined {acceptedInfo?.portfolioName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
You now have access. Head over when you're ready.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAcceptedInfo(null)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (acceptedInfo) {
|
||||
router.push(`/portfolio/${acceptedInfo.portfolioId}`);
|
||||
}
|
||||
setAcceptedInfo(null);
|
||||
}}
|
||||
>
|
||||
Go to {acceptedInfo?.portfolioName ?? "portfolio"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,8 @@ export async function PortfolioInvitationEmail({
|
|||
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 subject = `${inviterName} invited you to join ${portfolioName} on Ara`;
|
||||
const ctaLabel = "Sign in to Ara";
|
||||
|
||||
const result = await transport.sendMail({
|
||||
to: identifier,
|
||||
|
|
@ -104,15 +99,11 @@ function domnaHtml({
|
|||
const brown = "#c4a47c";
|
||||
const background = "#F9F9F9";
|
||||
|
||||
const heading =
|
||||
mode === "existing-user"
|
||||
? `You've been added to ${portfolioName}`
|
||||
: `${inviterName} invited you to ${portfolioName}`;
|
||||
|
||||
const heading = `${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.`;
|
||||
? `${inviterName} invited you to the <strong>${portfolioName}</strong> portfolio on Ara. Sign in and accept the invitation from your profile menu to start collaborating.`
|
||||
: `${inviterName} invited you to join the <strong>${portfolioName}</strong> portfolio on Ara. Sign in with this email address to create your account, then accept the invitation from your profile menu.`;
|
||||
|
||||
return `
|
||||
<body style="background: ${background}; font-family: Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
|
||||
|
|
@ -159,14 +150,11 @@ function plainText({
|
|||
ctaLabel: string;
|
||||
mode: InvitationMode;
|
||||
}) {
|
||||
const heading =
|
||||
mode === "existing-user"
|
||||
? `You've been added to ${portfolioName}`
|
||||
: `${inviterName} invited you to ${portfolioName}`;
|
||||
const heading = `${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.`;
|
||||
? `${inviterName} invited you to the ${portfolioName} portfolio on Ara. Sign in and accept from your profile menu to start collaborating.`
|
||||
: `${inviterName} invited you to join the ${portfolioName} portfolio on Ara. Sign in with this email to create your account, then accept from your profile menu.`;
|
||||
|
||||
return `${heading}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,12 +77,11 @@ async function invitePortfolioUser(
|
|||
portfolioId: string,
|
||||
email: string,
|
||||
role: Role,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/collaborators`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, role, name }),
|
||||
body: JSON.stringify({ email, role }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => "");
|
||||
|
|
@ -123,7 +122,6 @@ async function revokePortfolioInvitation(
|
|||
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] =
|
||||
|
|
@ -210,16 +208,13 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
mutationFn: ({
|
||||
email,
|
||||
role,
|
||||
name,
|
||||
}: {
|
||||
email: string;
|
||||
role: Role;
|
||||
name: string;
|
||||
}) => invitePortfolioUser(portfolioId, email, role, name),
|
||||
}) => invitePortfolioUser(portfolioId, email, role),
|
||||
onSuccess: (_data, vars) => {
|
||||
invalidateBoth();
|
||||
setInviteEmail("");
|
||||
setInviteName("");
|
||||
toast({
|
||||
title: "Invitation sent",
|
||||
description: `We've emailed ${vars.email} an invitation to this portfolio.`,
|
||||
|
|
@ -324,7 +319,6 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
inviteUserMutation.mutate({
|
||||
email: inviteEmail,
|
||||
role: inviteRole,
|
||||
name: inviteName,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -380,12 +374,6 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
</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"
|
||||
|
|
@ -404,11 +392,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
<Button
|
||||
className="w-28"
|
||||
onClick={handleInvite}
|
||||
disabled={
|
||||
!inviteEmail ||
|
||||
!inviteName ||
|
||||
inviteUserMutation.isPending
|
||||
}
|
||||
disabled={!inviteEmail || inviteUserMutation.isPending}
|
||||
>
|
||||
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
|
||||
</Button>
|
||||
|
|
@ -589,7 +573,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
description={
|
||||
pendingRevoke ? (
|
||||
<>
|
||||
<span className="font-medium">{pendingRevoke.email}</span> won't
|
||||
<span className="font-medium">{pendingRevoke.email}</span> won't
|
||||
be able to accept this invitation. You can invite them again
|
||||
later.
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export default function PropertyDetailDrawer({
|
|||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
role="tab"
|
||||
data-testid={`drawer-tab-${tab}`}
|
||||
aria-selected={activeTab === tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export default function DealPage({
|
|||
{VALID_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
role="tab"
|
||||
data-testid={`deal-page-tab-${tab}`}
|
||||
aria-selected={activeTab === tab}
|
||||
onClick={() => switchTab(tab)}
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ function EmptyPropertyState() {
|
|||
<div className="text-center text-gray-400">
|
||||
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
|
||||
<p>
|
||||
Hover over <strong>“New Property”</strong> to start adding properties
|
||||
to your portfolio.
|
||||
Hover over <strong>“New Property”</strong> to start adding
|
||||
properties to your portfolio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -388,7 +388,10 @@ export default function PropertyTable({
|
|||
filterGroups: allFilterGroups,
|
||||
});
|
||||
|
||||
const queryData = useMemo(() => filteredResponse?.data ?? [], [filteredResponse?.data]);
|
||||
const queryData = useMemo(
|
||||
() => filteredResponse?.data ?? [],
|
||||
[filteredResponse?.data],
|
||||
);
|
||||
const filteredTotal = filteredResponse?.total ?? 0;
|
||||
|
||||
// Second query for total (no filters) — React Query dedupes when filters are empty
|
||||
|
|
@ -500,7 +503,7 @@ export default function PropertyTable({
|
|||
const [previewError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="py-4 mx-4">
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{/* Left: results count */}
|
||||
|
|
@ -594,14 +597,18 @@ export default function PropertyTable({
|
|||
|
||||
{/* Export */}
|
||||
{filteredTotal > EXPORT_LIMIT ? (
|
||||
<Tooltip content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
|
||||
<Tooltip
|
||||
content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}
|
||||
>
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||
Export
|
||||
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">!</span>
|
||||
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">
|
||||
!
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
|
@ -680,7 +687,8 @@ export default function PropertyTable({
|
|||
<span className="font-semibold text-primary">
|
||||
{filteredTotal.toLocaleString()}
|
||||
</span>{" "}
|
||||
properties — more load automatically as you navigate to the last page.
|
||||
properties — more load automatically as you navigate to the last
|
||||
page.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue