diff --git a/.env.example b/.env.example index 55017296cf9..2d7f1a9c197 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,73 @@ - - # ============================================================================= -# ROOT SUPERSET ENV -# Shared by all worktrees via direnv +# SUPERSET LOCAL DEVELOPMENT ENV +# Copied to .env by `bun setup:local`. +# +# Keep .env machine-local and untracked. Blank optional integration keys are +# intentional in the local profile; the app should boot without third-party +# credentials and degrade only when those specific features are exercised. # ============================================================================= # ----------------------------------------------------------------------------- -# Neon Organization Credentials +# Local Contributor Profile # ----------------------------------------------------------------------------- -NEON_ORG_ID= -NEON_PROJECT_ID= -NEON_API_KEY= +# Required for fresh-clone local development without third-party credentials. +SUPERSET_PROFILE=local + +# Keep desktop dev state separate from production / canary installs. +# `bun setup:local` rewrites this to local-dev-. +SUPERSET_WORKSPACE_NAME=local-dev + +# ----------------------------------------------------------------------------- +# Local Database +# ----------------------------------------------------------------------------- +# `bun setup:local` rewrites these to a database name derived from the +# worktree path, so multiple local worktrees can share one Postgres container +# without sharing schema/auth state. +SUPERSET_LOCAL_DATABASE_NAME=superset +DATABASE_URL=postgres://superset:superset@localhost:5433/superset +DATABASE_URL_UNPOOLED=postgres://superset:superset@localhost:5433/superset # ----------------------------------------------------------------------------- -# Neon Database Credentials (Production) +# Local Ports # ----------------------------------------------------------------------------- -DATABASE_URL= -DATABASE_URL_UNPOOLED= +WEB_PORT=4640 +API_PORT=4641 +DESKTOP_VITE_PORT=4645 +DESKTOP_NOTIFICATIONS_PORT=4646 +ELECTRIC_PORT=4649 +CADDY_ELECTRIC_PORT=4650 +WRANGLER_PORT=4652 # ----------------------------------------------------------------------------- -# Cross-App URLs (Local Dev) +# Local App URLs # ----------------------------------------------------------------------------- -NEXT_PUBLIC_API_URL=http://localhost:3001 -NEXT_PUBLIC_WEB_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:4641 +NEXT_PUBLIC_WEB_URL=http://localhost:4640 NEXT_PUBLIC_ADMIN_URL=http://localhost:3003 NEXT_PUBLIC_MARKETING_URL=http://localhost:3002 NEXT_PUBLIC_DOCS_URL=http://localhost:3004 +NEXT_PUBLIC_ELECTRIC_URL=https://localhost:4650 # ----------------------------------------------------------------------------- -# Better Auth +# Local Auth Defaults # ----------------------------------------------------------------------------- -BETTER_AUTH_SECRET= +BETTER_AUTH_SECRET=local_dev_secret_not_for_production NEXT_PUBLIC_COOKIE_DOMAIN=localhost +# ============================================================================= +# OPTIONAL INTEGRATION CREDENTIALS +# Leave these blank for the default local contributor flow. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Neon Organization Credentials +# ----------------------------------------------------------------------------- +# Only needed for Neon-backed/internal workflows. The local setup script uses the +# Docker Postgres URLs above. +NEON_ORG_ID= +NEON_PROJECT_ID= +NEON_API_KEY= + # ----------------------------------------------------------------------------- # OAuth Credentials (for Desktop App direct auth) # ----------------------------------------------------------------------------- @@ -99,6 +134,7 @@ KV_REST_API_TOKEN= # QStash API token (used for background job scheduling in packages/trpc and apps/api) QSTASH_TOKEN= +QSTASH_URL= # QStash webhook signature verification keys (used to verify webhook requests) QSTASH_CURRENT_SIGNING_KEY= diff --git a/Caddyfile.example b/Caddyfile.example index b1043c33d78..719e224ae8d 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -2,14 +2,14 @@ # Avoids browser's 6-connection limit when using 10+ Electric streams. # # Copy to Caddyfile: cp Caddyfile.example Caddyfile -# Install caddy: brew install caddy (macOS) / see https://caddyserver.com/docs/install +# Install caddy: brew install caddy { auto_https disable_redirects } -https://localhost:3010 { - reverse_proxy localhost:3001 { +https://localhost:{$CADDY_ELECTRIC_PORT} { + reverse_proxy localhost:{$WRANGLER_PORT} { flush_interval -1 } } diff --git a/README.md b/README.md index e1d935232ec..478d5dea679 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ If it runs in a terminal, it runs on Superset | Requirement | Details | |:------------|:--------| -| **OS** | macOS (Windows/Linux untested) | +| **OS** | macOS | | **Runtime** | [Bun](https://bun.sh/) v1.0+ | | **Version Control** | Git 2.20+ | | **GitHub CLI** | [gh](https://cli.github.com/) | @@ -85,57 +85,27 @@ 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 +bun setup:local +bun dev ``` -**5. Build the desktop app** +`bun setup:local` copies local examples, assigns this worktree its own local database and desktop state, starts Docker Postgres/Electric, trusts Caddy's local CA, and runs migrations. It does not overwrite internal `.env` files. The desktop window opens auto-signed-in as `admin@local.test`, with state isolated under `~/.superset-local-dev-*` so source builds do not reuse production or canary desktop state. See [Local Development](docs/LOCAL_DEVELOPMENT.md) for details and troubleshooting. + +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/admin/src/env.ts b/apps/admin/src/env.ts index 0601dc61f69..2d9ea4aba6d 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -1,7 +1,10 @@ +import { shouldSkipEnvValidation } from "@superset/shared/deployment-profile"; import { createEnv } from "@t3-oss/env-nextjs"; import { vercel } from "@t3-oss/env-nextjs/presets-zod"; import { z } from "zod"; +const skipValidation = shouldSkipEnvValidation(); + export const env = createEnv({ extends: [vercel()], shared: { @@ -49,6 +52,6 @@ export const env = createEnv({ NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, }, - skipValidation: !!process.env.SKIP_ENV_VALIDATION, + skipValidation, emptyStringAsUndefined: true, }); diff --git a/apps/api/package.json b/apps/api/package.json index 2fb057d5453..77c73628b5c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,6 +8,7 @@ "clean": "git clean -xdf .cache .next .turbo node_modules", "dev": "dotenv -e ../../.env -- sh -c 'exec next dev --port ${API_PORT:-3001}'", "start": "next start --port 3001", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts index f1c6d7f5a85..7b14e4c2f1d 100644 --- a/apps/api/src/app/api/automations/evaluate/route.ts +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -2,17 +2,14 @@ import { dbWs } from "@superset/db/client"; import { automations, subscriptions } from "@superset/db/schema"; import { ACTIVE_SUBSCRIPTION_STATUSES } from "@superset/shared/billing"; import { nextOccurrenceAfter } from "@superset/shared/rrule"; -import { Client, Receiver } from "@upstash/qstash"; +import { Receiver } from "@upstash/qstash"; import { and, eq, exists, inArray, lte, ne, sql } from "drizzle-orm"; import { env } from "@/env"; +import { qstash } from "@/lib/qstash"; export const dynamic = "force-dynamic"; -const qstash = new Client({ - token: env.QSTASH_TOKEN, - baseUrl: env.QSTASH_URL, -}); const receiver = new Receiver({ currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, @@ -73,6 +70,13 @@ export async function POST(request: Request): Promise { return Response.json({ enqueued: 0 }); } + if (!qstash) { + return Response.json( + { error: "QSTASH_TOKEN is required to enqueue automation jobs" }, + { status: 503 }, + ); + } + await qstash.batchJSON( due.map((automation) => { const scheduledFor = bucketToMinute(automation.nextRunAt); diff --git a/apps/api/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts index 94021159083..6e220e14217 100644 --- a/apps/api/src/app/api/github/callback/route.ts +++ b/apps/api/src/app/api/github/callback/route.ts @@ -1,14 +1,12 @@ import { db } from "@superset/db/client"; import { githubInstallations, members } from "@superset/db/schema"; -import { Client } from "@upstash/qstash"; import { and, eq } from "drizzle-orm"; import { env } from "@/env"; import { verifySignedState } from "@/lib/oauth-state"; +import { qstash, requireQstash } from "@/lib/qstash"; import { githubApp } from "../octokit"; -const qstash = new Client({ token: env.QSTASH_TOKEN }); - /** * Callback handler for GitHub App installation. * GitHub redirects here after the user installs/configures the app. @@ -122,14 +120,28 @@ export async function GET(request: Request) { // Queue initial sync job try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, - body: { - installationDbId: savedInstallation.id, - organizationId, - }, - retries: 3, - }); + const syncUrl = `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`; + const syncBody = { + installationDbId: savedInstallation.id, + organizationId, + }; + + if (env.NODE_ENV === "development" && !qstash) { + const response = await fetch(syncUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(syncBody), + }); + if (!response.ok) { + throw new Error(`Local sync request failed: ${response.status}`); + } + } else { + await requireQstash("github/callback").publishJSON({ + url: syncUrl, + body: syncBody, + retries: 3, + }); + } } catch (error) { console.error( "[github/callback] Failed to queue initial sync job:", diff --git a/apps/api/src/app/api/health/route.ts b/apps/api/src/app/api/health/route.ts new file mode 100644 index 00000000000..9b3588d69ae --- /dev/null +++ b/apps/api/src/app/api/health/route.ts @@ -0,0 +1,19 @@ +import { + getDeploymentProfile, + isStrictProfile, +} from "@superset/shared/deployment-profile"; +import { NextResponse } from "next/server"; +import { getIntegrationStatuses } from "../../../lib/integration-status"; + +export function GET() { + const profile = getDeploymentProfile(); + if (isStrictProfile(profile)) { + return NextResponse.json({ ok: true }); + } + + return NextResponse.json({ + ok: true, + profile, + integrations: getIntegrationStatuses(), + }); +} diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts index 5b090f03a54..3a770bd097d 100644 --- a/apps/api/src/app/api/integrations/linear/callback/route.ts +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -2,18 +2,16 @@ import { LinearClient } from "@linear/sdk"; import { db } from "@superset/db/client"; import { integrationConnections, members, users } from "@superset/db/schema"; import { linearTokenResponseSchema } from "@superset/trpc/integrations/linear"; -import { Client } from "@upstash/qstash"; import { and, eq, isNull, ne } from "drizzle-orm"; import { env } from "@/env"; import { verifySignedState } from "@/lib/oauth-state"; +import { qstash, requireQstash } from "@/lib/qstash"; const UNIQUE_VIOLATION = "23505"; const ACTIVE_LINKAGE_INDEX = "integration_connections_provider_external_org_active_unique"; -const qstash = new Client({ token: env.QSTASH_TOKEN }); - export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get("code"); @@ -149,11 +147,25 @@ export async function GET(request: Request) { } try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, - body: { organizationId, creatorUserId: userId }, - retries: 3, - }); + const syncUrl = `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`; + const syncBody = { organizationId, creatorUserId: userId }; + + if (env.NODE_ENV === "development" && !qstash) { + const response = await fetch(syncUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(syncBody), + }); + if (!response.ok) { + throw new Error(`Local sync request failed: ${response.status}`); + } + } else { + await requireQstash("linear/callback").publishJSON({ + url: syncUrl, + body: syncBody, + retries: 3, + }); + } } catch (error) { console.error("Failed to queue initial sync job:", error); return Response.redirect( diff --git a/apps/api/src/app/api/integrations/slack/events/route.ts b/apps/api/src/app/api/integrations/slack/events/route.ts index 8706cb59ae9..ea098d5ca78 100644 --- a/apps/api/src/app/api/integrations/slack/events/route.ts +++ b/apps/api/src/app/api/integrations/slack/events/route.ts @@ -1,14 +1,12 @@ import type { LinkSharedEvent, SlackEvent } from "@slack/types"; -import { Client } from "@upstash/qstash"; import { env } from "@/env"; +import { qstash } from "@/lib/qstash"; import { verifySlackSignature } from "../verify-signature"; import { processAppHomeOpened } from "./process-app-home-opened"; import { processEntityDetails } from "./process-entity-details"; import { processLinkShared } from "./process-link-shared"; -const qstash = new Client({ token: env.QSTASH_TOKEN }); - type SlackEventEnvelope = { type?: string; challenge?: string; @@ -79,15 +77,21 @@ export async function POST(request: Request) { if (event.type === "app_mention") { try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, - body: { - event, - teamId: team_id, - eventId: event_id, - }, - retries: 3, - }); + if (qstash) { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-mention`, + body: { + event, + teamId: team_id, + eventId: event_id, + }, + retries: 3, + }); + } else { + console.warn( + "[slack/events] Skipping mention job; QStash is not configured", + ); + } } catch (error) { console.error("[slack/events] Failed to queue mention job:", error); } @@ -109,15 +113,21 @@ export async function POST(request: Request) { } try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, - body: { - event: messageEvent, - teamId: team_id, - eventId: event_id, - }, - retries: 3, - }); + if (qstash) { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/jobs/process-assistant-message`, + body: { + event: messageEvent, + teamId: team_id, + eventId: event_id, + }, + retries: 3, + }); + } else { + console.warn( + "[slack/events] Skipping assistant message job; QStash is not configured", + ); + } } catch (error) { console.error( "[slack/events] Failed to queue assistant message job:", diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 117ed25046a..cc3c98b3923 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -1,6 +1,9 @@ +import { shouldSkipEnvValidation } from "@superset/shared/deployment-profile"; import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; +const skipValidation = shouldSkipEnvValidation(); + export const env = createEnv({ shared: { NODE_ENV: z @@ -70,5 +73,5 @@ export const env = createEnv({ NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, }, emptyStringAsUndefined: true, - skipValidation: !!process.env.SKIP_ENV_VALIDATION, + skipValidation, }); diff --git a/apps/api/src/instrumentation.ts b/apps/api/src/instrumentation.ts index 5a4e9039446..125499e5b0a 100644 --- a/apps/api/src/instrumentation.ts +++ b/apps/api/src/instrumentation.ts @@ -1,8 +1,10 @@ import * as Sentry from "@sentry/nextjs"; +import { logBootSummary } from "./lib/boot-summary"; export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { await import("../sentry.server.config"); + logBootSummary(); } if (process.env.NEXT_RUNTIME === "edge") { diff --git a/apps/api/src/lib/analytics.ts b/apps/api/src/lib/analytics.ts index aa8d66fce76..ae446bf440d 100644 --- a/apps/api/src/lib/analytics.ts +++ b/apps/api/src/lib/analytics.ts @@ -1,8 +1,43 @@ +import type { EventMessage } from "posthog-node"; 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, -}); +type FeatureFlagValue = Parameters[2]; +type FeatureFlagOptions = Parameters[2]; +type FeatureFlagPayloadOptions = Parameters< + PostHog["getFeatureFlagPayload"] +>[3]; +type FeatureFlagResult = Awaited>; +type FeatureFlagPayload = Awaited>; + +interface AnalyticsClient { + capture: (props: EventMessage) => void; + getFeatureFlag: ( + key: string, + distinctId: string, + options?: FeatureFlagOptions, + ) => Promise; + getFeatureFlagPayload: ( + key: string, + distinctId: string, + matchValue?: FeatureFlagValue, + options?: FeatureFlagPayloadOptions, + ) => Promise; +} + +const disabled: AnalyticsClient = { + capture: () => {}, + getFeatureFlag: async () => undefined, + getFeatureFlagPayload: async () => undefined, +}; + +function createAnalyticsClient(): AnalyticsClient { + if (!env.NEXT_PUBLIC_POSTHOG_KEY) return disabled; + return new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { + host: env.NEXT_PUBLIC_POSTHOG_HOST, + flushAt: 1, + flushInterval: 0, + }); +} + +export const posthog = createAnalyticsClient(); diff --git a/apps/api/src/lib/boot-summary.ts b/apps/api/src/lib/boot-summary.ts new file mode 100644 index 00000000000..e8ecfc44ad7 --- /dev/null +++ b/apps/api/src/lib/boot-summary.ts @@ -0,0 +1,32 @@ +import { + getDeploymentProfile, + isStrictProfile, +} from "@superset/shared/deployment-profile"; +import { getMissingIntegrations } from "./integration-status"; + +let logged = false; + +export function logBootSummary(): void { + if (logged) return; + logged = true; + + const profile = getDeploymentProfile(); + const missing = getMissingIntegrations(); + + if (isStrictProfile(profile)) { + console.log(`[superset] profile=${profile} (strict)`); + return; + } + + console.log(`[superset] profile=${profile} (lenient)`); + if (missing.length === 0) { + console.log("[superset] all integrations configured"); + return; + } + console.log( + `[superset] disabled features (set the listed env var(s) to enable):`, + ); + for (const { label, envVars } of missing) { + console.log(` - ${label.padEnd(28)} ${envVars.join(", ")}`); + } +} diff --git a/apps/api/src/lib/integration-status.test.ts b/apps/api/src/lib/integration-status.test.ts new file mode 100644 index 00000000000..3c29ba092f2 --- /dev/null +++ b/apps/api/src/lib/integration-status.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; + +import { + getIntegrationStatuses, + getMissingIntegrations, +} from "./integration-status"; + +describe("integration status", () => { + it("reports configured and missing integrations from the supplied env", () => { + const statuses = getIntegrationStatuses({ + STRIPE_SECRET_KEY: "sk_test", + RESEND_API_KEY: "", + NEXT_PUBLIC_POSTHOG_KEY: undefined, + }); + + expect(statuses.stripe).toBe("configured"); + expect(statuses.resend).toBe("missing"); + expect(statuses.posthog).toBe("missing"); + expect(statuses["upstash-kv"]).toBe("missing"); + expect(statuses.blob).toBe("missing"); + }); + + it("requires all env vars for multi-key integrations", () => { + expect( + getIntegrationStatuses({ + KV_REST_API_URL: "https://example.upstash.io", + })["upstash-kv"], + ).toBe("missing"); + + expect( + getIntegrationStatuses({ + KV_REST_API_URL: "https://example.upstash.io", + KV_REST_API_TOKEN: "token", + })["upstash-kv"], + ).toBe("configured"); + }); + + it("returns missing integrations with display labels for boot output", () => { + const missing = getMissingIntegrations({ + STRIPE_SECRET_KEY: "sk_test", + }); + + expect(missing.some(({ key }) => key === "stripe")).toBe(false); + expect(missing).toContainEqual({ + key: "blob", + label: "vercel-blob (uploads)", + envVars: ["BLOB_READ_WRITE_TOKEN"], + }); + }); +}); diff --git a/apps/api/src/lib/integration-status.ts b/apps/api/src/lib/integration-status.ts new file mode 100644 index 00000000000..ceb07171a3f --- /dev/null +++ b/apps/api/src/lib/integration-status.ts @@ -0,0 +1,51 @@ +export const INTEGRATIONS = [ + { key: "stripe", label: "stripe", envVars: ["STRIPE_SECRET_KEY"] }, + { key: "resend", label: "resend (email)", envVars: ["RESEND_API_KEY"] }, + { + key: "posthog", + label: "posthog (telemetry)", + envVars: ["NEXT_PUBLIC_POSTHOG_KEY"], + }, + { key: "sentry", label: "sentry", envVars: ["NEXT_PUBLIC_SENTRY_DSN_API"] }, + { key: "github-app", label: "github-app", envVars: ["GH_APP_ID"] }, + { key: "github-oauth", label: "github-oauth", envVars: ["GH_CLIENT_ID"] }, + { key: "google-oauth", label: "google-oauth", envVars: ["GOOGLE_CLIENT_ID"] }, + { key: "linear", label: "linear", envVars: ["LINEAR_CLIENT_ID"] }, + { key: "slack", label: "slack", envVars: ["SLACK_CLIENT_ID"] }, + { key: "qstash", label: "qstash (jobs)", envVars: ["QSTASH_TOKEN"] }, + { + key: "upstash-kv", + label: "upstash-kv (rate limit)", + envVars: ["KV_REST_API_URL", "KV_REST_API_TOKEN"], + }, + { + key: "blob", + label: "vercel-blob (uploads)", + envVars: ["BLOB_READ_WRITE_TOKEN"], + }, + { key: "anthropic", label: "anthropic", envVars: ["ANTHROPIC_API_KEY"] }, + { key: "tavily", label: "tavily (search)", envVars: ["TAVILY_API_KEY"] }, +] as const; + +export type IntegrationKey = (typeof INTEGRATIONS)[number]["key"]; +export type Integration = (typeof INTEGRATIONS)[number]; +export type IntegrationStatus = "configured" | "missing"; + +export function getIntegrationStatuses( + envSource: Record = process.env, +): Record { + return Object.fromEntries( + INTEGRATIONS.map(({ key, envVars }) => [ + key, + envVars.every((envVar) => envSource[envVar]) ? "configured" : "missing", + ]), + ) as Record; +} + +export function getMissingIntegrations( + envSource: Record = process.env, +): Integration[] { + return INTEGRATIONS.filter(({ envVars }) => + envVars.some((envVar) => !envSource[envVar]), + ); +} diff --git a/apps/api/src/lib/qstash.ts b/apps/api/src/lib/qstash.ts new file mode 100644 index 00000000000..b826b638086 --- /dev/null +++ b/apps/api/src/lib/qstash.ts @@ -0,0 +1,20 @@ +import { Client } from "@upstash/qstash"; + +import { env } from "@/env"; + +export const qstash = env.QSTASH_TOKEN + ? new Client({ + token: env.QSTASH_TOKEN, + ...(env.QSTASH_URL ? { baseUrl: env.QSTASH_URL } : {}), + }) + : null; + +export function requireQstash(context: string) { + if (!qstash) { + throw new Error( + `[${context}] QSTASH_TOKEN is required to enqueue background jobs`, + ); + } + + return qstash; +} diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index f11759ceaa2..add0f28f908 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -13,6 +13,7 @@ import { mainExternalizedDependencies } from "./runtime-dependencies"; import { copyResourcesPlugin, defineEnv, + devOrProdUrl, devPath, htmlEnvTransformPlugin, } from "./vite/helpers"; @@ -53,17 +54,26 @@ export default defineConfig({ process.env.SKIP_ENV_VALIDATION, "", ), - "process.env.NEXT_PUBLIC_API_URL": defineEnv( - process.env.NEXT_PUBLIC_API_URL, - "https://api.superset.sh", + "process.env.NEXT_PUBLIC_API_URL": JSON.stringify( + devOrProdUrl( + "NEXT_PUBLIC_API_URL", + "http://localhost:4641", + "https://api.superset.sh", + ), ), - "process.env.NEXT_PUBLIC_STREAMS_URL": defineEnv( - process.env.NEXT_PUBLIC_STREAMS_URL, - "https://streams.superset.sh", + "process.env.NEXT_PUBLIC_STREAMS_URL": JSON.stringify( + devOrProdUrl( + "NEXT_PUBLIC_STREAMS_URL", + "http://localhost:4647", + "https://streams.superset.sh", + ), ), - "process.env.NEXT_PUBLIC_WEB_URL": defineEnv( - process.env.NEXT_PUBLIC_WEB_URL, - "https://app.superset.sh", + "process.env.NEXT_PUBLIC_WEB_URL": JSON.stringify( + devOrProdUrl( + "NEXT_PUBLIC_WEB_URL", + "http://localhost:4640", + "https://app.superset.sh", + ), ), "process.env.NEXT_PUBLIC_MARKETING_URL": defineEnv( process.env.NEXT_PUBLIC_MARKETING_URL, @@ -76,7 +86,16 @@ export default defineConfig({ "process.env.SENTRY_DSN_DESKTOP": defineEnv( process.env.SENTRY_DSN_DESKTOP, ), - "process.env.RELAY_URL": defineEnv(process.env.RELAY_URL), + "process.env.RELAY_URL": JSON.stringify( + devOrProdUrl( + "RELAY_URL", + "http://localhost:4653", + "https://relay.superset.sh", + ), + ), + "process.env.SUPERSET_EXPLICIT_RELAY_URL": defineEnv( + process.env.RELAY_URL ? "1" : "", + ), // Must match renderer for analytics in main process "process.env.NEXT_PUBLIC_POSTHOG_KEY": defineEnv( process.env.NEXT_PUBLIC_POSTHOG_KEY, @@ -169,22 +188,35 @@ export default defineConfig({ process.env.SKIP_ENV_VALIDATION, "", ), + "process.env.SUPERSET_PROFILE": defineEnv( + process.env.SUPERSET_PROFILE, + "", + ), "process.platform": defineEnv(process.platform), - "process.env.NEXT_PUBLIC_API_URL": defineEnv( - process.env.NEXT_PUBLIC_API_URL, - "https://api.superset.sh", + "process.env.NEXT_PUBLIC_API_URL": JSON.stringify( + devOrProdUrl( + "NEXT_PUBLIC_API_URL", + "http://localhost:4641", + "https://api.superset.sh", + ), ), - "process.env.NEXT_PUBLIC_WEB_URL": defineEnv( - process.env.NEXT_PUBLIC_WEB_URL, - "https://app.superset.sh", + "process.env.NEXT_PUBLIC_WEB_URL": JSON.stringify( + devOrProdUrl( + "NEXT_PUBLIC_WEB_URL", + "http://localhost:4640", + "https://app.superset.sh", + ), ), "process.env.NEXT_PUBLIC_MARKETING_URL": defineEnv( process.env.NEXT_PUBLIC_MARKETING_URL, "https://superset.sh", ), - "process.env.NEXT_PUBLIC_ELECTRIC_URL": defineEnv( - process.env.NEXT_PUBLIC_ELECTRIC_URL, - "https://electric-proxy.avi-6ac.workers.dev", + "process.env.NEXT_PUBLIC_ELECTRIC_URL": JSON.stringify( + devOrProdUrl( + "NEXT_PUBLIC_ELECTRIC_URL", + "https://localhost:4650", + "https://electric-proxy.avi-6ac.workers.dev", + ), ), "process.env.NEXT_PUBLIC_DOCS_URL": defineEnv( process.env.NEXT_PUBLIC_DOCS_URL, @@ -200,7 +232,16 @@ export default defineConfig({ "import.meta.env.SENTRY_DSN_DESKTOP": defineEnv( process.env.SENTRY_DSN_DESKTOP, ), - "process.env.RELAY_URL": defineEnv(process.env.RELAY_URL), + "process.env.RELAY_URL": JSON.stringify( + devOrProdUrl( + "RELAY_URL", + "http://localhost:4653", + "https://relay.superset.sh", + ), + ), + "process.env.SUPERSET_EXPLICIT_RELAY_URL": defineEnv( + process.env.RELAY_URL ? "1" : "", + ), "process.env.STREAMS_URL": defineEnv( process.env.STREAMS_URL, "https://superset-stream.fly.dev", diff --git a/apps/desktop/scripts/clean-launch-services.ts b/apps/desktop/scripts/clean-launch-services.ts index 8f8dda03557..d60686b3f2f 100644 --- a/apps/desktop/scripts/clean-launch-services.ts +++ b/apps/desktop/scripts/clean-launch-services.ts @@ -12,6 +12,14 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; +import { config } from "dotenv"; + +// Let contributor-local .env opt out before touching global Launch Services. +config({ + path: resolve(import.meta.dirname, "../../../.env"), + override: true, + quiet: true, +}); if (process.platform !== "darwin") { process.exit(0); @@ -22,6 +30,11 @@ if (process.env.NODE_ENV !== "development") { process.exit(0); } +if (process.env.SUPERSET_PROFILE === "local") { + console.log("[clean-launch-services] Skipping - local profile"); + process.exit(0); +} + const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; const WORKTREE_BASE = resolve(homedir(), ".superset/worktrees"); diff --git a/apps/desktop/scripts/patch-dev-protocol.ts b/apps/desktop/scripts/patch-dev-protocol.ts index dc89ff36fe0..871905fdfbc 100644 --- a/apps/desktop/scripts/patch-dev-protocol.ts +++ b/apps/desktop/scripts/patch-dev-protocol.ts @@ -175,6 +175,11 @@ export function main() { process.exit(0); } + if (process.env.SUPERSET_PROFILE === "local") { + console.log("[patch-dev-protocol] Skipping - local profile"); + process.exit(0); + } + // Prefer path-derived name so stale .env values never override the active worktree. const { bundleDisplayWorkspaceName, workspaceName, worktreePath } = resolveWorkspaceIdentity({ diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index 13fe2e1af5a..6fa5939b502 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -9,23 +9,106 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod/v4"; +// NOTE: deployment-profile checks are inlined here rather than imported from +// @superset/shared/deployment-profile because electron.vite.config.ts does +// `await import("./src/main/env.main")` at config-load time, which Node's ESM +// loader handles directly (no Vite transform) — and Node can't load `.ts` +// files from sibling workspace packages. Keep this in sync with shared/. +function isTruthyFlag(value: string | undefined): boolean { + return value === "1" || value === "true"; +} + +type MainDeploymentProfile = "cloud" | "local" | "ci" | "internal"; +const VALID_PROFILES: MainDeploymentProfile[] = [ + "cloud", + "local", + "ci", + "internal", +]; + +function getExplicitProfile(): MainDeploymentProfile | undefined { + const explicitProfile = process.env.SUPERSET_PROFILE; + if (!explicitProfile) return undefined; + if (VALID_PROFILES.includes(explicitProfile as MainDeploymentProfile)) { + return explicitProfile as MainDeploymentProfile; + } + throw new Error( + `Invalid SUPERSET_PROFILE="${explicitProfile}". Expected one of: ${VALID_PROFILES.join( + ", ", + )}.`, + ); +} + +function getDeploymentProfile(): MainDeploymentProfile { + if (isTruthyFlag(process.env.VERCEL) || process.env.VERCEL_ENV) { + return "cloud"; + } + const explicitProfile = getExplicitProfile(); + if (explicitProfile) 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); + export const env = createEnv({ server: { NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), - NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), - NEXT_PUBLIC_STREAMS_URL: z.url().default("https://streams.superset.sh"), + // In dev builds (NODE_ENV=development) the URL defaults switch to + // localhost so fresh-clone local contributors never silently sync + // against hosted production endpoints. + NEXT_PUBLIC_API_URL: z + .url() + .default( + process.env.NODE_ENV === "development" + ? "http://localhost:4641" + : "https://api.superset.sh", + ), + NEXT_PUBLIC_STREAMS_URL: z + .url() + .default( + process.env.NODE_ENV === "development" + ? "http://localhost:4647" + : "https://streams.superset.sh", + ), NEXT_PUBLIC_ELECTRIC_URL: z .url() - .default("https://electric-proxy.avi-6ac.workers.dev"), - NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + .default( + process.env.NODE_ENV === "development" + ? "https://localhost:4650" + : "https://electric-proxy.avi-6ac.workers.dev", + ), + 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_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN_DESKTOP: z.string().optional(), - STREAMS_URL: z.url().default("https://superset-stream.fly.dev"), - RELAY_URL: z.url().default("https://relay.superset.sh"), + STREAMS_URL: z + .url() + .default( + process.env.NODE_ENV === "development" + ? "http://localhost:4647" + : "https://superset-stream.fly.dev", + ), + RELAY_URL: z + .url() + .default( + process.env.NODE_ENV === "development" + ? "http://localhost:4653" + : "https://relay.superset.sh", + ), }, runtimeEnv: { @@ -45,9 +128,7 @@ export const env = createEnv({ RELAY_URL: process.env.RELAY_URL, }, emptyStringAsUndefined: true, - // Only allow skipping validation in development (never in production) - skipValidation: - process.env.NODE_ENV === "development" && !!process.env.SKIP_ENV_VALIDATION, + skipValidation, // Main process runs in trusted Node.js environment isServer: true, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 181fde3e22e..ad5053c31d5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { settings } from "@superset/local-db"; +import { isLocalProfile } from "@superset/shared/deployment-profile"; import { app, BrowserWindow, @@ -28,6 +29,7 @@ import { initAppState } from "./lib/app-state"; import { requestAppleEventsAccess } from "./lib/apple-events-permission"; import { isUpdateReadyToInstall, 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 +57,13 @@ import { MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; +// Local profile only: expose Chrome DevTools Protocol for headless testing +// (e.g. import/host-service checks). Skip in internal / cloud profiles. +if (IS_DEV && isLocalProfile()) { + 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); }); @@ -418,6 +427,14 @@ if (!gotTheLock) { console.error("[main] Failed to install bundled CLI shim:", error); } + // Local profile only: auto-sign-in as the seed admin if no token is on disk. + // Fire-and-forget — the function polls the API for readiness internally + // (Turbo starts services concurrently, the API may still be compiling). + // AuthProvider in the renderer subscribes to auth.onTokenChanged and + // will re-hydrate when the token lands, so window creation doesn't + // 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(); 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..10c33969d04 --- /dev/null +++ b/apps/desktop/src/main/lib/dev-auto-sign-in.ts @@ -0,0 +1,165 @@ +import { isLocalProfile } from "@superset/shared/deployment-profile"; +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; +const DEV_AUTH_TIMEOUT_MS = 8000; + +// API may take a few seconds to compile on first dev launch (Turbo starts +// services concurrently). Poll /api/auth/ok before giving up. +const HEALTH_POLL_INTERVAL_MS = 1000; +const HEALTH_POLL_TIMEOUT_MS = 60_000; + +interface SignInResponse { + token?: string; + user?: { id: string }; +} + +interface AuthErrorBody { + code?: string; + message?: string; +} + +interface SessionResponse { + user?: unknown; +} + +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), + signal: AbortSignal.timeout(DEV_AUTH_TIMEOUT_MS), + }); + const data = (await res.json().catch(() => ({}))) as T | AuthErrorBody; + return { ok: res.ok, status: res.status, data }; +} + +async function waitForApiReady(): Promise { + const start = Date.now(); + const url = `${mainEnv.NEXT_PUBLIC_API_URL}/api/auth/ok`; + while (Date.now() - start < HEALTH_POLL_TIMEOUT_MS) { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return true; + } catch { + // connection refused / timeout — keep polling + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + return false; +} + +async function isStoredTokenValid(token: string): Promise { + try { + const res = await fetch( + `${mainEnv.NEXT_PUBLIC_API_URL}/api/auth/get-session`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(DEV_AUTH_TIMEOUT_MS), + }, + ); + if (!res.ok) return false; + + const data = (await res.json().catch(() => null)) as SessionResponse | null; + return !!data?.user; + } catch { + return false; + } +} + +/** + * Dev-only: in the local profile, 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. + * + * Polls the API for readiness before attempting sign-in (Turbo starts + * services concurrently and the API may still be compiling on first + * launch). Best-effort: failure is logged but doesn't crash boot. + */ +export async function ensureDevAuthToken(): Promise { + // Local profile only — internal devs, self-hosters, and prod all use real auth. + if (!isLocalProfile()) return; + + const stored = await loadToken(); + if (stored.token && stored.expiresAt) { + const expiresAt = new Date(stored.expiresAt); + const isExpired = + Number.isNaN(expiresAt.getTime()) || expiresAt < new Date(); + if (!isExpired) { + const ready = await waitForApiReady(); + if (!ready) { + console.warn( + `[dev-auto-sign-in] API at ${mainEnv.NEXT_PUBLIC_API_URL} did not respond within ${HEALTH_POLL_TIMEOUT_MS}ms — skipping. Use the sign-in form once the API is up.`, + ); + return; + } + + if (await isStoredTokenValid(stored.token)) return; + + console.log("[dev-auto-sign-in] stored token is stale; refreshing"); + } + } + + const ready = await waitForApiReady(); + if (!ready) { + console.warn( + `[dev-auto-sign-in] API at ${mainEnv.NEXT_PUBLIC_API_URL} did not respond within ${HEALTH_POLL_TIMEOUT_MS}ms — skipping. Use the sign-in form once the API is up.`, + ); + 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/main/lib/dev-workspace-name.ts b/apps/desktop/src/main/lib/dev-workspace-name.ts index ee730f59e34..472a74404e6 100644 --- a/apps/desktop/src/main/lib/dev-workspace-name.ts +++ b/apps/desktop/src/main/lib/dev-workspace-name.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { workspaces, worktrees } from "@superset/local-db"; +import { isLocalProfile } from "@superset/shared/deployment-profile"; import BetterSqlite3 from "better-sqlite3"; import { and, desc, eq, isNull } from "drizzle-orm"; import { getWorkspaceName as getEnvWorkspaceName } from "shared/env.shared"; @@ -102,16 +103,22 @@ export function resolveDevWorkspaceName( ): string | undefined { if (!IS_DEV) return undefined; + const workspaceNameFromEnv = getEnvWorkspaceName(); const segments = getWorktreeSegmentsFromCwd(cwd); - if (!segments) return getEnvWorkspaceName(); + if (!segments) return workspaceNameFromEnv; const workspaceNameFromPath = deriveWorkspaceNameFromWorktreeSegments(segments); + + if (isLocalProfile()) { + return workspaceNameFromEnv ?? workspaceNameFromPath; + } + const worktreePath = getWorktreePathFromSegments(segments); const workspaceNameFromDb = worktreePath ? (getWorkspaceNameForPathFromCurrentDb(worktreePath) ?? getWorkspaceNameForPathFromProdDb(worktreePath)) : undefined; - return workspaceNameFromDb ?? workspaceNameFromPath ?? getEnvWorkspaceName(); + return workspaceNameFromDb ?? workspaceNameFromPath ?? workspaceNameFromEnv; } diff --git a/apps/desktop/src/main/lib/relay-url/relay-url.ts b/apps/desktop/src/main/lib/relay-url/relay-url.ts index eafc30e7f62..8574c6470cc 100644 --- a/apps/desktop/src/main/lib/relay-url/relay-url.ts +++ b/apps/desktop/src/main/lib/relay-url/relay-url.ts @@ -1,5 +1,5 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; -import { env } from "main/env.main"; +import { deploymentProfile, env } from "main/env.main"; import { getPosthogClient, getUserId } from "main/lib/analytics"; interface RelayUrlPayload { @@ -16,7 +16,13 @@ interface RelayUrlPayload { * opens land on the same URL. */ export async function getRelayUrl(): Promise { - const fallback = env.RELAY_URL; + const usesLenientLocalEnv = + deploymentProfile === "local" || deploymentProfile === "ci"; + // Vite always injects RELAY_URL, so track whether the value came from a + // real env var before enabling local tunnels in contributor profiles. + const hasExplicitRelayUrl = process.env.SUPERSET_EXPLICIT_RELAY_URL === "1"; + const fallback = + usesLenientLocalEnv && !hasExplicitRelayUrl ? undefined : env.RELAY_URL; const client = getPosthogClient(); const userId = getUserId(); if (!client || !userId) return fallback; diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index 445b0be9d46..495be6feee9 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -15,16 +15,43 @@ const envSchema = z.object({ NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), - NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), - NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + // 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("https://electric-proxy.avi-6ac.workers.dev"), + .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(), - RELAY_URL: z.url().default("https://relay.superset.sh"), + 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", + ), }); /** @@ -47,6 +74,7 @@ const rawEnv = { | string | undefined, SENTRY_DSN_DESKTOP: import.meta.env.SENTRY_DSN_DESKTOP as string | undefined, + SUPERSET_PROFILE: process.env.SUPERSET_PROFILE, RELAY_URL: process.env.RELAY_URL, }; 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 fc0c44460f4..6fc59ad5d73 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 = 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 d758e23487e..639578896d4 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 = 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 b2023e809f6..52c39d07df4 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 = 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 9f2b09c8f43..ea26b80efa9 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 = 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 e19226947d5..94f2d8da568 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 = 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/components/LocalDevAuthForm/LocalDevAuthForm.tsx b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/LocalDevAuthForm.tsx new file mode 100644 index 00000000000..e0f1f6bc881 --- /dev/null +++ b/apps/desktop/src/renderer/routes/sign-in/components/LocalDevAuthForm/LocalDevAuthForm.tsx @@ -0,0 +1,148 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useNavigate } from "@tanstack/react-router"; +import { type FormEvent, useState } from "react"; +import { env } from "renderer/env.renderer"; +import { setAuthToken } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +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 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 LocalDevAuthForm() { + const navigate = useNavigate(); + const persistToken = electronTrpc.auth.persistToken.useMutation(); + const [email, setEmail] = useState(DEV_EMAIL); + const [password, setPassword] = useState(DEV_PASSWORD); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + + try { + let signIn = await postAuth("/api/auth/sign-in/email", { + email, + password, + }); + + const errBody = signIn.data as AuthErrorBody; + if ( + !signIn.ok && + errBody.code === "INVALID_EMAIL_OR_PASSWORD" && + email === DEV_EMAIL && + password === DEV_PASSWORD + ) { + const signUp = await postAuth("/api/auth/sign-up/email", { + email, + 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, + 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 }); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign-in failed"); + setSubmitting(false); + } + }; + + return ( +
+
+ + setEmail(event.target.value)} + required + /> +
+
+ + setPassword(event.target.value)} + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+ ); +} 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 ddfc693a961..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,9 +18,10 @@ 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: 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 ; } @@ -63,7 +65,11 @@ function SignInPage() {

-
+
+ {isLocalProfile && } + + {isLocalProfile &&
} + + + ); +} 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 && }