diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ec7c2ac03e..1b9eb3fe0e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -bd4de74eb37b32a2b6c7c69f6dedac031ef8436b +483513b70979aa9497cab869837108d948449984 diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 7ba930c3ea..c45d0c79f9 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1123,12 +1123,18 @@ async fn get_datatable_resource_inner( serde_json::to_value(&pg_creds) .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? } else { + // Name the data table too: the caller asked for one by name, and a bare + // "resource f/x/y does not exist" leaves them to work out which one points at it. transform_json_unchecked( &serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)), w_id, db, ) - .await? + .await + .map_err(|e| match e { + Error::NotFound(m) => Error::NotFound(format!("data table {name}: {m}")), + e => e, + })? }; Ok(db_resource) @@ -2105,25 +2111,32 @@ async fn transform_json_unchecked( serde_json::Value::Array(transformed_array) } serde_json::Value::String(s) if s.starts_with("$res:") => { + // A reference to something that was deleted is the common failure here, and + // `fetch_one` reports it as "no rows returned by a query that expected to + // return at least one row" -- which names neither what was missing nor where. + let path = &s[5..]; let resource = sqlx::query_scalar!( "SELECT value AS \"value!: _\" FROM resource WHERE workspace_id = $1 AND path = $2", &w_id, - &s[5..] + path ) - .fetch_one(db) + .fetch_optional(db) .await - .map_err(to_anyhow)?; + .map_err(to_anyhow)? + .ok_or_else(|| Error::NotFound(format!("resource {path} does not exist")))?; transform_json_unchecked(&resource, w_id, db).await? } serde_json::Value::String(s) if s.starts_with("$var:") => { + let path = &s[5..]; let (value, is_secret): (String, bool) = sqlx::query_as( "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", ) .bind(&w_id) - .bind(&s[5..]) - .fetch_one(db) + .bind(path) + .fetch_optional(db) .await - .map_err(to_anyhow)?; + .map_err(to_anyhow)? + .ok_or_else(|| Error::NotFound(format!("variable {path} does not exist")))?; let value = if is_secret { if is_external_stored_value(&value) { get_secret_value(db, w_id, &s[5..], &value).await? diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index e3927ddc64..e4ee3ca7b9 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -11,12 +11,14 @@ import Button from './common/button/Button.svelte' import { Loader2 } from 'lucide-svelte' import { untrack } from 'svelte' - import { base } from '$lib/base' import GitHubAppIntegration from './GitHubAppIntegration.svelte' import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte' import { isCloudHosted } from '$lib/cloud' import ResourceGen from './copilot/ResourceGen.svelte' import SyncResourceTypes from './SyncResourceTypes.svelte' + import { base } from '$lib/base' + import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte' + import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString' interface Props { resourceType: string @@ -98,35 +100,42 @@ let connectionString = $state('') let validConnectionString = $state(true) function parseConnectionString(close: (_: any) => void) { - const regex = - /postgres(?:ql)?:\/\/(?[^:@]+)(?::(?[^@]+))?@(?[^:\/?]+)(?::(?\d+))?\/(?[^\?]+)?(?:\?.*sslmode=(?[^&]+))?/ - const match = connectionString.match(regex) - if (match) { - validConnectionString = true - const { user, password, host, port, dbname, sslmode } = match.groups! - rawCode = JSON.stringify( - { - ...args, - user, - password: password || args?.password, - host, - port: (port ? Number(port) : undefined) || args?.port, - dbname: dbname || args?.dbname, - sslmode: sslmode || args?.sslmode - }, - null, - 2 - ) - rawCodeEditor?.setCode(rawCode) - close(null) - } else { + const parts = parsePostgresConnectionString(connectionString) + if (!parts) { validConnectionString = false + return } + validConnectionString = true + rawCode = JSON.stringify( + { + ...args, + user: parts.user, + password: parts.password || args?.password, + host: parts.host, + port: parts.port || args?.port, + dbname: parts.dbname || args?.dbname, + sslmode: parts.sslmode || args?.sslmode + }, + null, + 2 + ) + rawCodeEditor?.setCode(rawCode) + close(null) } let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined) let textFileContent: string | undefined = $state(undefined) + // The wizard's Supabase entry point is opt-in for now; without it the form keeps the link + // that hands the whole leg over to the resources page. + const wizardEnabled = isDataTableWizardEnabled() + + function applySupabasePick(value: Record) { + args = { ...(args ?? {}), ...value } + rawCode = JSON.stringify(args, null, 2) + rawCodeEditor?.setCode(rawCode) + } + function parseTextFileContent() { args = { content: textFileContent @@ -172,7 +181,7 @@ }} > {#snippet trigger()} - {/snippet} @@ -206,14 +215,28 @@ {/if} {#if resourceType == 'postgresql' && supabaseWizard} - - -
Connect Supabase
-
+ {#if wizardEnabled} + + {#await import('./workspaceSettings/SupabaseResourceConnect.svelte')} + + {:then Module} + + {/await} + {:else} + + + +
Connect Supabase
+
+ {/if} {/if} {:else if step == 2 && manual} -
+
{#if !emptyString(resourceTypeInfo?.description)} {/if} @@ -1332,18 +1332,22 @@ Acquire the token automatically via client credentials instead {/if} - {#key resourceTypeInfo} - - {/key} + +
+ {#key resourceTypeInfo} + + {/key} +
{:else if step == 2 && !manual} {#if manual == false && resourceType != ''} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 284677ce7c..639081cc3d 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -85,6 +85,10 @@ * workspace when the editor operates on a workspace other than the one the * top nav points at (see the sessions preview / dev-workspace flows). */ workspaceOverride?: string + /** One path that does not count as taken, for a caller creating something that may + * already have written there itself — a setup flow correcting its own failed attempt. + * Every other existing path is still refused. */ + allowedExistingPath?: string } let { @@ -102,7 +106,8 @@ disableEditing = false, size = 'md', drawerOffset = 0, - workspaceOverride = undefined + workspaceOverride = undefined, + allowedExistingPath = undefined }: Props = $props() let ws = $derived(workspaceOverride ?? $workspaceStore) @@ -240,6 +245,7 @@ } validateTimeout = setTimeout(async () => { if ( + path !== allowedExistingPath && (path == '' || checkInitialPathExistence || path != initialPath) && (await pathExists(path, kind)) ) { @@ -420,8 +426,12 @@ }) } }) + // Nothing depends on an item that does not exist yet, so editing a *suggested* path is not a + // rename. `checkInitialPathExistence` is what callers set when they are creating something, + // which is the same question asked the other way round. let displayPathChangedWarning = $derived( (['flow', 'script', 'resource', 'variable'] as PathKind[]).includes(kind) && + !checkInitialPathExistence && initialPath && initialPath !== path ) diff --git a/frontend/src/lib/components/WhitelistIp.svelte b/frontend/src/lib/components/WhitelistIp.svelte index be2f8f56dd..fbc32d79b6 100644 --- a/frontend/src/lib/components/WhitelistIp.svelte +++ b/frontend/src/lib/components/WhitelistIp.svelte @@ -22,7 +22,6 @@ {#if ips} -
If necessary, the workers IPs to whitelist are: {ips.join(', ')} diff --git a/frontend/src/lib/components/common/alert/Alert.svelte b/frontend/src/lib/components/common/alert/Alert.svelte index 7c03de5dca..c3d86693be 100644 --- a/frontend/src/lib/components/common/alert/Alert.svelte +++ b/frontend/src/lib/components/common/alert/Alert.svelte @@ -54,6 +54,10 @@ } const SvelteComponent = $derived(icons[type]) + + // A blank title would still occupy a text line and push the body down, leaving an alert + // that is visibly top-heavy. Body-only alerts skip the row, and the gap under it, entirely. + const hasTitleRow = $derived(!!title || collapsible || tooltip != '' || !!documentationLink)
-
- - {title} - {#if tooltip != '' || documentationLink} - {tooltip} - {/if} - - {#if collapsible} - - {/if} -
- - {#if children && !isCollapsed} -
-
- {@render children?.()} -
+ + {#if collapsible} + + {/if}
- {:else if children && !collapsible} -
-
- {@render children?.()} -
+ {/if} + + {#if children && (!collapsible || !isCollapsed)} +
+ {@render children?.()}
{/if}
diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index c00bf758d0..f33b1dec04 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -26,6 +26,9 @@ * and clicks "outside" the child would otherwise propagate * here and close the underlying modal. */ closeOnOutsideClick?: boolean + /** Wider side padding and a lighter title, for a dialog whose body is a form rather + * than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */ + formStyling?: boolean headerLeft?: import('svelte').Snippet headerRight?: import('svelte').Snippet children?: import('svelte').Snippet @@ -43,6 +46,7 @@ fixedHeight = 'md', contentClasses = '', closeOnOutsideClick = true, + formStyling = false, headerLeft, headerRight, children @@ -91,7 +95,9 @@ // Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so // the dialog isn't hidden behind it; otherwise keep the default modal // stacking just above disposables (zIndexes.disposables). - const overlayZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10) + const overlayZIndex = $derived( + chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10 + ) @@ -109,7 +115,8 @@ heightMap[fixedHeight] ? `height: ${heightMap[fixedHeight]}; ` : '' }${css?.popup?.style || ''}`} class={twMerge( - 'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface p-4', + 'max-h-screen-80 max-w-screen-80 rounded-lg relative bg-surface', + formStyling ? 'py-4 px-6' : 'p-4', css?.popup?.class, 'wm-modal-form-popup' )} @@ -120,7 +127,7 @@
-

{title}

+

{title}

diff --git a/frontend/src/lib/components/common/stepper/Stepper.svelte b/frontend/src/lib/components/common/stepper/Stepper.svelte index bbd6b1afe4..de6303cedc 100644 --- a/frontend/src/lib/components/common/stepper/Stepper.svelte +++ b/frontend/src/lib/components/common/stepper/Stepper.svelte @@ -4,12 +4,14 @@ import { createEventDispatcher } from 'svelte' interface Props { - tabs: string[]; - selectedIndex?: number; - maxReachedIndex?: number; - statusByStep?: Array<'success' | 'error' | 'pending'>; - hasValidations?: boolean; - allowStepNavigation?: boolean; + tabs: string[] + selectedIndex?: number + maxReachedIndex?: number + statusByStep?: Array<'success' | 'error' | 'pending'> + hasValidations?: boolean + allowStepNavigation?: boolean + /** Compact variant, for steering a dialog rather than a full page. */ + small?: boolean } let { @@ -18,8 +20,9 @@ maxReachedIndex = -1, statusByStep = [], hasValidations = false, - allowStepNavigation = false - }: Props = $props(); + allowStepNavigation = false, + small = false + }: Props = $props() const dispatch = createEventDispatcher() @@ -63,13 +66,20 @@
-
    +
      {#each tabs ?? [] as step, index}
    1. { @@ -77,11 +87,13 @@ }} > {#if statusByStep[index] === 'pending'} - + {:else} {#if index !== (tabs ?? []).length - 1}
    2. -
      +
    3. {/if} {/each} diff --git a/frontend/src/lib/components/copilot/ResourceGen.svelte b/frontend/src/lib/components/copilot/ResourceGen.svelte index 42e42fb9fa..34534285b4 100644 --- a/frontend/src/lib/components/copilot/ResourceGen.svelte +++ b/frontend/src/lib/components/copilot/ResourceGen.svelte @@ -124,6 +124,7 @@ + {:else} + {step.title} + {/if} + + {#if descriptionOpened} +
      + {step.description} +
      + {/if} +
      +
+
+ {#if step.substeps?.length} +
+ +
+ {/if} +
+ {/each} +
diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte new file mode 100644 index 0000000000..2e19cbea82 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -0,0 +1,1435 @@ + + + opened, + (v) => { + if (!v) requestClose() + else opened = v + } + } + target="#content" + formStyling + title="Add a data table" + contentClasses="flex flex-col" + fixedWidth="md" + fixedHeight="lg" +> +
+ goToStep(e.detail.index)} + /> + +
+
+ {#if run.steps.length} + + {#if run.running} +

+ Setting up. This can take a few minutes — leave this open until it finishes. +

+ {/if} + {#if run.result} + {@render poolerWarning()} + + {/if} + {:else if wiz.step === 1} + + A data table runs on a database of your own. It stays yours — you can take it with + you at any time. + +
+ {#if $isCustomInstanceDbEnabled} + {#snippet instanceIcon()} + + {/snippet} + {@render providerCard( + 'instance', + instanceIcon, + 'Windmill database', + 'Windmill creates and manages a database on this instance.' + )} + {/if} + {#if supabaseAvailable} + {#snippet supabaseIcon()} + + {/snippet} + {@render providerCard( + 'supabase', + supabaseIcon, + 'Supabase', + 'Create a project, or connect one you already have. Signing in is required, and connecting an existing project needs its database password.' + )} + {/if} + {#snippet ownIcon()} + + {/snippet} + {@render providerCard( + 'resource', + ownIcon, + 'Your own database', + 'Any Postgres — RDS, Neon, self-hosted. Pick a resource, or paste a connection string.' + )} +
+ {:else if wiz.step === 2} + {#if wiz.provider === 'supabase'} + {#if !supaOauth.authed} + + {#if supaOauth.pending} + Sign in and approve Windmill in the Supabase window, then come back here. + {:else} + Windmill needs your approval on Supabase to see your databases. + {/if} + + {:else} + invalidate()} + /> + {/if} + {:else if wiz.provider === 'instance'} + {@render instanceStep()} + {:else} + {@render ownStep()} + {/if} + + + {:else} +
+ {@render reviewStep()} +
+ {/if} +
+ +
+
+
+ {#if wiz.step > 1 && !run.steps.length} + + {:else if canEditAfterFailure} + + + {/if} +
+ +
+ {#if wiz.provider === 'supabase' && !supaOauth.authed} +

+ If you do not have a Supabase account you can create one for free. +

+ {/if} +
+
+
+
+ +{#snippet providerCard(key: Provider, icon: Snippet, title: string, subtitle: string)} + {@const selected = wiz.provider === key} + +{/snippet} + +{#snippet instanceStep()} + {@const instanceDbs = Object.entries(customInstanceDbs.current ?? {}) + .filter(([_, db]) => db.tag === 'datatable') + .map(([name, db]) => ({ name, db }))} + {#if instanceDbs.length} + wiz.instance.mode, + (v) => { + wiz.instance.mode = v + wiz.instance.dbName = v === 'create' ? defaultInstanceDbName() : undefined + } + } + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if wiz.instance.mode === 'existing'} + {@const shared = ( + customInstanceDbs.current?.[wiz.instance.dbName ?? '']?.used_by_workspaces ?? [] + ).filter((w) => w !== $workspaceStore)} + + {#if shared.length} + + This database is also used by workspace{shared.length > 1 ? 's' : ''} + {shared.join(', ')}. Any data written here will be shared + with {shared.length > 1 ? 'them' : 'it'}. + + {/if} +
+ {#each instanceDbs as { name, db } (name)} + {@const selected = wiz.instance.dbName === name} + {@const others = (db.used_by_workspaces ?? []).filter((w) => w !== $workspaceStore)} + + {/each} +
+ {:else} +
+ Database name + wiz.instance.dbName ?? '', (v) => (wiz.instance.dbName = v)} + error={!!instanceNameError} + inputProps={{ placeholder: defaultInstanceDbName() }} + /> + + {#if !instanceNameError} +

+ Created in the Windmill PostgreSQL instance when you finish. Windmill manages its + credentials. +

+ {/if} +
+ {/if} +{/snippet} + +{#snippet ownStep()} + {@const resources = pgResources.loading ? undefined : pgResources.current} + {#if resources === undefined} +

Loading resources...

+ {:else} + {#if resources.length} + Postgres resources in this workspace + {:else} +

A resource is a saved connection your scripts can use.

+ {/if} +
+ {#each resources as r (r.path)} + {@const selected = !wiz.own.creating && wiz.own.resourcePath === r.path} + + {/each} + +
+ + {#if wiz.own.creating} +
{@render newResourceForm()}
+ {/if} +
+
+ {/if} +{/snippet} + +{#snippet newResourceForm()} +
+
+ + {wiz.own.form === 'string' ? 'Connection string' : 'Connection'} + + +
+ {#if wiz.own.form === 'string'} + wiz.own.connectionString, + (v) => { + wiz.own.connectionString = v + absorbConnectionString(v) + invalidate() + } + } + error={!!connectionStringError} + inputProps={{ placeholder: 'postgres://user:password@host:5432/database' }} + /> + + {:else} +
+
+ Host + wiz.own.fields.host, (v) => setField('host', v)} + inputProps={{ placeholder: 'db.example.com' }} + /> +
+
+ Port + wiz.own.fields.port ?? '', + (v) => setField('port', v === '' ? undefined : Number(v)) + } + inputProps={{ placeholder: '5432', type: 'number' }} + /> +
+
+ Database + wiz.own.fields.dbname ?? '', (v) => setField('dbname', v)} + inputProps={{ placeholder: 'postgres' }} + /> +
+
+ SSL mode + certVerification, + (v) => + setAdvanced('accept_invalid_certs', v === 'default' ? undefined : v === 'accept') + } + clearable={false} + /> +
+ setAdvanced('use_iam_auth', e.detail)} + options={{ right: 'Authenticate with AWS IAM' }} + /> + {#if wiz.own.advanced.use_iam_auth} +
+ Region + wiz.own.advanced.region, (v) => setAdvanced('region', v)} + inputProps={{ placeholder: 'us-east-1' }} + /> +
+ {/if} +
+ +
+{/snippet} + +{#snippet poolerWarning()} + {#if poolerUnavailable} + +
+ {poolerUnavailable} + + Windmill connects directly instead, which needs IPv6 from the workers, or the IPv4 add-on + on the project. Granting the Supabase OAuth app + database_pooling_config_read and connecting again restores the + pooler. + +
+
+ {/if} +{/snippet} + +{#snippet reviewStep()} + {#if lastFailure} + {lastFailure} + {/if} + + + {#if wiz.provider === 'supabase'} + + + {@render poolerWarning()} + {:else if wiz.provider === 'instance'} + + {:else if !wiz.own.creating} + + {/if} + + {#if mintsResource} + + {/if} + + {#if sharesDatabaseWith} + + {sharesDatabaseWith.name} already uses this database. Both data + tables would write to the same schema, so each one's tables are visible to the other and two tables + of the same name collide. Migrations are tracked per data table, so those stay separate. + + {/if} + + {#if wiz.provider === 'supabase'} + + Your Supabase sign-in is not stored. If the database password ever changes, anyone with access + to the project can sign in and reconnect it. Deleting the data table never deletes the + Supabase project. + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte index 644f5e2af5..8246118c49 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte @@ -8,7 +8,7 @@ import { slide } from 'svelte/transition' import Modal2 from '../common/modal/Modal2.svelte' import Alert from '../common/alert/Alert.svelte' - import LoggedWizardResult, { firstEmptyStepIsError } from '../wizards/LoggedWizardResult.svelte' + import SetupChecklist from '../wizards/SetupChecklist.svelte' import Button from '../common/button/Button.svelte' import { sendUserToast } from '$lib/toast' import { isCustomInstanceDbEnabled } from './utils.svelte' @@ -20,6 +20,7 @@ import { truncate } from '$lib/utils' import Tooltip from '../meltComponents/Tooltip.svelte' import { superadmin } from '$lib/stores' + import { instanceSetupSteps } from './instanceDbSteps' type Props = { customInstanceDbs: ResourceReturn @@ -45,6 +46,7 @@ !!opened, (v) => !v && !preventClose && (opened = undefined)} target="#content" + formStyling title={'Custom Instance Database Setup'} contentClasses="flex flex-col" fixedWidth="md" @@ -59,7 +61,7 @@
{dbname} - + Custom instance databases are databases created in the Windmill PostgreSQL instance. Their credentials are automatically managed by Windmill and are never exposed to users. Only super admins can create them. @@ -127,68 +129,8 @@
{/if} -
{#if $superadmin} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte b/frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte new file mode 100644 index 0000000000..68984321a3 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DataTableConnectionReport.svelte @@ -0,0 +1,77 @@ + + +{#if error} + + {error} + +{:else if report} + +
+
+ Connects as {report.user}{#if report.schema}, resolving + unqualified statements to schema {report.schema}{/if}. +
+ {#if report.suggested_search_path} +
+ Its search_path resolves to no schema, so unqualified statements fail with + no schema has been selected to create in whatever + privileges the role holds. Point it at one, e.g. + {report.suggested_search_path}. +
+ {/if} +
    +
  • + Create tables{report.schema ? ` in ${report.schema}` : ''}: + {report.can_create_table ? 'yes' : 'no'} +
  • +
  • + Create schemas: + {report.can_create_schema ? 'yes' : 'no'} +
  • +
  • + Migration bookkeeping table exists: + {report.migrations_table_exists ? 'yes' : 'no'} +
  • +
+ {#if report.suggested_grants.length > 0} +
+ Windmill connects as the role that lacks these privileges, so it cannot grant them itself. + Run as a schema owner or superuser on that database: +
+
{report.suggested_grants.map((g) => `${g};`).join('\n')}
+ {#if report.schema && !report.can_create_table && !report.migrations_table_exists} +
+ Alternatively, create the _wm_migrations bookkeeping table + yourself and grant only SELECT, INSERT, UPDATE, DELETE on it. +
+ {/if} + {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index 254a20fe80..bb97e434f6 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -66,7 +66,11 @@ import Row from '../table/Row.svelte' import TextInput from '../text_input/TextInput.svelte' import Tooltip from '../Tooltip.svelte' - import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte' + import { + isCustomInstanceDbEnabled, + getUnusedInstanceDbName, + isDataTableWizardEnabled + } from './utils.svelte' import { random_adj } from '../random_positive_adjetive' import { sendUserToast } from '$lib/toast' import { @@ -89,6 +93,10 @@ import Alert from '../common/alert/Alert.svelte' import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte' import { isCloudHosted } from '$lib/cloud' + import AddDataTableWizard from './AddDataTableWizard.svelte' + import { takeParkedWizard, type WizardResume } from './wizardParking' + import { Database } from 'lucide-svelte' + import { onMount } from 'svelte' type Props = { dataTableSettings: DataTableSettingsType @@ -156,6 +164,8 @@ return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames) } + // Kept for the flag-off path: adding a data table is a row in this table that the user + // fills in and saves, rather than a wizard. function onNewDataTable() { const name = tempSettings.dataTables.some((d) => d.name === 'main') ? `${random_adj()}_datatable` @@ -211,6 +221,37 @@ } } + const wizardEnabled = isDataTableWizardEnabled() + let wizardOpen = $state(false) + /** Opened through the wizard's own `open()`, which is what sets a fresh run up. */ + let wizard: { open: (parked?: WizardResume) => void } | undefined = $state(undefined) + let wizardResume: WizardResume | undefined = $state(undefined) + + // Supabase sends the user back here after authorizing; pick the wizard back up where it + // was rather than making them start again. + onMount(() => { + if (!wizardEnabled) return + const parked = takeParkedWizard() + if (parked) { + wizardResume = parked + // Handed in, not left to the `resume` prop: the wizard rebuilds the run synchronously + // inside this call, and a parked run that arrived late would come back as a fresh one. + wizard?.open(parked) + } + }) + + /** + * The wizard persists what it creates, so the server is authoritative afterwards and the + * whole baseline comes from it. `tempSettings` derives from that baseline, so this discards + * uncommitted edits in the table -- which is why the wizard cannot be opened while there + * are any (see the disabled entry points below). + */ + async function reloadAfterWizard() { + const s = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + dataTableSettings = convertDataTableSettingsFromBackend(s.datatable) + wizardResume = undefined + } + let confirmationModal = createAsyncConfirmationModal() let dirtyMap = $derived.by(() => { const map: Record = {} @@ -241,7 +282,7 @@ @@ -273,9 +314,37 @@ {#if tempSettings.dataTables.length == 0} - - No data table in this workspace yet - + {#if wizardEnabled} + +
+ +
+ No data table yet +

+ Give your scripts a database to store and query data. + {#if isCloudHosted()} + Set one up free in about a minute. + {:else} + Use the Windmill database, or bring your own. + {/if} +

+
+ +
+
+ {:else} + + No data table in this workspace yet + + {/if}
{/if} {#each tempSettings.dataTables as dataTable, dataTableIndex (dataTable.id)} @@ -383,15 +452,27 @@ {/each} - - -
- -
-
-
+ {#if !wizardEnabled || tempSettings.dataTables.length > 0} + + +
+ +
+
+
+ {/if} @@ -467,3 +548,28 @@ /> + +{#if wizardEnabled} + wizardOpen, + (v) => { + wizardOpen = v + // Drop the parked run once the wizard closes: leaving it set would force the next + // open straight back to the Supabase setup step. + if (!v) wizardResume = undefined + } + } + existingNames={tempSettings.dataTables.map((d) => d.name)} + existingDataTables={tempSettings.dataTables.map((d) => ({ + name: d.name, + resourcePath: d.database.resource_path + }))} + resume={wizardResume} + onDone={reloadAfterWizard} + {customInstanceDbs} + {confirmationModal} + {defaultInstanceDbName} + /> +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte b/frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte new file mode 100644 index 0000000000..6f2523a729 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SupabaseConnectionMode.svelte @@ -0,0 +1,62 @@ + + +
+ + {#if open} +
+ + {#each OPTIONS as option (option.value)} + {@const selected = mode === option.value} + + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte b/frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte new file mode 100644 index 0000000000..0406587ce7 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SupabaseProjectStep.svelte @@ -0,0 +1,290 @@ + + +{#if loading} +
+ + Loading your Supabase projects... +
+{:else if (projects ?? []).length === 0 && existingOnly} + + This Supabase account has no projects yet. + +{:else} + {#if (projects ?? []).length} + Projects in your Supabase account + {:else} +

This Supabase account has no projects yet.

+ {/if} +
+ {#each projects ?? [] as p (projectRef(p))} + {@const selected = intent.mode === 'existing' && isSelected(intent.project, p)} + +
+ + {#if selected} +
+
+ Database password + intent.password, (v) => ((intent.password = v ?? ''), onIntentChange?.()) + } + placeholder="••••••••" + /> +

+ Supabase only shows this when the project is created, and never exposes it through + its API. If you no longer have it, set a new one — every existing connection to this project stops working when you do. +

+
+ +
+ {/if} +
+ {/each} + {#if !existingOnly} +
+ + {#if intent.mode === 'create'} +
{@render newProjectFields()}
+ {/if} +
+ {/if} +
+{/if} + +{#snippet newProjectFields()} +
+
+
+ Organization + + ({ label: r.label, value: r.code }))} + bind:value={() => intent.region, (v) => ((intent.region = v), onIntentChange?.())} + placeholder="Region" + /> +
+
+
+ Project name + intent.projectName, (v) => ((intent.projectName = String(v)), onIntentChange?.()) + } + inputProps={{ placeholder: 'windmill-data' }} + /> +
+ + Windmill generates and stores the database password. A new project takes a minute or two to + come up. + + +
+{/snippet} diff --git a/frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte b/frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte new file mode 100644 index 0000000000..f9f4586503 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SupabaseResourceConnect.svelte @@ -0,0 +1,120 @@ + + + + + +
+
+ {#if oauth.token} + + {/if} +
+
+ +
+
+
diff --git a/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts b/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts new file mode 100644 index 0000000000..cb42d73075 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const listSupabaseProjectsMock = vi.fn() +const createSupabaseProjectMock = vi.fn() +vi.mock('./supabaseProvisioning', async (importOriginal) => ({ + ...(await importOriginal()), + listSupabaseProjects: (...a: any[]) => listSupabaseProjectsMock(...a), + createSupabaseProject: (...a: any[]) => createSupabaseProjectMock(...a), + generateDbPassword: () => 'generated-password', + // Whatever the run does after creating a project is not what these tests are about, and the + // real ones poll Supabase until it answers. + waitUntilSupabaseHealthy: async (_t: string, _r: string) => ({ id: '2', name: 'later' }), + resolveSupabaseConnection: async () => { + throw new Error('stop the run here') + } +})) + +const existsVariableMock = vi.fn() +const getVariableMock = vi.fn() +const getResourceMock = vi.fn() +const createVariableMock = vi.fn() +const getSettingsMock = vi.fn() +const editDataTableConfigMock = vi.fn() +const testDataTableConnectionMock = vi.fn() +const setupCustomInstanceDbMock = vi.fn() +vi.mock('$lib/gen', () => ({ + VariableService: { + existsVariable: (...a: any[]) => existsVariableMock(...a), + getVariable: (...a: any[]) => getVariableMock(...a), + createVariable: (...a: any[]) => createVariableMock(...a), + updateVariable: vi.fn() + }, + ResourceService: { + existsResource: vi.fn(), + getResource: (...a: any[]) => getResourceMock(...a), + createResource: vi.fn(), + updateResource: vi.fn() + }, + SettingService: { setupCustomInstanceDb: (...a: any[]) => setupCustomInstanceDbMock(...a) }, + WorkspaceService: { + getSettings: (...a: any[]) => getSettingsMock(...a), + editDataTableConfig: (...a: any[]) => editDataTableConfigMock(...a), + testDataTableConnection: (...a: any[]) => testDataTableConnectionMock(...a) + } +})) + +import { + intentComplete, + newResourceParts, + newWizardState, + runSetup, + type WizardState +} from './addDataTableModel' +import { noClaims } from './setupClaims' + +/** Nothing at the path: the reads that answer "is this ours?" find no object. */ +function nothingThere() { + getVariableMock.mockRejectedValue(new Error('not found')) + getResourceMock.mockRejectedValue(new Error('not found')) +} + +/** A resource that exists, with the timestamp the claim is marked by. */ +function resourceEditedAt(at: string) { + getResourceMock.mockResolvedValue({ path: 'p', created_by: 'alice', edited_at: at }) +} + +/** A wizard about to create the Supabase project `later`, in the organization `acme`. */ +function creating(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'later', folder: 'f/team' }) + state.provider = 'supabase' + state.supabase.mode = 'create' + state.supabase.org = 'acme' + state.review.resourceName = 'db' + return state +} + +/** The path `creating()` writes to, and where an earlier attempt's password would sit. */ +const MINTED_PATH = 'f/team/db' + +const deps = (createdProjectName?: string, createdProjectPath = MINTED_PATH) => ({ + workspace: 'w', + supabaseToken: 'token', + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: createdProjectName + ? [{ name: createdProjectName, path: createdProjectPath }] + : [] +}) + +// `writeSecret` overwrites in place, and Supabase never shows a project's password twice, so +// minting a second one at the path where an earlier project's is stored destroys the only copy. +describe('runSetup refusing to mint over a project it already created', () => { + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + nothingThere() + }) + + it('refuses while the earlier project is still there', async () => { + listSupabaseProjectsMock.mockResolvedValue([ + { id: '1', name: 'earlier', organization_id: 'acme' } + ]) + const result = await runSetup(creating(), deps('earlier')) + expect(result.ok).toBe(false) + expect(result.error).toContain('earlier') + expect(createVariableMock).not.toHaveBeenCalled() + expect(createSupabaseProjectMock).not.toHaveBeenCalled() + }) + + // The name is also recorded when a create could not be confirmed -- an expired token answers + // neither the create nor the lookup. Refusing on that forever would strand the session. + it('proceeds when no project by that name exists after all', async () => { + listSupabaseProjectsMock.mockResolvedValue([]) + createSupabaseProjectMock.mockResolvedValue({ id: '2', name: 'later' }) + await runSetup(creating(), deps('earlier')) + expect(createSupabaseProjectMock).toHaveBeenCalled() + }) + + // Connecting the created project as an existing one reaches the same secret by another + // route: the project list on step 2 is where it now appears, so this is the likely move. + it('refuses to write over the secret from the existing-project branch', async () => { + const state = creating() + state.supabase.mode = 'existing' + state.supabase.project = { id: '1', name: 'earlier' } as any + state.supabase.password = 'typed-by-hand' + const result = await runSetup(state, deps('earlier')) + expect(result.ok).toBe(false) + expect(result.error).toContain(MINTED_PATH) + expect(createVariableMock).not.toHaveBeenCalled() + }) + + // Aimed somewhere else, there is nothing to protect -- and over-refusing here would block + // the ordinary way out of every refusal above, which is to choose another path. + it('writes when the run is aimed at a different path', async () => { + const state = creating() + state.supabase.mode = 'existing' + state.supabase.project = { id: '1', name: 'earlier' } as any + state.supabase.password = 'typed-by-hand' + await runSetup(state, deps('earlier', 'f/team/somewhere-else')) + expect(createVariableMock).toHaveBeenCalled() + }) + + // The organization selected now is not the one the earlier project was created under, and + // switching it is one of the ways to arrive here. + it('refuses a project listed under a different organization', async () => { + listSupabaseProjectsMock.mockResolvedValue([ + { id: '1', name: 'earlier', organization_id: 'other-org' } + ]) + const result = await runSetup(creating(), deps('earlier')) + expect(result.ok).toBe(false) + expect(createSupabaseProjectMock).not.toHaveBeenCalled() + }) +}) + +// The instance branch is the one that has to write its row before it can probe it, since the +// probe is by data table name. A database Windmill cannot store data in must not stay in the +// config -- and a probe that throws leaves exactly the same unusable row as one that says no. +describe('runSetup rolling the instance row back', () => { + function usingInstanceDb(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'instance' + state.instance = { mode: 'existing', dbName: 'shared' } + return state + } + + // The rollback reads the config back before deleting, so the config has to behave like one: + // a mock that always answers empty would let a rollback that never finds its own row pass. + let datatables: Record + + beforeEach(() => { + vi.clearAllMocks() + datatables = {} + getSettingsMock.mockImplementation(async () => ({ datatable: { datatables } })) + editDataTableConfigMock.mockImplementation(async ({ requestBody }: any) => { + datatables = { ...requestBody.settings.datatables } + }) + setupCustomInstanceDbMock.mockResolvedValue({ success: true, logs: {} }) + nothingThere() + }) + + // The pre-flight runs once, before a Supabase create that can take minutes, and every + // wizard suggests the same `main` -- so the name can be taken by the time the row is + // written. Repointing it would hand another admin's data table a database nobody chose. + it('refuses a name that was taken while it was running', async () => { + datatables = { main: { database: { resource_path: 'someone-else' } } } + const result = await runSetup(usingInstanceDb(), { + workspace: 'w', + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('main') + expect(editDataTableConfigMock).not.toHaveBeenCalled() + }) + + // Rolling back is just as dangerous once someone else owns the name: the row under it is + // no longer the one this run wrote. + it('leaves a row it no longer recognises alone', async () => { + // Repointed by someone else while this run was probing it. + testDataTableConnectionMock.mockImplementation(async () => { + datatables = { main: { database: { resource_path: 'someone-else' } } } + throw new Error('connection refused') + }) + const result = await runSetup(usingInstanceDb(), { + workspace: 'w', + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.rowRolledBack).toBe(false) + // One call: the write. The rollback found a row it did not write and left it. + expect(editDataTableConfigMock).toHaveBeenCalledTimes(1) + // And the name is not handed back as ours: claiming it would let Try again write over + // the row the other admin now owns. + expect(result.rowWritten).toBe(false) + }) + + it('takes the row back out when the probe never answers', async () => { + testDataTableConnectionMock.mockRejectedValue(new Error('connection refused')) + const result = await runSetup(usingInstanceDb(), { + workspace: 'w', + supabaseToken: undefined, + onProgress: () => {}, + claims: noClaims, + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('connection refused') + expect(result.rowRolledBack).toBe(true) + expect(result.rowWritten).toBe(false) + const lastWrite = editDataTableConfigMock.mock.calls.at(-1)?.[0] + expect(lastWrite.requestBody.settings.datatables).not.toHaveProperty('main') + }) +}) + +// The fields are the connection; a connection string is a way of writing one down. Reading the +// resource back out of the string is what let a URI grammar gap change what got saved. +describe('newResourceParts', () => { + function typedByHand(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.own.fields = { + host: 'db.example.com', + port: 5432, + dbname: 'mydb', + user: 'u', + password: 'p', + sslmode: 'prefer' + } + return state + } + + it('reads the fields whichever notation is on screen', () => { + const state = typedByHand() + state.own.form = 'string' + state.own.connectionString = 'postgres://u:p@db.example.com:5432/mydb' + // The string names no sslmode. The choice on the fields is what gets saved. + expect(newResourceParts(state)?.sslmode).toBe('prefer') + state.own.form = 'fields' + expect(newResourceParts(state)?.sslmode).toBe('prefer') + }) + + it('is unaffected by a string that cannot be parsed', () => { + const state = typedByHand() + state.own.form = 'string' + state.own.connectionString = 'not a uri' + expect(newResourceParts(state)?.host).toBe('db.example.com') + }) +}) + +// `created_by` survives an update, so it cannot tell an edit by somebody else from no edit at +// all. The claim is marked by `edited_at`, which moves on every write. +describe('runSetup writing over a resource', () => { + function ownResource(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.review.resourceName = 'db' + state.own.fields = { + host: 'h', + port: 5432, + dbname: 'd', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + getVariableMock.mockRejectedValue(new Error('not found')) + getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } }) + editDataTableConfigMock.mockResolvedValue(undefined) + testDataTableConnectionMock.mockResolvedValue({ can_create_table: true }) + }) + + it('refuses a resource edited since this run claimed it', async () => { + resourceEditedAt('2026-01-02T00:00:00Z') + const result = await runSetup(ownResource(), { + workspace: 'w', + onProgress: () => {}, + // Claimed when it looked like this; someone has written to it since. + claims: [{ kind: 'resource' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }], + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('f/team/db') + }) +}) + +describe('runSetup writing over its own secret', () => { + const ownDb = (): WizardState => { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.review.resourceName = 'db' + state.own.fields = { + host: 'h', + port: 5432, + dbname: 'd', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + beforeEach(() => { + vi.clearAllMocks() + getResourceMock.mockRejectedValue(new Error('not found')) + getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } }) + editDataTableConfigMock.mockResolvedValue(undefined) + testDataTableConnectionMock.mockResolvedValue({ can_create_table: true }) + }) + + // The same person editing the variable in another tab leaves `edited_by` unchanged, so an + // author is not enough to tell that write from none. + it('refuses a secret edited since this run claimed it, even by the same user', async () => { + getVariableMock.mockResolvedValue({ edited_by: 'alice', edited_at: '2026-01-02T00:00:00Z' }) + const result = await runSetup(ownDb(), { + workspace: 'w', + onProgress: () => {}, + claims: [{ kind: 'secret' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }], + username: 'alice', + createdProjects: [] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('f/team/db') + }) + + // A create whose confirmation also failed records the project name pessimistically. The + // variable it wrote is still its own, and a retry has to be able to reuse the path. + it('reuses the variable a previous attempt wrote when its project was never confirmed', async () => { + getVariableMock.mockResolvedValue({ edited_by: 'alice', edited_at: '2026-01-01T00:00:00Z' }) + listSupabaseProjectsMock.mockResolvedValue([]) + createSupabaseProjectMock.mockResolvedValue({ id: '2', name: 'later' }) + const state = creating() + const result = await runSetup(state, { + ...deps('later'), + claims: [{ kind: 'secret' as const, path: MINTED_PATH, mark: '2026-01-01T00:00:00Z' }] + } as any) + expect(result.error ?? '').not.toContain('was created at') + }) +}) + +// Editing a valid string into an invalid one keeps the fields, so they stay correctable. What +// must not happen is testing or saving those fields while the string on screen says otherwise. +describe('intentComplete with a connection string on screen', () => { + function typed(connectionString: string): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.own.form = 'string' + state.own.connectionString = connectionString + state.own.fields = { + host: 'db.example.com', + port: 5432, + dbname: 'mydb', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + it('refuses a string that will not parse, whatever the fields still hold', () => { + expect(intentComplete(typed('postgres://u:p@db.example.com:5432/mydb'))).toBe(true) + expect(intentComplete(typed('postgres://u:p@db.exa'))).toBe(false) + expect(intentComplete(typed(''))).toBe(false) + }) + + it('is unaffected once the fields are the notation on screen', () => { + const state = typed('nonsense') + state.own.form = 'fields' + expect(intentComplete(state)).toBe(true) + }) +}) + +// Each created project guards its own path. Keeping only the latest let a second attempt at +// another path unlock the first project's password, which Supabase will never show again. +describe('runSetup guarding more than one created project', () => { + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + nothingThere() + }) + + it('still refuses the first project’s path after a second was created elsewhere', async () => { + listSupabaseProjectsMock.mockResolvedValue([ + { id: '1', name: 'first', organization_id: 'acme' } + ]) + const state = creating() + const result = await runSetup(state, { + ...deps(), + createdProjects: [ + { name: 'first', path: MINTED_PATH }, + { name: 'second', path: 'f/team/other' } + ] + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('first') + expect(createVariableMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts b/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts new file mode 100644 index 0000000000..e01987c3ad --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts @@ -0,0 +1,853 @@ +/** + * Everything the "add a data table" wizard collects, and the one function that acts on it. + * + * The wizard writes nothing until the user finishes: steps 1 and 2 gather intent, step 3 + * reviews it, and `runSetup` performs it. That ordering is what lets the review step show + * the resource path before the resource exists. + * + * `runSetup` is also what Try again calls, so every step has to tolerate the results of a + * previous attempt still being there. + */ + +import { + ResourceService, + SettingService, + VariableService, + WorkspaceService, + type TestDataTableConnectionResponse +} from '$lib/gen' +import type { SetupStep } from '../wizards/SetupChecklist.svelte' +import { instanceSetupSteps } from './instanceDbSteps' +import { claim, stillOurs, type Claims } from './setupClaims' +import { probeDatatableConnection } from './datatableProbe' +import { + DEFAULT_SSLMODE, + parsePostgresConnectionString, + unsupportedConnectionParam, + type PostgresConnectionParts +} from '$lib/utils/postgresConnectionString' +import { + createSupabaseProject, + generateDbPassword, + resolveSupabaseConnection, + listSupabaseProjects, + projectOrg, + projectRef, + orgSlug, + supabaseResourceValue, + waitUntilSupabaseHealthy, + DEFAULT_SUPABASE_REGION, + type SupabaseConnectionMode, + type SupabaseOrg, + type SupabaseProject +} from './supabaseProvisioning' + +export type Provider = 'supabase' | 'instance' | 'resource' + +export type WizardState = { + step: 1 | 2 | 3 + provider: Provider | undefined + supabase: { + mode: 'existing' | 'create' + project: SupabaseProject | undefined + password: string + /** + * The whole organization, not its slug: the API is called with the slug, but a slug is a + * random string and the review step has a person reading it. + */ + org: SupabaseOrg | undefined + region: string + projectName: string + connectionMode: SupabaseConnectionMode + } + instance: { mode: 'existing' | 'create'; dbName: string | undefined } + /** + * One list: the workspace's Postgres resources, plus the one about to exist. A + * connection string is not an alternative to a resource, it is how one is written -- + * so `creating` and `resourcePath` are the two ways of answering the same question and + * are never both set. + */ + own: { + resourcePath: string | undefined + creating: boolean + /** Which notation the new resource is being entered in. Same object either way. */ + form: 'string' | 'fields' + connectionString: string + fields: PostgresConnectionParts + /** The resource fields no URI can carry, so they belong to neither notation. */ + advanced: PostgresAdvanced + } + review: { name: string; folder: string; resourceName: string } + /** Result of validating what step 2 collected. Cleared whenever its input changes. */ + probe: { + checking: boolean + report: TestDataTableConnectionResponse | undefined + error: string | undefined + } +} + +export function newWizardState(defaults: { + name: string + projectName: string + folder: string +}): WizardState { + return { + step: 1, + provider: undefined, + supabase: { + // Nothing is chosen yet; the step decides between the two once it knows whether the + // account has any projects. `create` here would be indistinguishable from the user + // having picked "New project", which is what survives a Back out of the step. + mode: 'existing', + project: undefined, + password: '', + org: undefined, + region: DEFAULT_SUPABASE_REGION, + projectName: defaults.projectName, + connectionMode: 'session' + }, + instance: { mode: 'create', dbName: undefined }, + own: { + resourcePath: undefined, + creating: false, + form: 'string', + connectionString: '', + fields: emptyFields(), + advanced: emptyAdvanced() + }, + review: { name: defaults.name, folder: defaults.folder, resourceName: '' }, + probe: { checking: false, report: undefined, error: undefined } + } +} + +export function clearProbe(state: WizardState) { + state.probe = { checking: false, report: undefined, error: undefined } +} + +/** Path of the resource and secret variable the run will write. They share one. */ +export function resourcePathOf(state: WizardState): string { + return `${state.review.folder}/${state.review.resourceName}` +} + +/** True once the branch has everything `runSetup` needs. */ +export function intentComplete(state: WizardState): boolean { + if (state.provider === 'supabase') { + return state.supabase.mode === 'create' + ? !!state.supabase.projectName.trim() && !!state.supabase.org + : !!state.supabase.project && !!state.supabase.password + } + if (state.provider === 'instance') return !!state.instance.dbName?.trim() + if (!state.own.creating) return !!state.own.resourcePath + // Text that will not parse leaves the fields on their last good values, which is what makes + // it correctable -- but the connection on screen is then not the one they describe, and + // testing or saving the old one behind an unparseable string points the data table + // somewhere nobody asked for. + if ( + state.own.form === 'string' && + (!parsePostgresConnectionString(state.own.connectionString) || + unsupportedConnectionParam(state.own.connectionString)) + ) + return false + return !!newResourceParts(state) +} + +/** + * The `postgresql` fields outside the connection-string vocabulary: TLS verification and + * AWS IAM auth. Kept apart from the parts so composing a string cannot appear to drop them. + */ +export type PostgresAdvanced = { + root_certificate_pem: string + /** + * Undefined is meaningful: the backend then verifies only when a root certificate is + * present. Only ever set by an explicit choice. + */ + accept_invalid_certs: boolean | undefined + use_iam_auth: boolean + region: string +} + +function emptyAdvanced(): PostgresAdvanced { + return { + root_certificate_pem: '', + accept_invalid_certs: undefined, + use_iam_auth: false, + region: '' + } +} + +/** Whether anything was set, so a notation that cannot show them can say they apply. */ +export function hasAdvanced(advanced: PostgresAdvanced): boolean { + return ( + !!advanced.root_certificate_pem.trim() || + advanced.accept_invalid_certs !== undefined || + advanced.use_iam_auth || + !!advanced.region.trim() + ) +} + +function emptyFields(): PostgresConnectionParts { + return { + host: '', + port: 5432, + dbname: 'postgres', + user: '', + password: '', + sslmode: DEFAULT_SSLMODE + } +} + +const RESERVED_DB_NAMES = ['template0', 'template1', 'postgres'] +const VALID_DB_NAME = /^[a-zA-Z][a-zA-Z0-9_-]*$/ + +/** + * Why `setup_custom_instance_db` would refuse this name, checked as it is typed. Deliberately + * not exhaustive -- the backend stays the authority, this only catches what the browser + * already knows. Empty is incomplete rather than wrong. + */ +export function instanceDbNameError(name: string, existing: Iterable): string | undefined { + const trimmed = name.trim() + if (!trimmed) return undefined + if (trimmed.length > 63) return 'A database name cannot exceed 63 characters.' + if (!VALID_DB_NAME.test(trimmed)) + return 'Start with a letter, then letters, digits, underscores or hyphens only.' + if (RESERVED_DB_NAMES.includes(trimmed.toLowerCase())) + return `${trimmed} is a reserved PostgreSQL database name.` + if (new Set(existing).has(trimmed)) + return `A database called ${trimmed} already exists on this instance.` + return undefined +} + +const VALID_DATATABLE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/ + +/** + * Why `edit_datatable_config` would refuse this name, checked as it is typed because the write + * is the *last* step of the run: by the time the backend rejects it a Supabase project may + * have been billed. `existing` are the names already in the workspace. + */ +export function datatableNameError(name: string, existing: Iterable): string | undefined { + const trimmed = name.trim() + if (!trimmed) return undefined + if (new Set(existing).has(trimmed)) + return `A data table called ${trimmed} already exists in this workspace.` + // `validate_datatable_path_segment` runs first on the backend and rejects `..` outright, + // before the charset check the regex below mirrors. + if (trimmed.includes('..')) return "A data table name cannot contain '..'." + if (!VALID_DATATABLE_NAME.test(trimmed)) + return "Start with a letter or digit, then letters, digits, '_', '-' and '.' only — the name has to survive being synced to a git repository." + return undefined +} + +/** + * What the new resource describes. The fields are the connection; a connection string is a way + * of writing one down, parsed into the fields as it is typed. Reading it back out here instead + * would put every gap in the URI grammar between the user and what gets saved. + */ +export function newResourceParts(state: WizardState): PostgresConnectionParts | undefined { + const fields = state.own.fields + return fields.host.trim() && fields.user.trim() ? fields : undefined +} + +/** + * Those parts as a `postgresql` resource value -- the one shape everything downstream sees, + * so nothing after this point knows which notation produced it. The password is the + * caller's: the literal one when testing before anything is saved, a `$var:` reference once + * it has somewhere to live. + */ +export function postgresResourceValue( + parts: PostgresConnectionParts, + password: string, + advanced: PostgresAdvanced +): Record { + return { + host: parts.host, + user: parts.user, + port: parts.port ?? 5432, + dbname: parts.dbname || 'postgres', + sslmode: parts.sslmode || DEFAULT_SSLMODE, + password, + region: advanced.region, + root_certificate_pem: advanced.root_certificate_pem, + use_iam_auth: advanced.use_iam_auth, + // Omitted rather than sent as false: absent is its own state, and the one every + // resource that predates the flag is in. + ...(advanced.accept_invalid_certs !== undefined + ? { accept_invalid_certs: advanced.accept_invalid_certs } + : {}) + } +} + +/** + * The connection value a branch can be validated against before anything is saved. + * Undefined for branches with nothing to validate yet: creating a Supabase project has no + * database to reach, and an instance database does not exist until setup runs. + */ +export function probeValue(state: WizardState): Record | undefined { + if (state.provider !== 'resource' || !state.own.creating) return undefined + const parts = newResourceParts(state) + return parts ? postgresResourceValue(parts, parts.password ?? '', state.own.advanced) : undefined +} + +/** + * Where the Supabase project will live, for the review step to state plainly. Read off + * the project when it already exists, off what was picked when it is about to be created. + */ +export function supabaseSummary(state: WizardState): { org?: string; region?: string } { + if (state.supabase.mode === 'create') + return { org: state.supabase.org?.name, region: state.supabase.region } + const project = state.supabase.project + return { + // The name when the organization is known, its identifier only as a last resort. + org: state.supabase.org?.name ?? (project ? projectOrg(project) : undefined), + region: project?.region + } +} + +export type RunStepKey = + | 'create_project' + | 'wait_healthy' + | 'save_credentials' + | 'setup_instance' + | 'check' + +/** + * The steps this branch will run, in order. The key drives the runner and the title only + * the display, so rewording a step cannot change what it does. + */ +export function plan(state: WizardState): { key: RunStepKey; title: string }[] { + const path = resourcePathOf(state) + const steps: { key: RunStepKey; title: string }[] = [] + if (state.provider === 'supabase') { + if (state.supabase.mode === 'create') { + steps.push({ + key: 'create_project', + title: `Creating ${state.supabase.projectName.trim()} on Supabase` + }) + steps.push({ key: 'wait_healthy', title: 'Waiting for the database to start' }) + } + steps.push({ key: 'save_credentials', title: `Saving credentials to ${path}` }) + } else if (state.provider === 'instance') { + steps.push({ + key: 'setup_instance', + title: `Setting up ${state.instance.dbName} in the Windmill database` + }) + } else if (state.own.creating) { + steps.push({ key: 'save_credentials', title: `Saving the connection to ${path}` }) + } + steps.push({ key: 'check', title: 'Checking Windmill can store data' }) + return steps +} + +/** The same plan as a checklist, all pending. */ +export function planSteps(state: WizardState): SetupStep[] { + return plan(state).map((s) => ({ title: s.title, status: 'pending' })) +} + +/** A Supabase project this session created, and the path holding its only password. */ +export type CreatedProject = { name: string; path: string } + +export type RunDeps = { + workspace: string + /** Required for the Supabase branch. */ + supabaseToken?: string + /** So the settings page's pool reflects a database this run created. */ + onInstanceDbsChanged?: () => Promise + onProgress: (steps: SetupStep[]) => void + /** Session pooling was asked for but could not be read; a direct host was written. */ + onPoolerUnavailable?: (reason: string) => void + /** + * The Supabase project an earlier attempt in this session created. Minting a second password + * over the first one's variable would lose the only copy of credentials Supabase will not + * repeat, so a run that would do that refuses -- but only once it has seen that the project + * is really there, since the name is also recorded when a create could not be confirmed. + */ + createdProjects: CreatedProject[] + /** + * What earlier attempts in this session wrote, and this one may therefore write over again. + * The pre-flight checks the names are free, but the Supabase branch then spends minutes + * provisioning, and every wizard suggests the same `main` -- so a second admin can take the + * name or the path in between. + */ + claims: Claims + /** Stands in as the mark where the object was written but its timestamp could not be read back. */ + username: string +} + +export type RunResult = { + ok: boolean + report?: TestDataTableConnectionResponse + error?: string + /** + * The workspace config still holds this data table. False when the run never got that far, + * and when a refused instance database was taken back out again -- so the name is free and + * the caller must not claim it. + */ + rowWritten?: boolean + /** A row this run had written is gone again, so a claim on the name has to go with it. */ + rowRolledBack?: boolean + /** + * Every project created this session, each guarding the path holding its only password. + * Supabase never shows that password again, so the variable there is the only copy and no + * later attempt may write over it. + */ + createdProjects: CreatedProject[] + /** What this run holds now, for the next attempt to be given back. */ + claims: Claims +} + +/** + * Why a run will not write at a path that already holds a created project's password. Names + * the path the password is actually at, which is not always the one the wizard is pointing at + * now -- the review step can be edited after a failure. + */ +function createdSecretRefusal(projectName: string, passwordPath: string): string { + return `The password of the Supabase project ${projectName}, which this setup created, is stored at ${passwordPath}. Writing here would replace it and Supabase cannot show that password again. Name the project ${projectName} again to carry on with it, or use a different path.` +} + +async function exists(kind: 'variable' | 'resource', workspace: string, path: string) { + return kind === 'variable' + ? VariableService.existsVariable({ workspace, path }) + : ResourceService.existsResource({ workspace, path }) +} + +/** + * Adds the data table to the workspace config, once everything it points at exists. + * `edit_datatable_config` replaces the whole map, so the rest is read back and sent with + * it. Re-runnable: a second attempt overwrites the entry it wrote. + */ +async function writeRow( + deps: RunDeps, + claims: Claims, + name: string, + database: { resource_type: 'postgresql' | 'instance'; resource_path: string } +): Promise { + const settings = await WorkspaceService.getSettings({ workspace: deps.workspace }) + const datatables: Record = { ...(settings.datatable?.datatables ?? {}) } + // Free when the pre-flight looked, taken by the time we write: repointing it here would + // silently hand another admin's data table a database they never chose. + if ( + datatables[name] && + !stillOurs(claims, 'row', name, datatables[name]?.database?.resource_path) + ) { + throw new Error( + `A data table called ${name} was created while this setup was running. Choose another name and try again.` + ) + } + datatables[name] = { ...(datatables[name] ?? {}), database } + await WorkspaceService.editDataTableConfig({ + workspace: deps.workspace, + requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] } + }) + return claim(claims, 'row', name, database.resource_path) +} + +/** + * `removed` — the row this run wrote is gone. `kept` — the undo could not reach the server, so + * it is still there and the caller has to keep saying so. `foreign` — the name now points + * somewhere this run never wrote, so there is nothing of ours to take back. + */ +type Rollback = 'removed' | 'kept' | 'foreign' + +async function removeRow(deps: RunDeps, claims: Claims, name: string): Promise { + try { + const settings = await WorkspaceService.getSettings({ workspace: deps.workspace }) + const datatables: Record = { ...(settings.datatable?.datatables ?? {}) } + // Only take back the row this run put there. Between writing it and probing it, another + // admin can have pointed the same name somewhere else, and deleting that is worse than + // leaving ours behind. + if (!stillOurs(claims, 'row', name, datatables[name]?.database?.resource_path)) return 'foreign' + delete datatables[name] + // Not `deleted_datatables`: that exists to cascade migration bookkeeping and deployment + // records for a data table that was really in use, and this one never got that far. + await WorkspaceService.editDataTableConfig({ + workspace: deps.workspace, + requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] } + }) + return 'removed' + } catch { + return 'kept' + } +} + +/** + * The read answers both questions at once: whether anything is there, and who last wrote it. + * Replacing this run's own work is required for Try again; replacing anyone else's loses a + * generated Supabase password, which Supabase never shows twice. + */ +async function writeSecret( + deps: RunDeps, + claims: Claims, + path: string, + value: string, + description: string +): Promise { + const held = await secretMark(deps, path) + if (held) { + if (!stillOurs(claims, 'secret', path, held)) throw new Error(pathTakenLate('variable', path)) + await VariableService.updateVariable({ + workspace: deps.workspace, + path, + requestBody: { value, is_secret: true } + }) + } else { + await VariableService.createVariable({ + workspace: deps.workspace, + requestBody: { path, value, is_secret: true, description, is_oauth: false } + }) + } + return claim(claims, 'secret', path, (await secretMark(deps, path)) ?? deps.username) +} + +/** + * A revision, not an author: the same person editing the variable in another tab leaves + * `edited_by` unchanged, and that write is no more ours to discard than a stranger's. + * `undefined` when nothing is there. + */ +async function secretMark(deps: RunDeps, path: string): Promise { + // `decryptSecret` defaults to true, and the handler audit-logs a decryption when it does. + // Only the timestamp is wanted, and it is on the response either way -- asking for the + // plaintext records decrypting a secret nothing reads, including someone else's on the + // retry that is about to refuse it. + const held = await VariableService.getVariable({ + workspace: deps.workspace, + path, + decryptSecret: false + }).catch(() => undefined) + return held ? (held.edited_at ?? held.edited_by ?? '') : undefined +} + +function pathTakenLate(kind: 'variable' | 'resource', path: string): string { + return `A ${kind} was created at ${path} while this setup was running. Choose another path and try again.` +} + +async function writeResource( + deps: RunDeps, + claims: Claims, + path: string, + value: Record, + description: string +): Promise { + const held = await resourceMark(deps, path) + if (held) { + if (!stillOurs(claims, 'resource', path, held)) throw new Error(pathTakenLate('resource', path)) + await ResourceService.updateResource({ + workspace: deps.workspace, + path, + requestBody: { value, description } + }) + } else { + await ResourceService.createResource({ + workspace: deps.workspace, + requestBody: { resource_type: 'postgresql', path, value, description } + }) + } + // Read back rather than claim the username: `created_by` survives an update, so it cannot + // tell an edit by somebody else from no edit at all. `edited_at` moves on every write, which + // is what makes the next attempt able to see one that happened in between. + return claim(claims, 'resource', path, (await resourceMark(deps, path)) ?? deps.username) +} + +/** `undefined` when nothing is there. */ +async function resourceMark(deps: RunDeps, path: string): Promise { + const held = await ResourceService.getResource({ workspace: deps.workspace, path }).catch( + () => undefined + ) + return held ? (held.edited_at ?? held.created_by ?? '') : undefined +} + +/** + * Performs what the wizard collected, reporting each step as it goes. + * + * Every step is safe to re-run, because Try again runs the whole plan a second time: + * each one upserts rather than assuming what it creates is absent. + */ +export async function runSetup(state: WizardState, deps: RunDeps): Promise { + const planned = plan(state) + const steps: SetupStep[] = planned.map((s) => ({ title: s.title, status: 'pending' })) + let index = 0 + const advance = ( + status: 'running' | 'done' | 'failed', + description?: string, + substeps?: SetupStep[] + ) => { + steps[index] = { + ...steps[index], + status, + description, + substeps: substeps ?? steps[index].substeps + } + deps.onProgress([...steps]) + } + let rowWritten = false + let rowRolledBack = false + let claims = deps.claims + let createdProjects: CreatedProject[] = [...deps.createdProjects] + /** Records a created project once, so a second attempt cannot displace the first one's guard. */ + const rememberProject = (name: string, at: string) => { + if (!createdProjects.some((p) => p.path === at)) + createdProjects = [...createdProjects, { name, path: at }] + } + const fail = (message: string): RunResult => { + advance('failed', message) + return { + ok: false, + error: message, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + + const path = resourcePathOf(state) + const name = state.review.name.trim() + /** + * An earlier attempt stored a created project's password here. Supabase hands that out once + * and every write upserts, so every route back to this path refuses. Each created project + * guards its own path -- checking only the latest unlocked the earlier one's password. + */ + const guardedHere = deps.createdProjects.find((p) => p.path === path) + const instanceName = state.instance.dbName?.trim() ?? '' + + let project = state.supabase.project + let resourcePath = + state.provider === 'resource' && !state.own.creating ? state.own.resourcePath! : path + + for (; index < planned.length; index++) { + advance('running') + try { + if (planned[index].key === 'create_project') { + // The password is generated here and can never be read back from Supabase, so it + // is written to the secret variable before the project that uses it exists. A run + // that dies right after creation is then still repairable; the reverse order + // would strand a billed project nobody holds the password to. + const wanted = state.supabase.projectName.trim() + const inOrg = (name: string) => (p: SupabaseProject) => + p.name === name && (!state.supabase.org || projectOrg(p) === orgSlug(state.supabase.org)) + const projects = await listSupabaseProjects(deps.supabaseToken!) + const existing = projects.find(inOrg(wanted)) + if (existing) { + if (!(await exists('variable', deps.workspace, path))) { + // A project this same session created is the one case where the password is + // held after all, just not here: the path has been edited since. Saying so + // beats telling someone to reset or delete a project that is working. + const elsewhere = deps.createdProjects.find((p) => p.name === wanted) + if (elsewhere) + return fail( + `The password for ${wanted}, which this setup created, is stored at ${elsewhere.path}, not at ${path}. Set the path back to ${elsewhere.path} to carry on with that project.` + ) + return fail( + `A Supabase project called ${wanted} already exists, but Windmill does not hold its password and Supabase cannot return it. Reset the password in Supabase and connect it as an existing project, or delete the project and retry.` + ) + } + project = existing + } else { + // The project has to still exist for its password to be worth protecting: a name + // recorded from a create that could not be confirmed is a false alarm, and + // refusing on it leaves the session with nothing it can do. Matched by name + // across every organization -- a namesake costs a rename, a miss costs the + // password. + const earlier = guardedHere?.name + if (earlier && projects.some((p) => p.name === earlier)) { + return fail(createdSecretRefusal(earlier, guardedHere!.path)) + } + const password = generateDbPassword() + claims = await writeSecret( + deps, + claims, + path, + password, + `Password for the ${wanted} Supabase database` + ) + try { + project = await createSupabaseProject(deps.supabaseToken!, { + name: wanted, + organizationSlug: orgSlug(state.supabase.org!), + region: state.supabase.region, + dbPass: password + }) + // From here the password in `path` is the only copy of a billed project's + // credentials, and every later write to that path upserts. + rememberProject(wanted, path) + } catch (err) { + // A refusal and a lost response look the same from here, and only one of them + // bills. Ask Supabase which it was: a project that turned up is ours, holds the + // password just written, and is what the rest of the run is for. If even that + // cannot be answered -- an expired token answers nothing -- record the name + // anyway, and let the next attempt's own lookup decide whether it was real. + const appeared = await listSupabaseProjects(deps.supabaseToken!).then( + (after) => after.find(inOrg(wanted)), + () => { + rememberProject(wanted, path) + return undefined + } + ) + if (!appeared) throw err + rememberProject(wanted, path) + project = appeared + } + } + } else if (planned[index].key === 'wait_healthy') { + // Minutes of polling with nothing else to show: hang what Supabase reports off the + // step, so the longest wait in the wizard has something behind its chevron. + project = await waitUntilSupabaseHealthy( + deps.supabaseToken!, + projectRef(project!), + (status) => advance('running', status) + ) + } else if (planned[index].key === 'save_credentials') { + if (state.provider === 'supabase') { + if (state.supabase.mode === 'existing') { + if (guardedHere) return fail(createdSecretRefusal(guardedHere.name, path)) + claims = await writeSecret( + deps, + claims, + path, + state.supabase.password, + `Password for the ${project!.name} Supabase database` + ) + } + const connection = await resolveSupabaseConnection( + deps.supabaseToken!, + project!, + state.supabase.connectionMode + ) + if (connection.mode !== state.supabase.connectionMode) + state.supabase.connectionMode = connection.mode + if (connection.unavailable) deps.onPoolerUnavailable?.(connection.unavailable) + claims = await writeResource( + deps, + claims, + path, + supabaseResourceValue(project!, path, connection), + `Supabase project ${project!.name}` + ) + } else { + if (guardedHere) return fail(createdSecretRefusal(guardedHere.name, path)) + const parts = newResourceParts(state)! + claims = await writeSecret( + deps, + claims, + path, + parts.password ?? '', + `Password for the ${parts.host} database` + ) + claims = await writeResource( + deps, + claims, + path, + postgresResourceValue(parts, `$var:${path}`, state.own.advanced), + `Database for the ${name} data table` + ) + } + } else if (planned[index].key === 'setup_instance') { + // The call reports nothing until it returns, so name the checks it is about to run + // with the first one marked in flight; its answer replaces them when it lands. + // Otherwise the longest step in the wizard is a single line that sits there. + advance('running', undefined, instanceSetupSteps(instanceName, undefined, true)) + const status = await SettingService.setupCustomInstanceDb({ + name: instanceName, + requestBody: { tag: 'datatable' } + }) + await deps.onInstanceDbsChanged?.() + const checks = instanceSetupSteps(instanceName, status, false) + if (!status.success) { + advance('failed', status.error ?? 'Setup failed', checks) + return { + ok: false, + error: status.error ?? 'Setup failed', + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + advance('running', undefined, checks) + } else if (state.provider === 'instance') { + // An instance data table is probed by name, through the very entry being written + // here, so this is the one branch that cannot check first. A database Windmill + // cannot store data in must not stay in the config, so a refusal takes the row + // back out -- leaving it would also block retrying under the same name. + const database = { resource_type: 'instance' as const, resource_path: instanceName } + claims = await writeRow(deps, claims, name, database) + rowWritten = true + const report = await WorkspaceService.testDataTableConnection({ + workspace: deps.workspace, + datatableName: name + }).catch(async (err) => { + // A probe that never answered leaves the same unusable row behind as one that + // answered no -- an unreachable database or a timeout lands here -- so it takes + // the same way out rather than the bare outer catch. + const rollback = await removeRow(deps, claims, name) + rowRolledBack = rollback === 'removed' + // `foreign` means the name is somebody else's now: our row is not there to + // hand back to the collision checks, and a retry must not write over theirs. + rowWritten = rollback === 'kept' + throw err + }) + if (!report.can_create_table) { + const rollback = await removeRow(deps, claims, name) + rowRolledBack = rollback === 'removed' + rowWritten = rollback === 'kept' + advance('failed', 'The database is reachable but its user cannot create tables.') + return { + ok: false, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + advance('done') + return { + ok: true, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } else { + // Checked through the resource, so nothing is written until the database has proved + // it can hold a data table. + const report = await probeDatatableConnection(deps.workspace, `$res:${resourcePath}`) + if (!report.can_create_table) { + advance('failed', 'The database is reachable but its user cannot create tables.') + return { + ok: false, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + claims = await writeRow(deps, claims, name, { + resource_type: 'postgresql', + resource_path: resourcePath + }) + rowWritten = true + advance('done') + return { + ok: true, + report, + rowWritten, + rowRolledBack, + claims, + createdProjects + } + } + advance('done') + } catch (err: any) { + return fail(err?.body ?? err?.message ?? String(err)) + } + } + + return { + ok: true, + rowWritten, + rowRolledBack, + claims, + createdProjects + } +} diff --git a/frontend/src/lib/components/workspaceSettings/datatableProbe.ts b/frontend/src/lib/components/workspaceSettings/datatableProbe.ts new file mode 100644 index 0000000000..38368c4b7f --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/datatableProbe.ts @@ -0,0 +1,108 @@ +/** + * What a database lets the data table's role do, answered by the worker rather than by the API + * server. + * + * It runs as a preview job for the same reason `TestConnection` does: a job goes through the + * worker's Postgres executor, so IAM and Azure workload identity authenticate as the worker + * will when a real query runs. A connection opened from the API server proves something about + * the API server, which is a different machine with a different identity. + * + * Postgres composes the suggested statements itself through `format('%I')`, so identifier + * quoting stays where it is already implemented. + */ + +import { JobService, type Preview, type TestDataTableConnectionResponse } from '$lib/gen' +import { tryEvery } from '$lib/utils' + +const PRIVILEGES = `SELECT current_user AS usr, + current_schema() AS sch, + has_schema_privilege(current_schema(), 'CREATE') AS can_create_table, + has_database_privilege(current_database(), 'CREATE') AS can_create_schema, + to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table, + -- A role whose search_path names no valid schema has a NULL current_schema(), and + -- format('%I', NULL) raises rather than returning NULL, which would fail the whole + -- query on the one case fix_search_path exists to report. + CASE WHEN current_schema() IS NULL THEN NULL + ELSE format('GRANT CREATE ON SCHEMA %I TO %I', current_schema(), current_user) + END AS grant_schema, + format('GRANT CREATE ON DATABASE %I TO %I', current_database(), current_user) AS grant_database, + format('ALTER ROLE %I SET search_path = public', current_user) AS fix_search_path` + +type Row = { + usr?: string + sch?: string | null + can_create_table?: boolean + can_create_schema?: boolean + has_migrations_table?: boolean + grant_schema?: string | null + grant_database?: string | null + fix_search_path?: string | null +} + +/** + * `database` is whatever a Postgres step takes: the resource value, or a `$res:` path the + * worker resolves. Throws with the database's own message when the query fails, and after + * `timeout` when no worker picks the job up. + */ +export async function probeDatatableConnection( + workspace: string, + database: Record | string, + // Longer than the 20s the worker allows its own Postgres connect, or a host that accepts + // the connection and never answers -- a firewall with no rule for the workers, which this + // check exists to catch -- is cancelled first and reported as a missing worker. + timeout = 30000 +): Promise { + const job = await JobService.runScriptPreview({ + workspace, + requestBody: { + path: 'testConnection: datatable', + language: 'postgresql' as Preview['language'], + content: PRIVILEGES, + args: { database } + } + }) + + let completed: Awaited> | undefined = undefined + await tryEvery({ + tryCode: async () => { + completed = await JobService.getCompletedJob({ workspace, id: job }) + }, + timeoutCode: async () => { + await JobService.cancelQueuedJob({ + workspace, + id: job, + requestBody: { reason: 'The connection check did not start' } + }).catch(() => {}) + }, + interval: 500, + timeout + }) + + if (!completed) { + throw new Error( + 'The connection check did not run. Is a worker listening to the postgresql tag available?' + ) + } + const done = completed as { success: boolean; result?: any } + if (!done.success) { + throw new Error(done.result?.error?.message ?? 'Could not connect to the database') + } + + const row: Row = (Array.isArray(done.result) ? done.result[0] : done.result) ?? {} + // Suggested only where the privilege is actually missing; Postgres returns NULL for a + // statement it could not name, which is the case where no grant would help anyway. + const suggested_grants = [ + row.can_create_table ? undefined : (row.grant_schema ?? undefined), + row.can_create_schema ? undefined : (row.grant_database ?? undefined) + ].filter((s): s is string => !!s) + + return { + user: row.usr ?? '', + schema: row.sch ?? null, + can_create_table: !!row.can_create_table, + can_create_schema: !!row.can_create_schema, + migrations_table_exists: !!row.has_migrations_table, + suggested_grants, + suggested_search_path: row.sch ? undefined : (row.fix_search_path ?? undefined) + } +} diff --git a/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts b/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts new file mode 100644 index 0000000000..dd465626f4 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/instanceDbSteps.ts @@ -0,0 +1,89 @@ +import type { CustomInstanceDb } from '$lib/gen' +import { runningFrom, type SetupStep } from '../wizards/SetupChecklist.svelte' + +/** + * The same checks as [`instanceDbSteps`], in the vocabulary the wizard's checklist speaks. + * Nothing is reported until the call returns, so an unreported step is either the failure + * (when the call errored) or simply not reached yet. + */ +export function instanceSetupSteps( + dbname: string, + status: CustomInstanceDb | undefined, + running: boolean +): SetupStep[] { + let firstUnreported = true + const steps = instanceDbSteps(dbname, status).map((step): SetupStep => { + if (step.status === 'OK') return { ...step, status: 'done' } + if (step.status === 'FAIL') return { ...step, status: 'failed' } + if (step.status === 'SKIP') return { ...step, status: 'skipped' } + const failed = firstUnreported && !!status?.error + firstUnreported = false + return { ...step, status: failed ? 'failed' : 'pending' } + }) + return runningFrom(steps, running) +} + +/** + * The checks `setup_custom_instance_db` reports, in the order it runs them. Shared so the + * setup modal and the data table wizard describe the same failure the same way. + */ +export function instanceDbSteps(dbname: string, status: CustomInstanceDb | undefined) { + return [ + { + title: 'Super admin required', + status: status?.logs.super_admin, + description: + 'You need to be a super admin to create a new database in the Windmill PostgreSQL instance' + }, + { + title: 'Retrieve and parse database credentials', + status: status?.logs.database_credentials, + description: + 'Windmill uses the DATABASE_URL or DATABASE_URL_FILE environment variable to connect to the PostgreSQL instance. Make sure it is correctly set' + }, + { + title: 'Database name is valid', + status: status?.logs.valid_dbname, + description: + 'The database name must be alphanumeric (underscores and hyphens allowed) and cannot be named the same as the Windmill database (usually "windmill")' + }, + { + title: + 'Create database' + + (status?.logs.created_database === 'SKIP' ? ' (already exists, skipped)' : ''), + status: status?.logs.created_database, + description: `In the Windmill PostgreSQL instance, run: CREATE DATABASE "${dbname}".` + }, + { + title: `Connect to the ${dbname} database`, + status: status?.logs.db_connect, + description: + "Connect to the newly created database with the default admin user (the one in DATABASE_URL, usually 'postgres') to run the next commands" + }, + { + title: 'Grant permissions to custom_instance_user', + status: status?.logs.grant_permissions, + description: + 'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' + + `GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` + + 'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' + + 'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' + + `GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` + + 'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' + + ' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' + + 'ALTER ROLE custom_instance_user CREATEROLE;' + }, + { + title: 'Grant replication to custom_instance_replication_user', + status: status?.logs.replication_user, + description: + 'Postgres triggers on custom-instance datatables connect as custom_instance_replication_user, whose password is stored in global_settings.custom_instance_replication_pwd. The role is cluster-wide, so it is created on the Windmill PostgreSQL instance rather than on this database : \n\n' + + 'ALTER ROLE custom_instance_replication_user REPLICATION;\n' + + 'GRANT custom_instance_user TO custom_instance_replication_user;\n\n' + + 'Setting REPLICATION requires a superuser on PostgreSQL 15 and older. Managed instances never grant one, so on AWS RDS Windmill falls back to GRANT rds_replication TO custom_instance_replication_user. The database stays usable for datatables if this step fails, but postgres triggers on them do not.' + + (status?.logs.replication_user_error + ? `\n\nError: ${status.logs.replication_user_error}` + : '') + } + ] +} diff --git a/frontend/src/lib/components/workspaceSettings/setupClaims.test.ts b/frontend/src/lib/components/workspaceSettings/setupClaims.test.ts new file mode 100644 index 0000000000..766d2b5281 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/setupClaims.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + anythingClaimed, + claim, + claimsFromJSON, + claimsToJSON, + noClaims, + release, + stillOurs +} from './setupClaims' + +describe('stillOurs', () => { + it('honours a claim whose object has not moved', () => { + const claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + expect(stillOurs(claims, 'secret', 'f/team/db', 'alice')).toBe(true) + }) + + it('refuses when the object was last written by somebody else', () => { + const claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + expect(stillOurs(claims, 'secret', 'f/team/db', 'bob')).toBe(false) + }) + + // Deleted and recreated between two attempts: something is there, it is not ours. + it('refuses when the object is gone', () => { + const claims = claim(noClaims, 'resource', 'f/team/db', 'alice') + expect(stillOurs(claims, 'resource', 'f/team/db', undefined)).toBe(false) + }) + + it('refuses a path this run never claimed', () => { + expect(stillOurs(noClaims, 'secret', 'f/team/db', 'alice')).toBe(false) + }) + + // The secret and the resource are separate objects at one path. + it('keeps the two objects at one path apart', () => { + const claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + expect(stillOurs(claims, 'secret', 'f/team/db', 'alice')).toBe(true) + expect(stillOurs(claims, 'resource', 'f/team/db', 'alice')).toBe(false) + }) + + it('refuses a row repointed since it was written', () => { + const claims = claim(noClaims, 'row', 'main', 'f/team/db') + expect(stillOurs(claims, 'row', 'main', 'f/team/db')).toBe(true) + expect(stillOurs(claims, 'row', 'main', 'someone-elses-db')).toBe(false) + }) +}) + +describe('claims as a set', () => { + it('replaces the mark when the same object is claimed again', () => { + let claims = claim(noClaims, 'row', 'main', 'first') + claims = claim(claims, 'row', 'main', 'second') + expect(claims).toHaveLength(1) + expect(stillOurs(claims, 'row', 'main', 'second')).toBe(true) + }) + + it('gives a claim up so the name is free again', () => { + const claims = release(claim(noClaims, 'row', 'main', 'x'), 'row', 'main') + expect(anythingClaimed(claims)).toBe(false) + }) + + it('carries every claim across the redirect, whatever kinds are held', () => { + let claims = claim(noClaims, 'secret', 'f/team/db', 'alice') + claims = claim(claims, 'resource', 'f/team/db', 'alice') + claims = claim(claims, 'row', 'main', 'f/team/db') + const restored = claimsFromJSON(JSON.parse(JSON.stringify(claimsToJSON(claims)))) + expect(restored).toEqual(claims) + }) + + it('survives a payload that is not claims at all', () => { + expect(claimsFromJSON(undefined)).toEqual(noClaims) + expect(claimsFromJSON([{ kind: 'nonsense', path: 'p', mark: 'm' }])).toEqual(noClaims) + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/setupClaims.ts b/frontend/src/lib/components/workspaceSettings/setupClaims.ts new file mode 100644 index 0000000000..48f844d6f9 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/setupClaims.ts @@ -0,0 +1,87 @@ +/** + * What a setup run created, and whether it is still there. + * + * Try again re-runs the whole plan, so every write meets what the previous attempt left behind + * and has to answer one question: is the thing at this path the thing I made? Writing over its + * own work is required; writing over another admin's destroys a password Supabase shows once. + * + * A claim therefore carries a **mark** — the discriminator to compare against the object as it + * is now, rather than trusting that whatever sits at a remembered path is ours. + * + * Values, not runes, so the ownership matrix is testable without mounting a component. + */ + +export type ClaimKind = 'secret' | 'resource' | 'row' + +export type Claim = { + kind: ClaimKind + path: string + /** + * Compared against the live object. It has to move whenever anyone else writes: `edited_at` + * for a secret and a resource — an author survives an edit and so cannot tell one from no + * edit at all — and the target for a row. + */ + mark: string +} + +export type Claims = readonly Claim[] + +export const noClaims: Claims = [] + +function sameObject(a: Claim, kind: ClaimKind, path: string): boolean { + return a.kind === kind && a.path === path +} + +/** Re-claiming an object replaces its mark. */ +export function claim(claims: Claims, kind: ClaimKind, path: string, mark: string): Claims { + return [...claims.filter((c) => !sameObject(c, kind, path)), { kind, path, mark }] +} + +export function claimOf(claims: Claims, kind: ClaimKind, path: string): Claim | undefined { + return claims.find((c) => sameObject(c, kind, path)) +} + +/** Given up when a run takes its own object back out, so the path is free again. */ +export function release(claims: Claims, kind: ClaimKind, path: string): Claims { + return claims.filter((c) => !sameObject(c, kind, path)) +} + +/** + * Whether the object now at `path` is the one this run claimed. `observed` is the mark read back + * from the live object; `undefined` means nothing is there. + */ +export function stillOurs( + claims: Claims, + kind: ClaimKind, + path: string, + observed: string | undefined +): boolean { + const held = claimOf(claims, kind, path) + return !!held && observed !== undefined && held.mark === observed +} + +export function anythingClaimed(claims: Claims): boolean { + return claims.length > 0 +} + +/** + * Carried across the full-page redirect the blocked-popup Supabase leg falls back to. No secret + * travels: a mark is a timestamp or a resource path. + */ +export function claimsToJSON(claims: Claims): Claim[] { + return [...claims] +} + +const KINDS: ClaimKind[] = ['secret', 'resource', 'row'] + +export function claimsFromJSON(value: unknown): Claims { + if (!Array.isArray(value)) return noClaims + return value.filter( + (c): c is Claim => + !!c && + typeof c === 'object' && + typeof (c as Claim).path === 'string' && + typeof (c as Claim).mark === 'string' && + KINDS.includes((c as Claim).kind) + ) +} diff --git a/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts new file mode 100644 index 0000000000..3e11ec2b9d --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts @@ -0,0 +1,107 @@ +import { fromStore } from 'svelte/store' +import { base } from '$lib/base' +import { oauthStore } from '$lib/stores' + +const OAUTH_WINDOW = 'windmill_supabase_oauth' +const CONNECT_URL = `${base}/api/oauth/connect/supabase_wizard` + +/** + * The Supabase authorization leg, driven from a popup. + * + * A full-page redirect unmounts whatever opened it, so a user who stops to create a Supabase + * account lands on their dashboard with nothing left pointing back. Keeping the flow in a + * popup keeps the host on screen, and keeps the window ours to steer: after they sign up we + * send the same popup back through the connect endpoint and consent follows. + */ +export function useSupabaseOauth( + opts: { + onPopupBlocked?: () => void + /** + * Where popups are blocked, navigate this tab instead of opening a new one. Only for + * hosts that can be resumed afterwards -- a caller whose state dies with the page (a + * half-filled form) must leave this off and keep the user where they are. + */ + redirectIfBlocked?: boolean + /** Even the new tab was refused, so the caller has to say so rather than sit loading. */ + onFallbackBlocked?: () => void + /** The window went away without authorizing; the caller can drop its own waiting state. */ + onAbandoned?: () => void + /** + * Authorization came back and the token is in the store. Reported like the failures + * above so a caller does not have to watch `authed` to find out. Fires on any successful + * authorization, this caller's or another's -- every instance listens on the same window + * -- so a caller that acts on it has to know it was the one waiting. + */ + onAuthed?: () => void + } = {} +) { + const oauth = fromStore(oauthStore) + let pending = $state(false) + let win: Window | null = null + let abandonWatch: ReturnType | undefined = undefined + + $effect(() => { + function onMessage(e: MessageEvent) { + if (e.origin !== window.location.origin || e.data?.type !== 'supabase_oauth') return + oauthStore.set(e.data.res) + pending = false + clearInterval(abandonWatch) + win?.close() + opts.onAuthed?.() + } + window.addEventListener('message', onMessage) + return () => { + window.removeEventListener('message', onMessage) + clearInterval(abandonWatch) + } + }) + + /** + * Nothing arrives if the user closes the window, denies consent, or wanders off to create + * an account first -- which is a link this flow deliberately offers. Watch for the window + * going away, so the button comes back instead of staying disabled until a page reload. + */ + function watchForAbandon() { + clearInterval(abandonWatch) + abandonWatch = setInterval(() => { + if (!win || win.closed) { + clearInterval(abandonWatch) + pending = false + opts.onAbandoned?.() + } + }, 500) + } + + return { + get token(): string | undefined { + return oauth.current?.access_token + }, + get authed(): boolean { + return !!oauth.current?.access_token + }, + get pending(): boolean { + return pending + }, + /** Opens (or re-points) the popup, falling back to a new tab where popups are blocked. */ + connect() { + win = window.open(CONNECT_URL, OAUTH_WINDOW, 'width=600,height=820') + if (!win) { + opts.onPopupBlocked?.() + if (opts.redirectIfBlocked) { + window.location.href = CONNECT_URL + return + } + // No `noopener`: the callback hands the token back through `window.opener`, and + // severing that is what would leave the host waiting forever. The URL is our own + // origin, so there is nothing to protect against here. + win = window.open(CONNECT_URL, '_blank') + if (!win) { + opts.onFallbackBlocked?.() + return + } + } + pending = true + watchForAbandon() + } + } +} diff --git a/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts new file mode 100644 index 0000000000..5040b5ab38 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts @@ -0,0 +1,250 @@ +/** + * Supabase Management API calls, proxied through Windmill's backend. + * + * The Management API sends no access-control-allow-origin, so the browser cannot call it + * directly -- every request below goes through /api/oauth/*, which forwards the user's OAuth + * access token. + */ + +import { DEFAULT_SSLMODE } from '$lib/utils/postgresConnectionString' +import { base } from '$lib/base' +import { oauthStore } from '$lib/stores' +import { get } from 'svelte/store' + +export type SupabaseOrg = { id: string; slug?: string; name: string } + +export type SupabaseProject = { + /** `id` is Supabase's deprecated spelling of `ref`; both are sent today. */ + id?: string + ref?: string + name: string + region: string + status?: string + organization_slug?: string + organization_id?: string + database?: { host: string } +} + +/** One Supavisor endpoint of a project. A project has one per mode and replica. */ +export type SupabasePooler = { + database_type: 'PRIMARY' | 'READ_REPLICA' + pool_mode: 'transaction' | 'session' + db_user: string + db_host: string + db_port: number + db_name: string +} + +export type SupabaseConnectionMode = 'session' | 'direct' + +/** Supabase deprecated `id` in favour of `ref`, and still sends both. */ +export function projectRef(project: SupabaseProject): string { + return project.ref ?? project.id ?? '' +} + +export function projectOrg(project: SupabaseProject): string | undefined { + return project.organization_slug ?? project.organization_id +} + +/** Region codes accepted by region_selection, with the names Supabase shows for them. */ +export const SUPABASE_REGIONS: { code: string; label: string }[] = [ + { code: 'us-east-1', label: 'East US (N. Virginia)' }, + { code: 'us-west-1', label: 'West US (N. California)' }, + { code: 'eu-central-1', label: 'Central EU (Frankfurt)' }, + { code: 'eu-west-1', label: 'West EU (Ireland)' }, + { code: 'eu-west-3', label: 'West EU (Paris)' }, + { code: 'ap-southeast-1', label: 'Southeast Asia (Singapore)' }, + { code: 'ap-northeast-1', label: 'Northeast Asia (Tokyo)' } +] + +export const DEFAULT_SUPABASE_REGION = 'eu-central-1' + +function headers(token: string): HeadersInit { + return { 'Content-Type': 'application/json', 'X-Supabase-Token': token } +} + +async function unwrap(res: Response, what: string): Promise { + if (!res.ok) { + // Supabase access tokens are short-lived while `oauthStore` lasts as long as the tab, so + // a stale one otherwise leaves every caller "authorized" and unable to reach the button + // that would fix it. Forgetting it here is what puts Connect back on screen. + if (res.status === 401) oauthStore.set(undefined) + const body = await res.text() + throw new Error(`${what}: ${supabaseErrorMessage(body) || res.statusText}`) + } + return res.json() +} + +/** + * Supabase answers with `{ message }` or `{ error }` and occasionally plain text. + * Surfacing the raw body puts a JSON blob in front of the user, so unwrap it to + * the sentence inside. + */ +export function supabaseErrorMessage(body: string): string { + try { + const parsed = JSON.parse(body) + return parsed?.message ?? parsed?.error ?? parsed?.msg ?? body + } catch { + return body + } +} + +export async function listSupabaseOrgs(token: string): Promise { + const res = await fetch(`${base}/api/oauth/list_supabase_orgs`, { headers: headers(token) }) + return unwrap(res, 'Could not list your Supabase organizations') +} + +export async function listSupabaseProjects(token: string): Promise { + const res = await fetch(`${base}/api/oauth/list_supabase`, { headers: headers(token) }) + return unwrap(res, 'Could not list your Supabase projects') +} + +/** Plan of one organization, which the list endpoint does not carry. */ +export async function getSupabaseOrgPlan(token: string, slug: string): Promise { + try { + const res = await fetch(`${base}/api/oauth/get_supabase_org/${slug}`, { + headers: headers(token) + }) + if (!res.ok) return undefined + return (await res.json())?.plan + } catch { + return undefined + } +} + +/** organization_slug is what create takes; older payloads only carry an id. */ +export function orgSlug(org: SupabaseOrg): string { + return org.slug ?? org.id +} + +/** + * Supabase never lets a database password be read back, so the only way to know it is to be + * the one who set it: db_pass is an input to project creation. + */ +export function generateDbPassword(): string { + const charset = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const values = new Uint32Array(32) + crypto.getRandomValues(values) + return Array.from(values, (v) => charset[v % charset.length]).join('') +} + +export async function createSupabaseProject( + token: string, + args: { name: string; organizationSlug: string; region: string; dbPass: string } +): Promise { + const res = await fetch(`${base}/api/oauth/create_supabase_project`, { + method: 'POST', + headers: headers(token), + body: JSON.stringify({ + name: args.name, + organization_slug: args.organizationSlug, + db_pass: args.dbPass, + // region_selection is { type: 'specific' | 'smartGroup', code }. Neither the published + // docs nor the OpenAPI spec describe it correctly (they give `kind`/`region` and + // `primary`) -- this shape comes from the API's own validation errors, so do not + // "correct" it against the documentation. + region_selection: { type: 'specific', code: args.region } + }) + }) + return unwrap(res, 'Supabase refused to create the project') +} + +/** + * Creation returns immediately with the project still coming up, so the pooler is not + * reachable yet. Poll until Supabase reports it healthy before trying to connect. + */ +export async function waitUntilSupabaseHealthy( + token: string, + projectId: string, + onStatus?: (status: string | undefined) => void, + attempts = 60 +): Promise { + for (let i = 0; i < attempts; i++) { + await new Promise((r) => setTimeout(r, 5000)) + let list: SupabaseProject[] + try { + list = await listSupabaseProjects(token) + } catch (err) { + // A transient failure is worth another poll; an expired token is not -- retrying it + // burns five minutes and then reports a timeout, which names the wrong problem. + if (!get(oauthStore)?.access_token) throw err + continue + } + const project = list?.find?.((p) => projectRef(p) === projectId) + if (project?.status === 'ACTIVE_HEALTHY') return project + onStatus?.(project?.status) + } + throw new Error('Timed out waiting for the project to become reachable') +} + +/** + * The session-mode Supavisor endpoint of the project's primary database. + * + * Which pooler a project sits behind is assigned by Supabase, not derived from its + * region: constructing `aws-0-.pooler.supabase.com` is wrong for every project + * that landed on another one, and the resulting resource never connects. + */ +export async function getSupabasePooler(token: string, projectId: string): Promise { + const res = await fetch(`${base}/api/oauth/get_supabase_pooler/${projectId}`, { + headers: headers(token) + }) + const configs: SupabasePooler[] = await unwrap(res, 'Could not read the connection details') + const primary = configs.filter((c) => c.database_type === 'PRIMARY') + const pooler = primary.find((c) => c.pool_mode === 'session') ?? primary[0] ?? configs[0] + if (!pooler) throw new Error('Supabase returned no connection details for this project') + return pooler +} + +export type SupabaseConnection = { + mode: SupabaseConnectionMode + pooler?: SupabasePooler + /** Why session pooling was asked for and not used. Absent when nothing was given up. */ + unavailable?: string +} + +/** + * The endpoint a project should be reached through, degrading rather than failing. Reading the + * pooler config needs the `database_pooling_config_read` scope, which an instance's OAuth app + * may not have. A direct connection still works where the workers have IPv6, so fall back to + * it and say so. + */ +export async function resolveSupabaseConnection( + token: string, + project: SupabaseProject, + mode: SupabaseConnectionMode +): Promise { + if (mode !== 'session') return { mode } + try { + return { mode, pooler: await getSupabasePooler(token, projectRef(project)) } + } catch (err) { + return { mode: 'direct', unavailable: err instanceof Error ? err.message : String(err) } + } +} + +/** The resource value for a project, given the endpoint it should connect through. */ +export function supabaseResourceValue( + project: SupabaseProject, + passwordVarPath: string, + connection: { mode: SupabaseConnectionMode; pooler?: SupabasePooler } +) { + const direct = connection.mode === 'direct' || !connection.pooler + return { + host: direct + ? (project.database?.host ?? `db.${projectRef(project)}.supabase.co`) + : connection.pooler!.db_host, + user: direct ? 'postgres' : connection.pooler!.db_user, + port: direct ? 5432 : connection.pooler!.db_port, + dbname: direct ? 'postgres' : connection.pooler!.db_name, + // Supabase terminates TLS on every endpoint it hands out, and this connection carries a + // generated password, so there is no reason to leave a plaintext fallback open. + sslmode: DEFAULT_SSLMODE, + password: `$var:${passwordVarPath}`, + // Resource forms fill in every unset property from the schema as soon as they render, + // so a postgresql resource saved without these comes up already modified -- and saves a + // draft -- the first time anyone opens it. Write them here so opening one is a no-op. + // (accept_invalid_certs renders conditionally and is not seeded, so it stays out.) + region: '', + root_certificate_pem: '', + use_iam_auth: false + } +} diff --git a/frontend/src/lib/components/workspaceSettings/utils.svelte.ts b/frontend/src/lib/components/workspaceSettings/utils.svelte.ts index 6925be9c04..55dc531182 100644 --- a/frontend/src/lib/components/workspaceSettings/utils.svelte.ts +++ b/frontend/src/lib/components/workspaceSettings/utils.svelte.ts @@ -1,8 +1,20 @@ import { isCloudHosted } from '$lib/cloud' import { superadmin } from '$lib/stores' +import { getLocalSetting } from '$lib/utils' import { derived } from 'svelte/store' +/** + * Opt-in for the data table setup wizard while it is being tested. Browser-local and read + * once per page: `localStorage.setItem('dataTableWizard', 'true')`, then reload. With it + * off, adding a data table falls back to the inline row in the settings table. + */ +export const DATATABLE_WIZARD_SETTING_NAME = 'dataTableWizard' + +export function isDataTableWizardEnabled(): boolean { + return getLocalSetting(DATATABLE_WIZARD_SETTING_NAME) === 'true' +} + export let isCustomInstanceDbEnabled = derived( [superadmin], ([superadmin_]) => superadmin_ && !isCloudHosted() diff --git a/frontend/src/lib/components/workspaceSettings/wizardParking.ts b/frontend/src/lib/components/workspaceSettings/wizardParking.ts new file mode 100644 index 0000000000..37d031fa3d --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/wizardParking.ts @@ -0,0 +1,63 @@ +/** + * Where popups are blocked the Supabase leg falls back to a full-page redirect, which + * unmounts the wizard. What the user had chosen is parked here and picked back up by the + * settings page when Supabase sends them home. + * + * Kept out of the wizard component so the OAuth callback route can ask whether anything is + * parked without pulling the whole wizard into that page's bundle. + */ + +import type { SupabaseConnectionMode, SupabaseOrg, SupabaseProject } from './supabaseProvisioning' +import type { Claim } from './setupClaims' +import type { CreatedProject } from './addDataTableModel' + +const RESUME_KEY = 'datatable_wizard_resume' + +export type WizardResume = { + name: string + region: string + projectName: string + /** + * What the interrupted run had already created. Without these the resumed run meets its + * own secret variable and resource as somebody else's and refuses to write over them, + * which strands the Supabase project it just paid for. No secret is parked -- these are + * paths, and the password they name is already in the workspace. + */ + resourcePath?: string + /** Everything the run holds, serialised whole so a newly added kind cannot be left behind. */ + claims?: Claim[] + /** Every project created before the redirect, each still guarding its password's path. */ + createdProjects?: CreatedProject[] + /** + * Which side of the step-2 toggle the run was on, and where it was pointed. A run that + * died mid-create otherwise comes back on `existing`, is asked for the password it + * generated and never showed anyone, and looks for its project in whichever organization + * happens to be first. + */ + mode?: 'existing' | 'create' + org?: SupabaseOrg + /** The project that was picked. Without it a resume selects the first in the list, which is + * a different database from the one whose password the user had already typed. */ + project?: SupabaseProject + connectionMode?: SupabaseConnectionMode +} + +/** True while a wizard run is waiting on the Supabase redirect to come back. */ +export function hasParkedWizard(): boolean { + return sessionStorage.getItem(RESUME_KEY) != null +} + +export function parkWizard(state: WizardResume) { + sessionStorage.setItem(RESUME_KEY, JSON.stringify(state)) +} + +export function takeParkedWizard(): WizardResume | undefined { + const raw = sessionStorage.getItem(RESUME_KEY) + sessionStorage.removeItem(RESUME_KEY) + if (!raw) return undefined + try { + return JSON.parse(raw) + } catch { + return undefined + } +} diff --git a/frontend/src/lib/utils/postgresConnectionString.test.ts b/frontend/src/lib/utils/postgresConnectionString.test.ts new file mode 100644 index 0000000000..ae5fc19195 --- /dev/null +++ b/frontend/src/lib/utils/postgresConnectionString.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { + composePostgresConnectionString, + connectionParamRefusal, + parsePostgresConnectionString, + unsupportedConnectionParam +} from './postgresConnectionString' + +// Two callers depend on this producing the same resource value from the same string: +// the resource form's "From connection string", and the data table wizard. +describe('parsePostgresConnectionString', () => { + it('reads every part of a full URI', () => { + expect( + parsePostgresConnectionString('postgres://u:p@db.example.com:6543/mydb?sslmode=require') + ).toEqual({ + user: 'u', + password: 'p', + host: 'db.example.com', + port: 6543, + dbname: 'mydb', + sslmode: 'require' + }) + }) + + it('leaves optional parts undefined rather than empty', () => { + expect(parsePostgresConnectionString('postgresql://u@host/')).toEqual({ + user: 'u', + password: undefined, + host: 'host', + port: undefined, + dbname: undefined, + sslmode: undefined + }) + }) + + it('returns undefined for anything that is not a postgres URI', () => { + expect(parsePostgresConnectionString('mysql://u:p@host/db')).toBeUndefined() + expect(parsePostgresConnectionString('')).toBeUndefined() + }) + + // Verified against psql: `postgres://role:p%40ss@host/db` authenticates as `p@ss`, and an + // unencoded `@` puts the rest of the password in libpq's host too. Reading these any other + // way would make the same string mean something here that it means nowhere else. + it('decodes percent escapes in credentials, as libpq does', () => { + expect(parsePostgresConnectionString('postgres://u:p%40ss@host/db')?.password).toBe('p@ss') + expect(parsePostgresConnectionString('postgres://u%40corp:p@host/db')?.user).toBe('u@corp') + }) +}) + +// The wizard offers the same connection as a string or as fields and switches between them +// by composing and reparsing. A password holding a character the URI reserves is the case +// that breaks silently: it comes back wrong rather than failing to parse. +describe('composePostgresConnectionString', () => { + // `prefer` is libpq's default, so it is the one a composer is tempted to leave out -- and + // the one that silently becomes `require` when the wizard reparses the string and falls + // back to its own default. It is a weaker TLS setting chosen on purpose; it has to survive. + it('keeps an explicit prefer through the round trip', () => { + const parts = { user: 'u', host: 'h', port: undefined, dbname: 'db', sslmode: 'prefer' } + const composed = composePostgresConnectionString(parts) + expect(composed).toContain('sslmode=prefer') + expect(parsePostgresConnectionString(composed)?.sslmode).toBe('prefer') + }) + + // The wizard composes this from fields, so a database name holding a character the URI + // reserves has to survive the toggle. `?` is the one that truncates silently: the parser + // reads everything after it as the query string. + it('round-trips a database name holding reserved characters', () => { + const parts = { user: 'u', host: 'h', dbname: 'sales?archive', sslmode: 'require' } + expect(parsePostgresConnectionString(composePostgresConnectionString(parts))?.dbname).toBe( + 'sales?archive' + ) + }) + + // A literal IPv6 address is all colons, so the URI brackets it and the resource stores it + // bare. Both halves have to agree or the wizard's own toggle produces a string it rejects. + it('brackets an IPv6 host and reads it back bare', () => { + const composed = composePostgresConnectionString({ + user: 'u', + host: '2001:db8::1', + port: 5432, + dbname: 'db' + }) + expect(composed).toContain('@[2001:db8::1]:5432/') + expect(parsePostgresConnectionString(composed)?.host).toBe('2001:db8::1') + expect(parsePostgresConnectionString('postgres://u:p@[2001:db8::1]/db')?.host).toBe( + '2001:db8::1' + ) + }) + + it('round-trips through parse', () => { + const parts = { + user: 'u@corp', + password: 'p@ss/w:rd', + host: 'db.example.com', + port: 6543, + dbname: 'mydb', + sslmode: 'require' + } + expect(parsePostgresConnectionString(composePostgresConnectionString(parts))).toEqual(parts) + }) +}) + +// A parameter the resource has no field for is not a preference that can be dropped: it decides +// where data lands, or how the connection is verified. The check is an allowlist because the +// dangerous ones are precisely the ones a hand-written denylist would miss. +describe('unsupportedConnectionParam', () => { + it('names a parameter that decides where data lands', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?options=-csearch_path%3Dtenant')).toBe( + 'options' + ) + expect(unsupportedConnectionParam('postgres://u:p@h/db?search_path=tenant')).toBe('search_path') + }) + + // Dropping these saves a *weaker* connection than the one pasted. + it('names a parameter that decides how the connection is secured or routed', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?sslrootcert=system')).toBe('sslrootcert') + expect(unsupportedConnectionParam('postgres://u:p@h/db?channel_binding=require')).toBe( + 'channel_binding' + ) + expect(unsupportedConnectionParam('postgres://u:p@h/db?target_session_attrs=read-write')).toBe( + 'target_session_attrs' + ) + }) + + // The backend applies its own connect timeout, so accepting one and dropping it would make + // `connect_timeout=1` mean a twenty-second wait. + it('names a parameter whose behaviour the backend overrides', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?connect_timeout=1')).toBe( + 'connect_timeout' + ) + }) + + // `sslmode=` also occurs inside another parameter's value, and reading it there turns TLS + // off behind a string that never asked for it -- past the allowlist, since the parameter + // actually carrying it is one we accept. + it('reads sslmode by name, not from anywhere it appears in the query', () => { + const disguised = 'postgres://u:p@h/db?application_name=sslmode=disable' + expect(unsupportedConnectionParam(disguised)).toBeUndefined() + expect(parsePostgresConnectionString(disguised)?.sslmode).toBeUndefined() + }) + + // libpq rejects `?SslMode=` as an invalid URI query parameter rather than folding it, so a + // string carrying one does not connect anywhere. Naming it is the honest answer; honouring + // it would save a resource from a URI Postgres itself refuses. + it('refuses a parameter whose name is not the one libpq accepts', () => { + const shouted = 'postgres://u:p@h/db?SslMode=verify-full' + expect(unsupportedConnectionParam(shouted)).toBe('SslMode') + expect(parsePostgresConnectionString(shouted)?.sslmode).toBeUndefined() + }) + + // libpq takes the last of a repeated parameter. Taking the first reads a weaker mode than + // the string actually asks for. + it('takes the last value of a repeated parameter', () => { + expect( + parsePostgresConnectionString('postgres://u:p@h/db?sslmode=disable&sslmode=require')?.sslmode + ).toBe('require') + }) + + it('ignores the one it can store, and the ones that cost nothing', () => { + expect(unsupportedConnectionParam('postgres://u:p@h/db?sslmode=require')).toBeUndefined() + expect(unsupportedConnectionParam('postgres://u:p@h/db?application_name=wm')).toBeUndefined() + expect(unsupportedConnectionParam('postgres://u:p@h/db')).toBeUndefined() + }) +}) + +// One refusal reached the user through two very different causes, and the wrong explanation +// sends them to fix the wrong thing: respelling a parameter this resource cannot store changes +// nothing, and removing one it can store loses what the string asked for. +describe('connectionParamRefusal', () => { + it('blames the spelling only when the parameter is one the resource keeps', () => { + expect(connectionParamRefusal('postgres://u:p@h/db?SslMode=verify-full')).toContain( + 'case-sensitive' + ) + expect(connectionParamRefusal('postgres://u:p@h/db?SslMode=verify-full')).toContain('sslmode') + }) + + it('blames the resource when respelling would not help', () => { + const refusal = connectionParamRefusal('postgres://u:p@h/db?Connect_Timeout=1') + expect(refusal).toContain('cannot store') + expect(refusal).not.toContain('case-sensitive') + }) + + it('says nothing about a string it can save', () => { + expect(connectionParamRefusal('postgres://u:p@h/db?sslmode=require')).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/utils/postgresConnectionString.ts b/frontend/src/lib/utils/postgresConnectionString.ts new file mode 100644 index 0000000000..d684739904 --- /dev/null +++ b/frontend/src/lib/utils/postgresConnectionString.ts @@ -0,0 +1,144 @@ +/** + * `postgres://user:password@host:5432/dbname?sslmode=require` in both directions. + * + * Shared by the resource form and the data table wizard: both turn a pasted + * connection string into a `postgresql` resource value, and the two drifting + * apart would mean the same string produced two different resources. + * + * The wizard offers the same connection as a string or as fields and lets the + * user switch, so parse and compose have to be inverses: whatever one produces, + * the other must read back unchanged. + * + * libpq is the arbiter of what a connection string means, so this follows it rather than + * RFC 3986 where they differ: credentials are split at the *first* `@` -- an unencoded one + * lands in the host for libpq too -- and percent escapes in them are decoded, so `p%40ss` + * authenticates as `p@ss`. + */ + +/** + * The host alternation is what admits IPv6: a literal address is full of colons, so a URI + * has to bracket it (`@[2001:db8::1]:5432/`) and the brackets are what tell the port apart + * from the address. Brackets are stripped on the way in and added back on the way out, so + * what is stored is the bare address a Postgres client wants. + */ +const CONNECTION_STRING = + /postgres(?:ql)?:\/\/(?[^:@]+)(?::(?[^@]+))?@(?\[[^\]]+\]|[^:\/?]+)(?::(?\d+))?\/(?[^\?]+)?/ + +/** + * The query parameters, read the way libpq reads them: names are case-sensitive — `SslMode` is + * rejected outright as an invalid URI query parameter, not folded to `sslmode` — and a name + * repeated takes its last value. One reader for both the parser and the allowlist below, or + * they disagree about what a string says and a name is refused by neither and honoured by + * neither. + */ +function paramsOf(connectionString: string): Map { + const query = connectionString.split('?').slice(1).join('?') + const params = new Map() + if (!query) return params + new URLSearchParams(query).forEach((value, name) => params.set(name, value)) + return params +} + +/** + * A database someone types into Windmill is almost never localhost, so callers ask for TLS + * where libpq would settle for `prefer`. A string that names its own `sslmode` keeps it. + */ +export const DEFAULT_SSLMODE = 'require' + +export type PostgresConnectionParts = { + user: string + password?: string + host: string + port?: number + dbname?: string + sslmode?: string +} + +/** A lone `%` is not an escape, and a password is free to contain one. */ +function decode(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** Undefined when the string is not a postgres URI. */ +export function parsePostgresConnectionString( + connectionString: string +): PostgresConnectionParts | undefined { + const match = connectionString.match(CONNECTION_STRING) + if (!match?.groups) return undefined + const { user, password, host, port, dbname } = match.groups + // By parameter name, never by searching the query text: `sslmode=` also occurs inside + // another parameter's *value*, and a substring match there reads someone's + // `application_name=sslmode=disable` as a request to turn TLS off. + const sslmode = paramsOf(connectionString).get('sslmode') + return { + user: decode(user), + password: password ? decode(password) : undefined, + host: host.startsWith('[') ? host.slice(1, -1) : host, + port: port ? Number(port) : undefined, + dbname: dbname ? decode(dbname) : undefined, + sslmode: sslmode || undefined + } +} + +/** The only query parameter the `postgresql` resource has a field for. */ +const REPRESENTABLE_PARAMS = ['sslmode'] + +/** + * Parameters that change nothing about what the connection reaches, how it is secured, or how + * it behaves, so losing them costs the user nothing. `connect_timeout` is deliberately not one + * of them: the backend applies its own fixed timeout, so honouring it is not on offer. + */ +const COSMETIC_PARAMS = ['application_name'] + +/** + * The name of a parameter this string carries that the resource cannot honour. An allowlist, + * not a list of known-bad names: libpq keeps adding parameters, and the ones that matter are + * the ones that would be missed. Dropping one silently saves a connection weaker or simply + * other than the one pasted, behind a probe that reports success. + */ +export function unsupportedConnectionParam(connectionString: string): string | undefined { + for (const name of paramsOf(connectionString).keys()) { + if (!REPRESENTABLE_PARAMS.includes(name) && !COSMETIC_PARAMS.includes(name)) return name + } + return undefined +} + +/** + * Why the string cannot be saved, in the terms the reader needs. Two refusals come out of the + * check above and they call for opposite fixes: a name Postgres does not accept at all, where + * the parameter itself is fine and only its spelling is wrong, and a parameter this resource + * has no field for, where respelling it changes nothing. + */ +export function connectionParamRefusal(connectionString: string): string | undefined { + const name = unsupportedConnectionParam(connectionString) + if (!name) return undefined + const lower = name.toLowerCase() + const storableWhenSpelledRight = + REPRESENTABLE_PARAMS.includes(lower) || COSMETIC_PARAMS.includes(lower) + return storableWhenSpelledRight + ? `Postgres does not accept ${name}: connection parameter names are case-sensitive. Write it as ${lower}.` + : `Windmill cannot store ${name} on a Postgres resource, and ignoring it would connect differently from what this string asks for. Remove it, or set the connection with the fields.` +} + +/** + * Every part that was set is emitted, `sslmode` included. Leaving `prefer` out because it is + * libpq's own default would be shorter, but it does not survive the trip: a caller that + * reparses this string gets `undefined` back and substitutes its own default, which is how an + * explicit `prefer` silently became `require`. Whatever this produces, `parse` must read back. + */ +export function composePostgresConnectionString(parts: PostgresConnectionParts): string { + const credentials = parts.password + ? `${encodeURIComponent(parts.user)}:${encodeURIComponent(parts.password)}` + : encodeURIComponent(parts.user) + const port = parts.port ? `:${parts.port}` : '' + const query = parts.sslmode ? `?sslmode=${parts.sslmode}` : '' + const dbname = parts.dbname ? encodeURIComponent(parts.dbname) : '' + // A bare IPv6 address would put its own colons where the port separator goes. + const host = + parts.host.includes(':') && !parts.host.startsWith('[') ? `[${parts.host}]` : parts.host + return `postgres://${credentials}@${host}${port}/${dbname}${query}` +} diff --git a/frontend/src/routes/oauth/callback_supabase/+page.svelte b/frontend/src/routes/oauth/callback_supabase/+page.svelte index 83bd448a13..ac6776804d 100644 --- a/frontend/src/routes/oauth/callback_supabase/+page.svelte +++ b/frontend/src/routes/oauth/callback_supabase/+page.svelte @@ -5,6 +5,7 @@ import { onMount } from 'svelte' import { OauthService } from '$lib/gen' import { oauthStore } from '$lib/stores' + import { hasParkedWizard } from '$lib/components/workspaceSettings/wizardParking' import CenteredPage from '$lib/components/CenteredPage.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import { Loader2 } from 'lucide-svelte' @@ -15,25 +16,63 @@ let code = page.url.searchParams.get('code') ?? undefined let state = page.url.searchParams.get('state') ?? undefined + /** + * As the wizard's popup there is no page to land on: the tab behind us is still showing the + * flow and is watching for this window to go away. Leaving it open on a full Windmill page + * is what strands the caller's button spinning, and declining consent is a normal outcome, + * not an edge case. + */ + function closeIfPopup(): boolean { + if (!window.opener) return false + window.close() + return true + } + + /** + * Where a failed leg lands when this is not a popup. A parked run has to be handed back its + * own page: nothing else consumes the park, so sending it to `/resources` leaves the run in + * `sessionStorage` to spring the wizard open on some unrelated later visit. + */ + function failureDestination(): string { + return hasParkedWizard() ? '/workspace_settings?tab=windmill_data_tables' : '/resources' + } + onMount(async () => { if (error) { + if (closeIfPopup()) return sendUserToast(`Error trying to fetch projects from windmill: ${error}`, true) - goto('/resources') + goto(failureDestination()) } else if (code && state) { try { const res = await OauthService.connectCallback({ clientName: client_name, requestBody: { code, state } }) + // Opened as the data table wizard's popup: hand the token to the tab that is still + // sitting on the wizard and get out of the way, so nothing has to be resumed. + if (window.opener) { + window.opener.postMessage({ type: 'supabase_oauth', res }, window.location.origin) + window.close() + return + } $oauthStore = res - goto(`/resources?callback=${client_name}`) + // The data table wizard parks its state before redirecting, so it can be resumed + // where it left off. Everything else lands on the resources page, which opens the + // Supabase drawer for this callback. + if (hasParkedWizard()) { + goto(`/workspace_settings?tab=windmill_data_tables&callback=${client_name}`) + } else { + goto(`/resources?callback=${client_name}`) + } } catch (e) { + if (closeIfPopup()) return sendUserToast(`Error parsing the response token, ${e.body}`, true) - goto('/resources') + goto(failureDestination()) } } else { + if (closeIfPopup()) return sendUserToast('Missing code or state as query params', true) - goto('/resources') + goto(failureDestination()) } })