diff --git a/src/app/api/email/invite/route.ts b/src/app/api/email/invite/route.ts deleted file mode 100644 index 74c6321..0000000 --- a/src/app/api/email/invite/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/components/dashboard/team-management.tsx b/src/components/dashboard/team-management.tsx index 551f153..7bc5d3b 100644 --- a/src/components/dashboard/team-management.tsx +++ b/src/components/dashboard/team-management.tsx @@ -47,20 +47,6 @@ 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) => { @@ -323,11 +309,8 @@ export function TeamManagement() { }}>Copy link )} { - 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 }); } + try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was emailed to ${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" }); } @@ -348,14 +331,9 @@ export function TeamManagement() { onInvite={async (email, roleIds) => { setInviteOpen(false); try { - const { token } = await team.invite(email, roleIds); + await team.invite(email, roleIds); setTab("invites"); - 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}.`, - }); + toast.push({ tone: "success", title: "Invitation sent", desc: `We emailed an invite to ${email}. You can also copy the link.` }); } 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 deleted file mode 100644 index 7b89e5c..0000000 --- a/src/lib/invite-email.ts +++ /dev/null @@ -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 = `
-
-
${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 85187fa..60bf0f0 100644 --- a/src/lib/team-api.ts +++ b/src/lib/team-api.ts @@ -48,10 +48,10 @@ export interface TeamData { setMemberRoles: (id: string, roleIds: string[]) => Promise; updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise; removeMember: (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 }>; + /** Create an invite. be-crm emails the invitee automatically. */ + invite: (email: string, roleIds: string[]) => Promise; + /** Resend an invite (regenerates the token + re-emails it). */ + resendInvite: (id: string) => Promise; revokeInvite: (id: string) => Promise; setPermission: (roleId: string, permId: string, granted: boolean) => Promise; refetch: () => void; @@ -117,9 +117,8 @@ 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 : { @@ -191,16 +190,8 @@ function useLiveTeam(): TeamData { id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}), }), removeMember: (id) => cmd("crm.team.member.remove", { 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 }; - }, + invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }), + resendInvite: (id) => cmd("crm.team.invitation.resend", { id }), 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 }),