diff --git a/docs/pr/project-browser/login-expanded.png b/docs/pr/project-browser/login-expanded.png new file mode 100644 index 00000000..1c6657e1 Binary files /dev/null and b/docs/pr/project-browser/login-expanded.png differ diff --git a/docs/pr/project-browser/login.png b/docs/pr/project-browser/login.png new file mode 100644 index 00000000..7043491d Binary files /dev/null and b/docs/pr/project-browser/login.png differ diff --git a/docs/pr/project-browser/projects-dark.png b/docs/pr/project-browser/projects-dark.png index 281e9c8d..3e20a451 100644 Binary files a/docs/pr/project-browser/projects-dark.png and b/docs/pr/project-browser/projects-dark.png differ diff --git a/docs/pr/project-browser/projects.png b/docs/pr/project-browser/projects.png index fbbf4e66..3fca1ac3 100644 Binary files a/docs/pr/project-browser/projects.png and b/docs/pr/project-browser/projects.png differ diff --git a/docs/pr/project-browser/settings-motion.png b/docs/pr/project-browser/settings-motion.png new file mode 100644 index 00000000..5c2cf8ad Binary files /dev/null and b/docs/pr/project-browser/settings-motion.png differ diff --git a/docs/pr/project-browser/settings.png b/docs/pr/project-browser/settings.png index 7455ce8f..5cdae534 100644 Binary files a/docs/pr/project-browser/settings.png and b/docs/pr/project-browser/settings.png differ diff --git a/src/components/auth/RecordlySignInDialog.tsx b/src/components/auth/RecordlySignInDialog.tsx index c6f2cdef..07b23bef 100644 --- a/src/components/auth/RecordlySignInDialog.tsx +++ b/src/components/auth/RecordlySignInDialog.tsx @@ -1,5 +1,7 @@ +import { demoLoginEnabled } from "@/lib/auth/demoSession"; +import { WindowsLogo } from "@phosphor-icons/react"; import { useI18n } from "@/contexts/I18nContext"; -import { GoogleLogo, SignOut, XLogo } from "@/components/ui/icons"; +import { GoogleLogo, SignOut } from "@/components/ui/icons"; import type { User } from "@supabase/supabase-js"; import { type FormEvent, useEffect, useState } from "react"; import { @@ -43,9 +45,7 @@ function friendlyAuthError( if (action === "google") { return t("editor.cloud.googleUnavailable"); } - if (action === "x") { - return t("editor.cloud.xUnavailable"); - } + if (action === "azure") return "Microsoft sign-in is not available yet."; return t("editor.cloud.providerUnavailable"); } return message; @@ -95,8 +95,11 @@ export function RecordlySignInDialog({ const submitEmail = (event: FormEvent) => { event.preventDefault(); - if (!configured || busy) return; + if ((!configured && !demoLoginEnabled) || busy) return; void run("email", async () => { + if (!configured && email.trim().toLowerCase() !== "test@email.com") { + throw new Error("Email sign-in is not available yet."); + } await signInWithEmail(email.trim(), password); onAuthenticated(); }); @@ -114,139 +117,190 @@ export function RecordlySignInDialog({ }); }; - const disabled = !configured || Boolean(busy); + const disabled = Boolean(busy); + const expanded = email.trim().length > 0; + const artwork = `${import.meta.env.BASE_URL}wallpapers/wallpaper1.jpg`; return ( - - - - - - - {user - ? t("editor.cloud.accountHeading") - : t("editor.cloud.signInHeading")} - - - {user - ? user.email - : reason === "share" - ? t("editor.cloud.signInShareDescription") - : t("editor.cloud.signInDescription")} - - - - {user ? ( - - ) : ( - <> -
- + ) : ( + <> +
+ + +
+
+ + + or continue with email + + +
+
- - Google - - -
-
- - - {t("editor.cloud.or")} - - -
- - - - - - - - - - - - - - - - )} - {message || callbackError ? ( - - - - - {message || callbackError} - - - - ) : null} -
- {!configured && !user && ( - - - {t("editor.cloud.unavailable")} - - - )} + { + setEmail(value); + setMessage(undefined); + if (!value.trim()) setPassword(""); + }} + isRequired + isDisabled={disabled} + > + + + + + {expanded && ( +
+ + + + + + {configured && ( + + )} + +
+ )} + + + )} + {message || callbackError ? ( + + + + + {message || callbackError} + + + + ) : null} + {!configured && !demoLoginEnabled && !user && ( + + {t("editor.cloud.unavailable")} + + )} + + +
+ +
+ + Recordly + +

+ Make something +
+ worth sharing. +

+
diff --git a/src/components/auth/useRecordlyAuth.ts b/src/components/auth/useRecordlyAuth.ts index ee847063..c729244e 100644 --- a/src/components/auth/useRecordlyAuth.ts +++ b/src/components/auth/useRecordlyAuth.ts @@ -1,5 +1,6 @@ +import { demoUser, hasDemoSession, subscribeDemoSession } from "@/lib/auth/demoSession"; import type { User } from "@supabase/supabase-js"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { completeAuthCallback, recordlyAuth, @@ -7,6 +8,7 @@ import { } from "@/lib/auth/recordlyAuth"; export function useRecordlyAuth() { + const demo = useSyncExternalStore(subscribeDemoSession, hasDemoSession, () => false); const [user, setUser] = useState(null); const [accessToken, setAccessToken] = useState(); const [loading, setLoading] = useState(recordlyAuthConfigured); @@ -67,5 +69,11 @@ export function useRecordlyAuth() { }; }, []); - return { user, accessToken, loading, configured: recordlyAuthConfigured, callbackError }; + return { + user: demo ? demoUser : user, + accessToken: demo ? undefined : accessToken, + loading, + configured: recordlyAuthConfigured, + callbackError, + }; } diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f666690e..9ea6279d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -233,18 +233,26 @@ function MotionPresetCards({ value={activePresetId ?? undefined} onChange={(value) => onApply(value as CursorMotionPresetId)} > - + {MOTION_PRESET_ORDER.map((presetId) => ( - + - +
+ + + {tSettings(`effects.motionPresets.${presetId}.description`)} + +
- - {tSettings(`effects.motionPresets.${presetId}.description`)} -
))} @@ -2123,7 +2131,7 @@ export function SettingsPanel({ {whisperModelDownloadStatus === "downloading" ? (
@@ -2312,7 +2320,7 @@ export function SettingsPanel({ )} -
+
-
+
{description && ( {description} diff --git a/src/components/video-editor/dashboard/sidebarCardConfig.ts b/src/components/video-editor/dashboard/sidebarCardConfig.ts index 05b2e882..750fa91b 100644 --- a/src/components/video-editor/dashboard/sidebarCardConfig.ts +++ b/src/components/video-editor/dashboard/sidebarCardConfig.ts @@ -11,7 +11,7 @@ export type SidebarCard = { /** Code-only sidebar content. Empty cards or enabled:false hides this area. */ export const sidebarCardConfig: { enabled: boolean; cards: SidebarCard[] } = { - enabled: true, + enabled: false, cards: [ { id: "placeholder", diff --git a/src/components/video-editor/layout/EditorHeader.tsx b/src/components/video-editor/layout/EditorHeader.tsx index 18ec37b8..a1e9a48f 100644 --- a/src/components/video-editor/layout/EditorHeader.tsx +++ b/src/components/video-editor/layout/EditorHeader.tsx @@ -1,3 +1,4 @@ +import { Separator } from "@heroui/react"; import { House, FilmStrip, @@ -108,16 +109,6 @@ export function EditorHeader(props: Props) { className={`editor-header-start flex min-w-0 items-center gap-1 ${headerLeftControlsPaddingClass}`} style={{ WebkitAppRegion: "no-drag" } as CSSProperties} > - )}
+ +
library.setOpen((open) => !open)} onAccountClick={() => requestSignIn("account")} panelContent={ library.open ? : undefined diff --git a/src/components/video-editor/layout/EditorSidebar.tsx b/src/components/video-editor/layout/EditorSidebar.tsx index 207c2a08..89bd2197 100644 --- a/src/components/video-editor/layout/EditorSidebar.tsx +++ b/src/components/video-editor/layout/EditorSidebar.tsx @@ -1,4 +1,3 @@ -import { File } from "@/components/ui/icons"; import { AccountAvatar } from "@/components/ui/account-avatar"; import type { User } from "@supabase/supabase-js"; import { Camera, ClosedCaptioning, Cursor, Gear, FrameCorners } from "@/components/ui/icons"; @@ -20,7 +19,6 @@ import type { EditorEffectSection } from "../types"; type Props = { accountUser?: User | null; - onToggleVideos: () => void; panelContent?: ReactNode; onAccountClick?: () => void; t: ReturnType["t"]; @@ -32,7 +30,6 @@ type Props = { export function EditorSidebar({ t, accountUser, - onToggleVideos, activeSection, setActiveSection, settingsPanelProps, @@ -80,19 +77,12 @@ export function EditorSidebar({ className="w-full items-center gap-2" selectionMode="single" disallowEmptySelection - selectedKeys={[panelContent ? "videos" : activeSection]} + selectedKeys={panelContent ? [] : [activeSection]} onSelectionChange={(keys) => { const key = Array.from(keys)[0]; - if (key === "videos") onToggleVideos(); - else if (key) setActiveSection(key as EditorEffectSection); + if (key) setActiveSection(key as EditorEffectSection); }} > - - - - - Clips - {sections.map((section) => ( { + vi.resetModules(); + const values = new Map(); + vi.stubGlobal("window", new EventTarget()); + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }); +}); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +it("notifies local account changes and stores only a session marker", async () => { + vi.stubEnv("DEV", true); + const { setDemoSession, hasDemoSession, subscribeDemoSession } = await import("./demoSession"); + const listener = vi.fn(); + const unsubscribe = subscribeDemoSession(listener); + expect(hasDemoSession()).toBe(false); + setDemoSession(true); + expect(hasDemoSession()).toBe(true); + expect(sessionStorage.getItem("recordly.demo-session")).toBe("1"); + setDemoSession(false); + expect(hasDemoSession()).toBe(false); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + setDemoSession(true); + expect(listener).toHaveBeenCalledTimes(2); +}); + +it("cannot enable the local demo account in production", async () => { + vi.stubEnv("DEV", false); + sessionStorage.setItem("recordly.demo-session", "1"); + const { setDemoSession, hasDemoSession, demoLoginEnabled } = await import("./demoSession"); + setDemoSession(true); + expect(demoLoginEnabled).toBe(false); + expect(hasDemoSession()).toBe(false); +}); diff --git a/src/lib/auth/demoSession.ts b/src/lib/auth/demoSession.ts new file mode 100644 index 00000000..045a4b7b --- /dev/null +++ b/src/lib/auth/demoSession.ts @@ -0,0 +1,27 @@ +import type { User } from "@supabase/supabase-js"; + +// Temporary local UI account. Never creates a Supabase session or cloud access token. +export const demoLoginEnabled = import.meta.env.DEV; +const key = "recordly.demo-session"; +const event = "recordly-demo-session-changed"; +export const demoUser: User = { + id: "recordly-local-demo", + email: "test@email.com", + aud: "local-demo", + app_metadata: {}, + user_metadata: { full_name: "Test User" }, + created_at: "2026-09-23T00:00:00.000Z", +}; +export function hasDemoSession() { + return demoLoginEnabled && typeof window !== "undefined" && sessionStorage.getItem(key) === "1"; +} +export function setDemoSession(active: boolean) { + if (!demoLoginEnabled) return; + if (active) sessionStorage.setItem(key, "1"); + else sessionStorage.removeItem(key); + window.dispatchEvent(new Event(event)); +} +export function subscribeDemoSession(listener: () => void) { + window.addEventListener(event, listener); + return () => window.removeEventListener(event, listener); +} diff --git a/src/lib/auth/recordlyAuth.test.ts b/src/lib/auth/recordlyAuth.test.ts index fd09a18e..b5416867 100644 --- a/src/lib/auth/recordlyAuth.test.ts +++ b/src/lib/auth/recordlyAuth.test.ts @@ -1,16 +1,26 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; const exchange = vi.hoisted(() => vi.fn(async (_code: string) => ({ error: null }))); +const oauth = vi.hoisted(() => + vi.fn(async (_options: unknown) => ({ + data: { url: "https://auth.example.test/oauth" }, + error: null, + })), +); vi.mock("@supabase/supabase-js", () => ({ - createClient: () => ({ auth: { exchangeCodeForSession: exchange } }), + createClient: () => ({ auth: { exchangeCodeForSession: exchange, signInWithOAuth: oauth } }), })); beforeEach(() => { vi.resetModules(); exchange.mockClear(); + oauth.mockClear(); vi.stubEnv("VITE_SUPABASE_URL", "https://auth.example.test"); vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_KEY", "test-key"); }); -afterEach(() => vi.unstubAllEnvs()); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); it("exchanges a callback once when live and pending delivery overlap", async () => { const { completeAuthCallback } = await import("./recordlyAuth"); @@ -29,3 +39,17 @@ it("shows provider errors without attempting a code exchange", async () => { ).rejects.toThrow("Sign-in cancelled"); expect(exchange).not.toHaveBeenCalled(); }); + +it("requests Microsoft's email scope and opens its OAuth URL externally", async () => { + const openExternalUrl = vi.fn(async () => ({ success: true })); + vi.stubGlobal("window", { electronAPI: { openExternalUrl } }); + const { signInWithSocial } = await import("./recordlyAuth"); + await signInWithSocial("azure"); + expect(oauth).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "azure", + options: expect.objectContaining({ scopes: "email", skipBrowserRedirect: true }), + }), + ); + expect(openExternalUrl).toHaveBeenCalledExactlyOnceWith("https://auth.example.test/oauth"); +}); diff --git a/src/lib/auth/recordlyAuth.ts b/src/lib/auth/recordlyAuth.ts index 38195fbe..38c16fa9 100644 --- a/src/lib/auth/recordlyAuth.ts +++ b/src/lib/auth/recordlyAuth.ts @@ -1,3 +1,4 @@ +import { demoLoginEnabled, demoUser, hasDemoSession, setDemoSession } from "./demoSession"; import { createClient, type Provider, type User } from "@supabase/supabase-js"; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim(); @@ -29,6 +30,11 @@ function requireAuth() { } export async function signInWithEmail(email: string, password: string): Promise { + if (demoLoginEnabled && email.toLowerCase() === "test@email.com") { + if (password !== "1234") throw new Error("Incorrect email or password."); + setDemoSession(true); + return demoUser; + } const client = requireAuth(); const { data, error } = await client.auth.signInWithPassword({ email, password }); if (error) throw error; @@ -48,11 +54,15 @@ async function openAuthUrl(url: string | null) { if (!result.success) throw new Error(result.error || "Could not open the sign-in page."); } -export async function signInWithSocial(provider: "google" | "twitter"): Promise { +export async function signInWithSocial(provider: "google" | "azure"): Promise { const client = requireAuth(); const { data, error } = await client.auth.signInWithOAuth({ provider: provider as Provider, - options: { redirectTo: callbackUrl, skipBrowserRedirect: true }, + options: { + redirectTo: callbackUrl, + skipBrowserRedirect: true, + scopes: provider === "azure" ? "email" : undefined, + }, }); if (error) throw error; await openAuthUrl(data.url); @@ -82,6 +92,10 @@ async function exchangeAuthCallback(url: string): Promise { } export async function signOutRecordly(): Promise { + if (hasDemoSession()) { + setDemoSession(false); + return; + } const client = requireAuth(); const { error } = await client.auth.signOut(); if (error) throw error; diff --git a/tests/ui/login.spec.ts b/tests/ui/login.spec.ts new file mode 100644 index 00000000..c2062847 --- /dev/null +++ b/tests/ui/login.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { installDesktopBridge } from "./bridge"; + +test("split login expands email, rejects incorrect credentials, and persists the local account until sign-out", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.goto("/?windowType=editor"); + await page.getByRole("button", { name: "Home", exact: true }).click(); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + const login = page.getByRole("dialog", { name: "Welcome back", exact: true }); + await expect(login.getByRole("button", { name: "Google", exact: true })).toBeVisible(); + await expect(login.getByRole("button", { name: "Microsoft", exact: true })).toBeVisible(); + await expect(login.getByLabel("Password", { exact: true })).toHaveCount(0); + await page.screenshot({ path: "test-results/login.png", animations: "disabled" }); + await login.getByLabel("Email", { exact: true }).fill("test@email.com"); + await login.getByLabel("Password", { exact: true }).fill("incorrect"); + await login.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(login.getByText("Incorrect email or password.")).toBeVisible(); + await login.getByLabel("Password", { exact: true }).fill("1234"); + await page.screenshot({ path: "test-results/login-expanded.png", animations: "disabled" }); + await page.setViewportSize({ width: 800, height: 600 }); + await expect(login.getByRole("button", { name: "Sign in", exact: true })).toBeInViewport(); + await login.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(login).not.toBeVisible(); + await page.reload(); + await page.getByRole("button", { name: "Home", exact: true }).click(); + await page.getByRole("button", { name: "test@email.com", exact: true }).click(); + const account = page.getByRole("dialog", { name: "Your account", exact: true }); + await expect(account.getByText("test@email.com")).toBeVisible(); + await account.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Welcome back" })).toBeVisible(); +}); diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index 21454998..28a54185 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -327,7 +327,7 @@ test("dashboard supports creation sort, independent folders, shared settings and }); }); await page.goto("/?windowType=editor"); - await page.getByRole("radio", { name: "Clips", exact: true }).click(); + await page.getByRole("button", { name: "Clips", exact: true }).click(); await expect(page.getByRole("complementary", { name: "Clips" })).toBeVisible(); await page.getByRole("button", { name: "Home", exact: true }).click(); const home = page.getByRole("dialog", { name: "Projects dashboard" }); @@ -390,6 +390,16 @@ test("dashboard supports creation sort, independent folders, shared settings and await expect(home.getByText("Preview update UI", { exact: true })).toBeVisible(); await home.getByRole("row", { name: "Motion", exact: true }).click(); await expect(home.getByRole("switch", { name: "Connect Zooms" })).toBeVisible(); + const motion = home.getByRole("region", { name: "Motion settings", exact: true }); + const label = await motion.getByText("Connect Zooms", { exact: true }).boundingBox(); + const description = await motion + .getByText("Smooth consecutive zoom regions into a continuous camera move.", { + exact: true, + }) + .boundingBox(); + expect(description!.y - (label!.y + label!.height)).toBeGreaterThanOrEqual(3); + expect(description!.x).toBe(label!.x); + await page.screenshot({ path: "test-results/settings-motion.png", animations: "disabled" }); await home.getByRole("row", { name: "Recording", exact: true }).click(); await expect(home.getByText("Recordings folder", { exact: true })).toBeVisible(); await home.getByRole("button", { name: "Change folder" }).click(); @@ -435,16 +445,17 @@ test("Solar navigation selection, circular initials, and Raw sources are consist }); await page.goto("/?windowType=editor"); const scene = page.getByRole("radio", { name: "Scene", exact: true }); - const videos = page.getByRole("radio", { name: "Clips", exact: true }); + const videos = page.getByRole("button", { name: "Clips", exact: true }); + await expect(page.getByRole("radio", { name: "Clips", exact: true })).toHaveCount(0); await expect(scene).toBeChecked(); await expect(scene.locator("svg")).toHaveAttribute("data-icon-style", "bold"); await videos.click(); - await expect(videos).toBeChecked(); + await expect(videos).toHaveAttribute("aria-expanded", "true"); await expect(videos.locator("svg")).toHaveAttribute("data-icon-style", "bold"); await expect(scene.locator("svg")).toHaveAttribute("data-icon-style", "linear"); await scene.click(); await expect(scene).toBeChecked(); - await expect(videos).not.toBeChecked(); + await expect(videos).toHaveAttribute("aria-expanded", "false"); const homeButton = page.getByRole("button", { name: "Home", exact: true }); await expect(homeButton.locator("svg")).toHaveAttribute("data-icon-style", "bold"); await homeButton.click(); @@ -678,12 +689,8 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl const home = page.getByRole("dialog", { name: "Projects dashboard" }); const sidebar = home.getByRole("complementary", { name: "Library navigation" }); await expect(sidebar.getByRole("button", { name: "Import", exact: true })).toHaveCount(0); - const banner = sidebar.getByRole("img", { name: "Placeholder banner" }); - await expect - .poll(() => banner.evaluate((image: HTMLImageElement) => image.naturalWidth)) - .toBeGreaterThan(0); + await expect(sidebar.getByRole("img", { name: "Placeholder banner" })).toHaveCount(0); const settings = sidebar.getByRole("button", { name: "Settings", exact: true }); - expect((await banner.boundingBox())!.y).toBeLessThan((await settings.boundingBox())!.y); const importButton = home.getByRole("button", { name: "Import", exact: true }); const all = home.getByRole("button", { name: "All", exact: true }); expect( diff --git a/tests/ui/videos-library.spec.ts b/tests/ui/videos-library.spec.ts index 9829a64d..20d3a72c 100644 --- a/tests/ui/videos-library.spec.ts +++ b/tests/ui/videos-library.spec.ts @@ -75,8 +75,8 @@ test("Clips supports selection, remove all, undo and real timeline drag insertio .toBeGreaterThan(0); await expect(panel.getByText("first.mp4", { exact: true })).toBeVisible(); const videosButton = page.getByRole("button", { name: "Clips", exact: true }); - expect((await videosButton.boundingBox())!.x).toBeLessThan( - (await page.getByRole("button", { name: "Home", exact: true }).boundingBox())!.x, + expect((await videosButton.boundingBox())!.x).toBeGreaterThan( + (await page.getByRole("button", { name: "Rename project", exact: true }).boundingBox())!.x, ); expect((await panel.boundingBox())!.x).toBeLessThan(100); await expect(videosButton).toHaveClass(/button--secondary/);