mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Unify invitation flow: explicit accept/decline via profile-menu notifications
Replaces the auto-accept-on-signin behaviour with an explicit accept or
decline step. Every invitee (existing user or brand new) is now treated
the same way: an invitation lands in portfolio_invitations, the email
is a deep-link "Sign in to Ara", and the invitee accepts (or declines)
explicitly from a notifications panel hanging off their profile avatar.
Why: the previous flow silently added existing users to portfolios with
no way to refuse, and gave them no in-app confirmation that the invite
landed. Existing users complained they didn't know which account they
were signed in as either — both gaps are closed by the same panel.
Backend:
- POST /collaborators no longer creates portfolioUsers directly for
existing users; it writes a portfolio_invitations row in every case
except the trivial "already a member of THIS portfolio" path
(silent role update, no email)
- New /api/user/invitations endpoint: GET lists pending invitations
addressed to the current user across all portfolios, joined with
portfolio + inviter context; POST accepts or declines a single
invitation, scoped to session.email so users can't act on others'
- Accept reuses the existing planInvitationApplication helper for
the "already a member" idempotency check
- Decline is a silent delete (matches GitHub/Linear/Notion convention;
no email to inviter, no tombstone)
- signIn callback no longer auto-applies pending invitations — that
block is removed entirely along with its now-unused imports
- Email template subject + body unified, no longer suggests the user
is "added"; both modes say "invited" and direct them to the profile
menu
Frontend:
- ProfileDropDown rewritten as a notifications panel: shows the
signed-in email at the top (closing the "which account?" gap),
lists pending invitations with Accept/Decline buttons, displays a
red count badge on the avatar (max "9+"). Uses TanStack Query with
optimistic update on accept/decline and toast on outcome. Existing
Help + Sign Out menu items preserved.
- No useEffect — pending-count derived from query data, mutations
handle the rest
Vercel preview test plan:
- Invite a user already in another portfolio → red badge appears on
their next page load; Accept lands them in the portfolio
- Invite a new email → sign-up flow finishes; new account lands on
home with a badge waiting for Accept (no longer auto-accepted)
- Existing member of THIS portfolio re-invited → silent role update,
no email
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f3887a215c
commit
d70b15e705
5 changed files with 422 additions and 129 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();
|
||||
|
|
|
|||
|
|
@ -194,9 +194,10 @@ 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 }> }
|
||||
|
|
@ -255,6 +256,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 +270,6 @@ export async function POST(
|
|||
)
|
||||
.limit(1);
|
||||
|
||||
let portfolioUserId: bigint;
|
||||
if (existingMembership) {
|
||||
if (existingMembership.role !== body.role) {
|
||||
await db
|
||||
|
|
@ -275,53 +277,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 ?? body.name ?? 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 +321,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", {
|
||||
|
|
|
|||
184
src/app/api/user/invitations/route.ts
Normal file
184
src/app/api/user/invitations/route.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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 { 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,
|
||||
});
|
||||
|
||||
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,111 @@
|
|||
"use client";
|
||||
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/app/hooks/use-toast";
|
||||
|
||||
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 { 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);
|
||||
const portfolioLabel = inv ? `the ${inv.portfolioName} portfolio` : "the portfolio";
|
||||
toast({
|
||||
title: vars.action === "accept" ? "Joined portfolio" : "Invitation declined",
|
||||
description:
|
||||
vars.action === "accept"
|
||||
? `You now have access to ${portfolioLabel}.`
|
||||
: `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,22 +125,120 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue