diff --git a/src/cloud.js b/src/cloud.js index 3c28bbe..1af1b97 100644 --- a/src/cloud.js +++ b/src/cloud.js @@ -169,6 +169,11 @@ async function sendLoginOtp(email) { }); } +async function checkAuthEmail(email) { + if (!supabase) return { data: null, error: new Error("Cloud authentication is unavailable") }; + return supabase.functions.invoke("check-auth-email", { body: { email } }); +} + function passwordPromptStorageKey() { return session?.user?.id ? `${PASSWORD_PROMPT_KEY}:${session.user.id}` : PASSWORD_PROMPT_KEY; } @@ -298,7 +303,7 @@ function renderPasswordEditor({ mode = "setup" } = {}) { function renderPasswordRecoveryRequest(prefillEmail = "") { panel.innerHTML = `

${icon("cloud")} 找回密码

-

输入账号邮箱,我们会发送密码重设邮件。为了保护账号,无论邮箱是否存在,页面都会显示相同结果。

+

输入账号邮箱,我们会先确认账号是否存在,再发送密码重设邮件。

@@ -314,6 +319,15 @@ function renderPasswordRecoveryRequest(prefillEmail = "") { const submitButton = form.querySelector("[type=submit]"); await runLockedAction(submitButton, async () => { const email = String(new FormData(form).get("email") || "").trim(); + const { data: emailStatus, error: lookupError } = await checkAuthEmail(email); + if (lookupError || typeof emailStatus?.registered !== "boolean") { + showToast("暂时无法确认该邮箱,请稍后再试。", 6000); + return; + } + if (!emailStatus.registered) { + showToast("该邮箱尚未注册,请先注册账号或使用邮箱验证码登录。", 6000); + return; + } const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin, }); diff --git a/supabase/config.toml b/supabase/config.toml index 7fae991..666679f 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -249,17 +249,29 @@ otp_expiry = 3600 # content_path = "./supabase/templates/invite.html" [auth.email.template.confirmation] -subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}登录验证{{ end }} · 注册验证码' +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 注册验证码' content_path = "./supabase/templates/confirmation.html" [auth.email.template.magic_link] -subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}登录验证{{ end }} · 登录验证码' +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 登录验证码' content_path = "./supabase/templates/magic_link.html" [auth.email.template.recovery] -subject = '{{ if eq .RedirectTo "https://sm.shadow.wang" }}影伴{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang") }}影匣{{ else if eq .RedirectTo "https://ss.shadow.wang" }}影裁{{ else }}Shadow Nexus{{ end }} · 重设密码' +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 重设密码' content_path = "./supabase/templates/recovery.html" +[auth.email.template.email_change] +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 邮箱变更' +content_path = "./supabase/templates/email_change.html" + +[auth.email.template.invite] +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 邀请' +content_path = "./supabase/templates/invite.html" + +[auth.email.template.reauthentication] +subject = '{{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if or (eq .RedirectTo "https://flomo.shadow.wang") (eq .RedirectTo "https://flomo.shadow.wang/") }}Quick flomo{{ else }}Shadow Nexus{{ end }} · 安全确认' +content_path = "./supabase/templates/reauthentication.html" + # Uncomment to customize notification email template # [auth.email.notification.password_changed] # enabled = true @@ -435,3 +447,9 @@ entrypoint = "./functions/delete-account/index.ts" # Specifies static files to be bundled with the function. Supports glob patterns. # For example, if you want to serve static HTML pages in your function: # static_files = [ "./functions/delete-account/*.html" ] + +[functions.check-auth-email] +enabled = true +verify_jwt = false +import_map = "./functions/check-auth-email/deno.json" +entrypoint = "./functions/check-auth-email/index.ts" diff --git a/supabase/functions/check-auth-email/deno.json b/supabase/functions/check-auth-email/deno.json new file mode 100644 index 0000000..8246ef8 --- /dev/null +++ b/supabase/functions/check-auth-email/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.111.0" + } +} diff --git a/supabase/functions/check-auth-email/index.ts b/supabase/functions/check-auth-email/index.ts new file mode 100644 index 0000000..12fa647 --- /dev/null +++ b/supabase/functions/check-auth-email/index.ts @@ -0,0 +1,101 @@ +import { createClient } from "@supabase/supabase-js"; + +const corsHeaders = { + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", +}; +const MAX_REQUESTS_PER_MINUTE = 10; +const MAX_EMAIL_LENGTH = 320; +const requestBuckets = new Map(); + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { status, headers: corsHeaders }); +} + +function readSecret(name: string, fallbackName: string) { + const raw = Deno.env.get(name) || Deno.env.get(fallbackName); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Record; + return parsed.default || Object.values(parsed)[0] || null; + } catch { + return raw; + } +} + +function requestKey(request: Request) { + return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || "unknown"; +} + +function isRateLimited(request: Request) { + const now = Date.now(); + const key = requestKey(request); + const current = requestBuckets.get(key); + if (!current || current.resetAt <= now) { + requestBuckets.set(key, { count: 1, resetAt: now + 60_000 }); + return false; + } + current.count += 1; + return current.count > MAX_REQUESTS_PER_MINUTE; +} + +function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +async function checkAuthEmail(request: Request) { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + if (request.method !== "POST") { + return jsonResponse({ code: "method_not_allowed" }, 405); + } + if (isRateLimited(request)) { + return jsonResponse({ code: "rate_limited" }, 429); + } + + let payload: { email?: unknown }; + try { + payload = await request.json(); + } catch { + return jsonResponse({ code: "invalid_request" }, 400); + } + + if (typeof payload.email !== "string" || payload.email.length > MAX_EMAIL_LENGTH) { + return jsonResponse({ code: "invalid_email" }, 400); + } + + const email = normalizeEmail(payload.email); + if (!/^\S+@\S+\.\S+$/.test(email)) { + return jsonResponse({ code: "invalid_email" }, 400); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const secretKey = readSecret("SUPABASE_SECRET_KEYS", "SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !secretKey) { + return jsonResponse({ code: "auth_lookup_unavailable" }, 503); + } + + const admin = createClient(supabaseUrl, secretKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + for (let page = 1; ; page += 1) { + const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 1000 }); + if (error) { + return jsonResponse({ code: "auth_lookup_unavailable" }, 503); + } + + const users = data.users || []; + const registered = users.some((user) => normalizeEmail(user.email || "") === email); + if (registered) return jsonResponse({ registered: true }); + if (users.length < 1000) return jsonResponse({ registered: false }); + } +} + +Deno.serve(checkAuthEmail); diff --git a/supabase/templates/confirmation.html b/supabase/templates/confirmation.html index fc6aa99..cfbf86b 100644 --- a/supabase/templates/confirmation.html +++ b/supabase/templates/confirmation.html @@ -1,22 +1,109 @@ - -
-
-

- {{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:3000") (eq .RedirectTo "http://localhost:3000/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if eq .Data.product_id "shadow-mate" }}影伴 Shadow Mate{{ else if .Data.product_name }}{{ .Data.product_name }}{{ else }}Shadow Nexus{{ end }} -

-

欢迎注册

-

请使用下面的验证码完成邮箱注册:

-
- {{ .Token }} -
-

验证码只能使用一次。如果邮件客户端支持按钮,也可以点击下方按钮完成验证。

-

- 完成注册 -

-

如果您没有请求注册,请忽略此邮件。

-
-
+ + + + + + + + + + + + + diff --git a/supabase/templates/email_change.html b/supabase/templates/email_change.html new file mode 100644 index 0000000..c68e447 --- /dev/null +++ b/supabase/templates/email_change.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/supabase/templates/invite.html b/supabase/templates/invite.html new file mode 100644 index 0000000..f690c21 --- /dev/null +++ b/supabase/templates/invite.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html index 98f8e7b..d085bcc 100644 --- a/supabase/templates/magic_link.html +++ b/supabase/templates/magic_link.html @@ -1,22 +1,77 @@ - -
-
-

- {{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:3000") (eq .RedirectTo "http://localhost:3000/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else if eq .Data.product_id "shadow-mate" }}影伴 Shadow Mate{{ else if .Data.product_name }}{{ .Data.product_name }}{{ else }}Shadow Nexus{{ end }} -

-

邮箱登录验证

-

请使用下面的验证码登录:

-
- {{ .Token }} -
-

验证码只能使用一次。如果邮件客户端支持按钮,也可以点击下方按钮完成登录。

-

- 点击登录 -

-

如果您没有请求登录,请忽略此邮件。

-
-
+ + + + + + + + + + + + + diff --git a/supabase/templates/reauthentication.html b/supabase/templates/reauthentication.html new file mode 100644 index 0000000..37f2605 --- /dev/null +++ b/supabase/templates/reauthentication.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html index c897f58..aa9cceb 100644 --- a/supabase/templates/recovery.html +++ b/supabase/templates/recovery.html @@ -1,19 +1,70 @@ - -
-
-

- {{ if or (eq .RedirectTo "https://sm.shadow.wang") (eq .RedirectTo "https://sm.shadow.wang/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://shadow-mate.vercel.app") (eq .RedirectTo "https://shadow-mate.vercel.app/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:5173") (eq .RedirectTo "http://localhost:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://127.0.0.1:5173") (eq .RedirectTo "http://127.0.0.1:5173/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "http://localhost:3000") (eq .RedirectTo "http://localhost:3000/") }}影伴 Shadow Mate{{ else if or (eq .RedirectTo "https://sc.shadow.wang") (eq .RedirectTo "https://sc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://sbc.shadow.wang") (eq .RedirectTo "https://sbc.shadow.wang/") }}影匣 Shadow Card{{ else if or (eq .RedirectTo "https://ss.shadow.wang") (eq .RedirectTo "https://ss.shadow.wang/") }}影裁 Shadow Size{{ else }}Shadow Nexus{{ end }} -

-

重设共享账号密码

-

我们收到了重设密码的请求。点击下面的按钮返回发起请求的产品并设置新密码。

-

- 重设密码 -

-

此密码适用于使用同一 Supabase Auth 账号的 Shadow 系列产品。链接仅能使用一次,并将在短时间后失效。

-

如果您没有请求重设密码,请忽略此邮件。

-
-
+ + + + + + + + + + + + + diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index bb0952d..4969731 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -687,6 +687,13 @@ test.describe("Shared password authentication", () => { localStorage.clear(); sessionStorage.clear(); }); + await page.route("**/functions/v1/check-auth-email", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ registered: true }), + }); + }); await page.route("**/auth/v1/recover**", async (route) => { await route.fulfill({ status: 503, @@ -805,11 +812,18 @@ test.describe("Shared password authentication", () => { await expect(page.locator('#accountButton[data-state="online"]')).toBeVisible(); }); - test("requests a branded recovery email without revealing account existence", async ({ page }) => { - let recoveryRequestUrl = ""; + test("blocks password recovery for an unregistered email", async ({ page }) => { + let recoveryRequestCount = 0; await page.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); }); + await page.route("**/functions/v1/check-auth-email", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ registered: false }), + }); + }); await page.route("**/auth/v1/recover**", async (route) => { - recoveryRequestUrl = route.request().url(); + recoveryRequestCount += 1; await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); }); @@ -820,7 +834,7 @@ test.describe("Shared password authentication", () => { await page.click("[data-forgot-password]"); await page.click('#passwordRecoveryForm button[type="submit"]'); - await expect.poll(() => recoveryRequestUrl).toContain("redirect_to=http%3A%2F%2F127.0.0.1"); - await expect(page.locator("#cloudPanel")).toContainText("如果该邮箱已注册,密码重设邮件已经发送"); + await expect(page.locator("#syncToast")).toContainText("该邮箱尚未注册"); + expect(recoveryRequestCount).toBe(0); }); }); diff --git a/tests/unit/email-templates.test.js b/tests/unit/email-templates.test.js index b0ffb9b..5a9f03a 100644 --- a/tests/unit/email-templates.test.js +++ b/tests/unit/email-templates.test.js @@ -3,8 +3,12 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const recovery = readFileSync(resolve(process.cwd(), "supabase/templates/recovery.html"), "utf8"); +const confirmation = readFileSync(resolve(process.cwd(), "supabase/templates/confirmation.html"), "utf8"); +const magicLink = readFileSync(resolve(process.cwd(), "supabase/templates/magic_link.html"), "utf8"); const config = readFileSync(resolve(process.cwd(), "supabase/config.toml"), "utf8"); +const templates = [confirmation, magicLink, recovery]; + describe("multi-product password recovery email", () => { it("maps every supported production product from RedirectTo", () => { expect(recovery).toContain("https://sm.shadow.wang"); @@ -26,4 +30,66 @@ describe("multi-product password recovery email", () => { expect(config).toContain("[auth.email.template.recovery]"); expect(config).toContain('content_path = "./supabase/templates/recovery.html"'); }); + + it("uses the Editorial Utility shell for every Supabase Auth email", () => { + for (const template of templates) { + expect(template).toContain('meta name="color-scheme" content="light dark"'); + expect(template).toContain("prefers-color-scheme: dark"); + expect(template).toContain("[data-ogsc]"); + expect(template).toContain("Shadow Nexus"); + expect(template).toContain("https://shadow.wang/zh"); + expect(template).toContain("shadow_mate.svg"); + expect(template).toContain("shadow_card_logo.png"); + expect(template).toContain("shadow_size.png"); + expect(template).toContain("shadow_portal_logo.png"); + expect(template).toContain("https://shadow.wang/zh/products/shadow-mate"); + expect(template).toContain("https://shadow.wang/zh/products/shadow-card"); + expect(template).toContain("https://shadow.wang/zh/products/shadow-size"); + expect(template).not.toContain("🔐"); + } + }); + + it("preserves Supabase's auth variables for each email flow", () => { + expect(confirmation).toContain("{{ .Token }}"); + expect(confirmation).toContain("{{ .TokenHash }}"); + expect(magicLink).toContain("{{ .Token }}"); + expect(magicLink).toContain("{{ .TokenHash }}"); + expect(recovery).toContain("{{ .ConfirmationURL }}"); + }); + + it("registers all three local email templates", () => { + expect(config).toContain("[auth.email.template.confirmation]"); + expect(config).toContain('content_path = "./supabase/templates/confirmation.html"'); + expect(config).toContain("[auth.email.template.magic_link]"); + expect(config).toContain('content_path = "./supabase/templates/magic_link.html"'); + }); + + it("uses the project name in every Auth email subject, including local development", () => { + const subjects = config + .split("\n") + .filter((line) => line.startsWith("subject =")); + + expect(subjects).toHaveLength(6); + + for (const subject of subjects) { + expect(subject).toContain("http://127.0.0.1:5173"); + expect(subject).toContain("http://localhost:5173"); + expect(subject).toContain("影伴 Shadow Mate"); + expect(subject).toContain("影匣 Shadow Card"); + expect(subject).toContain("影裁 Shadow Size"); + expect(subject).toContain("Quick flomo"); + expect(subject).toContain("Shadow Nexus"); + } + }); + + it("keeps footer links on the same environment as the Auth redirect", () => { + for (const template of templates) { + expect(template).toContain('href="http://localhost:3000/zh/products/shadow-mate"'); + expect(template).toContain('href="http://localhost:3000/zh"'); + expect(template).toContain('href="https://shadow-portal.vercel.app/zh/products/shadow-mate"'); + expect(template).toContain('href="https://shadow-portal.vercel.app/zh"'); + expect(template).toContain('href="https://shadow.wang/zh/products/shadow-mate"'); + expect(template).toContain('href="https://shadow.wang/zh"'); + } + }); });