forked from Goutam/lynkeduppro-crm
refactor(email): remove frontend invite email; be-crm sends it now
Per AppShell, email delivery lives in the domain API. Delete the frontend /api/email/invite route + invite-email template, and the team-management email wiring. be-crm now emails invitees on create/resend. Copy link stays as a fallback (invite token still returned in the pending-invite list).
This commit is contained in:
@@ -1,74 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { buildInviteEmail } from "@/lib/invite-email";
|
|
||||||
|
|
||||||
// Sends a team-invitation email via the Twilio Emails API. Server-only: the Twilio
|
|
||||||
// Account SID + Auth Token come from Vercel env (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN)
|
|
||||||
// and never reach the browser. From EMAIL_FROM_ADDRESS (default support@lynkedup.dev).
|
|
||||||
//
|
|
||||||
// POST /api/email/invite { to, token, roleNames?[] } → { ok }
|
|
||||||
// The email body is a fixed invite template (only `to` + `token` + role names are
|
|
||||||
// caller-supplied), so this can't be used to send arbitrary content.
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const TWILIO_EMAILS_URL = "https://comms.twilio.com/v1/Emails";
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
const SESSION_COOKIE = "__Host-insignia_session";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
// Light guard: only a signed-in session (which the invite UI has) may trigger sends,
|
|
||||||
// so this isn't an open email relay. Presence check — the cookie is HttpOnly + __Host-.
|
|
||||||
const cookies = request.headers.get("cookie") ?? "";
|
|
||||||
if (!cookies.includes(`${SESSION_COOKIE}=`)) {
|
|
||||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sid = process.env.TWILIO_ACCOUNT_SID ?? "";
|
|
||||||
const authToken = process.env.TWILIO_AUTH_TOKEN ?? "";
|
|
||||||
const from = process.env.EMAIL_FROM_ADDRESS ?? "support@lynkedup.dev";
|
|
||||||
const fromName = process.env.EMAIL_FROM_NAME ?? "LynkedUp Pro";
|
|
||||||
if (!sid || !authToken) {
|
|
||||||
return NextResponse.json({ ok: false, error: "email_not_configured" }, { status: 503 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: { to?: string; token?: string; roleNames?: string[] };
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const to = (body.to ?? "").trim().toLowerCase();
|
|
||||||
const token = (body.token ?? "").trim();
|
|
||||||
const roleNames = Array.isArray(body.roleNames) ? body.roleNames.filter((r) => typeof r === "string").slice(0, 10) : [];
|
|
||||||
if (!EMAIL_RE.test(to)) return NextResponse.json({ ok: false, error: "invalid_email" }, { status: 400 });
|
|
||||||
if (token.length < 16 || token.length > 256) return NextResponse.json({ ok: false, error: "invalid_token" }, { status: 400 });
|
|
||||||
|
|
||||||
const origin = (process.env.PORTAL_BASE_URL || new URL(request.url).origin).replace(/\/$/, "");
|
|
||||||
const inviteUrl = `${origin}/portal/invite?token=${encodeURIComponent(token)}`;
|
|
||||||
const { subject, html, text } = buildInviteEmail({ inviteUrl, roleNames });
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
from: { address: from, name: fromName },
|
|
||||||
to: [{ address: to }],
|
|
||||||
content: { subject, html, text },
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(TWILIO_EMAILS_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Basic ${Buffer.from(`${sid}:${authToken}`).toString("base64")}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await res.text().catch(() => "");
|
|
||||||
return NextResponse.json({ ok: false, error: "send_failed", status: res.status, detail: detail.slice(0, 300) }, { status: 502 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ ok: false, error: "send_error", detail: (e as Error).message }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,20 +47,6 @@ export function TeamManagement() {
|
|||||||
}, [members]);
|
}, [members]);
|
||||||
const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]);
|
const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]);
|
||||||
|
|
||||||
// Email the invite link via the serverless route (Twilio). No token (mock mode) → skip.
|
|
||||||
const emailInvite = async (email: string, token: string | undefined, roleIds: string[]): Promise<boolean> => {
|
|
||||||
if (!token) return false;
|
|
||||||
const roleNames = roleIds.map((id) => roleById[id]?.name).filter((n): n is string => !!n);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/email/invite", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ to: email, token, roleNames }),
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch { return false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return members.filter((m) => {
|
return members.filter((m) => {
|
||||||
@@ -323,11 +309,8 @@ export function TeamManagement() {
|
|||||||
}}>Copy link</Btn>
|
}}>Copy link</Btn>
|
||||||
)}
|
)}
|
||||||
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
|
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
|
||||||
try {
|
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was emailed to ${inv.email}.` }); }
|
||||||
const { token } = await team.resendInvite(inv.id);
|
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
|
||||||
const emailed = await emailInvite(inv.email, token, inv.roleIds);
|
|
||||||
toast.push({ tone: "success", title: emailed ? "Invite re-emailed" : "Invite resent", desc: emailed ? `A fresh link was emailed to ${inv.email}.` : `A fresh link was generated for ${inv.email}.` });
|
|
||||||
} catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
|
|
||||||
}}>Resend</Btn>
|
}}>Resend</Btn>
|
||||||
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
|
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
|
||||||
try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); }
|
try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); }
|
||||||
@@ -348,14 +331,9 @@ export function TeamManagement() {
|
|||||||
onInvite={async (email, roleIds) => {
|
onInvite={async (email, roleIds) => {
|
||||||
setInviteOpen(false);
|
setInviteOpen(false);
|
||||||
try {
|
try {
|
||||||
const { token } = await team.invite(email, roleIds);
|
await team.invite(email, roleIds);
|
||||||
setTab("invites");
|
setTab("invites");
|
||||||
const emailed = await emailInvite(email, token, roleIds);
|
toast.push({ tone: "success", title: "Invitation sent", desc: `We emailed an invite to ${email}. You can also copy the link.` });
|
||||||
toast.push({
|
|
||||||
tone: "success",
|
|
||||||
title: emailed ? "Invitation emailed" : "Invitation created",
|
|
||||||
desc: emailed ? `We emailed the invite to ${email}.` : `Invite ready — use “Copy link” to share it with ${email}.`,
|
|
||||||
});
|
|
||||||
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
|
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// Invitation email content (subject/html/text). Server-only helper — no secrets here.
|
|
||||||
// The accept URL points at the frontend's /portal/invite?token=… route.
|
|
||||||
|
|
||||||
export interface InviteEmailInput {
|
|
||||||
inviteUrl: string;
|
|
||||||
roleNames: string[];
|
|
||||||
appName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapeHtml = (s: string): string =>
|
|
||||||
s.replace(/[&<>"']/g, (c) =>
|
|
||||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] as string),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function buildInviteEmail(input: InviteEmailInput): { subject: string; html: string; text: string } {
|
|
||||||
const app = input.appName ?? "LynkedUp Pro";
|
|
||||||
const roles = input.roleNames.filter(Boolean);
|
|
||||||
const roleLabel = roles.length ? roles.join(", ") : "a team member";
|
|
||||||
const url = input.inviteUrl;
|
|
||||||
const safeUrl = escapeHtml(url);
|
|
||||||
const safeRoles = escapeHtml(roleLabel);
|
|
||||||
|
|
||||||
const subject = `You've been invited to join ${app}`;
|
|
||||||
|
|
||||||
const text = [
|
|
||||||
`You've been invited to join ${app} as ${roleLabel}.`,
|
|
||||||
"",
|
|
||||||
"Accept your invitation and set up your account here:",
|
|
||||||
url,
|
|
||||||
"",
|
|
||||||
"If you didn't expect this, you can ignore this email.",
|
|
||||||
"",
|
|
||||||
`— The ${app} team`,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const html = `<div style="margin:0;padding:0;background:#0b0c11;">
|
|
||||||
<div style="max-width:520px;margin:0 auto;padding:40px 24px;font-family:Arial,Helvetica,sans-serif;color:#f3f4f8;">
|
|
||||||
<div style="font-size:20px;font-weight:800;letter-spacing:-0.02em;color:#fb923c;margin-bottom:24px;">${escapeHtml(app)}</div>
|
|
||||||
<h1 style="font-size:22px;font-weight:800;margin:0 0 12px;color:#f3f4f8;">You're invited to join the team</h1>
|
|
||||||
<p style="font-size:15px;line-height:1.6;color:#a3a8b5;margin:0 0 8px;">
|
|
||||||
You've been invited to join <strong style="color:#f3f4f8;">${escapeHtml(app)}</strong> as
|
|
||||||
<strong style="color:#f3f4f8;">${safeRoles}</strong>.
|
|
||||||
</p>
|
|
||||||
<p style="font-size:15px;line-height:1.6;color:#a3a8b5;margin:0 0 24px;">
|
|
||||||
Click below to accept your invitation and set up your account.
|
|
||||||
</p>
|
|
||||||
<a href="${safeUrl}" style="display:inline-block;background:#f97316;color:#ffffff;text-decoration:none;font-weight:700;font-size:15px;padding:13px 26px;border-radius:10px;">Accept invitation</a>
|
|
||||||
<p style="font-size:12.5px;line-height:1.6;color:#6c7280;margin:24px 0 0;">
|
|
||||||
Or paste this link into your browser:<br/>
|
|
||||||
<a href="${safeUrl}" style="color:#fb923c;word-break:break-all;">${safeUrl}</a>
|
|
||||||
</p>
|
|
||||||
<p style="font-size:12.5px;line-height:1.6;color:#6c7280;margin:24px 0 0;border-top:1px solid rgba(255,255,255,0.09);padding-top:16px;">
|
|
||||||
If you didn't expect this invitation, you can safely ignore this email.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
return { subject, html, text };
|
|
||||||
}
|
|
||||||
+7
-16
@@ -48,10 +48,10 @@ export interface TeamData {
|
|||||||
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
|
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
|
||||||
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
|
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
|
||||||
removeMember: (id: string) => Promise<void>;
|
removeMember: (id: string) => Promise<void>;
|
||||||
/** Create an invite; resolves with the accept token so the caller can email the link. */
|
/** Create an invite. be-crm emails the invitee automatically. */
|
||||||
invite: (email: string, roleIds: string[]) => Promise<{ token?: string }>;
|
invite: (email: string, roleIds: string[]) => Promise<void>;
|
||||||
/** Resend an invite (regenerates the token); resolves with the fresh token. */
|
/** Resend an invite (regenerates the token + re-emails it). */
|
||||||
resendInvite: (id: string) => Promise<{ token?: string }>;
|
resendInvite: (id: string) => Promise<void>;
|
||||||
revokeInvite: (id: string) => Promise<void>;
|
revokeInvite: (id: string) => Promise<void>;
|
||||||
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
|
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
@@ -117,9 +117,8 @@ function useMockTeam(): TeamData {
|
|||||||
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
|
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
|
||||||
const invite = useCallback(async (email: string, roleIds: string[]) => {
|
const invite = useCallback(async (email: string, roleIds: string[]) => {
|
||||||
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
|
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
|
||||||
return {};
|
|
||||||
}, []);
|
}, []);
|
||||||
const resendInvite = useCallback(async () => ({}), []);
|
const resendInvite = useCallback(async () => {}, []);
|
||||||
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
|
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
|
||||||
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
|
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
|
||||||
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
|
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
|
||||||
@@ -191,16 +190,8 @@ function useLiveTeam(): TeamData {
|
|||||||
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
|
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
|
||||||
}),
|
}),
|
||||||
removeMember: (id) => cmd("crm.team.member.remove", { id }),
|
removeMember: (id) => cmd("crm.team.member.remove", { id }),
|
||||||
invite: async (email, roleIds) => {
|
invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }),
|
||||||
const dto = (await sdk.command("crm.team.invitation.create", { email, roleIds })) as { token?: string } | undefined;
|
resendInvite: (id) => cmd("crm.team.invitation.resend", { id }),
|
||||||
refetch();
|
|
||||||
return { token: dto?.token };
|
|
||||||
},
|
|
||||||
resendInvite: async (id) => {
|
|
||||||
const res = (await sdk.command("crm.team.invitation.resend", { id })) as { token?: string } | undefined;
|
|
||||||
refetch();
|
|
||||||
return { token: res?.token };
|
|
||||||
},
|
|
||||||
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
|
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
|
||||||
setPermission: (roleId, permId, granted) =>
|
setPermission: (roleId, permId, granted) =>
|
||||||
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
|
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
|
||||||
|
|||||||
Reference in New Issue
Block a user