mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
* feat(frontend): guided setup wizard for data tables
On Cloud a data table cannot use the Windmill instance database, so a new
workspace hit a dead end: an alert telling the user to go find a PostgreSQL
resource somewhere else. Setting one up meant three disconnected places, and the
connection could only be tested after the config had already been saved.
Adds a three-step wizard (choose a database -> set it up -> name it) reached from
the data tables settings page:
- Supabase: signs in via the existing supabase_wizard OAuth client and creates
the project from inside Windmill. Because db_pass is an input to project
creation, Windmill sets the password and the user never visits a dashboard.
- Your own database: picks an existing postgresql resource, or adds one with a
connection string through the form that already supports it.
- Windmill database: hands back to the inline row editor, since instance
databases are provisioned by a superadmin.
Verifying access is no longer a step the user takes: Continue runs the check and
passing it is what advances the wizard, so a database that cannot create tables
never reaches the workspace config.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: pin ee-repo-ref to the Supabase provisioning endpoints
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): do not claim the database is ready when its check failed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review findings on the data table wizard
- The Supabase create branch advanced on `provisioning === 4` without consulting
the check it had just run, so a role that cannot create tables could reach
Finish. It now blocks and offers Try again.
- Retrying no longer mints a fresh secret variable + resource each time: the
credentials are only re-created when the password actually changed.
- The generated password is captured before the create call rather than after,
since a throw there can still leave a project behind.
- On a failed provision the project list is refreshed, so the just-created
project can be picked up from the other tab instead of provisioning a second.
- Finish refuses a name that already belongs to another data table, which
previously repointed it at the new database.
- Secrets go to the acting user's namespace instead of a literal `u/admin/`.
- The progress list no longer ticks "Created on Supabase" before the request is
sent, and does not claim the database is ready when its check failed.
- The wizard's resume state is cleared when it closes, so reopening after an
abandoned OAuth round trip is not stuck on step 2.
- The OAuth callback shares the session-storage key rather than repeating it.
- SupabaseConnect uses the shared provisioning helpers instead of a fork.
- Restores the doc comment displaced onto TestDataTableResourceQuery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): simplify Alert layout and balance its vertical padding
The body was rendered by two near-duplicate branches, each wrapping the text in an
extra div only to hang a margin on it, and the margins disagreed: the collapsible
branch spaced above with mt-2, the static one below with mb-2. Since isCollapsed
defaults to true, every non-collapsible alert took the static branch, so titled
alerts read as 24px of space below the text against 16px above -- visibly
off-centre -- with the title and body flush against each other.
Collapse both branches into one and drop the margins; the container's own padding
now sets top and bottom equally, with a small gap under the title row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): only offer Supabase when its OAuth client is configured
The wizard offered the Supabase card unconditionally, so on an instance whose
superadmin never configured a supabase_wizard client -- or whose backend is built
without the oauth2 feature, which compiles the whole /api/oauth router out -- the
card dead-ended at a 404. Gate it on listOauthConnects, the same check
ApiConnectForm already makes, fetched on open so configuring the client mid-session
does not require a reload.
Also drop the Supabase project ref from the existing-project cards: it is an opaque
identifier that means nothing outside Supabase's own dashboard URLs. Show the region
instead, plus a status word when the project is not healthy, since a paused project
is the one case where the connection check fails for a reason unrelated to the
password.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): run the Supabase OAuth leg in a popup
A full-page redirect unmounts the wizard, so anything the user does on Supabase's
side -- signing in, confirming an email, browsing their dashboard -- leaves them
with nothing pointing back at Windmill, and the wizard had to park its state in
sessionStorage to survive the trip.
Open the connect endpoint in a popup instead. The modal stays on screen throughout
and the callback hands the token back through postMessage rather than navigating.
The parked-state path stays as the fallback for browsers that block the popup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): scope the connection check to the choice that produced it
A failed check stayed on screen when the user switched Supabase mode or picked a
different provider, so a fresh tab opened showing an error about a database it had
nothing to do with. Clear the report and the error on both switches; re-clicking the
tab already selected leaves an error the user is reading in place.
Also polish the Supabase step: project cards get the provider-card treatment (icon,
p-3, flex column) instead of a hand-rolled variant whose block layout left more
padding above the name than below; form labels settle on text-emphasis; and the
signup link sits under the primary button for anyone who does not have an account
yet.
Drop the "free" badge and the "Free on Supabase" line -- every option in the wizard
is free, so neither told the user anything -- and say what the Supabase card
actually does now that connecting an existing project is the default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): one setup checklist and one Supabase step for every host
The data table wizard, the instance database modal and the resource drawer each had
their own version of the same two interactions, and they had already begun to drift:
the wizard's Supabase resource shape was rebuilt by hand in the drawer, and the
instance checks rendered with no notion of a step being in flight.
SetupChecklist replaces LoggedWizardResult, whose only consumer was the instance
modal. It adds the running state that component lacked, so a list driven by an
endpoint that reports nothing until it returns still shows where it is. Both the
instance checks and the Supabase provisioning stages render through it.
SupabaseProjectStep owns picking or creating a project, and useSupabaseOauth owns
the popup leg. Each host keeps only what is genuinely its own: the wizard saves a
variable and resource then verifies the connection, the resource drawer fills in its
own form. Both trigger authorization themselves, so a host can offer it a screen
earlier than the step does.
The lists load behind a spinner because which mode to open on depends on whether the
account has projects; deciding that after rendering flipped the toggle under the user.
Adds a kitchen_sink playground for the checklist so the animation and every failure
position can be exercised without a backend, a superadmin, or a Supabase account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): tidy the resource drawer around the Supabase entry point
Connect Supabase was a hand-styled anchor carrying Supabase's brand hex values
rather than a Button, and it sat in a row whose other controls had settled on
unifiedSize md. Making it a Button meant SupabaseIcon had to satisfy IconType, so it
now takes `size` (deriving height/width from it) alongside the string props its other
callers pass.
The manual resource form spaced every field 32px apart and WhitelistIp added another
16px of its own, which read as a gap rather than a rhythm. One gap of 16px, with the
form itself given a little more separation from the description above it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): stop Supabase resources coming up modified when first opened
Resource forms fill in every unset property from the schema as soon as they render,
so a postgresql resource saved without region, root_certificate_pem and use_iam_auth
was dirty -- and had saved a draft -- the first time anyone looked at it. Write them
with the rest of the value.
SupabaseConnect also rebuilt the resource shape by hand instead of using the shared
helper, which is how the pooler host format ended up in two places.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend): record where a data table came from and whether setup finished
edit_datatable_config replaces the whole datatables map and DataTable does not deny
unknown fields, so anything the request omits is dropped without a word. origin and
setup_incomplete would have been erased by any unrelated save;
preserve_unmanaged_datatable_fields carries them -- and migrations_enabled, which had
the same problem inline -- forward for entries that already exist, following renames.
setup_incomplete is what lets a row be recorded before the resource it points at
exists, so the wizard can write nothing until the user finishes. There is deliberately
no intermediate state: the setup runs entirely in the browser, so nothing server-side
could advance one.
datatable_health probes every data table at once for the settings page and skips the
incomplete ones, whose resource_path resolves to nothing yet. set_datatable_setup
patches a single entry instead of resending the map. test_datatable_connection_value
checks a connection the caller has not saved anywhere, which the wizard needs before
it has written a resource.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make destructive default and subtle buttons read red
Both variants were neutral until the pointer arrived, then filled solid red: nothing
marked the button as destructive until you were already on it. They now carry red text
at rest, with a faded red border on default and a light red wash on hover, which is
what the legacy red border style in the same file had always done.
Three call sites passed color="red" alongside a design-system variant. getStyleClass
returns before colour is read for accent, accent-secondary, default and subtle, so the
delete-migration control, its modal confirm and the import-database button had all been
rendering neutral. They pass destructive now.
The dropdown variant strips the button's own border, and matched border-border-light
literally -- a class the destructive style no longer contains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): rebuild data table setup around a read-only row
The wizard gathers intent over two steps, reviews it on a third and writes nothing
until Finish, so a billable Supabase project is created only once the user has seen
what will happen. runSetup is also the retry: every step probes for its own result
before doing anything, so running it again on a half-finished data table resumes
instead of duplicating. Its steps are keyed rather than dispatched on their titles,
where rewording one changed what it did.
The settings row stops being an editable form with a dirty/save cycle. It carries the
name, where the database came from, a health dot and two actions; everything rare
moved into the gear panel, which also offers Finish setup for a data table whose
wizard never completed. Manage is ExploreAssetButton, the control the ducklake list
already uses, and the row and panel both link out to the underlying resource.
supabaseResourceValue no longer assembles the pooler host from the region.
aws-0-<region>.pooler.supabase.com is wrong for any project Supabase allocated
elsewhere, so the host, user and port come from the pooler config endpoint.
Two data tables sharing one database also share _wm_migrations, which is probed
unqualified, so the review step warns when the database being connected is already
behind another data table.
SupabaseConnect is deleted. The resource drawer uses the shared project step
restricted to existing projects: creating one is a billed action and belongs in the
wizard, which has somewhere to report what it did. The kitchen_sink checklist
playground goes with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): fall back to a direct Supabase connection when the pooler cannot be read
Reading a project's Supavisor config needs the database_pooling_config_read scope, which
an instance's Supabase OAuth app may never have been granted. No retry recovers from
that, and the wizard treated it as fatal: the user was left with an error and no way to
finish connecting a project that was otherwise fine.
resolveSupabaseConnection replaces the bare pooler read everywhere it happened. Asking
for session pooling and failing now yields a direct connection plus the reason, which
supabaseResourceValue already knew how to write. Nothing about the fallback is silent --
direct is IPv6-only, which is the whole reason session pooling is the default -- so the
wizard warns on its review step and the resource drawer says so in its toast.
The row is recorded before credentials are saved, so an origin claiming session pooling
has to be corrected once a direct host is what gets written; the run patches it through
set_datatable_setup rather than leaving the panel to report a mode nothing uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): open the database behind a data table, and say when it cannot write
Every database in the list now opens the surface that owns its credentials. A postgres
one opens its resource in the editor drawer; a Windmill instance one opens the instance
modal, which is where its setup checks, password rotation and drop already lived. Both
are reachable from the row and from the panel's provenance list, and the provider icon
moved inside the button so the whole thing is one target.
CustomInstanceDbWizardModal targeted #content unconditionally, which put it underneath
the panel drawer that now opens it. It takes a target, and the panel portals it to the
body.
The status column gains a third state. The probe reports privileges but nothing gated
the dot on them, so a data table whose role cannot create tables showed as Connected and
only failed when someone ran a migration. It reads "Limited permissions" instead, and
opens the panel on the report carrying the GRANTs that fix it -- the settings page has
already probed, so the panel takes that report rather than asking the user to run Test
connection over work already done. fullyPrivileged is exported from the report component
so the dot and the report cannot disagree about what counts as healthy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert(frontend): keep the data tables settings table as it was
The settings table and the setup wizard are two changes that only shared a file. Splitting
them makes each reviewable: this branch keeps the wizard, and the read-only row, gear
panel, health probe and clickable databases move to their own branch.
The rows go back to the editable form with its pickers and save footer, still opening the
wizard from Add a database. DataTableSettingsPanel, dataTableHealth and dataTableOrigin
had no other consumers and go with them; the connection report stays, because the wizard
shows it too.
DataTableSettingsType keeps `origin`: the wizard writes it, and the review step reads it
back to warn when two data tables would share one database and therefore one
_wm_migrations table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): confirm before dismissing the data table wizard mid-setup
Closing was guarded while a run was in flight and unguarded before one, which is backwards:
a run leaves a row to resume from, whereas a backdrop click on the review step threw away
the project, the pasted password and the folder with nothing to recover them from.
Backdrop, Escape and the close button now go through one path that asks first. It only asks
when there is something to lose -- no provider chosen yet, or a run that already produced a
result, closes immediately -- so the dialog does not become something to click through.
Continue in the background still leaves in one click; that exit was always the deliberate
one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): stop the wizard claiming the resource folder controls who can use a data table
"Who can use this database" was wrong. Every path that resolves a datatable:// reference --
both executors and the agent-worker endpoint -- reads the resource unchecked, by workspace
and name. A resource in u/admin is usable by everyone's scripts. The folder governs who can
see and edit the connection, and who can reference the resource directly in a SQL step;
neither is who can use the data table. The wizard was contradicting the tab's own
description two screens later.
The folder select and name field become one Path picker, the same one the resource,
variable and script forms use, so the review step reads as a resource path rather than a
permission choice. Its initialPath is snapshotted when the step opens: Path seeds itself
from it, and a live value fights the typing. Finish now also gates on Path's error, so a
taken or malformed path stops the run before it writes anything.
The button that opens all this says "Add a data table" -- the data table is what you get;
the database is a detail chosen along the way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert(frontend): move the destructive button restyle out of the wizard PR
This reverts 3881e4d8ea. Making default and subtle destructive buttons red at rest changes
every existing caller of the prop -- the workspace integrations, AI skills, workspace
creation and the instance database drop -- so it is a design-system change, and the call
sites it fixed are the migrations list and the database manager. None of that is the setup
wizard.
Nothing on this branch passes destructive any more, so it leaves with no loose ends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make the wizard stepper navigate the steps it already offers
Stepper dispatches a click and paints cursor-pointer on every reached step, but the wizard
never listened, so the breadcrumbs invited a click and did nothing.
They now reach any step already passed, in either direction: going back to check something
should not cost the progress, which means tracking the furthest step reached rather than
the current one. Forward movement still only happens through the primary action, so a step
is never reachable without having been validated -- and changing the intent revokes the
steps ahead of it, or Finish could run against a review built from something the user has
since edited. The five places that cleared the probe on an edit now do both through one
call.
During a run nothing is reachable, and the stepper says so rather than showing a pointer
over steps that will not respond.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): restore the data tables description lost in the branch split
The rewritten description went into DataTableSettings.svelte shortly before that file was
restored wholesale to its pre-rebuild state, so it left with the row rework it had nothing
to do with. The tab went back to describing the plumbing -- a fully managed PostgreSQL
database, reachable from the SDK -- which never answered the question a new user actually
has: why this rather than a Postgres resource.
It leads with what a data table is, then the two things a resource cannot do -- nobody
needs the credentials to query it, and the name can be pointed at another database without
editing anything that uses it -- and closes with what Windmill runs on top. Both middle
claims are the ones every resolution path backs up: datatable:// resolves by workspace and
name, unchecked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): say what is missing when a $res: or $var: reference does not resolve
Both interpolations fetched with fetch_one and mapped the error through to_anyhow, so a
reference to something deleted surfaced as "no rows returned by a query that expected to
return at least one row @workspaces.rs:2169". It names neither the kind of thing that was
missing nor its path, and it is what a data table pointing at a deleted resource reports.
They now fetch_optional and return NotFound naming the path, and datatable resolution adds
the data table on the way out: the caller asked for one by name, and a bare "resource
f/x/y does not exist" leaves them to work out which of them points at it. The health probe
is new, so this string had only just become something users read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): gate the data table wizard behind a dev flag
The wizard only appears with `dataTableWizard` set in localStorage; without it the
settings page keeps the inline-row flow it had before this branch, down to the empty-state
copy and the "New Data Table" button, and the wizard component is not mounted at all. The
existing e2e suite drives that button, so the default-off flag is also what keeps it green.
Step 2 of "your own database" becomes one list rather than a segmented control: the
workspace's Postgres resources, then a New resource card that expands in place. A
connection string is not an alternative to a resource, it is how one is written, and the
old layout taught otherwise. The card holds the same connection as a string or as fields
and carries values across when you switch, so `parse` and `compose` have to be inverses --
hence the percent-encoding on both sides, which also fixes a password containing `@`
silently corrupting in the resource form. The Supabase step now uses the same shape.
Names and paths are checked as they are typed rather than at the end of a run that may
have created a billed project first: the data table name against the charset
`edit_datatable_config` enforces, the instance database name against what
`setup_custom_instance_db` will accept, and the resource path against both the resource
and variable namespaces, since the run writes to both and both writes upsert.
`test_datatable_connection_value` refuses `$var:`/`$res:` in its body. It feeds
`transform_json_value_unchecked`, which resolves references with no permission check of its
own, so an admin could otherwise have had the API server decrypt any workspace secret and
hand it to a host the same request chose -- without the audit trail a variable read leaves.
Callers testing something unsaved hold the literal value already.
Alert, SetupChecklist and postgresConnectionString change for everyone, not just behind the
flag: body-only alerts no longer reserve an empty title row, the checklist can nest the
checks a step is made of, and the connection-string parser is shared with the resource form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: pin ee-repo-ref to the EE branch merged with EE main
The Supabase proxies the wizard calls are still unmerged, so the ref cannot be an EE
main commit yet; it now names that branch merged with EE main rather than the branch
alone, which was nine commits behind and would have been built against a CE main it
never saw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): gate the supabase resource path behind the dev flag
* test(frontend): pin connection string parsing to libpq behaviour
* fix(frontend): keep the supabase resource link off the popup callback path
* refactor(frontend): load the supabase resource dialog only behind the flag
* fix(frontend): refuse a resource path the wizard run does not own
* fix(frontend): let a failed data table setup be corrected without losing what it made
* fix(frontend): let a failed setup reuse the resource path it claimed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): record the two data table connection tests in the audit log
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): use Section for the data table wizard advanced group
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): read connection strings the way libpq does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): pin the ee ref back to a commit this branch can build
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep a failed setup's claims across the redirect and rollback
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(backend): probe a data table with the auth mode the worker will use
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep every part of a connection string through the round trip
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): give a setup run one record of what it created
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): mark a resource claim by edited_at, not its creator
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): mark every claim by revision, and keep an unconfirmed project's secret
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): refuse to test or save behind a connection string that will not parse
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): refuse a connection string carrying options the resource cannot hold
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): allowlist the connection-string parameters a resource can honour
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): guard every created Supabase project, not just the last one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: three-step wizard for importing a hub project
Importing used to be a single page that inherited whatever workspace happened to
be active, with no way to say where the project should go — the hub cannot know,
since it only ever links to *an* instance. `/projects/import` now asks: which
kind of destination, which workspace, then imports.
Nothing is created, switched or written until the last step runs. The wizard's
state is a plain value in the URL (`importWizard/plan.ts`), so the back button,
the stepper and the Back control are the same operation, and none of them can
strand a half-created workspace — there is no state anywhere else to unwind.
`importWizard/execution.svelte.ts` is the only code that acts on a plan: it runs
create → fetch → import as an observable task list, reuses what already
succeeded when retried, and offers to delete the workspace it created if the run
stops early. Its UI needs — the data table migration review — are injected, so
it holds no components.
The old `/projects/install` becomes a redirect: hubs upgrade on their own
schedule and a self-hosted one may keep pointing at it for a long time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: let the import wizard survive sign-in and a missing workspace
Signing in with `rd=/projects/import?hub=...` dropped the destination: the login
redirect only honours `rd` verbatim for `/user/workspaces`, so anyone with more
than one workspace landed on the workspace picker instead — the page the wizard
exists to replace, asking the question it was about to ask. Both copies of that
logic now allow the wizard through.
The root layout's "no workspace selected" redirect skips the wizard too. It
picks the destination itself and may end in a workspace that does not exist yet,
so bouncing it to the picker forces the very choice it is there to make.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: bench page for the import project card
/kitchen_sink/import_project_card renders the card against fixtures — a real
project, an oversized one, a minimal one — so its layout can be judged without a
hub running or an import in flight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): do not warn about renaming an item that does not exist yet
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): make the review step read as one list of what will exist
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep the picked Supabase project across the redirect, reject connect_timeout
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: check the data table connection from a worker, not the API server
The wizard's connection check ran on the API server through two endpoints added
for it. That server is a different machine with a different identity, so the
answer was about the API server rather than about the worker that will run the
queries: a host reachable from one is not necessarily reachable from the other,
and IAM RDS and Azure workload identity authenticate as whichever process opens
the connection.
Run the privilege query as a preview job instead. A job goes through the
worker's Postgres executor, which is where `PgAuthMode::of` already picks the
authentication mode, and it takes either a resource value or a `$res:` path
exactly as a Postgres step does. Postgres composes the suggested GRANT
statements through `format('%I')`, so identifier quoting stays where it is
already implemented.
Removes `test_datatable_resource_connection` and
`test_datatable_connection_value`, and `connect_as_the_worker_would` with them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: fold check_datatable_connection back into its only caller
The helper was split out so the two connection-test endpoints could share a
body. Those endpoints are gone, leaving one caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert: keep the data table connection check schema inline
It was lifted into components so three endpoints could share it. Two of those
are gone, so it is back to one user and the extraction changes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: restore openapi.yaml to the branch point
The previous commit restored main's tip rather than the merge base, which
carried three unrelated main-only changes into this branch: the resource
mcp_tools truncation fields, the execution_mode description, and a version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): drop four effects from the data table wizard
Each was doing work a derived, a load callback or a real entry point does
better.
- The name conflict is kept with the name it was raised for and derived from
it. As an effect it was correct only because it never read what it wrote:
the pre-flight sets the message and the effect does not re-trigger, so adding
a read would have cleared it the instant it appeared. The message now also
comes back if the taken name is retyped, which is what the server will say.
- The default resource selection is seeded inside the fetcher that loads the
list, where "has the fetch settled" cannot be asked wrong.
- Reset-on-open becomes an exported open(), called by the settings page, so a
fresh run is set up by the act of opening rather than by a flag emulating
mount.
- The OAuth connects and the folder list become resources; supabaseAvailable
and folders are derived from them. defaultFolder takes the list rather than
reading it, so the fetch can seed off its own result.
Leaves the debounced path check, which is async with an out-of-order guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(frontend): drop three effects from the Supabase branch
- useSupabaseOauth reports success as onAuthed, alongside the failures it
already reported. SupabaseResourceConnect was watching `authed` to find out;
it takes the callback instead, keeping the guard that stops an authorization
started elsewhere on the page from opening its dialog.
- SupabaseProjectStep loads its orgs and projects through a resource keyed on
the token, so the `loaded` latch goes and re-authorizing reloads rather than
keeping the lists from the expired session.
- SetupChecklist records what the user toggled and derives the open state from
it, a failed step defaulting to open. Recording the open state instead needed
an effect to force it, and that effect re-ran on every progress update, so a
description closed while anything was still ticking reopened. A close now
holds for the life of the checklist, including across Try again.
Leaves the message listener, which subscribes to another window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): confine the modal restyle to the wizard, and trim the comments
The wider side padding and lighter dialog heading were changing all 17 Modal2
dialogs to suit this one flow. They move behind an opt-in `formStyling`, taken
by the three dialogs this branch owns; every other Modal2 renders as it did.
Also drops two comments that cited a design approval rather than a constraint,
and shortens the blocks that had grown past the four lines AGENTS.md asks for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): use the accent token for the wizard's links
`text-blue-500` is the marketing blue `#3B82F6`, which brand-guidelines.md
rules out in the app interface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: point ee-repo-ref at the EE branch head
Picks up EE main, which the branch now needs, and the Supabase proxy auth fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): read sslmode by name, and stop decrypting a secret to date it
- `sslmode` was found by searching the query text, so it also matched inside
another parameter's value: `?application_name=sslmode=disable` passed the
allowlist on the parameter name and then parsed as a request to turn TLS off,
which both the wizard and the resource form saved and probed. Parsed with
`URLSearchParams` by exact name, with a test.
- `secretMark` read the variable with `decryptSecret` defaulted to true, so
every write decrypted a secret nothing reads and recorded the decryption --
including someone else's on the retry about to refuse it. It wants only
`edited_at`, which is returned either way.
- The probe gave up at 15s while the worker allows its Postgres connect 20s, so
a host that accepts the connection and never answers was cancelled and
reported as a missing worker rather than a failed connection.
- The create-mode region and project name did not report an intent change, so
renaming a project after a name collision left the failure naming the old one.
- Two comments described the code as it was before the claim mark became a
revision, and a doc comment outlived the field it documented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): read connection parameters the way libpq does
One reader for both the parser and the allowlist, since they disagreed about
what a string says in two ways that both ended in a weaker connection than was
pasted:
- `URLSearchParams.get` takes the first of a repeated parameter and libpq takes
the last, so `?sslmode=disable&sslmode=require` was read as `disable`.
- The allowlist folded the parameter name and the parser did not, so
`?SslMode=verify-full` was refused by neither and honoured by neither, and
saved as the `require` default.
The parked Supabase run is now handed to `open()` rather than read back off the
`resume` prop it was just assigned to, so restoring it does not depend on when
that prop reaches the component.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): keep connection parameter names case-sensitive
libpq does not fold them: `?SslMode=disable` is rejected as an invalid URI
query parameter rather than read as `sslmode`, which a local server confirms.
Folding made Windmill accept and honour a string Postgres itself refuses;
naming the parameter instead tells the user why it cannot be stored.
The last-value-wins rule for a repeated parameter is unchanged, and matches
what the same server does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): seed the Supabase organization from the project it selects
The loader took `orgs[0]` independently of the project it seeded, so an account
whose first project sits outside its first organization had the review step name
an organization the database does not belong to. Picking a project by hand
already derives it; the seeding now does the same.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): let the probe report an empty search_path instead of failing on it
`format('%I', NULL)` raises rather than returning NULL, so a role whose
search_path names no valid schema failed the whole privilege query and was
reported as an unreachable database. That is the one case `fix_search_path`
exists to name, and it never reached the user. Verified against a local server
with `SET search_path = ''`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): say which of the two refusals a connection string hit
Making parameter names case-sensitive gave `unsupportedConnectionParam` two
reasons to refuse, and the single message explained only one. `?SslMode=` was
answered with "Windmill cannot store SslMode on a Postgres resource", which is
false twice over: sslmode is exactly what the resource stores, and the string
asks for nothing because Postgres rejects the URI. It now names the spelling
when the parameter is one we keep, and the storage limit otherwise.
The folder-list guard also still read the `resume` prop that `open(parked)` was
changed to stop trusting, so the resumed path now comes from whatever `reset`
was handed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): leave the Supabase organization unset when the lookup misses
Falling back to the first organization named one the seeded project is not in,
since `supabaseSummary` prefers `intent.org` over the project's own. Unset, it
falls through to the project's organization identifier — the right one, spelled
as a slug rather than a name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: harden the import wizard and put it on the design system
Review fixes, then the parts of the wizard that were hand-built where the
design system already had an answer.
Correctness:
- Hub SVGs are sanitised with DOMPurify before `{@html}`. The earlier comment
claimed the markup came from the hub's own icon package rather than user
input, which the custom-URL feature makes false: the hub is whatever address
the user typed.
- The run owns navigation while it is in flight. The stepper refuses to move,
`beforeNavigate` cancels browser back/forward, and unmounting resolves a
pending migration review so the executor cannot hang waiting on a component
that is gone.
- The folder edited on the last step reaches the executor, so a retry after
changing it imports where the field now says.
- `validateWorkspaceId` and the workspace-entry pair (`listUserWorkspaces` then
`switchWorkspace`) are extracted, so the wizard and the real create form
cannot drift on what an id is or on what entering a workspace means.
Design system:
- The destination tiles are `RadioCard`, which gains `showRadio` and a snippet
`description`; the wizard turns the glyph off because the border and tint
already say which one is picked. `RadioCard` now also carries `role="radio"`
and `aria-checked`, which it had neither of, and marks its selection with
`surface-accent-selected` — the token `FileExplorer`, `TriggersTable` and
`RunnableRow` all use for the chosen row.
- Form labels follow `brand-guidelines.md` — sentence case, real `<label>`
elements so the text focuses the field, Caption-styled errors — rather than
one-off 11px uppercase tertiary text. They use the lighter secondary weight,
since the fields arrive prefilled and the value carries the meaning.
Folder choice, restored and merged:
- Picking an existing folder came back for an existing-workspace destination.
`FolderPicker` takes a `workspace` prop so it can list a workspace without
switching to it, and resolves `whoami` there — its write flags came from
`$userStore`, i.e. the wrong workspace, which rendered every real folder
read-only and unselectable. A new workspace has no folders to choose between,
so it is not asked.
- The progress list and the imported paths are one component: the paths hang
off the import task that produces them instead of forming a second list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review — history, workspace restore, SVG styles
Two blockers and the findings around them.
- The wizard pushed history entries for navigations the user did not ask for.
`folder` initialises to the project slug while the plan holds none, so the
mirroring effect fired on mount and pushed a URL differing only by `&folder=`;
pressing Back returned to the entry without it, which re-fired and re-pushed.
Back could never leave step 3. `go` now takes `{ replace: true }`, used by that
effect and by the step guard — the two navigations the page decides on its own.
The comment claiming `go` replaced was written without checking that `goto`
forwards to SvelteKit, which defaults `replaceState` to false.
- Undoing a run left the app pointing at the workspace it had just deleted:
`#ensureWorkspace` switches in, `deleteCreatedWorkspace` deleted without
switching out. The dead id was persisted on the next navigation, `getUserExt`
then returned undefined, and the following reload logged the user out. The
executor now remembers where the app pointed before it started and puts it back.
- `FORBID_TAGS: ['style', 'image']` on the hub SVGs. The profile allows both; an
inline `<svg><style>` is document-scoped, so a hostile hub could restyle this
page — including moving the wizard's own Import and Delete controls — and
`<image href>` is a beacon. The doc comment asserted a guarantee the config did
not deliver.
- The existing-workspace id is validated like the new one and encoded where it is
interpolated into `/api/w/<ws>/...`; it arrives from the URL exactly as the new
one does and ends up in `workspaceStore`.
- `AppConnectInner`'s two RadioCards get a `role="radiogroup"` wrapper, since they
now carry `role="radio"` and a screen reader cannot place a radio without one.
- `FolderPicker` records a created folder against the membership it is reading, and
before reloading, so a non-admin can re-pick the folder they just made in another
workspace instead of finding it `(read-only)`.
- Step 3 shows trigger and data table migration counts once the export is fetched.
The page this replaced showed them, and the warning underneath talks about
triggers the user was never told about.
- First tests for the two pure modules: the workspace-id contract the wizard and
the create form must not drift on, and the plan/URL round trip the whole wizard
rests on.
- Doc fixes: the retry claim (the granularity is the task, not the item), the bench
header, a fractional `?step=`, an empty name in the destination card, and the
three copies of one rationale AGENTS.md asks to state once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(frontend): pin which refusal a connection string gets
The two messages differ in what they ask the user to do, and the condition
choosing between them — whether the lowercased name is one the resource keeps —
is not visible from either call site. `Connect_Timeout` is the case that keeps
them honest: miscased *and* unstorable, so respelling it would not help and the
message must not suggest it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): hand a failed Supabase leg back to the page holding its run
Denial, a token error and a malformed callback all sent the user to
/resources whether or not a run was parked. Nothing else consumes the park, so
the run stayed in sessionStorage and sprang the wizard open on an unrelated
later visit instead. A parked run now lands on the data tables tab, where the
wizard resumes on the setup step and can authorize again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): let a run reuse the name of a row it could not take back out
`removeRow` reports `kept` when the undo cannot reach the server, so the row
this run wrote stays in the workspace config and comes back in `existingNames`.
The client-side name check then refused the retry on the run's own name, with
no way forward but a rename. The instance database name has carried the same
exemption since it was written; this is the data table name catching up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): discard a variable check the wizard has moved on from
The post-await guard compared only the path, and the path is built from the
review step's fields -- so picking an existing resource stops the wizard minting
one without changing it. A check already in flight then answered for a branch
nobody was on, and a `true` disabled Finish over a path the run no longer
writes. The cleanup cannot help: it cancels a pending timer, not a live request.
Both sides of the await now ask the same question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 483513b70979aa9497cab869837108d948449984
This commit updates the EE repository reference after PR #715 was merged in windmill-ee-private.
Previous ee-repo-ref: 8604b30a740c5620069208801a7ae50937b61977
New ee-repo-ref: 483513b70979aa9497cab869837108d948449984
Automated by sync-ee-ref workflow.
* feat: a setup step for what the import cannot bring with it
A project's data tables and credentials cannot travel with it: a data table is a
named database connection the workspace owns, and resource values are secrets the
hub never publishes — `importResourceStub` creates every one of them empty. The
wizard used to state that as a dead end. Mid-import it asked the user to cancel,
create the data tables by hand and start over, which for a *new* workspace was
every single time, since a new workspace has no data tables at all.
Step 4 replaces that. It appears only when the run leaves something undone, lists
what that is, and does it in place: a Postgres resource per missing data table
(one merged `editDataTableConfig` write, then the migrations), and the existing
resource editor for each credential. Skipping is allowed and says plainly which
parts of the project will not run.
It is self-sufficient from `workspace` + `slug` — it re-fetches the export rather
than reading the executor — so reloading on it works and the plan in the URL stays
the whole state. Rows are marked done rather than removed, with SaveButton's
confirmation flash, because a checklist line that vanishes when completed reads as
something going wrong.
Two things the step needed from elsewhere:
- `ResourceEditorDrawer` gained `onSaved`. `onRestored` fires only when an old
version is restored, so a caller showing state derived from the resource had no
way to know a save had happened — the row kept saying "missing token" after the
token was filled in.
- The run now loads the destination's membership into `userStore`. The wizard's
page is reparented out of `(logged)` and never gets that layout's `getUserExt`,
so anything asking what the user may do reads "no user" and refuses.
`applyOneMigration` is exported for the same reason the step exists: the import
skips a migration whose data table is not configured, and this is where it is not
skipped any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: set up data tables through the wizard, not a hand-rolled form
The setup step drove `editDataTableConfig` itself, which meant it could
name a table and record migrations but could not create the database
behind it — the case a brand-new workspace is always in. It now opens
`AddDataTableWizard`, which owns that whole path.
Four additive props carry what the import flow needs and nothing else,
so `DataTableSettings` is unchanged:
- `initialName` — the migrations only apply to a table of the name they
target, so the wizard opens on it. Still editable.
- `modalTarget` — `#content` is the `(logged)` shell's scroll container,
and the import page reparents out of it, so the portal would find
nothing and the dialog never appear.
- `finishAlso` / `onFinishAlso` — running the migrations was invisible
until it had already happened. It is now named on the final button
("Create data table and run migrations") and reported as the last row
of the wizard's own checklist, failing there rather than silently.
Rows are marked done rather than removed, so the list still says what
was set up. Resources keep their card and swap "Fill in" for "Saved".
`Finish` is the primary and stays disabled until nothing is outstanding;
`Skip for now` sits beside it, and the info alert explaining the skip
turns into a success one when everything is configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: show each credential's own integration icon, and cut the data table blurb
The credentials list marked every row with the same key glyph, so the only
thing distinguishing them was the path. `IconedResourceType` renders the
provider's own mark from the resource type already on the row, falling back
to a generic box for types with no icon.
The data table explanation said "a data table is a database this workspace
owns" directly under a label reading "Data tables to set up", and "this
project ships with one it expects to find" directly next to the count that
says so. Both halves went; what a data table is *for* and what to do next
are what a first-time reader needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: build the Google sign-in button from the design system
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: qualify a data table FK target with its schema
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: confirm before skipping an unconfigured data table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: show a loader while the wizard hands off to the workspace
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: resume an import whose workspace was already created
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* refactor: draw the import run with SetupChecklist and ask before leaving it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: portal the setup step's confirmation above the data table wizard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
* fix: supply the three APIs the import wizard already calls
`AppConnectDrawer`, `ImportProjectStep` and `execution.svelte.ts` landed
calling into props and exports that were never committed alongside them,
so the branch did not type-check. Each half is here now:
- `AppConnectInner.fillPath` — connect into a resource that already
exists instead of refusing the path. The import creates every resource
as an empty stub, so without it the connect flow can only ever say
"already exists, delete it or pick another path". Opt-in: unset, the
flow still refuses to write over anything, which is what `ResourcePicker`
and the resources page rely on.
- `ProjectContentBadges.contentSummary` — the badge counts as one line of
text, for the import step's task row. Shares `kinds()` with the badges
so a project cannot be counted two ways.
- `installProject.onMigrationsStart` — fires before the reviewed
migrations run, which is the only signal that phase has begun; the
import step draws them as their own checklist row off the back of it.
Also fixes the wizard wedging itself shut: `requestClose` set `dismissing`
and cleared it after awaiting the confirmation, so an `ask` that threw left
the flag set — and the backdrop, Escape and the close button all return
early on it, leaving a reload as the only way out. Now `finally`, plus a
reset on open, since a promise that never settles never reaches `finally`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: offer Connect wherever the connect dialog would actually work
The setup step decided a resource was connectable by looking only at the
instance's configured OAuth clients, while the dialog it opens also accepts
a provider the registry marks client-credentials-capable — those carry their
credentials per resource, so no superadmin has to configure anything. The
two disagreed for bitbucket, coupa, linkedin, servicenow, spotify, visma,
xero and zoho: the step showed "Fill in" where the dialog would have
connected.
Rather than copy the predicate, `oauthRegistry.ts` now owns it, and
`AppConnectInner` reads it from there. That folds in three lookups of the
same registry that had drifted apart inside the component — `registryEntry`,
`isCcCapable`, and a raw index at the connect-template site — so the sandbox
suffix rule is written once instead of twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: draw the project card's icons from the ones we already ship
The card fetched each integration icon from the hub as SVG markup, sanitized
it and injected it with `{@html}`. The hub renders those icons out of
`@windmill-labs/components` — this frontend's own package — so it was a
cross-origin round trip to get our own assets back, and it made the card
depend on a read that a hub with `API_SECRET` set refuses outright.
`hubAppIcon` resolves them through `appIconComponent` instead, so they are
components again: no fetch, no DOMPurify, no `{@html}`, and they paint on
first render rather than after a round trip. Integration icons now show even
against a gated hub; only the summary and the uploaded logo still need it.
The one thing the hub was doing for us was resolving `postgres` to the
`postgresql` mark, which its `aliasApp` bridges and our icon map does not —
so that single alias comes along, next to a note pointing at its counterpart.
`ImportProjectSummary.hub` goes with it: it existed to build icon URLs and
nothing read it afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: two regressions this branch introduced into shared drawers
Found auditing the files here that are used elsewhere in the app.
`AppConnectDrawer`: the guard added to stop the inner component being opened
twice compared the last resource type against the current one, and reset it
to `undefined` on close. The resources page opens the drawer with no resource
type, so both sides were `undefined`, the guard matched, and the second
opening never handed off — the type list came up empty. The drawer destroys
its content on close, so this hit every reopen. Now a flag armed per `open()`
call, which cannot collide with a resource type.
`ResourceEditorDrawer`: adding `onSaved` had turned the Save handler into
`await save(); closeDrawer()`, so the drawer stopped closing immediately and
waited for the write. `save()` catches its own errors and never rejects, so
that was pure added latency for all ten callers. It now starts the save,
closes as it always did, and awaits only to fire `onSaved`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the destination through a first-time signup
Someone who follows a shared hub project without an account signs up, and the
OAuth callback sends a first-time user to onboarding — dropping the `rd` it
had already read out of localStorage. They finish onboarding in an empty
workspace with no sign of what they came to import, and have to go back to the
hub and click again. That is the path this feature exists for.
The callback now passes `rd` on, and onboarding's two exits honour it instead
of hardcoding `/user/workspaces`. Same-origin relative paths only: `//host` is
a valid URL that leaves the origin while still starting with `/`, so the guard
rejects it rather than bouncing a fresh account off-site.
Nothing changes for a signup without `rd`, which is every existing one.
Gets the user to the wizard with the project in hand; they still pick a
destination on step 1. Having onboarding create the workspace and hand into
step 3 is the larger version, not done here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review — username, name length, leaving mid-run
**The new-workspace username was never validated.** Step 2 shows the field when
the instance does not derive one, but neither the Continue gate nor
`planProblem` looked at it. `create_workspace` does not close that hole:
`nw.username.ok_or(...)` accepts `Some("")` and never runs the `VALID_USERNAME`
check `join_workspace` does, so a cleared field created a workspace whose owner
has an empty username, and a digit-first one was stored verbatim. Both now
refuse, using the same `validateUsername` the sibling creator has always run.
**The name length was unchecked**, so a >50-char name walked through two more
steps and failed at create. `WORKSPACE_NAME_MAX_LENGTH` sits next to the id
limit and `planProblem` checks it.
**Leaving mid-run did not stop the run.** The dialog promised "The import stops
where it is. Coming back to this link picks it up again", but navigating away
only unmounted the UI: the executor kept going, reached `done`, and called
`clearParkedImport()` — so returning to the link tried to create the workspace
again and failed with "already exists". Worse, the review drawer's teardown
resolved the pending review to `false`, meaning "skip the migrations", and the
orphan imported every item without the tables they need.
Nothing can abort a request already in flight — `installProject` takes no
signal — so `abandon()` stops the run at the next phase boundary and leaves the
workspace parked, and the teardown now resolves `'abort'`, which stops the
import rather than silently dropping the migrations.
Also drops a stale JSDoc above `hubAppIcon` still describing the fetch-and-
sanitize implementation that `ea31f73ed3` replaced.
Adds the coverage the review asked for: the parking decision at the end of a
run, and the two validation gates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review round 2 — XSS, retargeting, abandonment, redirects
**Hub data-table names were inlined as raw HTML.** `skip()` built the
confirmation body as an HTML string, and `createAsyncConfirmationModal` renders
`children` through `createRawSnippet`. A `datatable_name` comes straight from
the hub export, and a hub is not necessarily ours — `hub_base_url` is an
instance setting — so one carrying an event-bearing element ran script in this
authenticated origin. Escaped. Same class as the round-1 SVG finding, in a
different sink.
**The setup step read unretargeted resource paths.** `installProject` rewrites
every resource into `f/<folder>/`, but the step re-fetched the raw export and
used its paths verbatim. Importing into a folder other than the slug made
`getResource` throw for every stub, the catch skipped them, and the step
reported "You're all set" over credentials nobody had filled. It now retargets
the same way the import did, and filters to the import folder — the containment
guard the installer applies, so a crafted export cannot name a path outside it
and get offered for editing.
**Abandoning only stopped between phases.** `installProject` takes a `stopped`
callback now, checked before every write loop, so leaving mid-run stops the
remaining items instead of just the remaining phases.
**A failed setup migration reported success.** `runMigrationsFor` swallowed the
error, so the wizard marked its "Run migrations" step done and closed over a
failure — leaving the data table name taken and no way back to retry. Rethrown,
which is what the wizard's checklist reads.
**`onboardingDestination` used a weaker redirect check.** `/\evil.com` passes
`startsWith('/') && !startsWith('//')` but WHATWG URL parsing resolves it to
another origin. Replaced with `toSameOriginRelativePath`, which already rejects
that, control characters and oversized values.
**Two workspace ids reached step 3 that the backend refuses:** a blank one (the
Continue gate never required `id.trim()`) and `global`, which
`check_w_id_conflict` rejects outright while `existsWorkspace` reports it free.
Also: `size="xs2"` → `unifiedSize="2xs"`, and two doc comments reattached to the
functions they describe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review round 3 — both regressions from round 2
**`resume` never rejected a run from a different plan.** The seed computed its
tag from the plan being rendered, so `run.key === planKey` was true by
construction and the guard could not fire — the comment claimed the opposite.
Finishing an import into workspace X, stepping back to pick workspace Y, then
returning showed X's finished checklist against Y's plan, with a Continue
button, over an import into Y that never happened. `ImportExecution.planTag`
now carries the plan the run was made for, and the seed uses that.
**Abandoning mid-import still reported `done`.** `installProject` returns early
when `stopped` goes true, and it returns exactly as it does on success, so the
tail of `#import` could not tell the two apart: a run stopped after 3 of 10
items wrote `import: done — 3 items`, no error, `done = true`. Since the page
hands that run back on return, the primary button became Continue rather than
Retry and the seven skipped items were silently lost — breaking the promise the
leave dialog makes. The tail now checks the flag and leaves the run failed and
retryable.
`abandon.test.ts` was a hand-written copy of the parking decision, which is why
it guarded neither. It now drives a real `ImportExecution` with the install seam
mocked, abandons from inside the write loop (the only way it happens — `run()`
clears the flag on entry so a retry can proceed), and asserts `done`, the error,
and both parking outcomes. `planTag` is covered too: different destination,
different project, and that the editable folder does not change it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address review round 4 — unreviewed SQL, premature finish, stale Back
**Setup ran hub SQL nobody had seen.** Step 3 reviews the migrations it can run
there, but the ones deferred to setup went straight to `applyOneMigration`
against whatever database the wizard was pointed at — which can be an existing
resource holding unrelated objects. Each unconfigured row now carries a
disclosure showing exactly what will run, before "Set up" runs it.
**Finish was live while the setup decision was still outstanding.** For a project
with migrations but no resources, `execution.done` exposed the button while
`listDataTables` was still in flight and `setupNeeded` was still false — clicking
in that window left for the workspace and skipped a step the answer, a moment
later, said was needed. It now reads "Checking…" and is disabled until the check
settles.
**A reload on step 4 turned Back into a re-import.** `resume` only carries the
page's in-memory execution, so after a reload Back mounted a fresh step 3
offering Import over a bundle already in — and on a new workspace, a create that
now fails because the finished run cleared its parking. Back exists only while
the page still holds the run, which excludes exactly that case.
**`validateWorkspaceId` over-rejected a fork named `global`.** It reaches the
backend as `wm-fork-global`, which is accepted; only the effective id is checked
now, so a plain `global` is still refused. Covered by a test.
**An abandoned run left the migrate row spinning.** It is appended once the
review settles and set running by `onMigrationsStart`; stopping before its loop
left it on `running` forever, reading as work still in progress on a run that
had stopped.
Also moves the `run()` contract back onto `run()`, and gives `ImportSetupRow` an
optional `extra` snippet for detail that does not fit on one line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: close the other two routes back into a run-less import step
Round 4 gated the setup step's own Back button when the completed run was no
longer in memory, but that is the least used of three ways back into step 3, and
both reviewers landed on the same gap.
The stepper renders every earlier step as reachable, and `importIsRunning()` is
false after a reload, so its "Import" tab walked straight there. And `onFinish`
pushed step 4 over step 3, leaving the browser's own Back pointing at the same
place.
After a reload there is nothing to hand back: the executor was in memory, and a
clean finish clears the parking, so step 3 mounted with `resume` undefined and
offered a fresh run — re-importing a bundle already in (a wall of path
conflicts), or on a new workspace re-running a create that now fails as already
existing, with no Delete offered because that execution never made it.
`ImportWizardSteps` takes a `lowestStep`, which the page raises to 4 exactly
when the run is gone, and the step-3 → 4 transition replaces rather than pushes.
Verified against a real reload: the stepper stays on step 4 and says why, and
browser Back lands on step 2 with no runnable import.
Also adds the migration-phase abandonment assertion the review asked for — that
no task is left on `running` when a run stops.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: make the migration-phase abandonment test actually reach it
It asserted over a branch it never ran. The mock `installProject` never called
`onMigrationsStart`, and with `migrations: []` in the export and
`reviewMigrations` returning nothing, `#import` never appended the `migrate` row
at all — so "no task is left running" was true because no task existed. The
comment was wrong too: the real `onMigrationsStart` fires at the head of the
migration loop, past every item loop, not at the start of the writes.
The mock now mirrors that order — item loops, then `onMigrationsStart`, then the
migrations, with `stopped` checked before each write — and a second hook lets a
test abandon after the row is running. The export ships a migration and
`reviewMigrations` returns it, so the row exists to be pinned, and the test
asserts it exists before asserting its status.
Checked by removing the fix: it fails with `expected 'running' not to be
'running'`, and passes with it restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: ask the instance what exists instead of remembering it
The wizard kept a note in `sessionStorage` — "this run created workspace X" —
so a reload could tell that a create had already happened. A note is a second
copy of a fact the instance already holds, and it could outlive the workspace
it named: the comment on `createdWorkspace` said a parked id might point at a
workspace someone else made at that id afterwards, and that there was no way to
tell, because a workspace carries no discriminator.
It carries `owner`. It is set to the creator's email at `INSERT INTO workspace`,
`listWorkspaces` already selects it, and the generated `Workspace` type already
has it. So the question the note was answering can simply be asked:
`probeWorkspace` returns whether a workspace with the plan's id exists among
the caller's, and whether they own it. Ownership is what makes adopting one
safe — an id that exists but belongs to someone else is not this run's work.
`parking.ts` and its test are gone. Nothing in the wizard writes storage now:
the plan is in the URL, what exists is in the instance, and what is in flight is
in memory, which is where in-flight things belong.
`probe.ts` also carries the two reads the follow-up needs — which of the paths
an import would write are already there, and whether a migration's tables exist.
The second is the ground truth for "did this migration run", covering both paths
`applyOneMigration` takes: it records a migration when the data table has them
enabled, and otherwise runs the SQL as a job nothing remembers. The tables
outlive both. It returns `undefined` rather than `false` when it cannot tell,
since "not there" invites a caller to run the migration and "cannot tell" does
not.
Verified against a real reload mid-run: the second attempt makes no
`createWorkspace` call, one `workspaces/list` call, and carries on to the fetch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: import only what the destination does not already have
A retry resent the whole bundle. Everything that had already landed came back
as "already exists" — nine failures over work that had succeeded, with no way
to tell those from the ones that genuinely failed. The same thing happened
importing into a workspace that already held some of the project.
`installProject` now takes `alreadyPresent`, checked after retargeting because
that is what the items will actually be called, and `probeImportedPaths` fills
it from the destination on every run. On a workspace the run just created the
answer is empty and nothing is skipped, so this costs four scoped reads and
changes nothing about a first import.
Skipping is not replacing. An item that is there is left exactly as it is —
the same promise `updateIfExists: false` already makes for a resource whose
value someone has since filled in.
`InstallResult` gains `skipped`, because "already there" is neither an import
nor a failure and reporting it as either is a lie. The checklist still lists
every item the project ships; a skipped one shows as skipped and says why. The
import row now counts the three outcomes separately — `8 already there` rather
than a green tick over `2 apps, 4 scripts, 2 resources` it did not write. That
last part needed the pre-run breakdown to stand down once the run has an
outcome of its own, or it went on claiming the import had happened.
Checked by removing the gate: two of the four new tests fail. Verified against
a real backend by re-importing Calendly into a workspace that already had it —
0 failures, 0 create requests, and the row reads "8 already there", where the
same run previously produced 9 conflicts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: skip triggers that are already in the destination
probeImportedPaths asked about scripts, flows, apps and resources but not
triggers, so a retry replayed every trigger create into an API that rejects
an existing path — reporting a failure for something already there, which
is the wall the presence probe exists to remove.
Triggers have no prefix-filtered list endpoint, so they cost one call per
kind; the probe only asks when the project actually ships triggers.
The presence set is now keyed by kind as well as path. The five kinds share
one f/<folder>/ namespace, so a trigger and a script may both be called
f/cal/sync, and a flat path set would let either one mask the other.
Also drops expectedPaths, which was exported and tested but never called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: don't let a failed migration read as a finished setup
Three linked gaps around the step-4 data table setup:
Finish setup was clickable while the step was still loading. Empty rows and
blanks made outstanding === 0, which reads the same as having nothing to do,
so a quick click left the wizard before the missing data table was even
discovered. Skip already guarded on loading; Finish now does too.
When the data table wizard's appended migration step failed, run.result kept
runSetup's successful verdict, so the primary action offered Done over a
failed row and closing raised no warning. The failure is now tracked apart
from run.result, and Try again re-runs only the appended step — re-running
the setup would ask for the table name it just took and be refused.
A failed row in the import step reopened the full wizard, which rejected the
name it had itself created, leaving no way back to the migration that
actually failed. Such a row now offers "Run migrations again" instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: read the destination's real state instead of inferring it
Three ways step 4 could report work that had not happened:
AddDataTableWizard wrote through $workspaceStore while ImportSetupStep used
the workspace from the URL plan. The import page is reparented out of
(logged), so nothing re-runs the layout's workspace persistence; after a
reload the store still named the workspace the user came from. "Set up"
would then create the data table there and run the migrations in the
destination. The workspace is now a prop, defaulting to the store so every
other call site is unchanged.
load() marked a row done whenever the data table name existed. The wizard
creates the table and the migrations run after it, so a table can be there
with none of the project's tables inside it — and a reload rebuilds rows
from scratch, hiding the failure. It now asks probeMigrationApplied, which
already existed for exactly this question. An undefined answer ("cannot
tell") keeps whatever the row said rather than inventing an outstanding row.
A reviewed migration could fail in step 3 while the run still reported a
clean finish: the migrate row said failed, but `error` was set only from
item failures, and `error` is what offers Retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: keep the migration retry reachable after a reload
The retry-only action needed two things the reload did not have. load()
read the destination's data tables into a local set and dropped them, so
configuredNames was empty and the branch could not fire; it now seeds
configuredNames from the call it already makes.
And the branch keyed on the row saying `failed`, which only holds while the
failure is still in memory. A reload rebuilds every row from scratch, so the
same situation reads as `unconfigured`. It now keys on the data table
existing while its tables do not, which is the same state either way.
Without both, a reloaded failure sent the user back into the wizard, which
refuses the name it created — no way to reach the migration that failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* docs: state the constraint, not how the code got here
AGENTS.md: "Describe the code as it is, never its drafting history". Nine
comments across the wizard narrated what an earlier iteration did — "used
to remember", "The regression:", "would otherwise warn" — which says
nothing to a reader who never saw it. Each now states the durable reason
directly: what the code must hold to, and what breaks without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: page the presence probe, and keep a failed run retryable
probeImportedPaths called each list endpoint once. They paginate at 30 rows
by default, so it answered correctly for a small project and silently
under-reported a large one — every item past the first page went back
through a create call that rejects an existing path. It now pages at 100
until a short page, with a 100-page stop so an endpoint that never returns
one cannot loop.
And a run that finished with failures offered only Finish. `done` is what
the step reads as terminal, not `error`, so a failed migration left no way
to run the SQL again. Retry now sits beside Finish whenever the run reports
an error — beside rather than instead, so a migration that fails every time
cannot trap the user short of step 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: don't offer to discard a data table that was created
Closing the wizard after a failed appended step asked "Leave without adding
a data table?" and warned that what ran had left things behind. Every part
of that is false when the setup itself succeeded: the data table exists and
works, and only its migrations did not run.
hasUnfinishedIntent() now asks only whether the setup succeeded. The import
step is the only caller that passes onFinishAlso, and it shows that failure
on its own row with a way to run it again, and will not let Finish through
while it stands — so closing loses nothing.
The in-dialog "Try again" is unchanged; it is still the direct retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: address round 8 — trigger kinds, migration retry target, unknown state
Four findings, two of them real defects in this branch's own work.
The presence key flattened every trigger kind into `trigger`. Each kind is a
separate table keyed on (path, workspace_id), so a workspace can hold a
schedule and an HTTP trigger both called f/cal/sync; whichever existed
answered for the other and the second was reported "already there" without
being imported. The key now carries the kind, which both sides already had.
projectInstall's own doc makes this argument for the five top-level kinds —
it just stopped one level short.
The wizard's in-dialog "Try again" ran runMigrationsFor(wizardFor), but
afterWizard() clears wizardFor as soon as the failed run reports, while the
dialog stays up. It resolved against no row and the step was marked done
over SQL that never ran. The target is now held separately, and an unknown
name throws rather than resolving — a resolved promise is what the appended
step reads as success.
settle() resolved "cannot tell" to done exactly on the reload it was written
for. A data table whose database is unreachable read as Configured and the
step said "You're all set" over a project whose apps fail on open. There is
now an `unknown` state that says so and still counts as outstanding. It also
asked for one full schema per migration; migrations for one data table all
target the same schema, so probeMigrationsApplied reads it once.
run()'s doc still described the pre-probe retry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: address round 9 — abandon during the probe, and copy that outlived it
Abandoning while probeImportedPaths was in flight returned without settling
anything. `import` goes running before the probe is asked, so the checklist
kept a spinner on a run that had stopped, beside an enabled Retry and with
no explanation. The settling the post-installProject path already did is now
a helper both paths call.
Three pieces of copy still described the behaviour this branch replaced:
the resource alert said an existing path is "reported as failed" when the
probe now leaves it alone and reports it as already there; and the step-4
footer and skip confirmation both told the user to set up a data table that
the new `unknown` state means they already set up — only its schema could
not be read. Those two now branch, so the strong warning stays strong for a
data table that genuinely does not exist.
The presence-key doc named `trigger:http_trigger`; WorkspaceTriggerKind has
no such value. It is `http`, in the comment and in the two test mocks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: never rerun SQL whose applied state is unknown
`unknown` covers two different unknowns, and this treated them as one. The
schema could not be read, or the SQL names no table `expectedTables` can
resolve — and the second is arbitrary published SQL, which may carry a
non-idempotent INSERT or ALTER. The row offered "Run migrations" and the
footer claimed rerunning was safe; both were claims this code cannot make.
An unknown row now offers "Check again", which re-reads and executes
nothing. That settles the case which actually recovers — a database briefly
unreachable — and leaves Skip, which states the uncertainty, as the way past
one that does not.
The partitions behind the copy also missed `failed` rows entirely: the
footer rendered a title with no body, and Skip described them as unreadable.
Both now group by what it costs the project — tables that are missing
(never created, or a migration that failed) against tables that could not be
verified — which is also what makes the sentences true: a failed row is
configured, so "this data table does not exist yet" was wrong about it.
Skip and the footer now read the same partition instead of each computing
one, so they cannot disagree again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* docs: the SQL disclosure should not promise a run that cannot happen
An unknown row's only action re-reads the schema; nothing executes its SQL.
The summary still said "Show the SQL this will run", which is the sentence
the previous commit removed from the footer for the same reason. On those
rows it now says what the SQL is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: a row whose migrations are running does not offer "Set up"
Found by walking every branch on row.status rather than the ones I
remembered: `running` falls through to the catch-all action, which labelled
itself "Set up" in accent. Disabled, so nothing could come of it, but it is
the same label-outruns-state mistake the last rounds were spent on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: never fill a path a resource of another type already holds
The presence probe matches on path, and a path says nothing about type. A
workspace resource of another kind sitting where the project wanted one of
ours was skipped as "already there", then read for missing fields against
the *project's* expected schema — so it looked like an empty stub, offered
Connect, and had its value replaced with credentials for a different
provider while keeping its own type. A working resource unrelated to the
import, destroyed.
Guarded at both ends. AppConnectInner checks the occupant's type before
updating, because `fillPath` only says "write into this path" and a caller
cannot be trusted to have checked. And the setup step records the conflict,
so the row explains that the project did not get the resource it shipped and
offers no action at all — every action there writes to that path.
Such a row is always listed, however full the occupant's value looks: it is
the only thing that tells the user something is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: fail closed everywhere the import cannot tell
Codex was right that the occupant-type guard failed open: a getResource
that threw became `undefined`, which passed the mismatch test and left
filling enabled — so a transient read failure still overwrote the resource
the guard exists to protect. Only a read that succeeds and answers with
exactly this type now permits the write; a failed read, a missing type and
any other type all refuse.
That was the same "cannot tell, so proceed" this branch already fixed once
in settle(), so the rest of the wizard was swept for it. Two more:
findBlankResources dropped a row whenever getResource threw, on the
assumption that meant absent. Only a 404 means absent — and that failure the
import already reported. Any other error is a read that did not complete,
which says nothing about whether the credential needs filling; dropping the
row reports "all set" over one nobody filled. The row now stays and offers
no action, since none of them can be safe about a path this cannot read.
A resource type whose schema would not load left `required` empty, which
reads as "nothing missing" — so a half-filled resource passed as done. It
stays on the checklist; it just cannot name which fields are short.
The other four catches were checked and are already closed in the right
direction: probeWorkspace reports absent so the caller creates rather than
adopts, probeMigrationsApplied answers undefined which settles to a
non-actionable row, and afterWizard keeps whatever the run last said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: a refresh takes every field the fresh read decides
`refreshBlanks` merged only `missing` out of the new scan, so the two fields
added alongside it were left at whatever the row said before. Both reviewers
found the same seam from opposite ends: a resource that had just become
unreadable kept its old readable-looking row, and one that had come back
stayed blocked until a reload.
These fields describe what is at the path now, so the fresh read owns all of
them — and the branch that marks a row done clears them, because a row that
has left the blank list was read and is filled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: resolve the username in the destination, and lock a targeted name
Two P1s, both from the same earlier fix being half-done. Routing this
wizard's API calls through an explicit workspace left `$userStore` behind,
and that store describes the workspace the app is in. After a reload on
step 4 it names the workspace the user came from, so a resource path built
from it lands on `u/<someone-else>` inside the destination — failing an
ownership check, or for an admin, quietly putting database credentials in
another member's namespace. The membership is now resolved for the target
workspace, the way FolderPicker already did it.
And `initialName` was documented as "a starting point, not a lock" while
`onFinishAlso` targets that exact name. Renaming `main` to `other` created
`other`, ran the migrations against `main`, failed, and left a data table
nobody asked for. The field is locked when a caller passes follow-up work
bound to the name, and says why; without one it stays editable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: resolve membership before seeding, and hold the name lock for the dialog
Two follow-ons to the previous commit, both where a value is read live that
should have been settled once.
The username was fetched in an effect while `open()` reset the wizard
immediately, so `defaultFolder()` ran against an empty username and seeded
`u/admin`. It was corrected only if `whoami` happened to win a race against
the folder list, and never if `whoami` failed — which is the case that
matters, since an admin would then save database credentials in another
member's namespace. `open()` now awaits the membership before reset, and a
destination whose membership cannot be read blocks setup outright rather
than guessing a path.
And the name lock read the live `initialName`, which is the caller's
`wizardFor` — cleared from `onDone`, which fires after a *failed* run too,
while the dialog stays up offering Back. The lock released exactly when the
user was most likely to edit the name, so the rename-then-retry path still
diverged from the migration target. It is captured at reset, for the life of
the dialog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: do not show the data table dialog before it knows the destination
`open()` became async so it could resolve the destination's membership
before seeding a resource path from it. But `openWizard` still set
`wizardOpen` first, and that is bound to the dialog's `opened` — so the
dialog was mounted, visible and clickable for the whole lookup, with the
username unresolved and `membershipFailed` not yet set. Setup reached in
that window writes exactly the wrong-namespace path the await was added to
prevent, and a late response could reset a dialog the user had already
touched or closed.
`open()` sets `opened` itself, once it has an answer. `wizardFor` alone
mounts the component, which is all `wizard?.open()` needs to exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
* fix: honour the configured base path, and guard a second Set up click
Windmill can be served under a prefix (`paths.base`, from VITE_BASE_URL),
and four import entrypoints compared or emitted `/projects/import` without
it. Under a base of `/windmill` the real pathname is
`/windmill/projects/import`, so the layout's picker exemption and both login
redirect checks stopped matching and sent people through the workspace
picker — and the compatibility redirect emitted a path outside the base
entirely, which is a 404. All four are now built from `base`.
`Login.svelte` takes it from `$lib/base` rather than `$app/paths` because it
already did; both read VITE_BASE_URL, and importing the second name into
that file collides with the first.
And the previous commit left Set up clickable while `open()` resolves the
destination membership, deliberately — but with no guard, a second click
starts a second lookup whose `reset()` lands on the dialog the first one
opened, wiping fields already filled. The action is disabled while a dialog
is opening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg8vXUuHCH3aRkf91sfxjx
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1589 lines
54 KiB
Svelte
1589 lines
54 KiB
Svelte
<script lang="ts">
|
|
import { run } from 'svelte/legacy'
|
|
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import LabelsInput from './LabelsInput.svelte'
|
|
import IconedResourceType from './IconedResourceType.svelte'
|
|
import {
|
|
isCustomResourceTypeName,
|
|
resourceTypeDisplayName,
|
|
resourceTypeMatchRank,
|
|
resourceTypeSearchText,
|
|
sortResourceTypesByMatch
|
|
} from './resourceTypeDisplay'
|
|
import {
|
|
OauthService,
|
|
ResourceService,
|
|
WorkspaceService,
|
|
VariableService,
|
|
type TokenResponse,
|
|
type ResourceType
|
|
} from '$lib/gen'
|
|
import { emptyString, truncateRev, urlize } from '$lib/utils'
|
|
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
|
|
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte'
|
|
import Path from './Path.svelte'
|
|
import { Button, RadioCard, Skeleton } from './common'
|
|
import ApiConnectForm from './ApiConnectForm.svelte'
|
|
import SearchItems from './SearchItems.svelte'
|
|
import WhitelistIp from './WhitelistIp.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import OauthScopes from './OauthScopes.svelte'
|
|
import autosize from '$lib/autosize'
|
|
import { base } from '$lib/base'
|
|
import Required from './Required.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import { Pen, Search } from 'lucide-svelte'
|
|
import GfmMarkdown from './GfmMarkdown.svelte'
|
|
import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect'
|
|
import type { SchemaProperty } from '$lib/common'
|
|
import TextInput from './text_input/TextInput.svelte'
|
|
import { sameTopDomainOrigin } from '$lib/cookies'
|
|
import SyncResourceTypes from './SyncResourceTypes.svelte'
|
|
import Label from './Label.svelte'
|
|
import ResourcePathHint from './ResourcePathHint.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
|
|
interface Props {
|
|
step?: number
|
|
resourceType?: string
|
|
isGoogleSignin?: boolean
|
|
disabled?: boolean
|
|
manual?: boolean
|
|
express?: boolean
|
|
workspace?: string
|
|
/**
|
|
* Fill an existing resource instead of creating one. The path is fixed to it and the
|
|
* "already exists" guard becomes an update, so a caller holding a resource that is
|
|
* already there — the import wizard's empty stubs — can connect into it rather than
|
|
* making the user delete it first and retype the path.
|
|
*
|
|
* Opt-in: without it this flow still refuses to write over anything.
|
|
*/
|
|
fillPath?: string
|
|
}
|
|
|
|
let {
|
|
step = $bindable(1),
|
|
resourceType = $bindable(''),
|
|
isGoogleSignin = $bindable(false),
|
|
disabled = $bindable(false),
|
|
manual = $bindable(true),
|
|
express = false,
|
|
workspace = undefined,
|
|
fillPath = undefined
|
|
}: Props = $props()
|
|
|
|
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
|
|
|
let isValid = $state(true)
|
|
|
|
const nativeLanguagesCategory = [
|
|
'postgresql',
|
|
'mysql',
|
|
'bigquery',
|
|
'snowflake',
|
|
'mssql',
|
|
'graphql',
|
|
'oracledb'
|
|
]
|
|
|
|
const SEARCH_INPUT_ID = 'search-resource-type'
|
|
let searchInput: { focus: () => void } | undefined = $state(undefined)
|
|
|
|
let filter = $state('')
|
|
let value: string = $state('')
|
|
let valueToken: TokenResponse | undefined = undefined
|
|
let connects: string[] | undefined = $state(undefined)
|
|
/** Per-provider instance-entry metadata, keyed by provider name. */
|
|
let connectsInfo: Record<
|
|
string,
|
|
{ supports_client_credentials: boolean; has_shared_credentials: boolean }
|
|
> = $state({})
|
|
|
|
/** An instance entry with shared credentials (admin id+secret): connect with
|
|
* no input. Shown under "Instance-configured"; bring-your-own-only providers
|
|
* (no shared creds) are shown under "Others" instead. */
|
|
function isSharedConnect(key: string): boolean {
|
|
return connectsInfo[key]?.has_shared_credentials ?? false
|
|
}
|
|
|
|
// `resourceType` is always the canonical type (e.g. `docusign`) so resource
|
|
// rows are uniform. `connectClient` carries the suffixed OAuth client name
|
|
// (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime
|
|
// and stored on `account.client` so token refresh hits the right endpoint.
|
|
let connectClient: string = $state('')
|
|
let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined =
|
|
$state(undefined)
|
|
let resourceTypeDescriptions: Record<string, string> = $state({})
|
|
// Types made in this workspace, by the `c_` prefix the resources page adds or by the
|
|
// workspace they live in — the hub sync writes its own into `admins`, which every
|
|
// workspace reads from. `created_by` looks like the same signal but isn't: seeded hub
|
|
// types carry a username too.
|
|
let customResourceTypes: Set<string> = $state(new Set())
|
|
|
|
// Hub descriptions are markdown; a row shows one line of it, where fenced blocks and
|
|
// backticks read as noise.
|
|
const plainDescription = (d: string) =>
|
|
d
|
|
.replace(/```[\s\S]*?```/g, '')
|
|
.replace(/`/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
let args: any = $state({})
|
|
let renderDescription = $state(true)
|
|
|
|
function computeCandidates(resourceType: string, argsKeys: string[], passwords: string[]) {
|
|
return apiTokenApps[resourceType]?.linkedSecret
|
|
? ([apiTokenApps[resourceType]?.linkedSecret] as string[])
|
|
: argsKeys.filter(
|
|
(x) =>
|
|
passwords.includes(x) ||
|
|
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
|
|
)
|
|
}
|
|
|
|
let linkedSecrets: string[] = $state([])
|
|
let linkedSecretCandidates: string[] | undefined = $state(undefined)
|
|
function computeDefaultLinkedSecrets(
|
|
resourceType: string,
|
|
argsKeys: string[],
|
|
passwords: string[]
|
|
): string[] {
|
|
linkedSecretCandidates = computeCandidates(resourceType, argsKeys, passwords)
|
|
const forced = forceSecretValue(resourceType)
|
|
if (forced) {
|
|
return [forced]
|
|
}
|
|
const best = linkedSecretCandidates?.sort(
|
|
(ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua)
|
|
)?.[0]
|
|
return best ? [best] : []
|
|
}
|
|
|
|
let scopes: string[] = $state([])
|
|
/** The authorization-code default scopes (instance entry / registry), kept so
|
|
* toggling back from client-credentials can restore them. */
|
|
let instanceScopes: string[] = $state([])
|
|
let extra_params: [string, string][] = []
|
|
let responseExtra: Record<string, string> = $state({})
|
|
let path: string = $state('')
|
|
let description = $state('')
|
|
let labels: string[] | undefined = $state(undefined)
|
|
let wsSpecific = $state(false)
|
|
let deployTo: string | undefined = $state(undefined)
|
|
|
|
/**
|
|
* Client credentials OAuth flow support
|
|
* @description Determines if the selected OAuth provider supports client_credentials grant type
|
|
* alongside the traditional authorization_code flow
|
|
*/
|
|
let supportsClientCredentials = $state(false)
|
|
|
|
/**
|
|
* OAuth flow selection
|
|
* @description Controls which OAuth flow to use:
|
|
* - false: authorization_code flow (interactive, requires user consent)
|
|
* - true: client_credentials flow (server-to-server, no user interaction)
|
|
*/
|
|
let useClientCredentials = $state(false)
|
|
|
|
/**
|
|
* Client credentials for resource-level OAuth
|
|
*/
|
|
let clientId = $state('')
|
|
let clientSecret = $state('')
|
|
let ccInstance = $state('')
|
|
/** Bring-your-own resource-level token endpoint override (optional). Only sent
|
|
* for non-instance-templated providers, where it isn't host-pinned. */
|
|
let tokenUrl = $state('')
|
|
|
|
let resourceTypeInfo: ResourceType | undefined = $state(undefined)
|
|
let resourceTypeNotFound = $state(false)
|
|
|
|
// Both resolve `_sandbox` clients to their parent entry (e.g. salesforce_sandbox ->
|
|
// salesforce) so sandbox connections see the same metadata. Shared with callers that
|
|
// decide whether to open this dialog at all, so the two cannot disagree.
|
|
function registryEntry(): any {
|
|
return registryEntryFor(connectClient, resourceType)
|
|
}
|
|
|
|
/** The static registry declares this provider supports client credentials */
|
|
function registryCcCapable(): boolean {
|
|
return registryCcCapableFor(connectClient, resourceType)
|
|
}
|
|
|
|
/** Instance-name metadata for providers whose token URL is instance-templated
|
|
* (carried in `connect_config_template`): the user enters an instance name
|
|
* instead of a full token URL, and the backend substitutes it into the
|
|
* fixed-host template so the exchange host stays pinned. */
|
|
let ccInstanceMeta = $derived(
|
|
registryEntry()?.connect_config_template as
|
|
| { label: string; placeholder: string; help_url?: string }
|
|
| undefined
|
|
)
|
|
|
|
/** Instance entry declares client credentials but not authorization_code
|
|
* (custom provider configured with only a token URL) */
|
|
let authCodeUnavailable = $state(false)
|
|
|
|
/** Instance entry carries shared client-credentials (id + secret); the user
|
|
* doesn't enter their own — the exchange runs server-side with those creds */
|
|
let ccInstanceConfigured = $state(false)
|
|
|
|
/** The user wants their own credentials (picked the provider from the "Others"
|
|
* section) — overrides the shared instance credentials for this connection */
|
|
let ccBringYourOwn = $state(false)
|
|
|
|
/** Connect with the shared instance credentials (no form) rather than the
|
|
* bring-your-own form */
|
|
let useSharedInstanceCreds = $derived(ccInstanceConfigured && !ccBringYourOwn)
|
|
|
|
/** Connectable via client credentials only: registry-declared provider with
|
|
* no instance OAuth client, or instance provider without an authorize URL */
|
|
let ccOnly = $derived.by(
|
|
() =>
|
|
authCodeUnavailable ||
|
|
(registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false))
|
|
)
|
|
|
|
/** Clear CC inputs and scopes so a previous selection never leaks into a new one */
|
|
function resetClientCredentialsState() {
|
|
supportsClientCredentials = false
|
|
useClientCredentials = false
|
|
authCodeUnavailable = false
|
|
ccInstanceConfigured = false
|
|
ccBringYourOwn = false
|
|
clientId = ''
|
|
clientSecret = ''
|
|
ccInstance = ''
|
|
tokenUrl = ''
|
|
scopes = []
|
|
}
|
|
|
|
/** Default scopes for the client-credentials grant. Registry providers use
|
|
* their `cc_scopes` (auth-code scopes are invalid in a 2-legged request);
|
|
* custom (non-registry) providers configured at the instance level have no
|
|
* registry entry, so they keep their admin-configured scopes (`instanceScopes`)
|
|
* instead of being zeroed. */
|
|
function defaultCcScopes(): string[] {
|
|
const entry = registryEntry()
|
|
return entry ? (entry.cc_scopes ?? []) : instanceScopes
|
|
}
|
|
|
|
function enableClientCredentials() {
|
|
manual = false
|
|
supportsClientCredentials = true
|
|
if (!useClientCredentials) {
|
|
// Switching into client-credentials: default to the CC scopes (never the
|
|
// authorization-code scopes — most providers reject member/consent scopes
|
|
// in a 2-legged request). Only reset on the transition so edits made while
|
|
// already in CC mode are preserved.
|
|
scopes = defaultCcScopes()
|
|
}
|
|
useClientCredentials = true
|
|
}
|
|
|
|
/** Switch to the browser sign-in (authorization-code) grant, restoring its
|
|
* default scopes when coming from the client-credentials grant. */
|
|
function selectAuthCodeGrant() {
|
|
if (useClientCredentials) {
|
|
scopes = instanceScopes
|
|
}
|
|
useClientCredentials = false
|
|
}
|
|
|
|
/** Static registry declares client-credentials support for `key`. */
|
|
function isCcCapable(key: string): boolean {
|
|
return registryCcCapableFor(key)
|
|
}
|
|
|
|
/** Step-1 "Others" selection: CC-capable resource types open the client-
|
|
* credentials form with the user's own credentials — even when the instance
|
|
* has shared ones (the "Instance-configured OAuth APIs" section is the entry
|
|
* point for those). Every other type opens the raw manual form. */
|
|
function connectOauth(key: string) {
|
|
manual = false
|
|
connectClient = key
|
|
resourceType = stripSandboxSuffix(key)
|
|
resetClientCredentialsState()
|
|
next()
|
|
}
|
|
|
|
function selectFromOthers(key: string) {
|
|
connectClient = key
|
|
resourceType = key
|
|
resetClientCredentialsState()
|
|
// Registry CC providers and instance-configured providers that declare the
|
|
// client-credentials grant (incl. custom providers set up with only a token
|
|
// URL and no shared creds) open the bring-your-own form. Everything else is
|
|
// a manual resource.
|
|
if (isCcCapable(key) || (connectsInfo[key]?.supports_client_credentials ?? false)) {
|
|
ccBringYourOwn = true
|
|
enableClientCredentials()
|
|
} else {
|
|
manual = true
|
|
}
|
|
next()
|
|
}
|
|
|
|
let pathError = $state('')
|
|
|
|
export async function open(rt?: string) {
|
|
if (!rt) {
|
|
loadResourceTypes()
|
|
}
|
|
step = 1 //express && !manual ? 3 : 1
|
|
// The list is keyboard-driven from the search field, so it takes focus on open.
|
|
tick().then(() => searchInput?.focus())
|
|
value = ''
|
|
description = ''
|
|
labels = undefined
|
|
wsSpecific = false
|
|
const rawRt = rt ?? ''
|
|
connectClient = rawRt
|
|
resourceType = stripSandboxSuffix(rawRt)
|
|
valueToken = undefined
|
|
|
|
resetClientCredentialsState()
|
|
|
|
await loadConnects()
|
|
const inConnects = connects?.includes(connectClient) ?? false
|
|
// Registry-declared client-credentials providers are connectable even
|
|
// without an instance OAuth client
|
|
manual = !inConnects && !(rt && registryCcCapable())
|
|
if (manual && express) {
|
|
dispatch('error', 'Express OAuth setup is not available for non OAuth resource types')
|
|
return
|
|
}
|
|
if (!inConnects && !manual && express) {
|
|
// Client-credentials connections need interactive credential entry
|
|
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
|
|
return
|
|
}
|
|
if (!inConnects && !manual) {
|
|
enableClientCredentials()
|
|
}
|
|
if (rt) {
|
|
if (!manual && express) {
|
|
await getScopesAndParams()
|
|
if (authCodeUnavailable) {
|
|
// No popup flow to drive express setup with
|
|
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
|
|
return
|
|
}
|
|
step = 2
|
|
}
|
|
next()
|
|
}
|
|
}
|
|
|
|
async function loadConnects() {
|
|
if (!connects) {
|
|
try {
|
|
const list = (await OauthService.listOauthConnects())
|
|
.filter((x) => x.name != 'supabase_wizard')
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
connects = list.map((x) => x.name)
|
|
connectsInfo = Object.fromEntries(list.map((x) => [x.name, x]))
|
|
} catch (e) {
|
|
connects = []
|
|
connectsInfo = {}
|
|
console.error('Error loading OAuth connects', e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Google's terms require its own button on the control that starts the sign-in, which is
|
|
// the step-2 Connect: step 1 only picks a type, and a manual step 2 saves a resource
|
|
// without ever reaching Google.
|
|
run(() => {
|
|
isGoogleSignin =
|
|
step == 2 &&
|
|
!manual &&
|
|
(resourceType == 'google' ||
|
|
resourceType == 'gmail' ||
|
|
resourceType == 'gcal' ||
|
|
resourceType == 'gdrive' ||
|
|
resourceType == 'gsheets')
|
|
})
|
|
|
|
run(() => {
|
|
disabled =
|
|
(step == 1 && resourceType == '') ||
|
|
(step == 2 &&
|
|
(manual
|
|
? value == '' &&
|
|
args &&
|
|
args['token'] == '' &&
|
|
args['password'] == '' &&
|
|
args['api_key'] == '' &&
|
|
args['key'] == '' &&
|
|
linkedSecrets.length > 0
|
|
: useClientCredentials &&
|
|
!useSharedInstanceCreds &&
|
|
(clientId.trim() == '' ||
|
|
clientSecret.trim() == '' ||
|
|
(!!ccInstanceMeta && ccInstance.trim() == '')))) ||
|
|
step == 3 ||
|
|
(step == 4 && pathError != '') ||
|
|
!isValid
|
|
})
|
|
|
|
export async function loadResourceTypes() {
|
|
if (connectsManual) {
|
|
return
|
|
}
|
|
const availableRts = await ResourceService.listResourceTypeNames({
|
|
workspace: effectiveWorkspace
|
|
})
|
|
// The prefix alone identifies a workspace-made type, and it rides on the names call the
|
|
// list already needs — so the custom section survives the full list below 403ing.
|
|
customResourceTypes = new Set(availableRts.filter(isCustomResourceTypeName))
|
|
|
|
// Descriptions only feed search, so they are fetched off the critical path and
|
|
// allowed to fail: `resources/type/list` is not on the public app domain's route
|
|
// allow-list (`listnames` is), and it carries every type's full schema. Awaiting it
|
|
// would hold the list behind a request nothing on screen needs -- in a published
|
|
// app, behind one that is guaranteed to 403. resourceTypeDescriptions feeds a
|
|
// $derived, so search re-ranks when they land.
|
|
ResourceService.listResourceType({ workspace: effectiveWorkspace })
|
|
.then((types) => {
|
|
resourceTypeDescriptions = Object.fromEntries(
|
|
types.filter((t) => t.description).map((t) => [t.name, t.description!])
|
|
)
|
|
// A type sitting in this workspace was made here too, but only the full list carries
|
|
// `workspace_id`. Inside `admins` the two are indistinguishable — every type lives
|
|
// there — so the prefix is all there is to go on.
|
|
customResourceTypes = new Set([
|
|
...customResourceTypes,
|
|
...types.filter((t) => t.workspace_id && t.workspace_id !== 'admins').map((t) => t.name)
|
|
])
|
|
})
|
|
.catch(() => {})
|
|
|
|
// "Others" lists every resource type — including instance-configured OAuth
|
|
// providers — so any of them can also be connected with the user's own
|
|
// credentials or manually, not only via the shared instance setup (same as
|
|
// the authorization-code behavior).
|
|
connectsManual = availableRts
|
|
.map(
|
|
(x) =>
|
|
({
|
|
key: x,
|
|
...(apiTokenApps[x] ?? {
|
|
instructions: '',
|
|
img: undefined,
|
|
linkedSecret: undefined
|
|
})
|
|
}) as { key: string; img?: string; instructions: string[] }
|
|
)
|
|
.sort((a, b) => a.key.localeCompare(b.key))
|
|
const filteredNativeLanguages = filteredConnectsManual?.filter(
|
|
(o) => nativeLanguagesCategory?.includes(o[0]) ?? false
|
|
)
|
|
|
|
try {
|
|
filteredConnectsManual = [
|
|
...(filteredNativeLanguages ?? []),
|
|
...(filteredConnectsManual ?? []).filter(
|
|
({ key }) => !nativeLanguagesCategory.includes(key)
|
|
)
|
|
]
|
|
} catch (e) {}
|
|
}
|
|
|
|
function popupListener(event) {
|
|
console.log('Received oauth popup message', event)
|
|
let data = event.data
|
|
if (!sameTopDomainOrigin(event.origin, window.location.origin)) {
|
|
console.log(
|
|
'Received oauth popup message from different origin',
|
|
event.origin,
|
|
window.location.origin
|
|
)
|
|
return
|
|
}
|
|
|
|
if (data.type == 'success' || data.type == 'error') {
|
|
window.removeEventListener('message', popupListener)
|
|
processPopupData(data)
|
|
}
|
|
}
|
|
|
|
function handleStorageEvent(event) {
|
|
if (event.key === 'oauth-callback') {
|
|
try {
|
|
processPopupData(JSON.parse(event.newValue))
|
|
console.log('OAuth from storage', event.newValue)
|
|
// Clean up
|
|
localStorage.removeItem('oauth-callback')
|
|
window.removeEventListener('storage', handleStorageEvent)
|
|
} catch (e) {
|
|
console.error('Error processing oauth-callback', e)
|
|
}
|
|
} else {
|
|
console.log('Storage event', event.key)
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
window.removeEventListener('message', popupListener)
|
|
window.removeEventListener('storage', handleStorageEvent)
|
|
})
|
|
|
|
$effect(() => {
|
|
if (!effectiveWorkspace) {
|
|
deployTo = undefined
|
|
return
|
|
}
|
|
|
|
WorkspaceService.getDeployTo({ workspace: effectiveWorkspace }).then((x) => {
|
|
deployTo = x.deploy_to
|
|
})
|
|
})
|
|
|
|
function processPopupData(data) {
|
|
console.log('Processing oauth popup data')
|
|
if (data.type === 'error') {
|
|
sendUserToast(data.error, true)
|
|
step = 2
|
|
} else if (data.type === 'success') {
|
|
connectClient = data.resource_type
|
|
resourceType = stripSandboxSuffix(connectClient)
|
|
value = data.res.access_token!
|
|
valueToken = data.res
|
|
responseExtra = data.extra ?? {}
|
|
step = 4
|
|
// `fillPath` decides the path as surely as express does, so neither stops here.
|
|
if (fillPath || express) {
|
|
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
|
next()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getScopesAndParams() {
|
|
if (!connects?.includes(connectClient)) {
|
|
// No instance OAuth client (registry-declared CC-only provider):
|
|
// defaults come from the static registry instead.
|
|
instanceScopes = registryEntry()?.scopes ?? []
|
|
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
|
|
extra_params = []
|
|
supportsClientCredentials = registryCcCapable()
|
|
return
|
|
}
|
|
const connect = await OauthService.getOauthConnect({ client: connectClient })
|
|
instanceScopes = connect.scopes ?? []
|
|
extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][]
|
|
|
|
/**
|
|
* The CC flow is offered when the static registry declares it for the
|
|
* provider, or the admin enabled it on the instance entry (custom
|
|
* providers)
|
|
*/
|
|
supportsClientCredentials =
|
|
registryCcCapable() || (connect.grant_types?.includes('client_credentials') ?? false)
|
|
// Shared instance credentials: the user connects without entering any creds
|
|
ccInstanceConfigured = connect.client_credentials_configured ?? false
|
|
// Custom provider configured with only a token URL: no popup flow possible
|
|
authCodeUnavailable =
|
|
supportsClientCredentials && !(connect.grant_types?.includes('authorization_code') ?? true)
|
|
if (authCodeUnavailable) {
|
|
useClientCredentials = true
|
|
}
|
|
// Default scopes to the active grant: client-credentials uses the registry's
|
|
// cc_scopes (auth-code scopes are invalid in a 2-legged request), every other
|
|
// path keeps the instance entry's scopes. Applies to shared instance creds,
|
|
// not just bring-your-own. Switching grants resets to these defaults.
|
|
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
|
|
}
|
|
|
|
async function getResourceTypeInfo() {
|
|
try {
|
|
resourceTypeNotFound = false
|
|
resourceTypeInfo = await ResourceService.getResourceType({
|
|
workspace: effectiveWorkspace,
|
|
path: resourceType
|
|
})
|
|
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
|
|
const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? []
|
|
|
|
const passwords = newArgsKeys.filter((x) => {
|
|
return props?.[x]?.password
|
|
})
|
|
if (linkedSecrets.length === 0) {
|
|
linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords)
|
|
}
|
|
} catch (err) {
|
|
resourceTypeInfo = undefined
|
|
resourceTypeNotFound = true
|
|
}
|
|
}
|
|
export async function next() {
|
|
if (step == 1) {
|
|
linkedSecrets = []
|
|
if (manual) {
|
|
getResourceTypeInfo()
|
|
args = {}
|
|
} else {
|
|
getResourceTypeInfo()
|
|
// Awaited: the popup is built from `scopes`, so advancing before this
|
|
// resolves sends the user to an authorize url with no scope at all.
|
|
await getScopesAndParams()
|
|
}
|
|
step += 1
|
|
} else if (step == 2 && !manual) {
|
|
if (useClientCredentials) {
|
|
/**
|
|
* Client credentials flow: Direct API call to backend
|
|
* No popup window or user interaction required — the resource-level
|
|
* credentials are exchanged directly against the token URL
|
|
*/
|
|
try {
|
|
// Trim whitespace from credentials to avoid false negatives
|
|
const trimmedClientId = clientId.trim()
|
|
const trimmedClientSecret = clientSecret.trim()
|
|
const trimmedInstance = ccInstance.trim()
|
|
// Instance-templated providers collect an instance name; the backend
|
|
// builds the host-pinned token URL from it. Other registry providers
|
|
// need no URL input (the token URL comes from the registry).
|
|
const needsInstance = !!ccInstanceMeta
|
|
|
|
// Bring-your-own credentials are required unless the provider has
|
|
// shared instance credentials, in which case the exchange runs
|
|
// server-side with those and no input is collected here.
|
|
if (
|
|
!useSharedInstanceCreds &&
|
|
(!trimmedClientId || !trimmedClientSecret || (needsInstance && !trimmedInstance))
|
|
) {
|
|
sendUserToast(
|
|
needsInstance
|
|
? `Client ID, Client Secret and ${ccInstanceMeta?.label} are required for client credentials flow`
|
|
: 'Client ID and Client Secret are required for client credentials flow',
|
|
true
|
|
)
|
|
return
|
|
}
|
|
|
|
const tokenResponse = await OauthService.connectClientCredentials({
|
|
workspace: effectiveWorkspace,
|
|
client: connectClient,
|
|
requestBody: useSharedInstanceCreds
|
|
? { scopes: scopes }
|
|
: {
|
|
scopes: scopes,
|
|
cc_client_id: trimmedClientId,
|
|
cc_client_secret: trimmedClientSecret,
|
|
// Instance-templated providers are host-pinned via the instance
|
|
// name; only other providers accept a free-form token URL override.
|
|
...(needsInstance
|
|
? { cc_instance: trimmedInstance }
|
|
: tokenUrl.trim()
|
|
? { cc_token_url: tokenUrl.trim() }
|
|
: {})
|
|
}
|
|
})
|
|
|
|
// Process the token response like in popup flow
|
|
value = tokenResponse.access_token!
|
|
valueToken = {
|
|
...tokenResponse,
|
|
grant_type: 'client_credentials' // Mark this token as client_credentials
|
|
}
|
|
step = 4
|
|
if (fillPath || express) {
|
|
path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
|
|
next()
|
|
}
|
|
} catch (error) {
|
|
sendUserToast(
|
|
`Failed to connect with client credentials: ${error.body || error.message}`,
|
|
true
|
|
)
|
|
}
|
|
} else {
|
|
/**
|
|
* Authorization code flow: Traditional OAuth popup window
|
|
* Requires user interaction and consent
|
|
* Opens popup for user to authenticate with OAuth provider
|
|
*/
|
|
const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin)
|
|
url.searchParams.append('scopes', scopes.join('+'))
|
|
if (extra_params.length > 0) {
|
|
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
|
|
}
|
|
window.addEventListener('message', popupListener)
|
|
window.addEventListener('storage', handleStorageEvent)
|
|
console.log('opening popup', url.toString())
|
|
window.open(url.toString(), '_blank', 'popup=true')
|
|
step += 1
|
|
}
|
|
} else {
|
|
if (!path) {
|
|
if (step == 2) return
|
|
throw Error('Path is not set')
|
|
}
|
|
// Check if variable paths already exist
|
|
if (!manual || linkedSecrets.length <= 1) {
|
|
const exists = await VariableService.existsVariable({
|
|
workspace: effectiveWorkspace,
|
|
path
|
|
})
|
|
if (exists) {
|
|
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
|
|
}
|
|
} else {
|
|
for (const secretField of linkedSecrets) {
|
|
const varPath = `${path}_${secretField}`
|
|
const exists = await VariableService.existsVariable({
|
|
workspace: effectiveWorkspace,
|
|
path: varPath
|
|
})
|
|
if (exists) {
|
|
throw Error(
|
|
`Variable at path ${varPath} already exists. Delete it or pick another path`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
let exists = await ResourceService.existsResource({
|
|
workspace: effectiveWorkspace,
|
|
path
|
|
})
|
|
|
|
// Filling one names its path up front; anything else reaching an occupied path got
|
|
// there by the user typing it, which is the case worth refusing.
|
|
//
|
|
// The type is checked here and not only by the caller: `fillPath` says "write into
|
|
// this path", and a path says nothing about what lives at it. A workspace resource
|
|
// of another type sitting where the project wanted one of ours would otherwise have
|
|
// its value replaced with credentials for a different provider, while keeping its
|
|
// own type — destroying a working resource that has nothing to do with the import.
|
|
const filling = exists && !!fillPath && path === fillPath
|
|
if (filling) {
|
|
// Fails closed. Only a read that succeeds and answers with exactly this type
|
|
// permits the write — a failed read, a missing type, or any other type all
|
|
// refuse. Letting "could not tell" through is how the overwrite this guard
|
|
// exists to stop would happen anyway, on the one occasion the check was needed
|
|
// and could not run.
|
|
let occupantType: string | undefined
|
|
try {
|
|
occupantType = (
|
|
await ResourceService.getResource({ workspace: effectiveWorkspace, path })
|
|
)?.resource_type
|
|
} catch (e: any) {
|
|
throw Error(
|
|
`Could not read what is already at ${path} (${e?.body ?? e?.message ?? e}), ` +
|
|
`so it will not be written over. Try again.`
|
|
)
|
|
}
|
|
if (occupantType !== resourceType) {
|
|
throw Error(
|
|
`Resource at path ${path} is ${
|
|
occupantType ? `a ${occupantType} resource` : 'of an unknown type'
|
|
}, not ${resourceType}. Move or rename it, then import again.`
|
|
)
|
|
}
|
|
}
|
|
if (exists && !filling) {
|
|
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
|
|
}
|
|
|
|
// Per-instance OAuth providers (Snowflake, ServiceNow, …): fill the
|
|
// resource args from the connection's instance, per the registry
|
|
// template's resource_mapping (e.g. ServiceNow -> instance_url:
|
|
// https://{instance}.service-now.com). Bring-your-own carries the instance
|
|
// the user entered in `ccInstance` (raw, possibly a full host); the shared
|
|
// path carries it (already normalized) in the connect entry's extra_params.
|
|
// Prefer the user-entered one so the saved resource matches the exchange.
|
|
const connectTemplate = registryEntryFor(resourceType)?.connect_config_template
|
|
if (connectTemplate?.resource_mapping) {
|
|
const instanceKey = connectTemplate.extra_params_key ?? 'instance'
|
|
let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? ''
|
|
if (ccInstance.trim()) {
|
|
const stripSuffix = connectTemplate.strip_suffix as string | undefined
|
|
let v = ccInstance
|
|
.trim()
|
|
.replace(/^https?:\/\//, '')
|
|
.replace(/\/.*$/, '')
|
|
if (stripSuffix && v.endsWith(stripSuffix)) {
|
|
v = v.slice(0, -stripSuffix.length)
|
|
}
|
|
instanceValue = v.replace(/\.+$/, '')
|
|
}
|
|
if (instanceValue) {
|
|
for (const [argField, valueTemplate] of Object.entries(
|
|
connectTemplate.resource_mapping as Record<string, string>
|
|
)) {
|
|
args[argField] = valueTemplate.replaceAll('{instance}', instanceValue)
|
|
}
|
|
}
|
|
}
|
|
if (resourceType === 'quickbooks' && responseExtra['realmId']) {
|
|
args['realmId'] = responseExtra['realmId']
|
|
}
|
|
|
|
let account: number | undefined = undefined
|
|
if (valueToken?.expires_in != undefined) {
|
|
const accountData: any = {
|
|
refresh_token: valueToken.refresh_token ?? '',
|
|
expires_in: valueToken.expires_in,
|
|
client: connectClient,
|
|
grant_type: valueToken.grant_type || 'authorization_code'
|
|
}
|
|
|
|
// Store scopes so token refresh uses the same scopes
|
|
if (scopes.length > 0) {
|
|
accountData.scopes = scopes
|
|
}
|
|
|
|
// Client-credentials accounts are self-contained: the refresh worker
|
|
// re-exchanges using only what is stored on the account row. With
|
|
// shared instance credentials the backend copies them onto the row,
|
|
// so nothing is sent from here.
|
|
if (useClientCredentials && !useSharedInstanceCreds) {
|
|
accountData.cc_client_id = clientId.trim()
|
|
accountData.cc_client_secret = clientSecret.trim()
|
|
// Instance-templated providers send an instance name; the backend
|
|
// resolves and stores the host-pinned token URL. Other providers may
|
|
// send an optional token URL override (stored for refresh); without
|
|
// it the token URL comes from the registry/instance config.
|
|
if (ccInstanceMeta) {
|
|
accountData.cc_instance = ccInstance.trim()
|
|
} else if (tokenUrl.trim()) {
|
|
accountData.cc_token_url = tokenUrl.trim()
|
|
}
|
|
}
|
|
|
|
account = Number(
|
|
await OauthService.createAccount({
|
|
workspace: effectiveWorkspace,
|
|
requestBody: accountData
|
|
})
|
|
)
|
|
}
|
|
|
|
const resourceValue = args
|
|
|
|
let savedVariableCount = 0
|
|
if (!manual) {
|
|
// OAuth flow: single secret variable for the token
|
|
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
|
|
savedVariableCount++
|
|
await VariableService.createVariable({
|
|
workspace: effectiveWorkspace,
|
|
requestBody: {
|
|
path,
|
|
value: value,
|
|
is_secret: true,
|
|
description: emptyString(description)
|
|
? `OAuth token for ${resourceType}`
|
|
: description,
|
|
is_oauth: true,
|
|
account: account,
|
|
ws_specific: wsSpecific
|
|
}
|
|
})
|
|
resourceValue['token'] = `$var:${path}`
|
|
}
|
|
} else if (linkedSecrets.length === 1) {
|
|
// Single secret: use the resource path as variable name (original behavior)
|
|
const secretField = linkedSecrets[0]
|
|
const v = args[secretField]
|
|
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
|
|
savedVariableCount++
|
|
await VariableService.createVariable({
|
|
workspace: effectiveWorkspace,
|
|
requestBody: {
|
|
path,
|
|
value: v,
|
|
is_secret: true,
|
|
description: emptyString(description) ? `Token for ${resourceType}` : description,
|
|
is_oauth: false,
|
|
ws_specific: wsSpecific
|
|
}
|
|
})
|
|
resourceValue[secretField] = `$var:${path}`
|
|
}
|
|
} else if (linkedSecrets.length > 1) {
|
|
// Multiple secrets: append _field_name to each variable path
|
|
for (const secretField of linkedSecrets) {
|
|
const v = args[secretField]
|
|
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
|
|
const varPath = `${path}_${secretField}`
|
|
savedVariableCount++
|
|
await VariableService.createVariable({
|
|
workspace: effectiveWorkspace,
|
|
requestBody: {
|
|
path: varPath,
|
|
value: v,
|
|
is_secret: true,
|
|
description: emptyString(description)
|
|
? `${secretField} for ${resourceType}`
|
|
: description,
|
|
is_oauth: false,
|
|
ws_specific: wsSpecific
|
|
}
|
|
})
|
|
resourceValue[secretField] = `$var:${varPath}`
|
|
}
|
|
}
|
|
}
|
|
|
|
if (filling) {
|
|
// The stub the import made carries no description, so this is the one chance to
|
|
// give it one; its resource_type and path are already what we want.
|
|
await ResourceService.updateResource({
|
|
workspace: effectiveWorkspace,
|
|
path,
|
|
requestBody: { value: resourceValue, description }
|
|
})
|
|
} else {
|
|
await ResourceService.createResource({
|
|
workspace: effectiveWorkspace,
|
|
requestBody: {
|
|
resource_type: resourceType,
|
|
path,
|
|
value: resourceValue,
|
|
description,
|
|
labels,
|
|
ws_specific: wsSpecific
|
|
}
|
|
})
|
|
}
|
|
dispatch('refresh', path)
|
|
dispatch('close')
|
|
sendUserToast(
|
|
`Saved resource${savedVariableCount > 0 ? ` and ${savedVariableCount} variable${savedVariableCount > 1 ? 's' : ''}` : ''} path: ${path}`
|
|
)
|
|
step = 1
|
|
resourceType = ''
|
|
connectClient = ''
|
|
}
|
|
}
|
|
|
|
export async function back() {
|
|
if (step == 4) {
|
|
step -= 2
|
|
} else if (step > 1) {
|
|
step -= 1
|
|
}
|
|
if (step == 1) {
|
|
loadConnects()
|
|
loadResourceTypes()
|
|
}
|
|
}
|
|
|
|
const dispatch = createEventDispatcher<{ error: string; refresh: string; close: void }>()
|
|
|
|
let filteredConnects: { key: string }[] = $state([])
|
|
let filteredConnectsManual: { key: string; img?: string; instructions: string[] }[] = $state([])
|
|
|
|
// uFuzzy scores the name and the description as one string, so searching "google" ranks
|
|
// every type whose description mentions Google alongside the ones named after it. Re-sort
|
|
// on which field matched, keeping uFuzzy's order within a tier.
|
|
const rank = (items: { key: string }[] | undefined) =>
|
|
items &&
|
|
sortResourceTypesByMatch(
|
|
items,
|
|
filter,
|
|
(x) => x.key,
|
|
(x) => resourceTypeDescriptions[x.key]
|
|
)
|
|
let rankedConnects = $derived(rank(filteredConnects))
|
|
let rankedConnectsManual = $derived(
|
|
rank(filteredConnectsManual) as typeof filteredConnectsManual | undefined
|
|
)
|
|
|
|
let searching = $derived(filter.trim() !== '')
|
|
|
|
// Browsing, the "Others" list leads with the native database types. Searching, that
|
|
// grouping would outrank the search itself — `ms_sql_server` sorting under `mysql` on
|
|
// "sql" — so the ranked order stands on its own.
|
|
let manualOrderedKeys = $derived(
|
|
!searching
|
|
? [
|
|
...(rankedConnectsManual ?? [])
|
|
.filter((x) => nativeLanguagesCategory.includes(x.key))
|
|
.map((x) => x.key),
|
|
...(rankedConnectsManual ?? [])
|
|
.filter((x) => !nativeLanguagesCategory.includes(x.key))
|
|
.map((x) => x.key)
|
|
]
|
|
: (rankedConnectsManual ?? []).map((x) => x.key)
|
|
)
|
|
|
|
let customKeys = $derived(manualOrderedKeys.filter((key) => customResourceTypes.has(key)))
|
|
let otherKeys = $derived(manualOrderedKeys.filter((key) => !customResourceTypes.has(key)))
|
|
|
|
// Every row in the order it is rendered, so arrow keys walk the sections as one list.
|
|
// A provider appears in more than one, so rows are addressed by index, not by name.
|
|
let navItems = $derived([
|
|
...customKeys.map((key) => ({ key, oauth: false })),
|
|
...(rankedConnects ?? []).map((x) => ({ key: x.key, oauth: true })),
|
|
...otherKeys.map((key) => ({ key, oauth: false }))
|
|
])
|
|
// Both lists start undefined and render skeletons; "nothing found" only means something
|
|
// once they have landed.
|
|
let listsLoaded = $derived(rankedConnectsManual !== undefined && rankedConnects !== undefined)
|
|
let highlightedIndex = $state(-1)
|
|
const rowDomId = (index: number) => `resource-type-row-${index}`
|
|
|
|
// Set at hover time rather than up front, so only the descriptions the row actually cut
|
|
// off carry a tooltip.
|
|
function titleIfTruncated(e: MouseEvent & { currentTarget: HTMLElement }) {
|
|
const el = e.currentTarget
|
|
el.title = el.scrollWidth > el.clientWidth ? (el.textContent?.trim() ?? '') : ''
|
|
}
|
|
const oauthRowOffset = $derived(customKeys.length)
|
|
const otherRowOffset = $derived(customKeys.length + (rankedConnects?.length ?? 0))
|
|
|
|
// Sections are rendered in a fixed order, so the best match is not necessarily the first
|
|
// row: rank the rows against the query to find it.
|
|
function bestMatchIndex(): number {
|
|
let best = navItems.length > 0 ? 0 : -1
|
|
let bestRank = Infinity
|
|
navItems.forEach((item, index) => {
|
|
const rank = resourceTypeMatchRank(item.key, resourceTypeDescriptions[item.key], filter)
|
|
if (rank < bestRank) {
|
|
bestRank = rank
|
|
best = index
|
|
}
|
|
})
|
|
return best
|
|
}
|
|
|
|
// Filtering reshuffles the rows under the highlight: point it at the best match so Enter
|
|
// takes the top hit, and drop it entirely once the filter is cleared.
|
|
$effect(() => {
|
|
navItems
|
|
filter
|
|
untrack(() => (highlightedIndex = searching ? bestMatchIndex() : -1))
|
|
})
|
|
|
|
// Scrolling rows under a resting pointer makes the browser fire `mouseenter` on each one,
|
|
// which would drag the highlight back under the cursor as the arrow keys move it. Only a
|
|
// real pointer move hands the highlight back to the mouse.
|
|
let pointerOwnsHighlight = $state(true)
|
|
|
|
function highlightHovered(index: number) {
|
|
if (pointerOwnsHighlight) highlightedIndex = index
|
|
}
|
|
|
|
function moveHighlight(delta: number) {
|
|
const count = navItems.length
|
|
if (count === 0) return
|
|
pointerOwnsHighlight = false
|
|
// Rows are tabbable buttons, so focus can sit on one. Enter then activates whatever is
|
|
// focused, which has to stay the highlighted row.
|
|
const rowWasFocused = document.activeElement?.id?.startsWith('resource-type-row-') ?? false
|
|
highlightedIndex =
|
|
highlightedIndex < 0
|
|
? delta > 0
|
|
? 0
|
|
: count - 1
|
|
: (highlightedIndex + delta + count) % count
|
|
const row = document.getElementById(rowDomId(highlightedIndex))
|
|
row?.scrollIntoView({ block: 'nearest' })
|
|
if (rowWasFocused) row?.focus()
|
|
}
|
|
|
|
function onListKeydown(e: KeyboardEvent) {
|
|
if (step !== 1) return
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
moveHighlight(e.key === 'ArrowDown' ? 1 : -1)
|
|
} else if (e.key === 'Enter' && (e.target as HTMLElement)?.id === SEARCH_INPUT_ID) {
|
|
// A focused row activates itself on Enter; this covers Enter typed in the search field.
|
|
const item = navItems[highlightedIndex]
|
|
if (!item) return
|
|
e.preventDefault()
|
|
item.oauth ? connectOauth(item.key) : selectFromOthers(item.key)
|
|
}
|
|
}
|
|
|
|
let editScopes = $state(false)
|
|
</script>
|
|
|
|
{#if !express}
|
|
<SearchItems
|
|
{filter}
|
|
items={connects
|
|
? connects.filter(isSharedConnect).map((key) => ({
|
|
key
|
|
}))
|
|
: undefined}
|
|
bind:filteredItems={filteredConnects}
|
|
f={(x) => resourceTypeSearchText(x.key, resourceTypeDescriptions[x.key])}
|
|
/>
|
|
<SearchItems
|
|
{filter}
|
|
items={connectsManual}
|
|
bind:filteredItems={filteredConnectsManual}
|
|
f={(x) => resourceTypeSearchText(x.key, resourceTypeDescriptions[x.key])}
|
|
/>
|
|
{#if step == 1}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- Arrow keys and Enter are caught here so they work whether the search field or a row
|
|
holds focus. -->
|
|
<!-- Full height so the rows scroll inside their own box: the search field and the sync
|
|
button stay put, and the drawer itself never scrolls. -->
|
|
<div
|
|
class="flex flex-col h-full min-h-0"
|
|
onkeydown={onListKeydown}
|
|
onpointermove={() => (pointerOwnsHighlight = true)}
|
|
>
|
|
<div class="shrink-0 pb-4">
|
|
<div class="relative w-full">
|
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
|
|
<TextInput
|
|
bind:this={searchInput}
|
|
inputProps={{ placeholder: 'Search resource type', id: SEARCH_INPUT_ID }}
|
|
bind:value={filter}
|
|
class="pl-7 text-xs w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{#snippet resourceRow(key: string)}
|
|
<div class="flex flex-row items-center gap-4 w-full min-w-0 text-left">
|
|
<div class="shrink-0">
|
|
<IconedResourceType name={key} silent width="20px" height="20px" />
|
|
</div>
|
|
<div class="flex flex-col gap-1 min-w-0">
|
|
<div class="flex flex-row items-baseline gap-2 min-w-0">
|
|
<span class="truncate leading-5">{resourceTypeDisplayName(key)}</span>
|
|
<span class="shrink-0 font-mono text-2xs font-normal text-hint">{key}</span>
|
|
</div>
|
|
{#if resourceTypeDescriptions[key]}
|
|
<span
|
|
class="truncate text-xs font-normal leading-4 text-secondary"
|
|
onmouseenter={titleIfTruncated}
|
|
>
|
|
{plainDescription(resourceTypeDescriptions[key])}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet sectionHeading(title: string, count: number)}
|
|
<h2 class="mb-3 text-2xs font-normal uppercase text-secondary">
|
|
{title}{#if searching}<span class="ml-2 text-hint">{count}</span>{/if}
|
|
</h2>
|
|
{/snippet}
|
|
|
|
{#snippet resourceButton(key: string, index: number, oauth: boolean)}
|
|
<Button
|
|
id={rowDomId(index)}
|
|
aiId={`app-connect-inner-${oauth ? 'oauth-' : ''}${key}`}
|
|
aiDescription={`Connect to ${key}${oauth ? ' with the instance OAuth client' : ''}`}
|
|
unifiedSize="md"
|
|
variant="subtle"
|
|
btnClasses={twMerge(
|
|
'justify-start px-3 h-auto py-3 scroll-my-2',
|
|
// The pointer moves the same highlight the arrow keys move, so the variant's
|
|
// own hover is off: two lit rows at once would be ambiguous.
|
|
'hover:bg-transparent',
|
|
// `!` so the highlight also wins on the row the pointer is over, whose own
|
|
// hover was turned off just above.
|
|
index === highlightedIndex ? '!bg-surface-hover' : ''
|
|
)}
|
|
on:mouseenter={() => highlightHovered(index)}
|
|
on:click={() => (oauth ? connectOauth(key) : selectFromOthers(key))}
|
|
>
|
|
{@render resourceRow(key)}
|
|
</Button>
|
|
{/snippet}
|
|
|
|
<div class="flex-1 min-h-0 overflow-y-auto">
|
|
{#if searching && listsLoaded && navItems.length === 0}
|
|
<div class="flex flex-col items-center gap-1 py-16 text-center">
|
|
<span class="text-sm text-primary">No resource type matches “{filter.trim()}”</span>
|
|
<span class="text-xs text-secondary">
|
|
Search on the name, the product or what the resource holds — or sync resource types
|
|
with the hub for more.
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<!-- One gap between sections, owned by the column: a section that a search empties
|
|
out then takes its spacing with it. -->
|
|
<div class="flex flex-col gap-10">
|
|
{#if customKeys.length > 0}
|
|
<section>
|
|
{@render sectionHeading('Custom resource types', customKeys.length)}
|
|
<div class="flex flex-col gap-1">
|
|
{#each customKeys as key, i}
|
|
{@render resourceButton(key, i, false)}
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if !searching || (rankedConnects?.length ?? 0) > 0}
|
|
<section>
|
|
{@render sectionHeading(
|
|
'Instance-configured OAuth APIs',
|
|
rankedConnects?.length ?? 0
|
|
)}
|
|
<div class="flex flex-col gap-1">
|
|
{#if rankedConnects}
|
|
{#each rankedConnects as { key }, i}
|
|
{@render resourceButton(key, oauthRowOffset + i, true)}
|
|
{/each}
|
|
{:else}
|
|
{#each new Array(3) as _}
|
|
<Skeleton layout={[[2]]} />
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{#if !searching && connects && connects.filter(isSharedConnect).length == 0}
|
|
<div class="text-secondary text-xs w-full"
|
|
>No OAuth APIs have been set up on this instance. To add OAuth APIs, first sync
|
|
the resource types with the hub, then add OAuth configuration. See <a
|
|
href="https://www.windmill.dev/docs/misc/setup_oauth">documentation</a
|
|
>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
{#if !searching || otherKeys.length > 0}
|
|
<section>
|
|
{@render sectionHeading('Others', otherKeys.length)}
|
|
|
|
{#if !searching && connectsManual && connectsManual?.length < 10}
|
|
<div class="text-secondary text-xs p-2">
|
|
Resource types have not been synced with the hub
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex flex-col gap-1">
|
|
{#if rankedConnectsManual}
|
|
{#each otherKeys as key, i}
|
|
{@render resourceButton(key, otherRowOffset + i, false)}
|
|
{/each}
|
|
{:else}
|
|
{#each new Array(9) as _}
|
|
<Skeleton layout={[[2]]} />
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="shrink-0 pt-4">
|
|
<SyncResourceTypes
|
|
onSynced={async () => {
|
|
connectsManual = undefined
|
|
await loadResourceTypes()
|
|
connects = undefined
|
|
await loadConnects()
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{:else if step == 2 && manual}
|
|
<div class="flex flex-col gap-4">
|
|
{#if !emptyString(resourceTypeInfo?.description)}
|
|
<GfmMarkdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} prose="sm" noPadding />
|
|
{/if}
|
|
<Label label="Path">
|
|
<ResourcePathHint />
|
|
<Path
|
|
bind:error={pathError}
|
|
bind:path
|
|
initialPath=""
|
|
namePlaceholder={resourceType}
|
|
kind="resource"
|
|
/>
|
|
</Label>
|
|
<LabelsInput bind:labels class="-mt-5" />
|
|
{#if deployTo}
|
|
<Label
|
|
label="Workspace specific"
|
|
tooltip="Prevents this resource from being deployed to prod/staging"
|
|
>
|
|
<Toggle bind:checked={wsSpecific} />
|
|
</Label>
|
|
{/if}
|
|
|
|
{#if apiTokenApps[resourceType]}
|
|
<div class="flex flex-col gap-2">
|
|
<h2 class="text-sm font-semibold text-emphasis">Instructions</h2>
|
|
<ol class="list-decimal pl-5 text-xs text-primary flex flex-col gap-1">
|
|
{#each apiTokenApps[resourceType].instructions as step}
|
|
<li>
|
|
{@html step}
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
</div>
|
|
{#if apiTokenApps[resourceType].img}
|
|
<div class="mt-4 w-full overflow-hidden">
|
|
<img
|
|
class="m-auto max-h-60"
|
|
alt="connect"
|
|
src={base + apiTokenApps[resourceType].img}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{#if resourceType == 'postgresql' || resourceType == 'mysql' || resourceType == 'mongodb'}
|
|
<WhitelistIp />
|
|
{/if}
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label class="inline-flex items-center gap-2" for="resource-description">
|
|
<span class="text-xs font-semibold text-emphasis">Resource description</span>
|
|
<Required required={false} />
|
|
<div class="flex gap-1 items-center">
|
|
<Toggle size="xs" bind:checked={renderDescription} />
|
|
<Pen size={14} />
|
|
</div>
|
|
</label>
|
|
{#if renderDescription}
|
|
<div>
|
|
<div class="flex flex-row-reverse text-2xs text-primary -mt-4">GH Markdown</div>
|
|
<textarea
|
|
id="resource-description"
|
|
use:autosize
|
|
bind:value={description}
|
|
placeholder={'Resource description'}
|
|
></textarea>
|
|
</div>
|
|
{:else if description == undefined || description == ''}
|
|
<div class="text-xs text-primary font-normal">No description provided</div>
|
|
{:else}
|
|
<GfmMarkdown md={description} prose="sm" />
|
|
{/if}
|
|
</div>
|
|
|
|
{#if resourceTypeNotFound}
|
|
<div class="flex flex-col gap-2 mb-4">
|
|
<p class="text-red-500 dark:text-red-400 text-xs">
|
|
Resource type '{resourceType}' not found in your workspace
|
|
</p>
|
|
<SyncResourceTypes {resourceType} onSynced={getResourceTypeInfo} />
|
|
</div>
|
|
{/if}
|
|
{#if registryCcCapable()}
|
|
<button
|
|
onclick={() => enableClientCredentials()}
|
|
class="text-xs font-normal text-accent w-fit -mt-4"
|
|
>
|
|
Acquire the token automatically via client credentials instead
|
|
</button>
|
|
{/if}
|
|
<!-- The form is a section of its own, not just the next field: it needs more of a break
|
|
from the description than the uniform gap gives. -->
|
|
<div class="mt-2">
|
|
{#key resourceTypeInfo}
|
|
<ApiConnectForm
|
|
bind:linkedSecrets
|
|
bind:description
|
|
{linkedSecretCandidates}
|
|
{resourceType}
|
|
{resourceTypeInfo}
|
|
bind:args
|
|
bind:isValid
|
|
onSynced={getResourceTypeInfo}
|
|
/>
|
|
{/key}
|
|
</div>
|
|
</div>
|
|
{:else if step == 2 && !manual}
|
|
{#if manual == false && resourceType != ''}
|
|
<div class="flex flex-col gap-8">
|
|
<div class="flex flex-col gap-1">
|
|
<h2 class="text-lg font-semibold text-emphasis">{resourceType}</h2>
|
|
<div class="text-primary font-normal text-xs"
|
|
>Create a resource backed by an OAuth connection, whose token is fetched from the
|
|
external services and refreshed automatically if needed before expiration.</div
|
|
>
|
|
{#if ccBringYourOwn}
|
|
<button
|
|
onclick={() => {
|
|
manual = true
|
|
useClientCredentials = false
|
|
}}
|
|
class="text-xs font-normal text-accent w-fit mt-2"
|
|
>
|
|
Create resource manually instead
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if resourceTypeInfo?.description}
|
|
<GfmMarkdown
|
|
md={urlize(resourceTypeInfo?.description ?? '', 'md')}
|
|
prose="sm"
|
|
noPadding
|
|
/>
|
|
{/if}
|
|
|
|
<LabelsInput bind:labels class="-mt-5" />
|
|
|
|
{#if supportsClientCredentials}
|
|
<div class="flex flex-col gap-1">
|
|
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3>
|
|
{#if ccOnly || ccBringYourOwn}
|
|
<div class="text-xs text-secondary font-normal mb-2">
|
|
{#if useSharedInstanceCreds}
|
|
{resourceType} connects server-to-server using the credentials configured for this
|
|
instance. The token is acquired and refreshed automatically.
|
|
{:else}
|
|
{resourceType} connects server-to-server. Enter a client ID and secret; the token is
|
|
acquired and refreshed automatically.
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- role=radiogroup: the cards below carry `role="radio"`, which a screen
|
|
reader can only place ("2 of 2") inside a named group. -->
|
|
<div
|
|
class="flex flex-col gap-2 mb-2"
|
|
role="radiogroup"
|
|
aria-label="How to authenticate"
|
|
>
|
|
<RadioCard
|
|
label={`Sign in through ${resourceType}`}
|
|
description="Opens a browser window to log in and authorize. Connects as you."
|
|
selected={!useClientCredentials}
|
|
onSelect={selectAuthCodeGrant}
|
|
/>
|
|
<RadioCard
|
|
label={useSharedInstanceCreds
|
|
? 'Use the configured instance credentials'
|
|
: 'Use a client ID and secret'}
|
|
description={useSharedInstanceCreds
|
|
? "Runs server-to-server with this instance's credentials. No input needed."
|
|
: 'Runs server-to-server. Best for automation or service accounts.'}
|
|
selected={useClientCredentials}
|
|
onSelect={() => enableClientCredentials()}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if useClientCredentials && !useSharedInstanceCreds}
|
|
<form class="flex flex-col gap-6">
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Client ID</span>
|
|
<TextInput
|
|
bind:value={clientId}
|
|
inputProps={{ placeholder: 'Enter OAuth client ID', required: true }}
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Client secret</span>
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'password',
|
|
placeholder: 'Enter OAuth client secret',
|
|
required: true
|
|
}}
|
|
bind:value={clientSecret}
|
|
/>
|
|
</label>
|
|
{#if ccInstanceMeta}
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">{ccInstanceMeta.label}</span>
|
|
<div class="text-xs text-secondary font-normal">
|
|
Used to build this provider's token endpoint, stored with the connection for
|
|
automatic token refresh
|
|
</div>
|
|
<TextInput
|
|
inputProps={{ placeholder: ccInstanceMeta.placeholder, required: true }}
|
|
bind:value={ccInstance}
|
|
/>
|
|
</label>
|
|
{:else}
|
|
<label class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis"
|
|
>Token URL override (optional)</span
|
|
>
|
|
<div class="text-xs text-secondary font-normal">
|
|
Override the provider's token endpoint for this resource, stored with the
|
|
connection and reused on token refresh
|
|
</div>
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'url',
|
|
placeholder: 'https://provider.example.com/oauth/token'
|
|
}}
|
|
bind:value={tokenUrl}
|
|
/>
|
|
</label>
|
|
{/if}
|
|
</form>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<h3 class="text-xs font-semibold text-emphasis flex gap-4"
|
|
>Scopes <button
|
|
onclick={() => {
|
|
editScopes = !editScopes
|
|
}}><Pen size={14} /></button
|
|
></h3
|
|
>
|
|
|
|
{#if editScopes}
|
|
<OauthScopes bind:scopes />
|
|
{:else}
|
|
<div class="flex flex-col gap-1">
|
|
{#each scopes as scope}
|
|
<div class="py-0.5 pl-2 text-xs">- {scope}</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{:else if step == 3 && !manual && !express}
|
|
{#if useClientCredentials}
|
|
<span class="text-xs text-primary font-normal"> Connecting with client credentials... </span>
|
|
{:else}
|
|
<span class="text-xs text-primary font-normal"> Finish connection in popup window </span>
|
|
{/if}
|
|
{:else}
|
|
<Label label="Path">
|
|
<Path
|
|
initialPath=""
|
|
namePlaceholder={resourceType}
|
|
bind:error={pathError}
|
|
bind:path
|
|
kind="resource"
|
|
/>
|
|
</Label>
|
|
<LabelsInput bind:labels class="-mt-5" />
|
|
{#if deployTo}
|
|
<Label
|
|
label="Workspace specific"
|
|
tooltip="Prevents this resource from being deployed to prod/staging"
|
|
>
|
|
<Toggle bind:checked={wsSpecific} />
|
|
</Label>
|
|
{/if}
|
|
{#if apiTokenApps[resourceType] || !manual}
|
|
<ul class="mt-6">
|
|
<li class="text-xs text-primary font-normal">
|
|
1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? 'token'}
|
|
<span class="font-semibold text-emphasis">{truncateRev(value, 5, '*****')}</span>
|
|
will be stored a
|
|
<span class="font-mono whitespace-nowrap text-emphasis">{path}</span>.
|
|
</li>
|
|
<li class="mt-2 text-xs text-primary font-normal">
|
|
2. The resource containing that token will be stored at the same path <span
|
|
class="font-mono whitespace-nowrap text-emphasis">{path}</span
|
|
>. The Variable and Resource will be "linked together", they will be deleted and renamed
|
|
together.
|
|
</li></ul
|
|
>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|