Add split login screen and local demo account, refine settings and navigation
|
After Width: | Height: | Size: 806 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 39 KiB |
@@ -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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<Modal isOpen={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container size="sm" placement="center">
|
||||
<Modal.Dialog>
|
||||
<Modal.CloseTrigger aria-label={t("common.actions.close")} />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>
|
||||
{user
|
||||
? t("editor.cloud.accountHeading")
|
||||
: t("editor.cloud.signInHeading")}
|
||||
</Modal.Heading>
|
||||
<Description>
|
||||
{user
|
||||
? user.email
|
||||
: reason === "share"
|
||||
? t("editor.cloud.signInShareDescription")
|
||||
: t("editor.cloud.signInDescription")}
|
||||
</Description>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-4">
|
||||
{user ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={Boolean(busy)}
|
||||
onPress={() => void run("signout", signOutRecordly)}
|
||||
>
|
||||
<SignOut className="size-4" />
|
||||
{busy === "signout"
|
||||
? t("editor.cloud.signingOut")
|
||||
: t("editor.cloud.signOut")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
void run("google", () => signInWithSocial("google"))
|
||||
}
|
||||
<Modal.Backdrop
|
||||
className="bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#10102066, #10102066), url(${artwork})`,
|
||||
}}
|
||||
>
|
||||
<Modal.Container size="cover" placement="center" className="p-4 sm:p-8">
|
||||
<Modal.Dialog className="grid h-[min(760px,calc(100dvh-64px))] min-h-0 w-full max-w-[1120px] grid-cols-1 gap-0 overflow-hidden rounded-[32px] p-2 md:grid-cols-2">
|
||||
<Modal.CloseTrigger
|
||||
aria-label={t("common.actions.close")}
|
||||
className="z-20"
|
||||
/>
|
||||
<div className="flex min-h-0 items-center justify-center overflow-y-auto px-6 py-10 sm:px-10">
|
||||
<div className="my-auto w-full max-w-[340px] space-y-7">
|
||||
<Modal.Header className="items-center text-center">
|
||||
<Modal.Heading className="text-4xl font-semibold tracking-tight">
|
||||
{user ? "Your account" : "Welcome back"}
|
||||
</Modal.Heading>
|
||||
<Description className="text-sm">
|
||||
{user
|
||||
? user.email
|
||||
: reason === "share"
|
||||
? "Sign in to share your recordings."
|
||||
: "Sign in to your Recordly account."}
|
||||
</Description>
|
||||
</Modal.Header>
|
||||
{user ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
onPress={() => void run("signout", signOutRecordly)}
|
||||
>
|
||||
<SignOut className="size-4" />
|
||||
{busy === "signout"
|
||||
? t("editor.cloud.signingOut")
|
||||
: t("editor.cloud.signOut")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-11 w-full rounded-xl"
|
||||
isDisabled={disabled || !configured}
|
||||
onPress={() =>
|
||||
void run("google", () =>
|
||||
signInWithSocial("google"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<GoogleLogo className="size-5" />
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-11 w-full rounded-xl"
|
||||
isDisabled={disabled || !configured}
|
||||
onPress={() =>
|
||||
void run("azure", () =>
|
||||
signInWithSocial("azure"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<WindowsLogo className="size-5" />
|
||||
Microsoft
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
or continue with email
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={submitEmail}
|
||||
>
|
||||
<GoogleLogo className="size-4" />
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
void run("x", () => signInWithSocial("twitter"))
|
||||
}
|
||||
>
|
||||
<XLogo className="size-4" />X
|
||||
</Button>
|
||||
</div>
|
||||
<div className="my-1 flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
{t("editor.cloud.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Form className="flex flex-col gap-4" onSubmit={submitEmail}>
|
||||
<TextField
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
isRequired
|
||||
isDisabled={Boolean(busy)}
|
||||
>
|
||||
<Label>{t("editor.cloud.email")}</Label>
|
||||
<Input
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
<TextField
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isRequired
|
||||
isDisabled={Boolean(busy)}
|
||||
>
|
||||
<Label>{t("editor.cloud.password")}</Label>
|
||||
<Input autoComplete="current-password" />
|
||||
<FieldError />
|
||||
</TextField>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mt-2 self-end"
|
||||
isDisabled={disabled}
|
||||
onPress={forgotPassword}
|
||||
>
|
||||
{t("editor.cloud.forgotPassword")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
>
|
||||
{busy === "email"
|
||||
? t("editor.cloud.signingIn")
|
||||
: t("editor.cloud.signIn")}
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{message || callbackError ? (
|
||||
<Alert status={resetSent && !callbackError ? "success" : "danger"}>
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Description>
|
||||
{message || callbackError}
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
{!configured && !user && (
|
||||
<Modal.Footer>
|
||||
<Description role="status" className="min-w-0 flex-1">
|
||||
{t("editor.cloud.unavailable")}
|
||||
</Description>
|
||||
</Modal.Footer>
|
||||
)}
|
||||
<TextField
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(value) => {
|
||||
setEmail(value);
|
||||
setMessage(undefined);
|
||||
if (!value.trim()) setPassword("");
|
||||
}}
|
||||
isRequired
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
className="h-12 rounded-xl bg-default/50 shadow-none"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<TextField
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isRequired
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
className="h-12 rounded-xl bg-default/50 shadow-none"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mt-3 self-end"
|
||||
isDisabled={disabled}
|
||||
onPress={forgotPassword}
|
||||
>
|
||||
{t("editor.cloud.forgotPassword")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 w-full rounded-xl"
|
||||
isDisabled={
|
||||
disabled ||
|
||||
(!configured && !demoLoginEnabled)
|
||||
}
|
||||
>
|
||||
{busy === "email"
|
||||
? t("editor.cloud.signingIn")
|
||||
: "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{message || callbackError ? (
|
||||
<Alert
|
||||
status={resetSent && !callbackError ? "success" : "danger"}
|
||||
>
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Description>
|
||||
{message || callbackError}
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!configured && !demoLoginEnabled && !user && (
|
||||
<Description role="status">
|
||||
{t("editor.cloud.unavailable")}
|
||||
</Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden min-h-0 overflow-hidden rounded-[26px] md:block">
|
||||
<img
|
||||
src={artwork}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-transparent to-black/10" />
|
||||
<span className="absolute left-8 top-8 text-lg font-semibold tracking-tight text-white">
|
||||
Recordly
|
||||
</span>
|
||||
<p className="absolute bottom-10 left-8 right-8 text-4xl font-light leading-tight tracking-tight text-white">
|
||||
Make something
|
||||
<br />
|
||||
<strong className="font-semibold">worth sharing.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
|
||||
@@ -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<User | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,18 +233,26 @@ function MotionPresetCards({
|
||||
value={activePresetId ?? undefined}
|
||||
onChange={(value) => onApply(value as CursorMotionPresetId)}
|
||||
>
|
||||
<Label className="text-xs font-medium">{title}</Label>
|
||||
<Label className="text-[13px] font-medium">{title}</Label>
|
||||
{MOTION_PRESET_ORDER.map((presetId) => (
|
||||
<Radio key={presetId} value={presetId}>
|
||||
<Radio
|
||||
key={presetId}
|
||||
value={presetId}
|
||||
className="rounded-xl border border-separator p-3"
|
||||
>
|
||||
<Radio.Content>
|
||||
<Radio.Control>
|
||||
<Radio.Indicator />
|
||||
</Radio.Control>
|
||||
<Label>{tSettings(`effects.motionPresets.${presetId}.label`)}</Label>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Label className="text-[13px] font-medium">
|
||||
{tSettings(`effects.motionPresets.${presetId}.label`)}
|
||||
</Label>
|
||||
<Description className="text-xs leading-relaxed">
|
||||
{tSettings(`effects.motionPresets.${presetId}.description`)}
|
||||
</Description>
|
||||
</div>
|
||||
</Radio.Content>
|
||||
<Description>
|
||||
{tSettings(`effects.motionPresets.${presetId}.description`)}
|
||||
</Description>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
@@ -2123,7 +2131,7 @@ export function SettingsPanel({
|
||||
{whisperModelDownloadStatus === "downloading" ? (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-foreground/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#2196f3] transition-all"
|
||||
className="h-full rounded-full bg-accent transition-all"
|
||||
style={{ width: `${whisperModelDownloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -2312,7 +2320,7 @@ export function SettingsPanel({
|
||||
)}
|
||||
</SettingsCategory>
|
||||
<SettingsCategory category="motion">
|
||||
<section className="flex flex-col gap-3">
|
||||
<section className="flex flex-col gap-6">
|
||||
<SettingsRow
|
||||
title={tSettings(
|
||||
"effects.autoApplyFreshRecordingZooms",
|
||||
|
||||
@@ -17,7 +17,7 @@ export function SettingsRow({
|
||||
<section
|
||||
className={stacked ? "flex flex-col gap-3" : "flex items-center justify-between gap-4"}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Label className="text-[13px] font-medium">{title}</Label>
|
||||
{description && (
|
||||
<Description className="text-xs leading-relaxed">{description}</Description>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-2 h-9 shrink-0 gap-2"
|
||||
aria-expanded={props.clipsOpen}
|
||||
onClick={props.onToggleClips}
|
||||
>
|
||||
<FilmStrip weight={props.clipsOpen ? "fill" : "regular"} className="size-4" />
|
||||
Clips
|
||||
</Button>
|
||||
<Button
|
||||
ref={projectBrowserTriggerRef}
|
||||
type="button"
|
||||
@@ -139,7 +130,7 @@ export function EditorHeader(props: Props) {
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="editor-header-title flex min-w-0 flex-1 items-center"
|
||||
className="editor-header-title flex min-w-0 items-center"
|
||||
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
|
||||
>
|
||||
{isEditingProjectName ? (
|
||||
@@ -193,6 +184,17 @@ export function EditorHeader(props: Props) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator orientation="vertical" className="mx-3 h-5 shrink-0" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 shrink-0 gap-2"
|
||||
aria-expanded={props.clipsOpen}
|
||||
onClick={props.onToggleClips}
|
||||
>
|
||||
<FilmStrip weight={props.clipsOpen ? "fill" : "regular"} className="size-4" />
|
||||
Clips
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -326,7 +326,6 @@ export function EditorShell(props: Props) {
|
||||
<div className="relative z-10 flex min-h-0 flex-1 pt-3">
|
||||
<EditorSidebar
|
||||
accountUser={auth.user}
|
||||
onToggleVideos={() => library.setOpen((open) => !open)}
|
||||
onAccountClick={() => requestSignIn("account")}
|
||||
panelContent={
|
||||
library.open ? <RecordingLibraryPanel library={library} /> : undefined
|
||||
|
||||
@@ -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<typeof useI18n>["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);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<ToggleButton id="videos" variant="ghost" isIconOnly aria-label="Clips">
|
||||
<File weight={panelContent ? "fill" : "regular"} className="size-5" />
|
||||
</ToggleButton>
|
||||
<Tooltip.Content placement="right">Clips</Tooltip.Content>
|
||||
</Tooltip>
|
||||
{sections.map((section) => (
|
||||
<Tooltip key={section.id}>
|
||||
<ToggleButton
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
const values = new Map<string, string>();
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<User> {
|
||||
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<void> {
|
||||
export async function signInWithSocial(provider: "google" | "azure"): Promise<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
export async function signOutRecordly(): Promise<void> {
|
||||
if (hasDemoSession()) {
|
||||
setDemoSession(false);
|
||||
return;
|
||||
}
|
||||
const client = requireAuth();
|
||||
const { error } = await client.auth.signOut();
|
||||
if (error) throw error;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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/);
|
||||
|
||||