diff --git a/src/app/api/email/invite/route.ts b/src/app/api/email/invite/route.ts new file mode 100644 index 0000000..74c6321 --- /dev/null +++ b/src/app/api/email/invite/route.ts @@ -0,0 +1,74 @@ +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 }); + } +} diff --git a/src/components/dashboard/team-management.tsx b/src/components/dashboard/team-management.tsx index 41d22b4..551f153 100644 --- a/src/components/dashboard/team-management.tsx +++ b/src/components/dashboard/team-management.tsx @@ -47,6 +47,20 @@ export function TeamManagement() { }, [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 => { + 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 q = query.trim().toLowerCase(); return members.filter((m) => { @@ -309,8 +323,11 @@ export function TeamManagement() { }}>Copy link )} { - try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` }); } - catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); } + try { + const { token } = await team.resendInvite(inv.id); + 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 { try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); } @@ -331,9 +348,14 @@ export function TeamManagement() { onInvite={async (email, roleIds) => { setInviteOpen(false); try { - await team.invite(email, roleIds); + const { token } = await team.invite(email, roleIds); setTab("invites"); - toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited with ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` }); + const emailed = await emailInvite(email, token, roleIds); + 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 }); } }} /> diff --git a/src/lib/invite-email.ts b/src/lib/invite-email.ts new file mode 100644 index 0000000..7b89e5c --- /dev/null +++ b/src/lib/invite-email.ts @@ -0,0 +1,59 @@ +// 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 = `
+
+
${escapeHtml(app)}
+

You're invited to join the team

+

+ You've been invited to join ${escapeHtml(app)} as + ${safeRoles}. +

+

+ Click below to accept your invitation and set up your account. +

+ Accept invitation +

+ Or paste this link into your browser:
+ ${safeUrl} +

+

+ If you didn't expect this invitation, you can safely ignore this email. +

+
+
`; + + return { subject, html, text }; +} diff --git a/src/lib/team-api.ts b/src/lib/team-api.ts index 168d128..5877d2b 100644 --- a/src/lib/team-api.ts +++ b/src/lib/team-api.ts @@ -48,8 +48,10 @@ export interface TeamData { setMemberRoles: (id: string, roleIds: string[]) => Promise; updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise; removeMember: (id: string) => Promise; - invite: (email: string, roleIds: string[]) => Promise; - resendInvite: (id: string) => Promise; + /** Create an invite; resolves with the accept token so the caller can email the link. */ + invite: (email: string, roleIds: string[]) => Promise<{ token?: string }>; + /** Resend an invite (regenerates the token); resolves with the fresh token. */ + resendInvite: (id: string) => Promise<{ token?: string }>; revokeInvite: (id: string) => Promise; setPermission: (roleId: string, permId: string, granted: boolean) => Promise; refetch: () => void; @@ -115,8 +117,9 @@ function useMockTeam(): TeamData { const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []); const invite = useCallback(async (email: string, roleIds: string[]) => { 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 setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => { setRoles((l) => l.map((r) => (r.id !== roleId ? r : { @@ -184,8 +187,16 @@ function useLiveTeam(): TeamData { id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}), }), removeMember: (id) => cmd("crm.team.member.remove", { id }), - invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }), - resendInvite: (id) => cmd("crm.team.invitation.resend", { id }), + invite: async (email, roleIds) => { + const dto = (await sdk.command("crm.team.invitation.create", { email, roleIds })) as { token?: string } | undefined; + 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 }), setPermission: (roleId, permId, granted) => cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),