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 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-20 10:17:48 +02:00
co-authored by Claude Opus 5.5
parent 9217608471
commit 81274b2017
4 changed files with 135 additions and 45 deletions
@@ -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}
>
<DrawerContent
title={addResourceTitle(step > 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()}
<div class="flex gap-1">
@@ -124,7 +128,7 @@
{disabled}
onClick={() => appConnectInner?.next()}
>
{#if step == 2 && !manual}
{#if (step == 2 && !manual) || native}
Connect
{:else if step == 1}
Next
@@ -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 @@
<Label label="Path">
<Path
initialPath=""
namePlaceholder={defaultName}
namePlaceholder={native?.defaultName ?? defaultName}
fullNamePlaceholder={native?.defaultName}
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}
<!-- Not for express or `fillPath`, which save as soon as the token arrives: the fields
{#if native}
<p class="text-xs text-secondary">
The account is saved as a resource and its token as a variable at this path. Anyone who can
read them can create triggers that act as this account.
</p>
{:else}
<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}
<!-- Not for express or `fillPath`, which save as soon as the token arrives: the fields
are then filled by editing the resource. -->
{#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0}
<Label
label="Connection details"
tooltip="Saved on the resource with the token, and editable later"
class="mt-6"
>
<SchemaForm onlyMaskPassword noDelete schema={resourceFieldsSchema} bind:args />
</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 step == 4 && !manual && !express && !fillPath && resourceFields.length > 0}
<Label
label="Connection details"
tooltip="Saved on the resource with the token, and editable later"
class="mt-6"
>
<SchemaForm onlyMaskPassword noDelete schema={resourceFieldsSchema} bind:args />
</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}
{/if}
@@ -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<NativeTriggerConnection[] | undefined>(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)}
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: RefreshCw }}
disabled={connecting}
onclick={() => (close(), reconnect(connection))}
>
Reconnect
</Button>
{/if}
{#if connection && canDisconnect(connection)}
<Button
unifiedSize="xs"
@@ -168,7 +199,7 @@
btnClasses="rounded-none"
startIcon={{ icon: Plus }}
disabled={disabled || connecting}
onclick={() => (close(), connect())}
onclick={() => (close(), connectDrawer?.open(getServiceConfig(service)?.resourceType))}
>
Connect a {displayName} account
</Button>
@@ -188,4 +219,13 @@
{/if}
</div>
<AppConnectDrawer
bind:this={connectDrawer}
native={{
title: `Connect a ${displayName} account`,
defaultName: `native_${service}`,
connect
}}
/>
<ConfirmationModal {...confirmationModal.props} />
@@ -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<void>
}
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<NativeServiceName, NativeTriggerCon
nextcloud: {
serviceDisplayName: 'Nextcloud',
serviceKey: 'nextcloud',
resourceType: 'nextcloud',
supportsSync: true,
isCloudCompatible: true,
templates: {
@@ -106,6 +117,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon
google: {
serviceDisplayName: 'Google',
serviceKey: 'google',
resourceType: 'gworkspace',
supportsSync: true,
isCloudCompatible: true,
templates: {
@@ -116,6 +128,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon
github: {
serviceDisplayName: 'GitHub',
serviceKey: 'github',
resourceType: 'github',
supportsSync: true,
isCloudCompatible: true,
templates: {