mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
feat(frontend): gate the supabase resource path behind the dev flag
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user