Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions convex/betterAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ describe("magic-link delivery HTTP contract", () => {
expect(await response.json()).toEqual({ status: true });
expect(send).toHaveBeenCalledTimes(1);
});
it("sends the branded template with the frontend verify URL in both parts", async () => {
await request();
const message = send.mock.calls[0][0];
expect(message).toMatchObject({ from: "SongScript <songscript@contact.heymans.dev>", to: "test@example.com", subject: "Sign in to SongScript" });
const verifyUrl = message.html.match(/<a href="([^"]+)" class="ss-button"/)?.[1];
expect(verifyUrl).toMatch(/^https:\/\/songscript-eight\.vercel\.app\/auth\/verify\?token=[A-Za-z0-9]+$/);
expect(message.text.split("\n")).toContain(verifyUrl);
expect(message.text).toContain("expires in 5 minutes");
});
it("keeps unknown callback origins blocked before sending", async () => {
expect((await request("https://untrusted.example")).status).toBe(403);
expect(send).not.toHaveBeenCalled();
Expand Down
147 changes: 12 additions & 135 deletions convex/betterAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { components, internal } from "./_generated/api";
import type { GenericCtx } from "@convex-dev/better-auth";
import type { DataModel } from "./_generated/dataModel";
import { Resend } from "resend";
import { renderMagicLinkEmail } from "./lib/magicLinkEmail";

// Auth functions for triggers (must match internal.auth exports)
const authFunctions: AuthFunctions = internal.auth;
Expand All @@ -18,6 +19,9 @@ const authFunctions: AuthFunctions = internal.auth;
const siteUrl = process.env.SITE_URL!;
const resendApiKey = process.env.RESEND_API_KEY;

// Better Auth's default magic-link lifetime, pinned so the email copy stays truthful.
const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 5;

// Initialize Resend client (only if API key is present)
const resend = resendApiKey ? new Resend(resendApiKey) : null;

Expand Down Expand Up @@ -134,6 +138,7 @@ export const createAuth = (ctx: GenericCtx<DataModel>) => {
crossDomain({ siteUrl }),
// Magic link passwordless authentication
magicLink({
expiresIn: MAGIC_LINK_EXPIRES_IN_SECONDS,
sendMagicLink: async ({ email, url, token }) => {
// Extract frontend origin from the callbackURL in the original URL
// The URL looks like: .../verify?token=xxx&callbackURL=http://localhost:3001/dashboard
Expand All @@ -156,144 +161,16 @@ export const createAuth = (ctx: GenericCtx<DataModel>) => {
if (!resend) deliveryFailure("not_configured");
let failureCode: string | undefined;
try {
const { subject, html, text } = renderMagicLinkEmail({
url: frontendVerifyUrl,
expiresInSeconds: MAGIC_LINK_EXPIRES_IN_SECONDS,
});
const { error } = await resend.emails.send({
from: "SongScript <songscript@contact.heymans.dev>",
to: email,
subject: "Your SongScript Login Link",
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your SongScript Login Link</title>
<style>
/* Base styles */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f9fafb;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.header {
padding: 24px;
text-align: center;
background-color: #10b981;
}
.header h1 {
color: #ffffff;
margin: 0;
font-size: 28px;
}
.content {
padding: 32px;
text-align: center;
}
.content p {
margin-bottom: 24px;
font-size: 16px;
}
.button {
display: inline-block;
background-color: #10b981;
color: #ffffff;
padding: 14px 28px;
text-decoration: none;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
}
.button:hover {
background-color: #0f9d6a;
}
.fallback {
margin-top: 24px;
font-size: 12px;
color: #6b7280;
}
.fallback a {
color: #10b981;
word-break: break-all;
}
.footer {
padding: 24px;
text-align: center;
font-size: 12px;
color: #9ca3af;
background-color: #f3f4f6;
}

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
body {
background-color: #111827;
color: #e5e7eb;
}
.container {
background-color: #1f2937;
border-color: #374151;
}
.header {
background-color: #10b981;
}
.header h1 {
color: #ffffff;
}
.content p {
color: #d1d5db;
}
.button {
background-color: #10b981;
color: #ffffff;
}
.button:hover {
background-color: #0f9d6a;
}
.fallback {
color: #9ca3af;
}
.fallback a {
color: #10b981;
}
.footer {
background-color: #111827;
color: #6b7280;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SongScript</h1>
</div>
<div class="content">
<p>Click the button below to sign in to your account:</p>
<a href="${frontendVerifyUrl}" class="button">Sign In</a>
<div class="fallback">
<p>Or copy and paste this link into your browser:<br>
<a href="${frontendVerifyUrl}">${frontendVerifyUrl}</a>
</p>
<p>This link will expire in 1 hour.</p>
</div>
</div>
<div class="footer">
&copy; ${new Date().getFullYear()} SongScript. All rights reserved.
</div>
</div>
</body>
</html>
`,
subject,
html,
text,
});

if (error) {
Expand Down
67 changes: 67 additions & 0 deletions convex/lib/magicLinkEmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { MAGIC_LINK_SUBJECT, renderMagicLinkEmail } from "./magicLinkEmail";

const url = "https://songscript-eight.vercel.app/auth/verify?token=abc123DEF456ghi789";

function anchors(html: string) {
return [...html.matchAll(/<a\s[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g)].map(([tag, href, inner]) => ({ tag, href, inner }));
}

describe("magic-link email template", () => {
const email = renderMagicLinkEmail({ url, expiresInSeconds: 300 });

it("uses the sign-in subject", () => {
expect(email.subject).toBe("Sign in to SongScript");
expect(MAGIC_LINK_SUBJECT).toBe(email.subject);
});

it("renders exactly one white, non-underlined button pointing at the verify URL", () => {
const buttons = anchors(email.html).filter((a) => a.tag.includes('class="ss-button"'));
expect(buttons).toHaveLength(1);
expect(buttons[0].href).toBe(url);
expect(buttons[0].tag).toContain("color:#ffffff");
expect(buttons[0].tag).toContain("text-decoration:none");
expect(buttons[0].inner).toContain("Sign in");
});

it("links only to the verify URL and shows a shortened fallback without the token", () => {
const links = anchors(email.html);
expect(links).toHaveLength(2);
expect(links.every((a) => a.href === url)).toBe(true);
const fallback = links.find((a) => !a.tag.includes("ss-button"))!;
expect(fallback.inner).toBe("songscript-eight.vercel.app/auth/verify");
expect(email.html.split("abc123DEF456ghi789")).toHaveLength(3); // the two hrefs only
});

it("is email-safe: table layout, 600px max width, dark-mode meta, no images or pixels", () => {
expect(email.html).toContain('role="presentation"');
expect(email.html).toContain("max-width:600px");
expect(email.html).toContain('<meta name="color-scheme" content="dark light">');
expect(email.html).toContain('<meta name="supported-color-schemes" content="dark light">');
expect(email.html).not.toMatch(/<img|url\(|<script/i);
});

it("keeps every attribute value well-quoted", () => {
// A double quote inside a value (e.g. a font name) would end the attribute early.
expect(email.html).not.toMatch(/="[^"]*"[^\s>/]/);
});

it("states the real expiry in both parts", () => {
expect(email.html).toContain("expires in 5&nbsp;minutes");
expect(email.text).toContain("expires in 5 minutes");
expect(renderMagicLinkEmail({ url, expiresInSeconds: 3600 }).text).toContain("expires in 1 hour");
});

it("has a plain-text part with the full link", () => {
expect(email.text).toContain("Sign in to SongScript");
expect(email.text.split("\n")).toContain(url);
expect(email.text).not.toMatch(/<[a-z]/i);
});

it("escapes the URL inside HTML attributes", () => {
const html = renderMagicLinkEmail({ url: `${url}&x="><b>`, expiresInSeconds: 300 }).html;
expect(html).not.toContain('"><b>');
expect(html).toContain("&amp;x=&quot;&gt;&lt;b&gt;");
});
});
135 changes: 135 additions & 0 deletions convex/lib/magicLinkEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Magic-link sign-in email
*
* Email clients strip most CSS, so this template uses a table layout with
* inline styles and bgcolor fallbacks. Colours follow the app: gray-950 page,
* gray-900 card, emerald buttons, and the brand-gradient wordmark
* (green → white → red), rendered as per-letter colours because
* background-clip: text is not supported in email.
*/

export const MAGIC_LINK_SUBJECT = "Sign in to SongScript";

// Interpolated from .brand-gradient in src/styles.css (#22c55e → #ffffff → #ef4444)
const WORDMARK: ReadonlyArray<readonly [string, string]> = [
["S", "#22c55e"], ["o", "#53d282"], ["n", "#84dfa6"], ["g", "#b5ecc9"], ["S", "#e6f9ed"],
["c", "#fdeaea"], ["r", "#fac1c1"], ["i", "#f69797"], ["p", "#f36e6e"], ["t", "#ef4444"],
];

const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";

// emerald-700: white text on the site's emerald-600 is 3.77:1, below WCAG AA.
const BUTTON = "#047857";
const BUTTON_HOVER = "#065f46";

// Keeps clients from pulling body copy into the inbox preview after the preheader.
const PREHEADER_PAD = "&#8199;&#847;".repeat(40);

function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

/** Host + path only; the token stays in the href, not the visible text. */
function shortLink(url: string): string {
const { host, pathname } = new URL(url);
return `${host}${pathname}`;
}

function expiryLabel(seconds: number): string {
const minutes = Math.round(seconds / 60);
if (minutes >= 60 && minutes % 60 === 0) {
const hours = minutes / 60;
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
}
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
}

export function renderMagicLinkEmail({ url, expiresInSeconds }: { url: string; expiresInSeconds: number }) {
const href = escapeHtml(url);
const expiry = expiryLabel(expiresInSeconds);
const expiryHtml = expiry.replace(" ", "&nbsp;");
const wordmark = WORDMARK.map(([letter, color]) => `<span style="color:${color};">${letter}</span>`).join("");

const html = `<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="dark light">
<meta name="supported-color-schemes" content="dark light">
<title>${MAGIC_LINK_SUBJECT}</title>
<style>
:root { color-scheme: dark light; supported-color-schemes: dark light; }
body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; }
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
a.ss-button { transition: background-color 150ms ease, transform 160ms ease-out; }
a.ss-button:active { transform: scale(0.97); }
a.ss-button:focus-visible { outline: 2px solid #34d399; outline-offset: 3px; }
@media (hover: hover) and (pointer: fine) { a.ss-button:hover { background-color: ${BUTTON_HOVER} !important; } }
@media (prefers-reduced-motion: reduce) { a.ss-button { transition: background-color 150ms ease; } a.ss-button:active { transform: none; } }
@media (max-width: 620px) { .ss-pad { padding-left: 24px !important; padding-right: 24px !important; } }
</style>
</head>
<body style="margin:0;padding:0;background-color:#030712;" bgcolor="#030712">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;color:#030712;">Your sign-in link works once and expires in ${expiry}.${PREHEADER_PAD}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#030712" style="background-color:#030712;">
<tr>
<td align="center" style="padding:48px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
<tr>
<td bgcolor="#111827" class="ss-pad" style="background-color:#111827;border:1px solid #1f2937;border-radius:16px;padding:40px 48px;font-family:${FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding:0 0 32px 0;font-size:30px;line-height:36px;font-weight:800;letter-spacing:-0.6px;">${wordmark}</td>
</tr>
<tr>
<td style="padding:0 0 8px 0;font-size:20px;line-height:28px;font-weight:600;color:#ffffff;">Sign in to SongScript</td>
</tr>
<tr>
<td style="padding:0 0 28px 0;font-size:15px;line-height:24px;color:#d1d5db;">This link works once and expires in ${expiryHtml}.</td>
</tr>
<tr>
<td style="padding:0 0 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td bgcolor="${BUTTON}" style="background-color:${BUTTON};border-radius:10px;mso-padding-alt:14px 32px;">
<a href="${href}" class="ss-button" target="_blank" style="display:inline-block;padding:14px 32px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:10px;background-color:${BUTTON};"><span style="color:#ffffff;text-decoration:none;">Sign in</span></a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 0 0 0;border-top:1px solid #1f2937;font-size:13px;line-height:20px;color:#9ca3af;">Button not working? Open <a href="${href}" target="_blank" style="color:#34d399;text-decoration:none;white-space:nowrap;">${escapeHtml(shortLink(url))}</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="ss-pad" style="padding:20px 48px 0 48px;font-family:${FONT};font-size:13px;line-height:20px;color:#9ca3af;">Didn't ask for this? You can ignore it; nothing changes without the link.</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;

const text = [
"Sign in to SongScript",
"",
`This link works once and expires in ${expiry}:`,
url,
"",
"Didn't ask for this? You can ignore it; nothing changes without the link.",
].join("\n");

return { subject: MAGIC_LINK_SUBJECT, html, text };
}
Binary file added docs/pr-assets/magic-link-email/after-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-assets/magic-link-email/before-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading