From e0841a485c0e94bf71f898878ba353291abfd37b Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 16 Oct 2026 18:33:17 +0200 Subject: [PATCH] feat: connect native trigger accounts at a chosen path through the resource drawer, and reconnect from the picker Co-Authored-By: Claude Opus 5.5 --- .../lib/components/AppConnectDrawer.svelte | 10 +- .../src/lib/components/AppConnectInner.svelte | 109 ++++++++++++------ .../native/NativeConnectionPicker.svelte | 48 +++++++- .../lib/components/triggers/native/utils.ts | 13 +++ 4 files changed, 135 insertions(+), 45 deletions(-) diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 8de484929e..6cdfdc2998 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -9,17 +9,20 @@ import GoogleSigninButton from './GoogleSigninButton.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' + import type { NativeConnect } from './triggers/native/utils' interface Props { expressOAuthSetup?: boolean workspace?: string disableChatOffset?: boolean + native?: NativeConnect } let { expressOAuthSetup = false, workspace = undefined, - disableChatOffset = false + disableChatOffset = false, + native = undefined }: Props = $props() /** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a @@ -82,7 +85,7 @@ {disableChatOffset} > 1 ? resourceType : undefined)} + title={native?.title ?? addResourceTitle(step > 1 ? resourceType : undefined)} id="add-resource-drawer" on:close={drawer?.closeDrawer} tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs." @@ -105,6 +108,7 @@ express={expressOAuthSetup} {workspace} {fillPath} + {native} /> {#snippet actions()}
@@ -124,7 +128,7 @@ {disabled} onClick={() => appConnectInner?.next()} > - {#if step == 2 && !manual} + {#if (step == 2 && !manual) || native} Connect {:else if step == 1} Next diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 173de7e12b..d29d97a1dc 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -53,6 +53,7 @@ import ResourcePathHint from './ResourcePathHint.svelte' import SchemaForm from './SchemaForm.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import type { NativeConnect } from './triggers/native/utils' const operatingWorkspace = useOperatingWorkspace() @@ -73,6 +74,11 @@ * Opt-in: without it this flow still refuses to write over anything. */ fillPath?: string + /** + * Connect a native trigger account: only the path is asked, and `connect` runs the + * popup through the trigger service's OAuth app, whose callback saves the connection. + */ + native?: NativeConnect } let { @@ -83,7 +89,8 @@ manual = $bindable(true), express = false, workspace = undefined, - fillPath = undefined + fillPath = undefined, + native = undefined }: Props = $props() let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!) @@ -419,6 +426,13 @@ let pathError = $state('') export async function open(rt?: string) { + if (native) { + resourceType = rt ?? '' + manual = false + path = '' + step = 4 + return + } if (!rt) { loadResourceTypes() loadPopularity() @@ -507,7 +521,7 @@ // authorize endpoint counts; `google` itself is a login provider, not a registry entry. run(() => { isGoogleSignin = - step == 2 && + (step == 2 || (!!native && step == 4)) && !manual && (resourceType == 'google' || (registryEntryFor(resourceType)?.auth_url?.startsWith('https://accounts.google.com/') ?? @@ -532,7 +546,7 @@ clientSecret.trim() == '' || (!!ccInstanceMeta && ccInstance.trim() == '')))) || step == 3 || - (step == 4 && pathError != '') || + (step == 4 && (pathError != '' || (!!native && !path))) || !isValid }) @@ -729,6 +743,16 @@ } } export async function next() { + if (native) { + step = 3 + try { + await native.connect(path) + dispatch('close') + } catch { + step = 4 + } + return + } if (step == 1) { linkedSecrets = [] // Both branches: the OAuth one fills `resourceFields` into the same map, and fields @@ -1653,47 +1677,56 @@ - - {#if deployTo} - - {/if} - - {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} - - {/if} - {#if apiTokenApps[resourceType] || !manual} -
    -
  • - 1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? 'token'} - {truncateRev(value, 5, '*****')} - will be stored a - {path}. -
  • -
  • - 2. The resource containing that token will be stored at the same path {path}. The Variable and Resource will be "linked together", they will be deleted and renamed - together. -
+ {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} + + {/if} + {#if apiTokenApps[resourceType] || !manual} +
    +
  • + 1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? + 'token'} + {truncateRev(value, 5, '*****')} + will be stored a + {path}. +
  • +
  • + 2. The resource containing that token will be stored at the same path {path}. The Variable and Resource will be "linked together", they will be deleted and renamed + together. +
+ {/if} {/if} {/if} {/if} diff --git a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte index 9a280bd758..5f93660a71 100644 --- a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -4,10 +4,11 @@ import { Button } from '$lib/components/common' import Alert from '$lib/components/common/alert/Alert.svelte' import Select from '$lib/components/select/Select.svelte' + import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' - import { Plus, Unplug } from 'lucide-svelte' + import { Plus, RefreshCw, Unplug } from 'lucide-svelte' import { userStore } from '$lib/stores' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { connectNativeAccount, getServiceConfig } from './utils' @@ -49,6 +50,7 @@ let connections = $state(undefined) let connecting = $state(false) + let connectDrawer: AppConnectDrawer | undefined = $state(undefined) async function load() { try { @@ -76,21 +78,34 @@ else value = path } - async function connect() { + async function connect(path: string) { connecting = true try { - const path = await connectNativeAccount($operatingWorkspace!, service) + path = await connectNativeAccount($operatingWorkspace!, service, path) await load() sendUserToast(`${displayName} account connected`) onConnectionsChange?.() select(path) } catch (err: any) { sendUserToast(`Could not connect ${displayName}: ${err.body ?? err.message}`, true) + throw err } finally { connecting = false } } + async function reconnect(c: NativeTriggerConnection) { + if (c.owner && c.owner !== $userStore?.username) { + const confirmed = await confirmationModal.ask({ + title: `Reconnect ${c.path} with your account?`, + confirmationText: 'Reconnect', + children: `${c.path} will act as the ${displayName} account you sign in with, instead of ${c.owner}'s. Every trigger using it will act as you.` + }) + if (!confirmed) return + } + await connect(c.path).catch(() => {}) + } + async function disconnect(path: string) { const confirmed = await confirmationModal.ask({ title: `Disconnect ${path}?`, @@ -119,6 +134,11 @@ return !c.workspace && !!c.owner && c.owner === $userStore?.username } + // The server lets only the connection's owner or an admin replace it. + function canReconnect(c: NativeTriggerConnection): boolean { + return !!$userStore?.is_admin || (!!c.owner && c.owner === $userStore?.username) + } + const items = $derived.by(() => { const list = (connections ?? []).map((c) => ({ label: c.workspace && labelWorkspace ? `${c.path} (shared account)` : c.path, @@ -147,6 +167,17 @@ > {#snippet endSnippet({ item, close })} {@const connection = connections?.find((c) => c.path === item.value)} + {#if connection && canReconnect(connection)} + + {/if} {#if connection && canDisconnect(connection)} @@ -188,4 +219,13 @@ {/if}
+ + diff --git a/frontend/src/lib/components/triggers/native/utils.ts b/frontend/src/lib/components/triggers/native/utils.ts index b0d71a212c..5d0a7f0fca 100644 --- a/frontend/src/lib/components/triggers/native/utils.ts +++ b/frontend/src/lib/components/triggers/native/utils.ts @@ -19,6 +19,14 @@ export function useNativeConnection(): () => string | undefined { const CONNECT_MESSAGE = 'native_trigger_oauth' +/** Turns the resource connect drawer into the connect step of a native trigger account. */ +export type NativeConnect = { + title: string + defaultName: string + /** Rejects when the connection was not saved; the drawer then stays open on the path. */ + connect: (path: string) => Promise +} + export type NativeOAuthMessage = | { type: typeof CONNECT_MESSAGE; ok: true; path: string } | { type: typeof CONNECT_MESSAGE; ok: false; error: string } @@ -83,6 +91,8 @@ export async function connectNativeAccount( export interface NativeTriggerConfig { readonly serviceDisplayName: string readonly serviceKey: NativeServiceName + /** The type of the resource a connection is saved as. */ + readonly resourceType: string readonly supportsSync: boolean readonly isCloudCompatible: boolean readonly templates?: { @@ -95,6 +105,7 @@ export const NATIVE_TRIGGER_SERVICES: Record