diff --git a/electron/ipc/register/permissions.test.ts b/electron/ipc/register/permissions.test.ts index 208cd2d7..60866734 100644 --- a/electron/ipc/register/permissions.test.ts +++ b/electron/ipc/register/permissions.test.ts @@ -42,6 +42,31 @@ it("does not prompt or open settings merely by registering permission handlers", expect(mocks.open).not.toHaveBeenCalled(); }); +it.each([ + "mailto:support@recordly.dev", + "mailto:youngchen3442@gmail.com", +])("opens the fixed contact address %s in the default mail client", async (url) => { + expect(await mocks.handlers.get("open-external-url")!(undefined, url)).toEqual({ + success: true, + }); + expect(mocks.open).toHaveBeenCalledExactlyOnceWith(url); +}); + +it.each([ + "mailto:other@example.com", + "mailto:support@recordly.dev?bcc=other@example.com", + "mailto:support@recordly.dev.evil.test", + "mailto:youngchen3442@gmail.com?bcc=other@example.com", + "mailto:youngchen3442@gmail.com.evil.test", + "file:///tmp/example", + "custom:example", +])("blocks unsupported external URL %s", async (url) => { + expect(await mocks.handlers.get("open-external-url")!(undefined, url)).toMatchObject({ + success: false, + }); + expect(mocks.open).not.toHaveBeenCalled(); +}); + it("reports microphone and camera status without requesting access", async () => { mocks.status.mockImplementation((device: string) => device === "microphone" ? "denied" : "not-determined", diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts index 4ae5b48e..f1a093de 100644 --- a/electron/ipc/register/permissions.ts +++ b/electron/ipc/register/permissions.ts @@ -20,9 +20,14 @@ export function registerPermissionHandlers() { ipcMain.handle("close-permission-accessory", ({ sender }) => dismissPermissionAccessory(sender)); ipcMain.handle("open-external-url", async (_, url: string) => { try { - // Security: only allow http/https URLs to prevent file:// or custom protocol abuse + // Only the two displayed contact addresses may launch a mail client; other protocols stay blocked. const parsed = new URL(url); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + if ( + parsed.protocol !== "https:" && + parsed.protocol !== "http:" && + url !== "mailto:support@recordly.dev" && + url !== "mailto:youngchen3442@gmail.com" + ) { return { success: false, error: `Blocked non-HTTP URL: ${parsed.protocol}` }; } await shell.openExternal(url); diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx index 599d4fea..6d31b903 100644 --- a/src/components/video-editor/TutorialHelp.tsx +++ b/src/components/video-editor/TutorialHelp.tsx @@ -1,4 +1,4 @@ -import { GithubLogo, XLogo, YoutubeLogo } from "@phosphor-icons/react"; +import { EnvelopeSimple, GithubLogo, XLogo, YoutubeLogo } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; import { UpgradeButton } from "@/components/billing/UpgradeButton"; import { Button } from "@/components/ui/button"; @@ -111,43 +111,86 @@ export function CommunityLinks() { truncateLabel className="h-8 w-full min-w-0 px-1.5 text-xs" /> - - - - - - - {t("socials", "Socials")} - -
- {links.map(({ label, accessibleLabel, url, Icon }) => ( - - ))} -
-
-
+
+ + + + + + + {t("socials", "Socials")} + +
+ {links.map(({ label, accessibleLabel, url, Icon }) => ( + + ))} +
+
+
+ + + + + + + {tBilling("contact", "Contact")} + +
+ {["support@recordly.dev", "youngchen3442@gmail.com"].map((email) => ( + + ))} +
+
+
+
); } diff --git a/tests/ui/settings-fixed-sidebar.spec.ts b/tests/ui/settings-fixed-sidebar.spec.ts index f88db9bc..fb7e506c 100644 --- a/tests/ui/settings-fixed-sidebar.spec.ts +++ b/tests/ui/settings-fixed-sidebar.spec.ts @@ -167,6 +167,13 @@ for (const { width, height, theme } of [ const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true }); const socials = home.getByRole("button", { name: "Socials", exact: true }); await expect(socials).toBeInViewport({ ratio: 1 }); + const contact = home.getByRole("button", { name: "Contact", exact: true }); + await expect(contact).toBeInViewport({ ratio: 1 }); + const socialsBox = (await socials.boundingBox())!; + const contactBox = (await contact.boundingBox())!; + expect(contactBox.y).toBe(socialsBox.y); + expect(contactBox.x).toBeGreaterThanOrEqual(socialsBox.x + socialsBox.width); + await expect(home.getByRole("button", { name: "YouTube", exact: true })).toHaveCount(0); await socials.click(); const links = page.getByRole("dialog", { name: "Socials", exact: true }); @@ -196,6 +203,23 @@ for (const { width, height, theme } of [ await expect(socials).toBeFocused(); await expect(home).toBeVisible(); await home.locator("aside").screenshot({ path: test.info().outputPath(`footer-${theme}.png`) }); + await contact.click(); + const contactDialog = page.getByRole("dialog", { name: "Contact", exact: true }); + await expect(contactDialog).toBeVisible(); + for (const email of ["support@recordly.dev", "youngchen3442@gmail.com"]) { + const emailButton = contactDialog.getByRole("button", { name: email, exact: true }); + await expect(emailButton).toBeInViewport({ ratio: 1 }); + await emailButton.focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("html")).toHaveAttribute("data-opened-community-url", `mailto:${email}`); + await expect(page).toHaveURL(url); + } + await contactDialog.screenshot({ path: test.info().outputPath(`contact-${theme}.png`) }); + await page.keyboard.press("Escape"); + await expect(contactDialog).toBeHidden(); + await expect(contact).toBeFocused(); + await expect(home).toBeVisible(); + }); } @@ -240,7 +264,7 @@ test("Japanese community footer fits Upgrade and Socials without overlap", async } boxes.push(box); } - await expect(footer.getByRole("button")).toHaveCount(2); + await expect(footer.getByRole("button")).toHaveCount(3); const socials = footer.getByRole("button", { name: "SNS", exact: true }); await expect(socials).toBeVisible(); await socials.click();