feat(frontend): gate the supabase resource path behind the dev flag

This commit is contained in:
Guilhem Lemouel
2026-08-13 10:38:31 +02:00
parent c0fceaaf38
commit d5d51c9bcd
5 changed files with 341 additions and 118 deletions
+22 -113
View File
@@ -16,15 +16,9 @@
import { isCloudHosted } from '$lib/cloud'
import ResourceGen from './copilot/ResourceGen.svelte'
import SyncResourceTypes from './SyncResourceTypes.svelte'
import Modal2 from './common/modal/Modal2.svelte'
import SupabaseProjectStep from './workspaceSettings/SupabaseProjectStep.svelte'
import { newWizardState } from './workspaceSettings/addDataTableModel'
import {
resolveSupabaseConnection,
supabaseResourceValue
} from './workspaceSettings/supabaseProvisioning'
import { useSupabaseOauth } from './workspaceSettings/supabaseOauth.svelte'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import SupabaseResourceConnect from './workspaceSettings/SupabaseResourceConnect.svelte'
import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte'
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
interface Props {
@@ -133,74 +127,14 @@
let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined)
let textFileContent: string | undefined = $state(undefined)
let supabaseOpen = $state(false)
let supaBusy = $state(false)
// Only the intent this form can act on. Creating a project is a billed action and belongs
// in the data table wizard, which can show what it is provisioning and record the result;
// a resource form has nowhere to put either.
let supaIntent = $state(newWizardState({ name: '', projectName: '', folder: '' }).supabase)
// 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()
// Authorizing is not something to present a dialog about first: the button goes straight
// to the popup, and the dialog opens on the way back, already holding the projects.
// No `redirectIfBlocked`: navigating this tab away would take the half-filled resource form
// with it, and there is nothing here to park and resume.
const supaOauth = useSupabaseOauth({
onFallbackBlocked: () => {
awaitingSupabaseAuth = false
sendUserToast('Allow pop-ups for this site to connect your Supabase account.', true)
},
onAbandoned: () => (awaitingSupabaseAuth = false)
})
let awaitingSupabaseAuth = $state(false)
function connectSupabase() {
if (supaOauth.authed) {
supabaseOpen = true
return
}
awaitingSupabaseAuth = true
supaOauth.connect()
}
$effect(() => {
if (awaitingSupabaseAuth && supaOauth.authed) {
awaitingSupabaseAuth = false
supabaseOpen = true
}
})
// The resource is being edited here rather than created for us, so the project's password
// goes straight into the form as a value. The user can link it to a secret variable with
// the same affordance every other password field has.
async function applySupabasePick() {
const project = supaIntent.project
if (!project || !supaIntent.password) return
supaBusy = true
try {
const connection = await resolveSupabaseConnection(
supaOauth.token!,
project,
supaIntent.connectionMode
)
args = {
...(args ?? {}),
...supabaseResourceValue(project, '', connection),
password: supaIntent.password
}
rawCode = JSON.stringify(args, null, 2)
rawCodeEditor?.setCode(rawCode)
supabaseOpen = false
sendUserToast(
connection.unavailable
? `Filled in a direct connection for ${project.name}: ${connection.unavailable}`
: `Filled in the connection for ${project.name}`,
!!connection.unavailable
)
} catch (err) {
sendUserToast(String(err), true)
} finally {
supaBusy = false
}
function applySupabasePick(value: Record<string, any>) {
args = { ...(args ?? {}), ...value }
rawCode = JSON.stringify(args, null, 2)
rawCodeEditor?.setCode(rawCode)
}
function parseTextFileContent() {
@@ -282,15 +216,18 @@
</Popover>
{/if}
{#if resourceType == 'postgresql' && supabaseWizard}
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: SupabaseIcon }}
loading={awaitingSupabaseAuth}
on:click={connectSupabase}
>
Connect Supabase
</Button>
{#if wizardEnabled}
<SupabaseResourceConnect onPicked={applySupabasePick} />
{:else}
<a
target="_blank"
href="{base}/api/oauth/connect/supabase_wizard"
class="border rounded-lg flex flex-row gap-2 items-center text-xs px-3 py-1.5 h-8 bg-[#F1F3F5] hover:bg-[#E6E8EB] dark:bg-[#1C1C1C] dark:hover:bg-black"
>
<SupabaseIcon height="16px" width="16px" />
<div class="text-[#11181C] dark:text-[#EDEDED] font-semibold">Connect Supabase</div>
</a>
{/if}
{/if}
<GitHubAppIntegration
{resourceType}
@@ -363,31 +300,3 @@
bind:args
/>
{/if}
<Modal2
bind:isOpen={supabaseOpen}
target="#content"
title="Connect Supabase"
contentClasses="flex flex-col"
fixedWidth="md"
fixedHeight="lg"
>
<div class="flex h-full flex-col gap-3">
<div class="flex-1 flex flex-col gap-3 min-h-0">
{#if supaOauth.token}
<SupabaseProjectStep bind:intent={supaIntent} token={supaOauth.token} existingOnly />
{/if}
</div>
<div class="flex justify-end pt-3">
<Button
size="sm"
variant="accent"
disabled={!supaIntent.project || !supaIntent.password}
loading={supaBusy}
onClick={applySupabasePick}
>
Use this project
</Button>
</div>
</div>
</Modal2>
@@ -0,0 +1,189 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { Loader2, RotateCwIcon } from 'lucide-svelte'
import { Button, DrawerContent } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import Path from './Path.svelte'
import { sendUserToast } from '$lib/toast'
import { Highlight } from 'svelte-highlight'
import { json } from 'svelte-highlight/languages'
import autosize from '$lib/autosize'
import { ResourceService, VariableService } from '$lib/gen'
import { oauthStore, workspaceStore } from '$lib/stores'
import Password from './Password.svelte'
import { createEventDispatcher } from 'svelte'
import HighlightTheme from './HighlightTheme.svelte'
let drawer: Drawer | undefined = $state()
let token: undefined | string = $state(undefined)
export async function open() {
token = $oauthStore?.access_token ?? ''
drawer?.openDrawer?.()
step = 'init'
description = ''
}
let step: 'init' | 'resource' = $state('init')
async function listDatabases() {
if (!token) return
databases = undefined
const res = await fetch('/api/oauth/list_supabase', {
headers: {
'Content-Type': 'application/json',
'X-Supabase-Token': token
}
})
databases = await res.json()
}
type Database = {
name: string
database?: { host: string }
region: string
id: string
}
let databases: undefined | Database[] = $state(undefined)
run(() => {
token != undefined && listDatabases()
})
let selectedDatabase: undefined | Database = $state(undefined)
let description = $state('')
let pathError = $state('')
let password = $state('')
let path: string | undefined = $state(undefined)
/**
* https://github.com/orgs/supabase/discussions/17817
* host is in the format of `aws-0-${region}.pooler.supabase.com`
* user is in the format of `postgres.${id}`
*/
let resourceValue = $derived.by(() => ({
host: `aws-0-${selectedDatabase?.region}.pooler.supabase.com`,
user: `postgres.${selectedDatabase?.id}`,
port: 5432,
dbname: 'postgres',
sslmode: 'prefer',
password: `$var:${path}`
}))
let disabled = $derived(path == undefined || pathError != '' || path == '')
const dispatch = createEventDispatcher()
async function save() {
if (!path) return
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value: password,
is_secret: true,
description: 'Password for supabase postgres database',
is_oauth: false
}
})
await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type: 'postgresql',
path,
value: resourceValue,
description
}
})
sendUserToast('Saved postgres resource')
dispatch('refresh')
drawer?.closeDrawer?.()
}
</script>
<HighlightTheme />
<Drawer bind:this={drawer} size="800px">
<DrawerContent title="Add a Supabase Database" on:close={drawer.closeDrawer}>
{#if step === 'init' || selectedDatabase == undefined}
<h2
>Connect an existing database <div class="inline-block ml-2"
><Button variant="default" wrapperClasses="self-stretch" on:click={listDatabases}
><RotateCwIcon size={12} /></Button
></div
>
</h2>
<div class="mt-6"></div>
{#if databases == undefined}
<Loader2 class="animate-spin" />
{:else}
<div class=" flex flex-col gap-y-2"></div>
{#each databases as database}
<button
class="btn btn-outline-primary mt-2 border p-2 w-full border-secondary-inverse hover:border-secondary rounded"
onclick={() => {
selectedDatabase = database
step = 'resource'
}}
>
<div class="flex flex-row items-center">
<div class="flex-grow">
<h3 class="text-lg font-semibold">{database.name}</h3>
<p class="text-sm text-secondary">id: {database.id} - region: {database.region}</p>
</div>
</div>
</button>
{/each}
{/if}
<h3 class="mt-8 mb-2">Create a new database</h3>
<p class="text-sm text-secondary"
><a href="https://supabase.com/dashboard/projects" target="_blank" rel="noopener noreferrer"
>Create a new database in your Supabase account
</a>
</p>
{:else if step === 'resource'}
<Path
bind:error={pathError}
bind:path
initialPath=""
fullNamePlaceholder={'supabase_' +
selectedDatabase?.name?.replace(/\s+/g, '').replace(/[^\w\s]/gi, '')}
kind="resource"
/>
<h2 class="mt-8 mb-2">Database Password</h2>
<p class="text-sm text-secondary mb-1"
>For security reasons from supabase, the password of the database cannot be retrieved
automatically. In a future update, a dedicated role for windmill will be created and the
password for it will be generated automatically. The password of the database is shown
during the project creation.</p
>
<Password required bind:password />
<h3 class="mt-6 mb-2">Description</h3>
<textarea autocomplete="off" use:autosize bind:value={description}></textarea>
<div class="mt-12"></div>
<p class="my-1 text-sm text-secondary"
>A resource and a variable will be created at path: {path}. The content of the resource will
be:</p
>
<Highlight language={json} code={JSON.stringify(resourceValue, null, 4)} />
{/if}
{#snippet actions()}
<div class="flex gap-1">
{#if step == 'resource' && selectedDatabase != undefined}
<Button variant="default" on:click={() => (step = 'init')}>Back</Button>
<Button {disabled} on:click={save} variant="accent">Save</Button>
{/if}
</div>
{/snippet}
</DrawerContent>
</Drawer>
@@ -0,0 +1,119 @@
<script lang="ts">
import Button from '../common/button/Button.svelte'
import Modal2 from '../common/modal/Modal2.svelte'
import SupabaseIcon from '../icons/SupabaseIcon.svelte'
import SupabaseProjectStep from './SupabaseProjectStep.svelte'
import { newWizardState } from './addDataTableModel'
import { resolveSupabaseConnection, supabaseResourceValue } from './supabaseProvisioning'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import { sendUserToast } from '$lib/toast'
type Props = {
/** The `postgresql` resource value for the project that was picked. */
onPicked: (value: Record<string, any>) => void
}
let { onPicked }: Props = $props()
let open = $state(false)
let busy = $state(false)
// Only the intent a resource form can act on. Creating a project is a billed action and
// belongs in the data table wizard, which can show what it is provisioning and record the
// result; a resource form has nowhere to put either.
let intent = $state(newWizardState({ name: '', projectName: '', folder: '' }).supabase)
let awaiting = $state(false)
// Authorizing is not something to present a dialog about first: the button goes straight
// to the popup, and the dialog opens on the way back, already holding the projects.
// No `redirectIfBlocked`: navigating this tab away would take the half-filled resource form
// with it, and there is nothing here to park and resume.
const oauth = useSupabaseOauth({
onFallbackBlocked: () => {
awaiting = false
sendUserToast('Allow pop-ups for this site to connect your Supabase account.', true)
},
onAbandoned: () => (awaiting = false)
})
function connect() {
if (oauth.authed) {
open = true
return
}
awaiting = true
oauth.connect()
}
$effect(() => {
if (awaiting && oauth.authed) {
awaiting = false
open = true
}
})
// The resource is being edited by the user rather than created for them, so the project's
// password goes straight into the form as a value. They can link it to a secret variable
// with the same affordance every other password field has.
async function apply() {
const project = intent.project
if (!project || !intent.password) return
busy = true
try {
const connection = await resolveSupabaseConnection(
oauth.token!,
project,
intent.connectionMode
)
onPicked({ ...supabaseResourceValue(project, '', connection), password: intent.password })
open = false
sendUserToast(
connection.unavailable
? `Filled in a direct connection for ${project.name}: ${connection.unavailable}`
: `Filled in the connection for ${project.name}`,
!!connection.unavailable
)
} catch (err) {
sendUserToast(String(err), true)
} finally {
busy = false
}
}
</script>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: SupabaseIcon }}
loading={awaiting}
on:click={connect}
>
Connect Supabase
</Button>
<Modal2
bind:isOpen={open}
target="#content"
title="Connect Supabase"
contentClasses="flex flex-col"
fixedWidth="md"
fixedHeight="lg"
>
<div class="flex h-full flex-col gap-3">
<div class="flex-1 flex flex-col gap-3 min-h-0">
{#if oauth.token}
<SupabaseProjectStep bind:intent token={oauth.token} existingOnly />
{/if}
</div>
<div class="flex justify-end pt-3">
<Button
size="sm"
variant="accent"
disabled={!intent.project || !intent.password}
loading={busy}
onClick={apply}
>
Use this project
</Button>
</div>
</div>
</Modal2>
@@ -27,6 +27,7 @@
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import SupabaseConnect from '$lib/components/SupabaseConnect.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
@@ -119,6 +120,7 @@
let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
let appConnect: AppConnect | undefined = $state(undefined)
let supabaseConnect: SupabaseConnect | undefined = $state(undefined)
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteIsLinked = $state(false)
let deletePath = $state('')
@@ -393,6 +395,11 @@
}
onMount(() => {
const callback = page.url.searchParams.get('callback')
if (callback == 'supabase_wizard') {
supabaseConnect?.open?.()
}
const connect_app = page.url.searchParams.get('connect_app')
if (connect_app) {
const rt = connect_app ?? undefined
@@ -1363,6 +1370,7 @@
</CenteredPage>
{/if}
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
<ResourceEditorDrawer
bind:this={resourceEditor}
@@ -48,14 +48,12 @@
}
$oauthStore = res
// The data table wizard parks its state before redirecting, so it can be resumed
// where it left off. Anything else that started this leg was unmounted by the
// redirect and has nothing to return to -- say so, since the token is now held
// and reopening the form is all that is left to do.
// 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 {
sendUserToast('Connected to Supabase. Reopen the resource to finish setting it up.')
goto('/resources')
goto(`/resources?callback=${client_name}`)
}
} catch (e) {
if (closeIfPopup()) return