From 04130c0fe2fdcf44add63d60c7909e74f722082e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 18:25:47 -0700 Subject: [PATCH 01/28] feat: local dev without third-party credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh clone can now boot the full dev stack (web + api + desktop + electric + electric-proxy + caddy) against a local Postgres in Docker, with no Neon, OAuth, Stripe, Resend, or other cloud-service keys required. Key changes: - packages/db/src/client.ts: driver swap to drizzle-orm/node-postgres for non-Neon DATABASE_URLs (@neondatabase/serverless only speaks Neon's HTTP/WS protocol, not vanilla Postgres TCP). - packages/auth/src/server.ts: emailAndPassword auth enabled + autoSignIn. - apps/desktop/src/main/lib/dev-auto-sign-in.ts: during app.whenReady(), if SKIP_ENV_VALIDATION=1 and no token on disk, auto-signs-in as admin@local.test and persists the token via the existing saveToken(). Renderer hydrates like a real OAuth user — no special renderer code. - 17 renderer files: flip MOCK_ORG_ID priority — prefer real session's activeOrganizationId, fall back to MOCK_ORG_ID only without a session. Fixes the "Host service not available" toast that fired because the renderer was looking up host-service connections by the fake org id while host-service spawned for the real admin@local.test org. - Lazy-init guards for Stripe (Proxy), Resend (Proxy), and PostHog (no-op surface) so they don't crash module load when keys are missing. - apps/web: dev-only email/password form on sign-in/sign-up, gated on NODE_ENV !== "production". - packages/db: bun db:seed:dev script to create admin@local.test; refuses prod + non-localhost DATABASE_URL. - turbo.jsonc: SKIP_ENV_VALIDATION added to globalPassThroughEnv. - docs/LOCAL_DEVELOPMENT.md: contributor-facing setup guide. - plans/20260515-oss-local-setup.md: full record of what shipped + what's deferred (env vars .optional(), docker-compose.dev.yml, bun setup orchestrator, CI fresh-clone smoke test). Verified end-to-end via Chrome DevTools Protocol (port 9333) — the renderer's LocalHostServiceContext.activeHostUrl resolves to a live host-service URL, so the import gate is open. --- README.md | 49 ++---- apps/api/src/lib/analytics.ts | 33 +++- apps/desktop/src/main/index.ts | 13 ++ apps/desktop/src/main/lib/dev-auto-sign-in.ts | 96 +++++++++++ .../OpenInWorkspaceV2/OpenInWorkspaceV2.tsx | 6 +- .../RunInWorkspacePopoverV2.tsx | 6 +- .../RunIssuesInWorkspacePopover.tsx | 6 +- .../useAccessibleV2Workspaces.ts | 6 +- .../useWorkspaceHostOptions.ts | 6 +- .../DashboardNewWorkspaceModalContent.tsx | 6 +- .../V1ImportModal/V1ImportModal.tsx | 6 +- .../renderer/routes/_authenticated/layout.tsx | 6 +- .../CollectionsProvider.tsx | 6 +- .../LocalHostServiceProvider.tsx | 6 +- .../HostsSettingsSidebar.tsx | 6 +- .../_authenticated/settings/hosts/page.tsx | 6 +- .../settings/projects/$projectId/page.tsx | 6 +- .../ProjectsSettingsSidebar.tsx | 6 +- .../_authenticated/settings/projects/page.tsx | 6 +- .../_authenticated/setup/project/page.tsx | 6 +- .../src/renderer/routes/sign-in/page.tsx | 4 +- .../components/DevAuthForm/DevAuthForm.tsx | 103 ++++++++++++ .../(auth)/components/DevAuthForm/index.ts | 1 + .../(auth)/sign-in/[[...sign-in]]/page.tsx | 4 + .../(auth)/sign-up/[[...sign-up]]/page.tsx | 6 + bun.lock | 6 +- docs/LOCAL_DEVELOPMENT.md | 154 ++++++++++++++++++ package.json | 1 + packages/auth/src/lib/resend.ts | 54 +++++- packages/auth/src/server.ts | 4 + packages/auth/src/stripe.ts | 16 +- packages/db/package.json | 3 + packages/db/src/client.ts | 48 ++++-- packages/db/src/seed-dev.ts | 73 +++++++++ packages/trpc/src/lib/analytics.ts | 32 +++- packages/trpc/src/router/support/support.ts | 19 ++- plans/20260515-oss-local-setup.md | 119 ++++++++++++++ turbo.jsonc | 3 +- 38 files changed, 822 insertions(+), 115 deletions(-) create mode 100644 apps/desktop/src/main/lib/dev-auto-sign-in.ts create mode 100644 apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx create mode 100644 apps/web/src/app/(auth)/components/DevAuthForm/index.ts create mode 100644 docs/LOCAL_DEVELOPMENT.md create mode 100644 packages/db/src/seed-dev.ts create mode 100644 plans/20260515-oss-local-setup.md diff --git a/README.md b/README.md index e1d935232ec..e2758e59ecf 100644 --- a/README.md +++ b/README.md @@ -85,57 +85,32 @@ If it runs in a terminal, it runs on Superset ### Build from Source -
-Click to expand build instructions +For a complete contributor workflow that boots a fresh clone with no third-party credentials (Neon / OAuth / Stripe / Resend keys are all optional), follow **[Local Development](docs/LOCAL_DEVELOPMENT.md)**. -**1. Clone the repository** +Short version: ```bash git clone https://github.com/superset-sh/superset.git cd superset -``` - -**2. Set up environment variables** (choose one): - -Option A: Full setup -```bash -cp .env.example .env -# Edit .env and fill in the values -``` - -Option B: Skip env validation (for quick local testing) -```bash -cp .env.example .env -echo 'SKIP_ENV_VALIDATION=1' >> .env -``` - -**3. Set up Caddy** (reverse proxy for Electric SQL streams): - -```bash -# Install caddy: brew install caddy (macOS) or see https://caddyserver.com/docs/install -cp Caddyfile.example Caddyfile - -# Without this, Chromium rejects https://localhost:* with ERR_CERT_AUTHORITY_INVALID. -# Prompts for sudo once. -caddy trust -``` - -**4. Install dependencies and run** - -```bash bun install -bun run dev +docker run -d --name superset-pg \ + -e POSTGRES_USER=superset -e POSTGRES_PASSWORD=superset -e POSTGRES_DB=superset \ + -p 5433:5432 postgres:16 -c wal_level=logical +cp .env.example .env # then edit DATABASE_URL + BETTER_AUTH_SECRET +bun run db:migrate +cp Caddyfile.example Caddyfile && caddy trust +SKIP_ENV_VALIDATION=1 bun dev ``` -**5. Build the desktop app** +The desktop window opens auto-signed-in as a seed admin (`admin@local.test`). See [Local Development](docs/LOCAL_DEVELOPMENT.md) for details, troubleshooting, and what's stubbed without integration keys. + +To build a distributable desktop app: ```bash bun run build open apps/desktop/release ``` -
- ## Keyboard Shortcuts All shortcuts are customizable via **Settings > Keyboard Shortcuts** (`⌘/`). See [full documentation](https://docs.superset.sh/keyboard-shortcuts). diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts index aa8d66fce76..8f5536ff5d3 100644 --- a/apps/api/src/lib/analytics.ts +++ b/apps/api/src/lib/analytics.ts @@ -1,8 +1,33 @@ import { PostHog } from "posthog-node"; import { env } from "@/env"; -export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { - host: env.NEXT_PUBLIC_POSTHOG_HOST, - flushAt: 1, - flushInterval: 0, +let client: PostHog | null = null; + +// Disabled stub — accepts any args, returns the right shape for each method. +const disabled = { + capture: (..._args: unknown[]) => {}, + identify: (..._args: unknown[]) => {}, + alias: (..._args: unknown[]) => {}, + groupIdentify: (..._args: unknown[]) => {}, + shutdown: (..._args: unknown[]) => Promise.resolve(), + flush: (..._args: unknown[]) => Promise.resolve(), + getFeatureFlag: (..._args: unknown[]) => Promise.resolve(undefined), + getFeatureFlagPayload: (..._args: unknown[]) => Promise.resolve(undefined), + isFeatureEnabled: (..._args: unknown[]) => Promise.resolve(undefined), +} as unknown as PostHog; + +// Lazy-init: if NEXT_PUBLIC_POSTHOG_KEY is missing, return a no-op surface +// so analytics calls don't crash the API at module load. +export const posthog = new Proxy({} as PostHog, { + get(_target, prop) { + if (!env.NEXT_PUBLIC_POSTHOG_KEY) return Reflect.get(disabled, prop); + if (!client) { + client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { + host: env.NEXT_PUBLIC_POSTHOG_HOST, + flushAt: 1, + flushInterval: 0, + }); + } + return Reflect.get(client, prop); + }, }); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 03879a6d9ea..eca914a7d3b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -28,6 +28,7 @@ import { initAppState } from "./lib/app-state"; import { requestAppleEventsAccess } from "./lib/apple-events-permission"; import { setupAutoUpdater } from "./lib/auto-updater"; import { installBundledCliShim } from "./lib/bundled-cli"; +import { ensureDevAuthToken } from "./lib/dev-auto-sign-in"; import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; @@ -55,6 +56,12 @@ import { MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; +// Dev: expose Chrome DevTools Protocol for headless testing (e.g. import/host-service checks) +if (IS_DEV && process.env.SKIP_ENV_VALIDATION) { + app.commandLine.appendSwitch("remote-debugging-port", "9333"); + app.commandLine.appendSwitch("remote-allow-origins", "*"); +} + void applyShellEnvToProcess().catch((error) => { console.error("[main] Failed to apply shell environment:", error); }); @@ -416,6 +423,12 @@ if (!gotTheLock) { console.error("[main] Failed to install bundled CLI shim:", error); } + // Dev-only: auto-sign-in as the seed admin if SKIP_ENV_VALIDATION is + // set and no token is on disk. Lets fresh-clone contributors boot the + // desktop without OAuth. Runs before host-service discovery so the + // renderer's AuthProvider hydrates with a valid token on first paint. + await ensureDevAuthToken(); + // Discover and adopt host-services that survived a previous quit // before the tray initializes, so it shows accurate status immediately. await getHostServiceCoordinator().discoverAll(); diff --git a/apps/desktop/src/main/lib/dev-auto-sign-in.ts b/apps/desktop/src/main/lib/dev-auto-sign-in.ts new file mode 100644 index 00000000000..e5f1f5a0650 --- /dev/null +++ b/apps/desktop/src/main/lib/dev-auto-sign-in.ts @@ -0,0 +1,96 @@ +import { env as mainEnv } from "main/env.main"; +import { + loadToken, + saveToken, +} from "../../lib/trpc/routers/auth/utils/auth-functions"; + +const DEV_EMAIL = "admin@local.test"; +const DEV_PASSWORD = "supersetdev"; +const DEV_NAME = "Local Admin"; +const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; + +interface SignInResponse { + token?: string; + user?: { id: string }; +} + +interface AuthErrorBody { + code?: string; + message?: string; +} + +async function postAuth( + path: string, + body: Record, +): Promise<{ ok: boolean; status: number; data: T | AuthErrorBody }> { + const res = await fetch(`${mainEnv.NEXT_PUBLIC_API_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: mainEnv.NEXT_PUBLIC_API_URL, + }, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as T | AuthErrorBody; + return { ok: res.ok, status: res.status, data }; +} + +/** + * Dev-only: if SKIP_ENV_VALIDATION is set and no usable token is on disk, + * sign in (or sign up) as the seed admin user and persist the token so + * the renderer's AuthProvider can hydrate normally — no special renderer code. + * Best-effort: failure is logged but doesn't crash boot. + */ +export async function ensureDevAuthToken(): Promise { + if ( + process.env.NODE_ENV !== "development" || + !process.env.SKIP_ENV_VALIDATION + ) + return; + + const stored = await loadToken(); + if (stored.token && stored.expiresAt) { + const isExpired = new Date(stored.expiresAt) < new Date(); + if (!isExpired) return; + } + + try { + let signIn = await postAuth("/api/auth/sign-in/email", { + email: DEV_EMAIL, + password: DEV_PASSWORD, + }); + + const errBody = signIn.data as AuthErrorBody; + if (!signIn.ok && errBody.code === "INVALID_EMAIL_OR_PASSWORD") { + const signUp = await postAuth("/api/auth/sign-up/email", { + email: DEV_EMAIL, + password: DEV_PASSWORD, + name: DEV_NAME, + }); + if (!signUp.ok) { + const e = signUp.data as AuthErrorBody; + throw new Error(`dev sign-up failed (${signUp.status}): ${e.message}`); + } + signIn = await postAuth("/api/auth/sign-in/email", { + email: DEV_EMAIL, + password: DEV_PASSWORD, + }); + } + + if (!signIn.ok) { + const e = signIn.data as AuthErrorBody; + throw new Error(`dev sign-in failed (${signIn.status}): ${e.message}`); + } + const token = (signIn.data as SignInResponse).token; + if (!token) throw new Error("dev sign-in: no token in response"); + + const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString(); + await saveToken({ token, expiresAt }); + console.log(`[dev-auto-sign-in] signed in as ${DEV_EMAIL}`); + } catch (err) { + console.warn( + `[dev-auto-sign-in] failed (is the API up at ${mainEnv.NEXT_PUBLIC_API_URL}?):`, + err, + ); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx index d853bf926d9..8b8b74465b2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx @@ -54,9 +54,9 @@ export function OpenInWorkspaceV2({ task }: OpenInWorkspaceV2Props) { const { machineId, activeHostUrl } = useLocalHostService(); const { otherHosts } = useWorkspaceHostOptions(); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { submit } = useWorkspaceCreates(); const lastProjectId = useV2WorkspaceCreateDefaultsStore( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx index 6759919c96a..75843cbaf59 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx @@ -59,9 +59,9 @@ export function RunInWorkspacePopoverV2({ const collections = useCollections(); const { machineId, activeHostUrl } = useLocalHostService(); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { otherHosts } = useWorkspaceHostOptions(); const { submit } = useWorkspaceCreates(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx index a05788c30f9..0c877cfed52 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx @@ -63,9 +63,9 @@ export function RunIssuesInWorkspacePopover({ const collections = useCollections(); const { machineId, activeHostUrl } = useLocalHostService(); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { otherHosts } = useWorkspaceHostOptions(); const { submit } = useWorkspaceCreates(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts index eb904571c63..6a95ea646b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts @@ -221,9 +221,9 @@ export function useAccessibleV2Workspaces( const collections = useCollections(); const { machineId } = useLocalHostService(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const currentUserId = session?.user?.id ?? null; const { data: rows = [] } = useLiveQuery( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts index b96c7534db5..6014b9ee911 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts @@ -26,9 +26,9 @@ export function useWorkspaceHostOptions(): UseWorkspaceHostOptionsResult { const collections = useCollections(); const { machineId, activeHostUrl } = useLocalHostService(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const currentUserId = session?.user?.id ?? null; const { data: accessibleHosts = [] } = useLiveQuery( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx index da8399adba2..da6b96ba19d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx @@ -32,9 +32,9 @@ export function DashboardNewWorkspaceModalContent({ ); const collections = useCollections(); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: v2Projects } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx index bd292fd0e90..8b127423c10 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx @@ -27,9 +27,9 @@ export function V1ImportModal() { const close = useCloseV1ImportModal(); const { data: session } = authClient.useSession(); const { activeHostUrl } = useLocalHostService(); - const organizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const organizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); if (!organizationId) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index dbc28401f70..4717948ec01 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -62,9 +62,9 @@ function AuthenticatedLayout() { const isV2CloudEnabled = useIsV2CloudEnabled(); const isSignedIn = env.SKIP_ENV_VALIDATION || !!session?.user; - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : session?.session?.activeOrganizationId; + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); useAgentHookListener(); useUpdateListener(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index f5562295086..518fb6b4495 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -33,9 +33,9 @@ export function preloadActiveOrganizationCollections( export function CollectionsProvider({ children }: { children: ReactNode }) { const { data: session, refetch: refetchSession } = authClient.useSession(); const [isSwitching, setIsSwitching] = useState(false); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : session?.session?.activeOrganizationId; + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const switchOrganization = useCallback( async (organizationId: string) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx index 5ef6fdfd5cc..75276559f9d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx @@ -31,9 +31,9 @@ export function LocalHostServiceProvider({ const { mutate: startHostService } = electronTrpc.hostServiceCoordinator.start.useMutation(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: organizations } = useLiveQuery( (q) => q.from({ organizations: collections.organizations }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx index 0b4567a52e4..5a0c0454b7d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx @@ -30,9 +30,9 @@ export function HostsSettingsSidebar({ const collections = useCollections(); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: hosts = [] } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx index 643c8e8adbd..3c50905ddd5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx @@ -16,9 +16,9 @@ function HostsIndexPage() { const { data: session } = authClient.useSession(); const navigate = useNavigate(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: hosts = [] } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx index c93c0d2e7ad..7f11dda2af9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx @@ -25,9 +25,9 @@ function ProjectDetailPage() { const { data: session } = authClient.useSession(); const searchQuery = useSettingsSearchQuery(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: v2Match = [] } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx index ce7d751ecc4..bedd6a8612b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx @@ -31,9 +31,9 @@ export function ProjectsSettingsSidebar({ const collections = useCollections(); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: groups = [] } = electronTrpc.workspaces.getAllGrouped.useQuery(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx index 5db0608732d..60605248b49 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx @@ -17,9 +17,9 @@ function ProjectsIndexPage() { const { data: session } = authClient.useSession(); const navigate = useNavigate(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: groups = [] } = electronTrpc.workspaces.getAllGrouped.useQuery(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx index fe5b99a03f3..10626d1ce1f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx @@ -26,9 +26,9 @@ function OnboardingProjectPage() { const markSkipped = useOnboardingStore((s) => s.markSkipped); const { data: session } = authClient.useSession(); - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); + const activeOrganizationId = + session?.session?.activeOrganizationId ?? + (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); const { data: projects = [], isLoading } = useQuery({ queryKey: ["onboarding", "v2Projects", activeOrganizationId], diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index ddfc693a961..aa08c51ad5d 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -18,8 +18,8 @@ function SignInPage() { const signInMutation = electronTrpc.auth.signIn.useMutation(); const { hasLocalToken, isPending, session } = useSessionRecovery(); - // Dev bypass: skip sign-in entirely - if (env.SKIP_ENV_VALIDATION) { + // Dev bypass: AuthProvider handles auto-sign-in; if session lands, redirect + if (env.SKIP_ENV_VALIDATION && session?.user) { return ; } diff --git a/apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx b/apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx new file mode 100644 index 00000000000..d07f9a35f6a --- /dev/null +++ b/apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { authClient } from "@superset/auth/client"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +interface DevAuthFormProps { + mode: "sign-in" | "sign-up"; + callbackURL: string; +} + +export function DevAuthForm({ mode, callbackURL }: DevAuthFormProps) { + const router = useRouter(); + const [email, setEmail] = useState( + mode === "sign-up" ? "" : "admin@local.test", + ); + const [password, setPassword] = useState( + mode === "sign-up" ? "" : "supersetdev", + ); + const [name, setName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + + try { + if (mode === "sign-up") { + const res = await authClient.signUp.email({ + email, + password, + name: name || email.split("@")[0] || "Dev User", + }); + if (res.error) throw new Error(res.error.message); + } else { + const res = await authClient.signIn.email({ email, password }); + if (res.error) throw new Error(res.error.message); + } + router.push(callbackURL); + } catch (err) { + const message = + err instanceof Error ? err.message : "Authentication failed"; + setError(message); + setSubmitting(false); + } + }; + + return ( +
+

+ Dev only — email + password auth +

+ {mode === "sign-up" && ( +
+ + setName(e.target.value)} + placeholder="Optional" + /> +
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+ ); +} diff --git a/apps/web/src/app/(auth)/components/DevAuthForm/index.ts b/apps/web/src/app/(auth)/components/DevAuthForm/index.ts new file mode 100644 index 00000000000..eddbcfa938b --- /dev/null +++ b/apps/web/src/app/(auth)/components/DevAuthForm/index.ts @@ -0,0 +1 @@ +export { DevAuthForm } from "./DevAuthForm"; diff --git a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx index b27142e33f0..63049a01b27 100644 --- a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -8,6 +8,9 @@ import { useState } from "react"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; import { env } from "@/env"; +import { DevAuthForm } from "../../components/DevAuthForm"; + +const isDev = process.env.NODE_ENV !== "production"; export default function SignInPage() { const searchParams = useSearchParams(); @@ -66,6 +69,7 @@ export default function SignInPage() { {error && (

{error}

)} + {isDev && } + + ); +} diff --git a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts new file mode 100644 index 00000000000..9514c0436a0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts @@ -0,0 +1 @@ +export { LocalDevAuthForm } from "./LocalDevAuthForm"; diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index aa08c51ad5d..d7d7f5eb4e2 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -7,6 +7,7 @@ import { FcGoogle } from "react-icons/fc"; import { env } from "renderer/env.renderer"; import { track } from "renderer/lib/analytics"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { LocalDevAuthForm } from "./components/LocalDevAuthForm"; import { SupersetLogo } from "./components/SupersetLogo"; import { useSessionRecovery } from "./hooks/useSessionRecovery"; @@ -17,6 +18,7 @@ export const Route = createFileRoute("/sign-in/")({ function SignInPage() { const signInMutation = electronTrpc.auth.signIn.useMutation(); const { hasLocalToken, isPending, session } = useSessionRecovery(); + const isLocalProfile = env.SUPERSET_PROFILE === "local"; // Dev bypass: AuthProvider handles auto-sign-in; if session lands, redirect if (env.SKIP_ENV_VALIDATION && session?.user) { @@ -63,7 +65,11 @@ function SignInPage() {

-
+
+ {isLocalProfile && } + + {isLocalProfile &&
} + - - ); -} diff --git a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts deleted file mode 100644 index 9514c0436a0..00000000000 --- a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LocalDevAuthForm } from "./LocalDevAuthForm"; diff --git a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevSignInButton/LocalDevSignInButton.tsx b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevSignInButton/LocalDevSignInButton.tsx new file mode 100644 index 00000000000..dcb8741210d --- /dev/null +++ b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevSignInButton/LocalDevSignInButton.tsx @@ -0,0 +1,89 @@ +import { + DEV_EMAIL, + DEV_NAME, + DEV_PASSWORD, +} from "@superset/shared/dev-credentials"; +import { DevSignInButton as SharedDevSignInButton } from "@superset/ui/dev-sign-in-button"; +import { useNavigate } from "@tanstack/react-router"; +import { env } from "renderer/env.renderer"; +import { setAuthToken } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; + +interface AuthResponse { + token?: string; +} + +interface AuthErrorBody { + code?: string; + message?: string; +} + +async function postAuth( + path: string, + body: Record, +): Promise<{ + ok: boolean; + status: number; + data: AuthResponse | AuthErrorBody; +}> { + const res = await fetch(`${env.NEXT_PUBLIC_API_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "omit", + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as + | AuthResponse + | AuthErrorBody; + return { ok: res.ok, status: res.status, data }; +} + +export function LocalDevSignInButton() { + const navigate = useNavigate(); + const persistToken = electronTrpc.auth.persistToken.useMutation(); + + const onSignIn = async () => { + let signIn = await postAuth("/api/auth/sign-in/email", { + email: DEV_EMAIL, + password: DEV_PASSWORD, + }); + + const errBody = signIn.data as AuthErrorBody; + if (!signIn.ok && errBody.code === "INVALID_EMAIL_OR_PASSWORD") { + const signUp = await postAuth("/api/auth/sign-up/email", { + email: DEV_EMAIL, + password: DEV_PASSWORD, + name: DEV_NAME, + }); + if (!signUp.ok) { + const signUpError = signUp.data as AuthErrorBody; + throw new Error( + signUpError.message ?? `Sign-up failed (${signUp.status})`, + ); + } + signIn = await postAuth("/api/auth/sign-in/email", { + email: DEV_EMAIL, + password: DEV_PASSWORD, + }); + } + + if (!signIn.ok) { + const signInError = signIn.data as AuthErrorBody; + throw new Error( + signInError.message ?? `Sign-in failed (${signIn.status})`, + ); + } + + const token = (signIn.data as AuthResponse).token; + if (!token) throw new Error("Sign-in did not return a token"); + + const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString(); + await persistToken.mutateAsync({ token, expiresAt }); + setAuthToken(token); + await navigate({ to: "/workspace", replace: true }); + }; + + return ; +} diff --git a/apps/desktop/src/renderer/routes/sign-in/components/LocalDevSignInButton/index.ts b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevSignInButton/index.ts new file mode 100644 index 00000000000..62835abcfc8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevSignInButton/index.ts @@ -0,0 +1 @@ +export { LocalDevSignInButton } from "./LocalDevSignInButton"; diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index d7d7f5eb4e2..2103e2578b5 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -7,7 +7,7 @@ import { FcGoogle } from "react-icons/fc"; import { env } from "renderer/env.renderer"; import { track } from "renderer/lib/analytics"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { LocalDevAuthForm } from "./components/LocalDevAuthForm"; +import { LocalDevSignInButton } from "./components/LocalDevSignInButton"; import { SupersetLogo } from "./components/SupersetLogo"; import { useSessionRecovery } from "./hooks/useSessionRecovery"; @@ -66,7 +66,7 @@ function SignInPage() {
- {isLocalProfile && } + {isLocalProfile && } {isLocalProfile &&
} diff --git a/apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx b/apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx deleted file mode 100644 index d07f9a35f6a..00000000000 --- a/apps/web/src/app/(auth)/components/DevAuthForm/DevAuthForm.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { authClient } from "@superset/auth/client"; -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { Label } from "@superset/ui/label"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -interface DevAuthFormProps { - mode: "sign-in" | "sign-up"; - callbackURL: string; -} - -export function DevAuthForm({ mode, callbackURL }: DevAuthFormProps) { - const router = useRouter(); - const [email, setEmail] = useState( - mode === "sign-up" ? "" : "admin@local.test", - ); - const [password, setPassword] = useState( - mode === "sign-up" ? "" : "supersetdev", - ); - const [name, setName] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSubmitting(true); - setError(null); - - try { - if (mode === "sign-up") { - const res = await authClient.signUp.email({ - email, - password, - name: name || email.split("@")[0] || "Dev User", - }); - if (res.error) throw new Error(res.error.message); - } else { - const res = await authClient.signIn.email({ email, password }); - if (res.error) throw new Error(res.error.message); - } - router.push(callbackURL); - } catch (err) { - const message = - err instanceof Error ? err.message : "Authentication failed"; - setError(message); - setSubmitting(false); - } - }; - - return ( -
-

- Dev only — email + password auth -

- {mode === "sign-up" && ( -
- - setName(e.target.value)} - placeholder="Optional" - /> -
- )} -
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - minLength={8} - /> -
- {error &&

{error}

} - -
- ); -} diff --git a/apps/web/src/app/(auth)/components/DevAuthForm/index.ts b/apps/web/src/app/(auth)/components/DevAuthForm/index.ts deleted file mode 100644 index eddbcfa938b..00000000000 --- a/apps/web/src/app/(auth)/components/DevAuthForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DevAuthForm } from "./DevAuthForm"; diff --git a/apps/web/src/app/(auth)/components/DevSignInButton/DevSignInButton.tsx b/apps/web/src/app/(auth)/components/DevSignInButton/DevSignInButton.tsx new file mode 100644 index 00000000000..2817ddce35f --- /dev/null +++ b/apps/web/src/app/(auth)/components/DevSignInButton/DevSignInButton.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { authClient } from "@superset/auth/client"; +import { + DEV_EMAIL, + DEV_NAME, + DEV_PASSWORD, +} from "@superset/shared/dev-credentials"; +import { DevSignInButton as SharedDevSignInButton } from "@superset/ui/dev-sign-in-button"; +import { useRouter } from "next/navigation"; + +interface DevSignInButtonProps { + callbackURL: string; +} + +export function DevSignInButton({ callbackURL }: DevSignInButtonProps) { + const router = useRouter(); + + const onSignIn = async () => { + let res = await authClient.signIn.email({ + email: DEV_EMAIL, + password: DEV_PASSWORD, + }); + if (res.error) { + const signUpRes = await authClient.signUp.email({ + email: DEV_EMAIL, + password: DEV_PASSWORD, + name: DEV_NAME, + }); + if (signUpRes.error) throw new Error(signUpRes.error.message); + res = await authClient.signIn.email({ + email: DEV_EMAIL, + password: DEV_PASSWORD, + }); + } + if (res.error) throw new Error(res.error.message); + router.push(callbackURL); + }; + + return ; +} diff --git a/apps/web/src/app/(auth)/components/DevSignInButton/index.ts b/apps/web/src/app/(auth)/components/DevSignInButton/index.ts new file mode 100644 index 00000000000..816b0169335 --- /dev/null +++ b/apps/web/src/app/(auth)/components/DevSignInButton/index.ts @@ -0,0 +1 @@ +export { DevSignInButton } from "./DevSignInButton"; diff --git a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 63049a01b27..75d6b41ec4d 100644 --- a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -8,7 +8,7 @@ import { useState } from "react"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; import { env } from "@/env"; -import { DevAuthForm } from "../../components/DevAuthForm"; +import { DevSignInButton } from "../../components/DevSignInButton"; const isDev = process.env.NODE_ENV !== "production"; @@ -69,7 +69,7 @@ export default function SignInPage() { {error && (

{error}

)} - {isDev && } + {isDev && } + {error &&

{error}

} +
+ ); +} From 71b11cdcd584dc8e4e18a5ac0385f3c24e9cf88f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 20 May 2026 14:31:38 -0700 Subject: [PATCH 16/28] Fix task sync test mock --- packages/trpc/src/router/task/task.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trpc/src/router/task/task.test.ts b/packages/trpc/src/router/task/task.test.ts index fc712cd395e..40f848abda7 100644 --- a/packages/trpc/src/router/task/task.test.ts +++ b/packages/trpc/src/router/task/task.test.ts @@ -3,7 +3,7 @@ import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; const getCurrentTxidMock = mock(async () => "txid-123"); const seedDefaultStatusesMock = mock(async () => "status-seeded"); -const syncTaskMock = mock(() => undefined); +const syncTaskMock = mock(async () => []); const verifyOrgAdminMock = mock(async () => ({ membership: { role: "owner" }, })); @@ -245,7 +245,7 @@ describe("task router authorization", () => { seedDefaultStatusesMock.mockImplementation(async () => "status-seeded"); syncTaskMock.mockReset(); - syncTaskMock.mockImplementation(() => undefined); + syncTaskMock.mockImplementation(async () => []); transactionMock.mockReset(); transactionMock.mockImplementation(async (callback) => From 6264cedcdbaaad3e49b1c50ec08348a9a5223512 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 20 May 2026 15:39:39 -0700 Subject: [PATCH 17/28] Fix canary app metadata --- apps/desktop/electron-builder.canary.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/electron-builder.canary.ts b/apps/desktop/electron-builder.canary.ts index 8cdac856a7b..518a563f15c 100644 --- a/apps/desktop/electron-builder.canary.ts +++ b/apps/desktop/electron-builder.canary.ts @@ -22,6 +22,9 @@ const config: Configuration = { ...baseConfig, appId: "com.superset.desktop.canary", productName, + extraMetadata: { + productName, + }, publish: { provider: "github", From 3391f458833b23d568b501a9f1abb13c2aa3c050 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 20 May 2026 15:47:13 -0700 Subject: [PATCH 18/28] Remove stale host service discovery call --- apps/desktop/src/main/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ffd26df62d4..41ddaadff4d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -434,10 +434,6 @@ if (!gotTheLock) { // block on this. void ensureDevAuthToken(); - // Discover and adopt host-services that survived a previous quit - // before the tray initializes, so it shows accurate status immediately. - await getHostServiceCoordinator().discoverAll(); - if (IS_DEV) { getHostServiceCoordinator().enableDevReload(async () => { const { token } = await loadToken(); From ed46380ac624c2f5245d2b02ada4be8a36200b34 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 20 May 2026 15:54:05 -0700 Subject: [PATCH 19/28] Allow unsigned canary desktop builds --- .github/workflows/build-desktop.yml | 17 +++-- .github/workflows/release-desktop-canary.yml | 7 ++ apps/desktop/electron-builder.ts | 2 + scripts/release-canary.sh | 69 +++++++++++++++++++- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 0bb7691abcf..8b0dfb67712 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -27,6 +27,11 @@ on: required: false type: number default: 30 + skip_signing: + description: "Build macOS artifacts without code signing or notarization" + required: false + type: boolean + default: false jobs: build-macos: @@ -112,11 +117,13 @@ jobs: - name: Build Electron app working-directory: apps/desktop env: - CSC_LINK: ${{ secrets.MAC_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CSC_LINK: ${{ inputs.skip_signing == false && secrets.MAC_CERTIFICATE || '' }} + CSC_KEY_PASSWORD: ${{ inputs.skip_signing == false && secrets.MAC_CERTIFICATE_PASSWORD || '' }} + APPLE_ID: ${{ inputs.skip_signing == false && secrets.APPLE_ID || '' }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.skip_signing == false && secrets.APPLE_ID_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.skip_signing == false && secrets.APPLE_TEAM_ID || '' }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ inputs.skip_signing && 'false' || '' }} + SKIP_CODE_SIGNING: ${{ inputs.skip_signing && 'true' || 'false' }} TARGET_ARCH: ${{ matrix.arch }} run: bun run package -- --publish never --config ${{ inputs.electron_builder_config }} --${{ matrix.arch }} diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml index 89d7ef1a5e0..091456eb5fd 100644 --- a/.github/workflows/release-desktop-canary.yml +++ b/.github/workflows/release-desktop-canary.yml @@ -11,6 +11,11 @@ on: required: false type: boolean default: false + skip_signing: + description: "Build macOS artifacts without code signing or notarization" + required: false + type: boolean + default: false permissions: contents: write @@ -77,6 +82,7 @@ jobs: electron_builder_config: electron-builder.canary.ts artifact_prefix: desktop-canary artifact_retention_days: 7 + skip_signing: ${{ github.event.inputs.skip_signing == 'true' }} secrets: inherit release: @@ -149,6 +155,7 @@ jobs: - Commit: ${{ github.sha }} - Short SHA: ${{ needs.check-changes.outputs.short_sha }} - Built: ${{ needs.check-changes.outputs.build_time }} + - Code signing: ${{ github.event.inputs.skip_signing == 'true' && 'skipped' || 'enabled' }} ⚠️ This build is for internal testing only and may be unstable. prerelease: true diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index f7712a06bf2..70f0a729226 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -18,6 +18,7 @@ const productName = pkg.productName; const macIconPath = join(pkg.resources, "build/icons/icon.icns"); const linuxIconPath = join(pkg.resources, "build/icons"); const winIconPath = join(pkg.resources, "build/icons/icon.ico"); +const skipCodeSigning = process.env.SKIP_CODE_SIGNING === "true"; const dmgBackgroundPath = join( pkg.resources, "build/installer/background.tiff", @@ -112,6 +113,7 @@ const config: Configuration = { hardenedRuntime: true, gatekeeperAssess: false, notarize: true, + ...(skipCodeSigning ? { identity: null, notarize: false } : {}), entitlements: join(pkg.resources, "build/entitlements.mac.plist"), entitlementsInherit: join( pkg.resources, diff --git a/scripts/release-canary.sh b/scripts/release-canary.sh index d0e1318c3c0..96d5ac5b6a9 100755 --- a/scripts/release-canary.sh +++ b/scripts/release-canary.sh @@ -1,10 +1,75 @@ #!/usr/bin/env bash set -euo pipefail -COMMIT="${1:-}" +usage() { + cat <<'EOF' +Usage: bun run release:canary -- [--skip-signing] [commit] + +Options: + --skip-signing Build the macOS canary artifact without code signing or notarization. + -h, --help Show this help message. + +Examples: + bun run release:canary -- HEAD + bun run release:canary -- --skip-signing HEAD +EOF +} + +COMMIT="" +SKIP_SIGNING=false REF_FLAG="" TEMP_BRANCH="" +while [ "$#" -gt 0 ]; do + case "$1" in + --skip-signing) + SKIP_SIGNING=true + ;; + --skip-signing=true) + SKIP_SIGNING=true + ;; + --skip-signing=false | --no-skip-signing) + SKIP_SIGNING=false + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + if [ "$#" -gt 0 ]; then + if [ -n "$COMMIT" ]; then + echo "Error: commit specified more than once" >&2 + usage >&2 + exit 1 + fi + COMMIT="$1" + shift + fi + if [ "$#" -gt 0 ]; then + echo "Error: unexpected arguments: $*" >&2 + usage >&2 + exit 1 + fi + break + ;; + -*) + echo "Error: unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + if [ -n "$COMMIT" ]; then + echo "Error: commit specified more than once: $COMMIT and $1" >&2 + usage >&2 + exit 1 + fi + COMMIT="$1" + ;; + esac + shift +done + if [ -n "$COMMIT" ]; then FULL_SHA=$(git rev-parse "$COMMIT") TEMP_BRANCH="canary-release-${FULL_SHA:0:9}" @@ -12,6 +77,6 @@ if [ -n "$COMMIT" ]; then REF_FLAG="--ref $TEMP_BRANCH" fi -gh workflow run release-desktop-canary.yml -f force_build=true $REF_FLAG +gh workflow run release-desktop-canary.yml -f force_build=true -f skip_signing="$SKIP_SIGNING" $REF_FLAG sleep 2 gh run list --workflow=release-desktop-canary.yml --limit=1 --json url -q '.[0].url' From 57b0b7861f395cfda99f73de1c6b4afb35bd43fc Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 20 May 2026 16:31:08 -0700 Subject: [PATCH 20/28] Fix renderer env empty profile handling --- .../src/renderer/env.renderer.schema.test.ts | 59 ++++++++++++++ .../src/renderer/env.renderer.schema.ts | 81 +++++++++++++++++++ apps/desktop/src/renderer/env.renderer.ts | 49 +---------- 3 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/renderer/env.renderer.schema.test.ts create mode 100644 apps/desktop/src/renderer/env.renderer.schema.ts diff --git a/apps/desktop/src/renderer/env.renderer.schema.test.ts b/apps/desktop/src/renderer/env.renderer.schema.test.ts new file mode 100644 index 00000000000..f37e75205f9 --- /dev/null +++ b/apps/desktop/src/renderer/env.renderer.schema.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { parseRendererEnv } from "./env.renderer.schema"; + +describe("parseRendererEnv", () => { + test("treats empty build-time strings as unset in production", () => { + const env = parseRendererEnv({ + NODE_ENV: "production", + SUPERSET_PROFILE: "", + NEXT_PUBLIC_API_URL: "", + NEXT_PUBLIC_WEB_URL: "", + NEXT_PUBLIC_MARKETING_URL: "", + NEXT_PUBLIC_ELECTRIC_URL: "", + NEXT_PUBLIC_POSTHOG_KEY: "", + NEXT_PUBLIC_POSTHOG_HOST: "", + SENTRY_DSN_DESKTOP: "", + RELAY_URL: "", + }); + + expect(env.SUPERSET_PROFILE).toBeUndefined(); + expect(env.NEXT_PUBLIC_API_URL).toBe("https://api.superset.sh"); + expect(env.NEXT_PUBLIC_WEB_URL).toBe("https://app.superset.sh"); + expect(env.NEXT_PUBLIC_POSTHOG_KEY).toBeUndefined(); + expect(env.SENTRY_DSN_DESKTOP).toBeUndefined(); + expect(env.RELAY_URL).toBe("https://relay.superset.sh"); + }); + + test("uses local defaults only for the development local profile", () => { + const env = parseRendererEnv({ + NODE_ENV: "development", + SUPERSET_PROFILE: "local", + }); + + expect(env.NEXT_PUBLIC_API_URL).toBe("http://localhost:4641"); + expect(env.NEXT_PUBLIC_WEB_URL).toBe("http://localhost:4640"); + expect(env.NEXT_PUBLIC_ELECTRIC_URL).toBe("https://localhost:4650"); + expect(env.RELAY_URL).toBe("http://localhost:4653"); + }); + + test("keeps production defaults when local profile leaks into a production build", () => { + const env = parseRendererEnv({ + NODE_ENV: "production", + SUPERSET_PROFILE: "local", + }); + + expect(env.SUPERSET_PROFILE).toBe("local"); + expect(env.NEXT_PUBLIC_API_URL).toBe("https://api.superset.sh"); + expect(env.NEXT_PUBLIC_WEB_URL).toBe("https://app.superset.sh"); + expect(env.RELAY_URL).toBe("https://relay.superset.sh"); + }); + + test("rejects non-empty invalid profiles", () => { + expect(() => + parseRendererEnv({ + NODE_ENV: "production", + SUPERSET_PROFILE: "invalid", + }), + ).toThrow(); + }); +}); diff --git a/apps/desktop/src/renderer/env.renderer.schema.ts b/apps/desktop/src/renderer/env.renderer.schema.ts new file mode 100644 index 00000000000..a4d04266cee --- /dev/null +++ b/apps/desktop/src/renderer/env.renderer.schema.ts @@ -0,0 +1,81 @@ +import { z } from "zod/v4"; + +export type RendererEnvInput = { + NODE_ENV?: string; + NEXT_PUBLIC_API_URL?: string; + NEXT_PUBLIC_WEB_URL?: string; + NEXT_PUBLIC_MARKETING_URL?: string; + NEXT_PUBLIC_ELECTRIC_URL?: string; + NEXT_PUBLIC_POSTHOG_KEY?: string; + NEXT_PUBLIC_POSTHOG_HOST?: string; + SENTRY_DSN_DESKTOP?: string; + SUPERSET_PROFILE?: string; + RELAY_URL?: string; +}; + +const deploymentProfileSchema = z + .enum(["cloud", "local", "ci", "internal"]) + .optional(); + +function emptyStringAsUndefined(rawEnv: RendererEnvInput): RendererEnvInput { + return Object.fromEntries( + Object.entries(rawEnv).map(([key, value]) => [ + key, + value === "" ? undefined : value, + ]), + ) as RendererEnvInput; +} + +function createRendererEnvSchema(isLocalDevelopment: boolean) { + return z.object({ + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + // Local contributor builds default to localhost. Internal dev keeps the + // existing hosted fallback unless the profile is explicitly local. + NEXT_PUBLIC_API_URL: z + .url() + .default( + isLocalDevelopment + ? "http://localhost:4641" + : "https://api.superset.sh", + ), + NEXT_PUBLIC_WEB_URL: z + .url() + .default( + isLocalDevelopment + ? "http://localhost:4640" + : "https://app.superset.sh", + ), + NEXT_PUBLIC_MARKETING_URL: z.url().default("https://superset.sh"), + NEXT_PUBLIC_ELECTRIC_URL: z + .url() + .default( + isLocalDevelopment + ? "https://localhost:4650" + : "https://electric-proxy.avi-6ac.workers.dev", + ), + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), + SENTRY_DSN_DESKTOP: z.string().optional(), + SUPERSET_PROFILE: deploymentProfileSchema, + RELAY_URL: z + .url() + .default( + isLocalDevelopment + ? "http://localhost:4653" + : "https://relay.superset.sh", + ), + }); +} + +export function parseRendererEnv(rawEnv: RendererEnvInput) { + const normalizedEnv = emptyStringAsUndefined(rawEnv); + const isLocalDevelopment = + normalizedEnv.NODE_ENV === "development" && + normalizedEnv.SUPERSET_PROFILE === "local"; + + return createRendererEnvSchema(isLocalDevelopment).parse(normalizedEnv); +} + +export type RendererEnv = ReturnType; diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index 495be6feee9..737897cbb3b 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -9,50 +9,7 @@ * * For main process env vars, use src/main/env.main.ts instead. */ -import { z } from "zod/v4"; - -const envSchema = z.object({ - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - // Hosted defaults for prod builds. In dev builds Vite replaces - // NODE_ENV with "development", so the dev-time defaults below kick in - // and a fresh-clone contributor never silently syncs against - // production Electric / API. - NEXT_PUBLIC_API_URL: z - .url() - .default( - process.env.NODE_ENV === "development" - ? "http://localhost:4641" - : "https://api.superset.sh", - ), - NEXT_PUBLIC_WEB_URL: z - .url() - .default( - process.env.NODE_ENV === "development" - ? "http://localhost:4640" - : "https://app.superset.sh", - ), - NEXT_PUBLIC_MARKETING_URL: z.url().default("https://superset.sh"), - NEXT_PUBLIC_ELECTRIC_URL: z - .url() - .default( - process.env.NODE_ENV === "development" - ? "https://localhost:4650" - : "https://electric-proxy.avi-6ac.workers.dev", - ), - NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), - NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), - SENTRY_DSN_DESKTOP: z.string().optional(), - SUPERSET_PROFILE: z.enum(["cloud", "local", "ci", "internal"]).optional(), - RELAY_URL: z - .url() - .default( - process.env.NODE_ENV === "development" - ? "http://localhost:4653" - : "https://relay.superset.sh", - ), -}); +import { parseRendererEnv, type RendererEnv } from "./env.renderer.schema"; /** * Build-time environment variables. @@ -83,8 +40,6 @@ const SKIP_ENV_VALIDATION = process.env.NODE_ENV === "development" && !!process.env.SKIP_ENV_VALIDATION; export const env = { - ...(SKIP_ENV_VALIDATION - ? (rawEnv as z.infer) - : envSchema.parse(rawEnv)), + ...(SKIP_ENV_VALIDATION ? (rawEnv as RendererEnv) : parseRendererEnv(rawEnv)), SKIP_ENV_VALIDATION, }; From 4c1b0e2891bb7236504b35d627aa061ca0afc056 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 20 May 2026 17:17:48 -0700 Subject: [PATCH 21/28] Constrain local dev profile behavior --- apps/desktop/src/main/env.main.ts | 33 +++++++++++-------- .../src/renderer/env.renderer.schema.test.ts | 4 +-- .../src/renderer/env.renderer.schema.ts | 6 ++++ .../OpenInWorkspaceV2/OpenInWorkspaceV2.tsx | 6 ++-- .../RunInWorkspacePopoverV2.tsx | 6 ++-- .../RunIssuesInWorkspacePopover.tsx | 6 ++-- .../useAccessibleV2Workspaces.ts | 6 ++-- .../useWorkspaceHostOptions.ts | 6 ++-- .../DashboardNewWorkspaceModalContent.tsx | 6 ++-- .../V1ImportModal/V1ImportModal.tsx | 6 ++-- .../renderer/routes/_authenticated/layout.tsx | 6 ++-- .../CollectionsProvider.tsx | 6 ++-- .../LocalHostServiceProvider.tsx | 6 ++-- .../HostsSettingsSidebar.tsx | 6 ++-- .../_authenticated/settings/hosts/page.tsx | 6 ++-- .../settings/projects/$projectId/page.tsx | 6 ++-- .../ProjectsSettingsSidebar.tsx | 6 ++-- .../_authenticated/settings/projects/page.tsx | 6 ++-- .../_authenticated/setup/project/page.tsx | 6 ++-- .../src/renderer/routes/sign-in/page.tsx | 3 +- apps/desktop/vite/helpers.ts | 7 ++-- .../(auth)/sign-in/[[...sign-in]]/page.tsx | 7 ++-- .../(auth)/sign-up/[[...sign-up]]/page.tsx | 9 +++-- packages/auth/src/server.ts | 9 +++-- .../shared/src/deployment-profile.test.ts | 20 +++++++++-- packages/shared/src/deployment-profile.ts | 14 ++++++-- 26 files changed, 127 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index 6fa5939b502..ec5536cc1ef 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -44,50 +44,57 @@ function getDeploymentProfile(): MainDeploymentProfile { return "cloud"; } const explicitProfile = getExplicitProfile(); - if (explicitProfile) return explicitProfile; + if (explicitProfile) { + if (explicitProfile === "local" && process.env.NODE_ENV === "production") { + return "internal"; + } + return explicitProfile; + } if (isTruthyFlag(process.env.CI)) return "ci"; return "internal"; } export const deploymentProfile = getDeploymentProfile(); -const isStrict = - deploymentProfile === "cloud" || deploymentProfile === "internal"; const skipValidation = - !isStrict || isTruthyFlag(process.env.SKIP_ENV_VALIDATION); + deploymentProfile === "ci" || + deploymentProfile === "local" || + (process.env.NODE_ENV !== "production" && + isTruthyFlag(process.env.SKIP_ENV_VALIDATION)); +const isLocalDevelopment = + process.env.NODE_ENV === "development" && deploymentProfile === "local"; export const env = createEnv({ server: { NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), - // In dev builds (NODE_ENV=development) the URL defaults switch to - // localhost so fresh-clone local contributors never silently sync - // against hosted production endpoints. + // In explicit local-profile dev builds, defaults switch to localhost so + // fresh-clone contributors never silently sync against hosted endpoints. NEXT_PUBLIC_API_URL: z .url() .default( - process.env.NODE_ENV === "development" + isLocalDevelopment ? "http://localhost:4641" : "https://api.superset.sh", ), NEXT_PUBLIC_STREAMS_URL: z .url() .default( - process.env.NODE_ENV === "development" + isLocalDevelopment ? "http://localhost:4647" : "https://streams.superset.sh", ), NEXT_PUBLIC_ELECTRIC_URL: z .url() .default( - process.env.NODE_ENV === "development" + isLocalDevelopment ? "https://localhost:4650" : "https://electric-proxy.avi-6ac.workers.dev", ), NEXT_PUBLIC_WEB_URL: z .url() .default( - process.env.NODE_ENV === "development" + isLocalDevelopment ? "http://localhost:4640" : "https://app.superset.sh", ), @@ -98,14 +105,14 @@ export const env = createEnv({ STREAMS_URL: z .url() .default( - process.env.NODE_ENV === "development" + isLocalDevelopment ? "http://localhost:4647" : "https://superset-stream.fly.dev", ), RELAY_URL: z .url() .default( - process.env.NODE_ENV === "development" + isLocalDevelopment ? "http://localhost:4653" : "https://relay.superset.sh", ), diff --git a/apps/desktop/src/renderer/env.renderer.schema.test.ts b/apps/desktop/src/renderer/env.renderer.schema.test.ts index f37e75205f9..e3cda3ebae2 100644 --- a/apps/desktop/src/renderer/env.renderer.schema.test.ts +++ b/apps/desktop/src/renderer/env.renderer.schema.test.ts @@ -36,13 +36,13 @@ describe("parseRendererEnv", () => { expect(env.RELAY_URL).toBe("http://localhost:4653"); }); - test("keeps production defaults when local profile leaks into a production build", () => { + test("ignores local profile when it leaks into a production build", () => { const env = parseRendererEnv({ NODE_ENV: "production", SUPERSET_PROFILE: "local", }); - expect(env.SUPERSET_PROFILE).toBe("local"); + expect(env.SUPERSET_PROFILE).toBeUndefined(); expect(env.NEXT_PUBLIC_API_URL).toBe("https://api.superset.sh"); expect(env.NEXT_PUBLIC_WEB_URL).toBe("https://app.superset.sh"); expect(env.RELAY_URL).toBe("https://relay.superset.sh"); diff --git a/apps/desktop/src/renderer/env.renderer.schema.ts b/apps/desktop/src/renderer/env.renderer.schema.ts index a4d04266cee..0906995c0de 100644 --- a/apps/desktop/src/renderer/env.renderer.schema.ts +++ b/apps/desktop/src/renderer/env.renderer.schema.ts @@ -71,6 +71,12 @@ function createRendererEnvSchema(isLocalDevelopment: boolean) { export function parseRendererEnv(rawEnv: RendererEnvInput) { const normalizedEnv = emptyStringAsUndefined(rawEnv); + if ( + normalizedEnv.NODE_ENV === "production" && + normalizedEnv.SUPERSET_PROFILE === "local" + ) { + normalizedEnv.SUPERSET_PROFILE = undefined; + } const isLocalDevelopment = normalizedEnv.NODE_ENV === "development" && normalizedEnv.SUPERSET_PROFILE === "local"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx index 017ff5f60ae..559d343b9dc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx @@ -56,9 +56,9 @@ export function OpenInWorkspaceV2({ task }: OpenInWorkspaceV2Props) { const { machineId, activeHostUrl } = hostService; const { otherHosts } = useWorkspaceHostOptions(); const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { submit } = useWorkspaceCreates(); const lastProjectId = useV2WorkspaceCreateDefaultsStore( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx index 91758e0fa29..6d150dcffd1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx @@ -61,9 +61,9 @@ export function RunInWorkspacePopoverV2({ const hostService = useLocalHostService(); const { machineId, activeHostUrl } = hostService; const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { otherHosts } = useWorkspaceHostOptions(); const { submit } = useWorkspaceCreates(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx index 060026ab9e3..0f0e6584105 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx @@ -65,9 +65,9 @@ export function RunIssuesInWorkspacePopover({ const hostService = useLocalHostService(); const { machineId, activeHostUrl } = hostService; const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { otherHosts } = useWorkspaceHostOptions(); const { submit } = useWorkspaceCreates(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts index 6a95ea646b5..eb904571c63 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts @@ -221,9 +221,9 @@ export function useAccessibleV2Workspaces( const collections = useCollections(); const { machineId } = useLocalHostService(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const currentUserId = session?.user?.id ?? null; const { data: rows = [] } = useLiveQuery( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts index 6014b9ee911..b96c7534db5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts @@ -26,9 +26,9 @@ export function useWorkspaceHostOptions(): UseWorkspaceHostOptionsResult { const collections = useCollections(); const { machineId, activeHostUrl } = useLocalHostService(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const currentUserId = session?.user?.id ?? null; const { data: accessibleHosts = [] } = useLiveQuery( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx index da6b96ba19d..da8399adba2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx @@ -32,9 +32,9 @@ export function DashboardNewWorkspaceModalContent({ ); const collections = useCollections(); const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: v2Projects } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx index 8b127423c10..bd292fd0e90 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx @@ -27,9 +27,9 @@ export function V1ImportModal() { const close = useCloseV1ImportModal(); const { data: session } = authClient.useSession(); const { activeHostUrl } = useLocalHostService(); - const organizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const organizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); if (!organizationId) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 4717948ec01..dbc28401f70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -62,9 +62,9 @@ function AuthenticatedLayout() { const isV2CloudEnabled = useIsV2CloudEnabled(); const isSignedIn = env.SKIP_ENV_VALIDATION || !!session?.user; - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : session?.session?.activeOrganizationId; useAgentHookListener(); useUpdateListener(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index 518fb6b4495..f5562295086 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -33,9 +33,9 @@ export function preloadActiveOrganizationCollections( export function CollectionsProvider({ children }: { children: ReactNode }) { const { data: session, refetch: refetchSession } = authClient.useSession(); const [isSwitching, setIsSwitching] = useState(false); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : session?.session?.activeOrganizationId; const switchOrganization = useCallback( async (organizationId: string) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx index ea26b80efa9..9f2b09c8f43 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx @@ -35,9 +35,9 @@ export function LocalHostServiceProvider({ const { mutate: startHostService } = electronTrpc.hostServiceCoordinator.start.useMutation(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: organizations } = useLiveQuery( (q) => q.from({ organizations: collections.organizations }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx index 5a0c0454b7d..0b4567a52e4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx @@ -30,9 +30,9 @@ export function HostsSettingsSidebar({ const collections = useCollections(); const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: hosts = [] } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx index 38b070ff699..5586e55e9af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx @@ -16,9 +16,9 @@ function HostsIndexPage() { const { data: session } = authClient.useSession(); const navigate = useNavigate(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: hosts = [], isReady } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx index 94f2d8da568..e19226947d5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx @@ -29,9 +29,9 @@ function ProjectDetailPage() { const { data: session } = authClient.useSession(); const searchQuery = useSettingsSearchQuery(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: v2Match = [] } = useLiveQuery( (q) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx index bedd6a8612b..ce7d751ecc4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx @@ -31,9 +31,9 @@ export function ProjectsSettingsSidebar({ const collections = useCollections(); const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: groups = [] } = electronTrpc.workspaces.getAllGrouped.useQuery(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx index 089d2ec66a6..584486bc71c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx @@ -17,9 +17,9 @@ function ProjectsIndexPage() { const { data: session } = authClient.useSession(); const navigate = useNavigate(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: groups = [], isLoading: groupsLoading } = electronTrpc.workspaces.getAllGrouped.useQuery(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx index 10626d1ce1f..fe5b99a03f3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx @@ -26,9 +26,9 @@ function OnboardingProjectPage() { const markSkipped = useOnboardingStore((s) => s.markSkipped); const { data: session } = authClient.useSession(); - const activeOrganizationId = - session?.session?.activeOrganizationId ?? - (env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : null); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); const { data: projects = [], isLoading } = useQuery({ queryKey: ["onboarding", "v2Projects", activeOrganizationId], diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index 2103e2578b5..c56bba6515f 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -18,7 +18,8 @@ export const Route = createFileRoute("/sign-in/")({ function SignInPage() { const signInMutation = electronTrpc.auth.signIn.useMutation(); const { hasLocalToken, isPending, session } = useSessionRecovery(); - const isLocalProfile = env.SUPERSET_PROFILE === "local"; + const isLocalProfile = + env.NODE_ENV === "development" && env.SUPERSET_PROFILE === "local"; // Dev bypass: AuthProvider handles auto-sign-in; if session lands, redirect if (env.SKIP_ENV_VALIDATION && session?.user) { diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index fd4c4c949c0..2aabdb83df9 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -26,7 +26,7 @@ export function defineEnv( /** * Returns a URL appropriate for the current build: * - the configured `process.env[key]` if set - * - the dev fallback in development builds (fresh-clone OSS contributors) + * - the dev fallback in explicit local-profile development builds * - the prod fallback otherwise (hosted production) * * Avoids fresh-clone OSS dev sessions silently syncing against hosted @@ -39,7 +39,10 @@ export function devOrProdUrl( ): string { const value = process.env[envKey]; if (value) return value; - return process.env.NODE_ENV === "development" ? devFallback : prodFallback; + const isLocalDevelopment = + process.env.NODE_ENV === "development" && + process.env.SUPERSET_PROFILE === "local"; + return isLocalDevelopment ? devFallback : prodFallback; } const RESOURCES_TO_COPY = [ diff --git a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 75d6b41ec4d..48f13e775a8 100644 --- a/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -10,7 +10,10 @@ import { FcGoogle } from "react-icons/fc"; import { env } from "@/env"; import { DevSignInButton } from "../../components/DevSignInButton"; -const isDev = process.env.NODE_ENV !== "production"; +const isLocalDev = + process.env.NODE_ENV !== "production" && + env.NEXT_PUBLIC_API_URL.startsWith("http://localhost:") && + env.NEXT_PUBLIC_WEB_URL.startsWith("http://localhost:"); export default function SignInPage() { const searchParams = useSearchParams(); @@ -69,7 +72,7 @@ export default function SignInPage() { {error && (

{error}

)} - {isDev && } + {isLocalDev && } ); return (
  • {run.error ? ( - {row} + {/* + * Wrap in a span: failed rows often have no v2WorkspaceId, + * which disables the inner