feat(email): send team invitations from a Vercel serverless route (Twilio)
- POST /api/email/invite: sends the invite email via the Twilio Emails API using server-only TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN (Vercel env), from EMAIL_FROM_ADDRESS (default support@lynkedup.dev). Fixed invite template; gated on a session cookie so it isn't an open relay. - Team Management emails the invite on create and resend (token now returned by both), with the Copy-link as a fallback. Mock mode skips email.
This commit is contained in:
@@ -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 = `<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 };
|
||||
}
|
||||
+16
-5
@@ -48,8 +48,10 @@ export interface TeamData {
|
||||
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
|
||||
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
|
||||
removeMember: (id: string) => Promise<void>;
|
||||
invite: (email: string, roleIds: string[]) => Promise<void>;
|
||||
resendInvite: (id: string) => Promise<void>;
|
||||
/** 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<void>;
|
||||
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
|
||||
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 }),
|
||||
|
||||
Reference in New Issue
Block a user