mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
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:
co-authored by
Claude Opus 5.5
parent
9217608471
commit
81274b2017
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user