mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 00:03:52 +00:00
feat: let native triggers act through per-user or shared connections (#11352)
* fix: only let an oauth account's creator or readers link it to a variable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop an oauth account link the caller may not hold instead of failing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover relinking an oauth account through a variable update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover a permitted relink through a variable update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep an oauth account's creator in step with the user's username Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: follow a non-member superadmin's instance username on the OAuth accounts they created Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: link a variable to its OAuth account only in the connect flow The variable endpoints no longer take an account link from the caller. The connect flows create the token variable first and pass its path to create_account, which links it server-side. This replaces the creator check and the created_by bookkeeping, along with both migrations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: keep the MCP secret variable test's formatting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: link the account to the token variable AppConnect created, even if the path is edited mid-save Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stop exporting the OAuth account a variable links, and regenerate the chat tool schema Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: pin the EE refresh and linking checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: delete AppConnect's token variable when linking its account fails, so Save can be retried Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the resource AppConnect fills when linking its account fails Deleting a variable also deletes the resource at its path, so the rollback is skipped when filling an existing resource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: create an OAuth token variable and its account in one request createAccount takes the variable and the store creates both in one transaction, so a connect is either fully saved or not at all. The connect flows make that single call instead of creating the variable and linking it afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stop listing the ignored account input in the chat's write_variable instructions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: drop the account visibility helper the oauth refresh route no longer uses Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let native triggers act through per-user or shared connections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bind native trigger oauth state to its user and follow connection renames Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: authorize oauth account links and scope native trigger disconnects Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: order the native trigger connection migration after account ownership Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: delete only the scope-checked triggers when disconnecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: skip triggers moved since their scope check when disconnecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: scope-check the connection path when connecting, keep a connection still in use Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: key token write-back by account and serialize trigger creation with disconnect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: let an admin share one native trigger connection with the whole workspace Members can use the shared connection without reading its token. It reuses workspace_integrations.resource_path, so existing workspace connections stay usable by members after upgrading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: pick the workspace connection like other workspace resources, connect and disconnect from the picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: label the disconnect action in the connection picker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop the own-account note from the workspace connection field Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let admins share any connection, serialize service removal with trigger creation An admin can already read every connection, so restricting sharing to their own added nothing. Removing a service now takes each connection's lock and keeps everything if a trigger was created meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show each native trigger service's OAuth app status and keep removal apart Remove now only appears with a workspace OAuth app, since a workspace using the instance app has nothing of its own to remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: configure a native trigger service's OAuth app in a dialog The card states once which OAuth app is in use, with a single Configure action, and the form follows the design system. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop a stray brace from the OAuth app dialog binding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: lead the OAuth app row with which app is in use, with a link to change it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: compact the native trigger service card Edit and Remove sit on the workspace OAuth app row, and the workspace connection reads as part of the same section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show the OAuth app status in each native trigger card's header App actions move into a menu, the card body holds only the shared account, and service descriptions name what fires the trigger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say who configured each native trigger service, and name removal for what it removes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: say what members can do with each native trigger service, keep the shared account optional Nextcloud, whose webhooks need an admin account, offers connecting and sharing one directly instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pick the shared account with an inline select, and keep Nextcloud's own-account path in its wording Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: one status line per native trigger service, a Nextcloud admin hint, and clearer refusals The shared account is a regular form field, Nextcloud shows an alert while no admin account is shared, a non-admin Nextcloud account gets a message saying why it cannot create triggers, and the OAuth dialog only accepts a full server URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: point native trigger error hints at the account picker and the shared account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say members can browse what a shared native trigger account can access Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: make the OAuth app dialog's open prop required Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep pre-connection triggers working, and guard reconnects and service removal - The migration recovers a service's connection when the workspace row lost its path, which saving the OAuth app or an abandoned instance connect used to do. - A trigger created without a connection uses the shared account, as older clients and trigger specs expect. - Only whoever connected a path, or an admin, may reconnect over it. - Removing a service or sharing an account checks a path-scoped token against what it touches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: say a disconnect deletes the triggers other members created on the account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer Reconnect next to an existing trigger's account and point the refresh hints at it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to da49c12e3006fc77de789277f1462a4d8d7d92ce This commit updates the EE repository reference after PR #829 was merged in windmill-ee-private. Previous ee-repo-ref: 58681a2e7eafb5689ed739cd2560fa0f5d82c3dc New ee-repo-ref: da49c12e3006fc77de789277f1462a4d8d7d92ce Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
d6adac8b75
commit
dc1fb5087b
@@ -84,7 +84,7 @@ pub trait External: Send + Sync + 'static {
|
||||
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>;
|
||||
fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>;
|
||||
fn additional_routes(&self) -> axum::Router;
|
||||
async fn http_client_request<T, B>(&self, url, method, workspace_id, tx, db, headers, body) -> Result<T>;
|
||||
async fn http_client_request<T, B>(&self, url, method, workspace_id, connection_path, db, headers, body) -> Result<T>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -115,28 +115,47 @@ Use this when the external_id is known before the create call (e.g., Google gene
|
||||
|
||||
Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
|
||||
|
||||
### OAuth Token Storage (Three-Table Pattern)
|
||||
### OAuth App and Connections
|
||||
|
||||
OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly:
|
||||
The OAuth **app** (client) is configured per workspace and service; **connections** (connected
|
||||
accounts) are many per service. Each trigger acts through one connection, named by
|
||||
`native_trigger.connection_path`:
|
||||
|
||||
| Table | What's Stored |
|
||||
|-------|---------------|
|
||||
| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable |
|
||||
| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column |
|
||||
| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` |
|
||||
| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag. One row per (workspace, service); it must exist for every connection, since token refresh reads the client from it |
|
||||
| `variable` | Encrypted `access_token` at the connection path, linked to `account` via its `account` column |
|
||||
| `account` | `refresh_token`, with `client` = service name and `is_workspace_integration = true` |
|
||||
| `resource` | The connection's resource at the same path, `{ "token": "$var:<path>" }` |
|
||||
|
||||
The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct:
|
||||
A user may use a connection when they can read its variable (checked with a user transaction,
|
||||
so the variable's row-level security decides): `resolve_usable_connection` /
|
||||
`list_usable_connections` in `lib.rs`. The exception is the **workspace connection**: an admin
|
||||
may share one connection per service (`workspace_integrations.resource_path`, set by
|
||||
`POST .../workspace_connection`), and every member may then use it without reading its token.
|
||||
Renames carry the marker along; disconnecting clears it. Background work (sync, renewal, rename re-registration,
|
||||
webhook handling) uses the stored `connection_path` directly.
|
||||
|
||||
`decrypt_oauth_data(db, w_id, service, connection_path)` assembles these into one struct, and
|
||||
every service's `OAuthData` carries `connection_path` so `http_client_request` can refresh and
|
||||
write back the right connection:
|
||||
```rust
|
||||
pub struct OAuthConfig {
|
||||
pub base_url: String,
|
||||
pub access_token: String, // decrypted from variable
|
||||
pub refresh_token: Option<String>, // from account table
|
||||
pub access_token: String, // decrypted from the connection's variable
|
||||
pub refresh_token: Option<String>, // from its account
|
||||
pub client_id: String, // from oauth_data or instance settings
|
||||
pub client_secret: String, // from oauth_data or instance settings
|
||||
pub connection_path: String,
|
||||
}
|
||||
```
|
||||
|
||||
Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations.
|
||||
A refreshed token is written back by connection path and account id, never by service: providers
|
||||
like Nextcloud rotate refresh tokens, so writing one onto another account breaks its next refresh.
|
||||
|
||||
Instance-level sharing: when the workspace has no OAuth app of its own and the instance admin
|
||||
shares one (`share_with_workspaces` in the `oauths` setting; not available for Nextcloud), the
|
||||
connect flow uses it and records `{"instance_shared": true}` in `workspace_integrations`.
|
||||
|
||||
### URL Resolution
|
||||
|
||||
@@ -311,7 +330,7 @@ impl External for NewService {
|
||||
});
|
||||
|
||||
let response: CreateTriggerResponse = self
|
||||
.http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload))
|
||||
.http_client_request(&url, Method::POST, w_id, &oauth_data.connection_path, db, None, Some(&payload))
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
@@ -343,7 +362,7 @@ impl External for NewService {
|
||||
});
|
||||
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload))
|
||||
.http_client_request(&url, Method::PUT, w_id, &oauth_data.connection_path, db, None, Some(&payload))
|
||||
.await?;
|
||||
|
||||
// Fetch back the updated state to get the resolved config
|
||||
@@ -361,7 +380,7 @@ impl External for NewService {
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Self::TriggerData> {
|
||||
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
|
||||
self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await
|
||||
self.http_client_request::<_, ()>(&url, Method::GET, w_id, &oauth_data.connection_path, db, None, None).await
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
@@ -374,7 +393,7 @@ impl External for NewService {
|
||||
) -> Result<()> {
|
||||
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None)
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, &oauth_data.connection_path, db, None, None)
|
||||
.await
|
||||
.or_else(|e| match &e {
|
||||
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
|
||||
@@ -749,7 +768,7 @@ pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body)
|
||||
|
||||
When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
|
||||
|
||||
The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`.
|
||||
`generate_connect_url` picks the client (`resolve_oauth_client`): the workspace's own app when configured, the instance's otherwise. A GitHub OAuth app only redirects below its single registered callback, so connections made with the instance GitHub app land on `/oauth/callback/github/native_trigger` instead of the workspace settings page.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2) RETURNING path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2 AND a.client = $3\n AND a.is_workspace_integration = true)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb"
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT v.path, r.created_by AS \"owner?\", true AS \"workspace!\"\n FROM workspace_integrations wi\n JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE wi.workspace_id = $1 AND wi.service_name = $2\n AND a.client = $3 AND a.is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "owner?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "workspace!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409"
|
||||
}
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n resource_path,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n resource_path = $4,\n updated_at = now()\n ",
|
||||
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n updated_at = now()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -19,11 +19,10 @@
|
||||
}
|
||||
},
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c"
|
||||
"hash": "2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8"
|
||||
}
|
||||
+3
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\",\n resource_path\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\"\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -23,11 +23,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "resource_path",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -37,9 +32,8 @@
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578"
|
||||
"hash": "30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86"
|
||||
}
|
||||
+4
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n resource_path,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -31,21 +31,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "resource_path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 4,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
@@ -71,11 +66,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef"
|
||||
"hash": "3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2"
|
||||
}
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
|
||||
"query": "SELECT external_id, script_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "webhook_token_hash",
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
@@ -28,7 +28,8 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -36,5 +37,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5"
|
||||
"hash": "4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM resource WHERE workspace_id = $1 AND path = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true\n AND NOT EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1 AND account = $2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, service_name) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL\n OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))\n RETURNING webhook_token_hash",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "webhook_token_hash",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n (SELECT created_by FROM resource WHERE workspace_id = $1 AND path = $2) AS \"owner?\",\n EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2)\n OR EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2) AS \"taken!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "owner?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "taken!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET\n refresh_token = COALESCE($1, refresh_token),\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_integrations SET resource_path = NULL\n WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET\n refresh_token = $1,\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT v.value, a.id AS account_id, a.refresh_token\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2\n AND a.client = $3 AND a.is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "account_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "refresh_token",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b"
|
||||
}
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "resource_path",
|
||||
"type_info": "Text"
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -27,8 +27,8 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e"
|
||||
"hash": "78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT v.path, r.created_by AS \"owner?\", false AS \"workspace!\"\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true\n ORDER BY v.path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "owner?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "workspace!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary,\n enabled\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ",
|
||||
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary,\n enabled,\n connection_path\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, connection_path = $10, error = NULL, updated_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -24,10 +24,11 @@
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63"
|
||||
"hash": "8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM variable WHERE workspace_id = $1 AND path = $2 RETURNING account",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "account",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_integrations SET resource_path = $3, updated_at = now()\n WHERE workspace_id = $1 AND service_name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND account = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled,\n nt.connection_path\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -73,6 +73,11 @@
|
||||
"ordinal": 11,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "connection_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -111,8 +116,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff"
|
||||
"hash": "c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT v.path FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled,\n connection_path\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -73,6 +73,11 @@
|
||||
"ordinal": 11,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "connection_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -106,8 +111,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd"
|
||||
"hash": "d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471"
|
||||
}
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash",
|
||||
"query": "SELECT script_path FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "webhook_token_hash",
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
@@ -30,5 +30,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a"
|
||||
"hash": "e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled,\n connection_path\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -73,6 +73,11 @@
|
||||
"ordinal": 11,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "connection_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -105,8 +110,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1"
|
||||
"hash": "efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9"
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT external_id, connection_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL\n OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "external_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "connection_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE native_trigger DROP COLUMN IF EXISTS connection_path;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Each native trigger names the connection (OAuth resource) it acts through, instead of every
|
||||
-- trigger of a service sharing the single workspace connection.
|
||||
ALTER TABLE native_trigger ADD COLUMN connection_path VARCHAR(255);
|
||||
|
||||
-- Saving the OAuth app, or starting a connect with the instance app, used to clear
|
||||
-- resource_path while the connection itself stayed. A service then had at most one connection,
|
||||
-- so when exactly one is found it is the one its triggers used.
|
||||
UPDATE workspace_integrations wi
|
||||
SET resource_path = (
|
||||
SELECT CASE WHEN count(*) = 1 THEN min(v.path) END
|
||||
FROM variable v
|
||||
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
|
||||
WHERE v.workspace_id = wi.workspace_id
|
||||
AND a.client = wi.service_name::text
|
||||
AND a.is_workspace_integration = true
|
||||
)
|
||||
WHERE wi.resource_path IS NULL;
|
||||
|
||||
UPDATE native_trigger nt
|
||||
SET connection_path = wi.resource_path
|
||||
FROM workspace_integrations wi
|
||||
WHERE wi.workspace_id = nt.workspace_id
|
||||
AND wi.service_name = nt.service_name
|
||||
AND wi.resource_path IS NOT NULL;
|
||||
@@ -14,6 +14,7 @@ use sqlx::{Pool, Postgres};
|
||||
use axum::http::StatusCode;
|
||||
use windmill_api_auth::{ApiAuthed, ScopePathFilter};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::Error,
|
||||
variables::{build_crypt, encrypt},
|
||||
};
|
||||
@@ -22,12 +23,13 @@ use windmill_native_triggers::{
|
||||
get_workspace_integration,
|
||||
github::GitHub,
|
||||
google::{parse_stop_channel_params, should_renew_channel},
|
||||
grant_refused, http_error_status, list_native_triggers, map_external_error,
|
||||
native_trigger_is_enabled,
|
||||
grant_refused, http_error_status, list_native_triggers, list_usable_connections,
|
||||
map_external_error, native_trigger_is_enabled,
|
||||
nextcloud::NextCloud,
|
||||
require_native_integration_use, set_native_trigger_enabled, store_native_trigger,
|
||||
store_workspace_integration, update_native_trigger, External, ExternalReadFailure,
|
||||
HttpRequestError, NativeTriggerConfig, OAuthConfig, ServiceName,
|
||||
require_native_integration_use, resolve_usable_connection, set_native_trigger_enabled,
|
||||
store_native_trigger, store_workspace_integration, update_native_trigger,
|
||||
update_oauth_token_resource, External, ExternalReadFailure, HttpRequestError,
|
||||
NativeTriggerConfig, OAuthConfig, ServiceName,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -133,7 +135,6 @@ async fn setup_oauth_integration(
|
||||
"test-workspace",
|
||||
service_name,
|
||||
oauth_data,
|
||||
Some(resource_path),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -149,73 +150,125 @@ fn now_ms() -> i64 {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 1. Resource Path Change
|
||||
// 1. Several connections of one service
|
||||
// ============================================================================
|
||||
|
||||
/// Each connection keeps its own tokens: a refresh written back for one, or disconnecting one,
|
||||
/// must not touch another. Providers that rotate refresh tokens invalidate the old one, so a
|
||||
/// refresh token copied onto the wrong account breaks that account's next refresh.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_resource_path_change(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let path_a = "u/test-user/native_gworkspace";
|
||||
setup_oauth_integration(
|
||||
async fn test_connections_are_isolated(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let path_a = "u/test-user/native_nextcloud";
|
||||
let path_b = "u/other-user/native_nextcloud";
|
||||
let mut account_a = 0;
|
||||
for (path, token) in [(path_a, "a"), (path_b, "b")] {
|
||||
let account = setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Nextcloud,
|
||||
path,
|
||||
&format!("access-{token}"),
|
||||
&format!("refresh-{token}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if path == path_a {
|
||||
account_a = account;
|
||||
}
|
||||
}
|
||||
|
||||
update_oauth_token_resource(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
path_a,
|
||||
"token-a",
|
||||
"refresh-a",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify decrypt works at path A
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
assert_eq!(config.access_token, "token-a");
|
||||
|
||||
// Cleanup old path
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
account_a,
|
||||
"access-a2",
|
||||
Some("refresh-a2"),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
|
||||
// Recreate at path B
|
||||
let path_b = "u/test-user/native_gworkspace_v2";
|
||||
setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
path_b,
|
||||
"token-b",
|
||||
"refresh-b",
|
||||
None,
|
||||
let a: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_a)).await?;
|
||||
let b: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?;
|
||||
assert_eq!(
|
||||
(a.access_token.as_str(), a.refresh_token.as_deref()),
|
||||
("access-a2", Some("refresh-a2"))
|
||||
);
|
||||
assert_eq!(
|
||||
(b.access_token.as_str(), b.refresh_token.as_deref()),
|
||||
("access-b", Some("refresh-b"))
|
||||
);
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_connection(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
path_a,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Path A resources should be gone
|
||||
let var_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
path_a,
|
||||
assert!(decrypt_oauth_data::<OAuthConfig>(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Nextcloud,
|
||||
Some(path_a)
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(var_count, 0, "variable at old path should be deleted");
|
||||
.await
|
||||
.is_err());
|
||||
let b: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?;
|
||||
assert_eq!(b.refresh_token.as_deref(), Some("refresh-b"));
|
||||
|
||||
let res_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
path_a,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A member who cannot read the admin's connection may still use it once it is shared as the
|
||||
/// workspace connection.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_workspace_connection_usable_by_members(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let path = "u/test-user/native_nextcloud";
|
||||
setup_oauth_integration(&db, ServiceName::Nextcloud, path, "a", "r", None).await?;
|
||||
let member = ApiAuthed {
|
||||
email: "test2@windmill.dev".to_string(),
|
||||
username: "test-user-2".to_string(),
|
||||
is_admin: false,
|
||||
..test_authed()
|
||||
};
|
||||
let usable_paths = || async {
|
||||
let mut tx = UserDB::new(db.clone()).begin(&member).await?;
|
||||
let connections =
|
||||
list_usable_connections(&db, &mut tx, "test-workspace", ServiceName::Nextcloud).await?;
|
||||
tx.commit().await?;
|
||||
anyhow::Ok(
|
||||
connections
|
||||
.into_iter()
|
||||
.map(|c| (c.path, c.workspace))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
|
||||
assert!(usable_paths().await?.is_empty());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_integrations SET resource_path = $1
|
||||
WHERE workspace_id = 'test-workspace' AND service_name = 'nextcloud'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(res_count, 0, "resource at old path should be deleted");
|
||||
.bind(path)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert_eq!(usable_paths().await?, vec![(path.to_string(), true)]);
|
||||
|
||||
// Path B should work
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
assert_eq!(config.access_token, "token-b");
|
||||
assert_eq!(config.refresh_token.as_deref(), Some("refresh-b"));
|
||||
// Clients and specs that predate connections name none: they keep getting the shared
|
||||
// account once the member also has their own.
|
||||
let own = "u/test-user-2/native_nextcloud";
|
||||
setup_oauth_integration(&db, ServiceName::Nextcloud, own, "b", "s", None).await?;
|
||||
let mut tx = UserDB::new(db.clone()).begin(&member).await?;
|
||||
let default =
|
||||
resolve_usable_connection(&db, &mut tx, "test-workspace", ServiceName::Nextcloud, None)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
assert_eq!(usable_paths().await?.len(), 2);
|
||||
assert_eq!(default, path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -237,8 +290,13 @@ async fn test_decrypt_workspace_level(db: Pool<Postgres>) -> anyhow::Result<()>
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
let config: OAuthConfig = decrypt_oauth_data(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
Some(resource_path),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.access_token, "ws-access-token");
|
||||
assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token"));
|
||||
@@ -282,8 +340,13 @@ async fn test_decrypt_instance_level(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
let config: OAuthConfig = decrypt_oauth_data(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
Some(resource_path),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.client_id, "instance-client-id");
|
||||
assert_eq!(config.client_secret, "instance-client-secret");
|
||||
@@ -306,8 +369,13 @@ async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.await?;
|
||||
|
||||
// Verify old tokens
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
let config: OAuthConfig = decrypt_oauth_data(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
Some(resource_path),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(config.access_token, "old-access-token");
|
||||
|
||||
// Simulate token refresh: update variable + account
|
||||
@@ -330,8 +398,13 @@ async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.await?;
|
||||
|
||||
// Verify new tokens
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
let config: OAuthConfig = decrypt_oauth_data(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
Some(resource_path),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(config.access_token, "new-access-token");
|
||||
assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token"));
|
||||
|
||||
@@ -491,6 +564,7 @@ async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Res
|
||||
json!({"triggerType": "drive"}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -501,12 +575,12 @@ async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Res
|
||||
|
||||
// Step 2: Cleanup OAuth resources
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
|
||||
windmill_native_triggers::workspace_integrations::cleanup_service_connections(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Step 3: Delete workspace integration
|
||||
@@ -582,17 +656,18 @@ async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<(
|
||||
json!({"triggerType": "drive"}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Cleanup OAuth only — should NOT remove the trigger
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
|
||||
windmill_native_triggers::workspace_integrations::cleanup_connection(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
resource_path,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// OAuth resources gone
|
||||
@@ -640,6 +715,7 @@ async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<
|
||||
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
// An unrelated trigger already sitting on the target path must not be reported as moved.
|
||||
@@ -657,6 +733,7 @@ async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<
|
||||
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -729,6 +806,7 @@ async fn test_list_native_triggers_scope_filter(db: Pool<Postgres>) -> anyhow::R
|
||||
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -834,7 +912,7 @@ fn test_provider_rejection_is_readable_and_not_internal() {
|
||||
"the envelope should not reach the user: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("Workspace settings > Integrations"),
|
||||
message.contains("the workspace's shared account"),
|
||||
"the hint should say what to do: {message}"
|
||||
);
|
||||
}
|
||||
@@ -964,6 +1042,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool<Postgres>) -> anyhow::Resul
|
||||
json!({"event": "OCA\\Files\\Event\\LoadAdditionalScriptsEvent"}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1013,6 +1092,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool<Postgres>) -> anyhow::Resul
|
||||
json!({}),
|
||||
None,
|
||||
false,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
@@ -1030,6 +1110,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool<Postgres>) -> anyhow::Resul
|
||||
json!({}),
|
||||
None,
|
||||
true,
|
||||
"u/test-user/native_conn",
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
|
||||
@@ -21102,12 +21102,22 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
requestBody:
|
||||
description: redirect_uri
|
||||
description: >-
|
||||
redirect_uri, and where to save the connection (defaults to
|
||||
u/{caller}/native_{resource_type}). Uses the workspace OAuth client, or the instance
|
||||
one when the instance shares it.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RedirectUri"
|
||||
type: object
|
||||
properties:
|
||||
redirect_uri:
|
||||
type: string
|
||||
resource_path:
|
||||
type: string
|
||||
required:
|
||||
- redirect_uri
|
||||
responses:
|
||||
"200":
|
||||
description: native trigger service connect url
|
||||
@@ -21137,10 +21147,59 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url:
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/connections:
|
||||
get:
|
||||
summary: list the native trigger connections of a service the caller can use
|
||||
operationId: listNativeTriggerConnections
|
||||
tags:
|
||||
- workspace_integration
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: service_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
responses:
|
||||
"200":
|
||||
description: connections
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/NativeTriggerConnection"
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/connections/delete:
|
||||
delete:
|
||||
summary: disconnect a native trigger connection and delete the triggers using it
|
||||
operationId: deleteNativeTriggerConnection
|
||||
tags:
|
||||
- workspace_integration
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: service_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
- name: path
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: connection deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/workspace_connection:
|
||||
post:
|
||||
summary: generate connect url using instance-level credentials
|
||||
operationId: generateInstanceConnectUrl
|
||||
summary: share a connection of this service with every member of the workspace (admin only), or stop sharing
|
||||
operationId: setNativeTriggerWorkspaceConnection
|
||||
tags:
|
||||
- workspace_integration
|
||||
parameters:
|
||||
@@ -21151,15 +21210,19 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
requestBody:
|
||||
description: redirect_uri
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RedirectUri"
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The connection to share, or null to stop sharing
|
||||
responses:
|
||||
"200":
|
||||
description: authorization URL using instance credentials
|
||||
description: workspace connection updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -21213,17 +21276,15 @@ paths:
|
||||
type: string
|
||||
redirect_uri:
|
||||
type: string
|
||||
resource_path:
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- state
|
||||
- redirect_uri
|
||||
responses:
|
||||
"200":
|
||||
description: native trigger service oauth completed
|
||||
description: path of the saved connection
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
@@ -21498,6 +21559,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: connection_path
|
||||
in: query
|
||||
description: Connection to browse through; defaults to the caller's only usable one
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of available NextCloud events
|
||||
@@ -21520,6 +21587,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: connection_path
|
||||
in: query
|
||||
description: Connection to browse through; defaults to the caller's only usable one
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of Google Calendars
|
||||
@@ -21542,6 +21615,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: connection_path
|
||||
in: query
|
||||
description: Connection to browse through; defaults to the caller's only usable one
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: q
|
||||
in: query
|
||||
description: search query to filter files by name
|
||||
@@ -21583,6 +21662,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: connection_path
|
||||
in: query
|
||||
description: Connection to browse through; defaults to the caller's only usable one
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of shared drives
|
||||
@@ -21605,6 +21690,12 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: connection_path
|
||||
in: query
|
||||
description: Connection to browse through; defaults to the caller's only usable one
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of GitHub repositories
|
||||
@@ -39757,6 +39848,10 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the trigger starts a job when it fires
|
||||
connection_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Path of the connection the trigger acts through on the external service
|
||||
required:
|
||||
- external_id
|
||||
- workspace_id
|
||||
@@ -39799,6 +39894,10 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the trigger starts a job when it fires
|
||||
connection_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Path of the connection the trigger acts through on the external service
|
||||
external_data:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -39830,10 +39929,6 @@ components:
|
||||
oauth_data:
|
||||
nullable: true
|
||||
$ref: "#/components/schemas/WorkspaceOAuthConfig"
|
||||
resource_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Path to the resource storing the OAuth token
|
||||
required:
|
||||
- service_name
|
||||
|
||||
@@ -39879,14 +39974,6 @@ components:
|
||||
- async
|
||||
- sync
|
||||
|
||||
RedirectUri:
|
||||
type: object
|
||||
properties:
|
||||
redirect_uri:
|
||||
type: string
|
||||
required:
|
||||
- redirect_uri
|
||||
|
||||
NativeTriggerData:
|
||||
type: object
|
||||
description: Data for creating or updating a native trigger
|
||||
@@ -39911,11 +39998,36 @@ components:
|
||||
Whether the trigger starts a job when it fires. Honoured on create only, so a
|
||||
trigger can be registered already paused; an update ignores it and setenabled is
|
||||
the only way to change an existing trigger's state. Defaults to true.
|
||||
connection_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
Path of the connection the trigger acts through. Honoured on create only; an update
|
||||
keeps the trigger's connection. When omitted on create, the caller's only usable
|
||||
connection for the service is used.
|
||||
required:
|
||||
- script_path
|
||||
- is_flow
|
||||
- service_config
|
||||
|
||||
NativeTriggerConnection:
|
||||
type: object
|
||||
description: An account connected for native triggers, stored as an OAuth resource
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: Path of the resource and variable holding the connection
|
||||
owner:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Username of whoever connected the account, when the caller can read its resource
|
||||
workspace:
|
||||
type: boolean
|
||||
description: Whether this is the workspace connection, which every member may use
|
||||
required:
|
||||
- path
|
||||
- workspace
|
||||
|
||||
CreateTriggerResponse:
|
||||
type: object
|
||||
description: Response returned when a native trigger is created
|
||||
|
||||
@@ -1209,6 +1209,11 @@ async fn offboard_user_from_workspace<'c>(
|
||||
// They are NOT done here for reassign-only (delete_user=false) since the user stays.
|
||||
|
||||
// ---- Related path tables ----
|
||||
sqlx::query!(
|
||||
r#"UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
&new_prefix, username, w_id
|
||||
).execute(&mut **tx).await?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
&new_prefix, username, w_id
|
||||
|
||||
@@ -671,6 +671,15 @@ async fn update_username_in_workpsace<'c>(
|
||||
).execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
old_username,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
|
||||
@@ -948,6 +948,7 @@ pub fn workspace_integration_oauth_key(client_name: &str) -> &str {
|
||||
pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String {
|
||||
match client_name {
|
||||
"google" => "https://oauth2.googleapis.com/token".to_string(),
|
||||
"github" => "https://github.com/login/oauth/access_token".to_string(),
|
||||
_ => format!("{}/apps/oauth2/api/v1/token", base_url),
|
||||
}
|
||||
}
|
||||
@@ -956,6 +957,7 @@ pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -
|
||||
pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String {
|
||||
match client_name {
|
||||
"google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
"github" => "https://github.com/login/oauth/authorize".to_string(),
|
||||
_ => format!("{}/apps/oauth2/authorize", base_url),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ impl External for GitHub {
|
||||
async fn create(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
webhook_token: &str,
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
@@ -69,7 +69,15 @@ impl External for GitHub {
|
||||
});
|
||||
|
||||
let response: CreateWebhookResponse = self
|
||||
.http_client_request(&url, Method::POST, w_id, db, None, Some(&payload))
|
||||
.http_client_request(
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(&payload),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
@@ -78,7 +86,7 @@ impl External for GitHub {
|
||||
async fn update(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
webhook_token: &str,
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
@@ -114,7 +122,15 @@ impl External for GitHub {
|
||||
});
|
||||
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request(&url, Method::PATCH, w_id, db, None, Some(&payload))
|
||||
.http_client_request(
|
||||
&url,
|
||||
Method::PATCH,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(&payload),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Return the resolved service_config
|
||||
@@ -125,7 +141,7 @@ impl External for GitHub {
|
||||
async fn get(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
_tx: &mut PgConnection,
|
||||
@@ -137,7 +153,14 @@ impl External for GitHub {
|
||||
};
|
||||
|
||||
let data = self
|
||||
.get_webhook(w_id, &owner, &repo, external_id, db)
|
||||
.get_webhook(
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
&owner,
|
||||
&repo,
|
||||
external_id,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
@@ -145,7 +168,7 @@ impl External for GitHub {
|
||||
async fn delete(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
_tx: &mut PgConnection,
|
||||
@@ -165,7 +188,15 @@ impl External for GitHub {
|
||||
// Swallow 404 only (webhook may already be deleted on GitHub); propagate
|
||||
// other errors so callers don't think cleanup succeeded when it didn't.
|
||||
let result: std::result::Result<serde_json::Value, _> = self
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::DELETE,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -180,7 +211,7 @@ impl External for GitHub {
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
triggers: &[NativeTrigger],
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
synced: &mut Vec<crate::sync::TriggerSyncInfo>,
|
||||
errors: &mut Vec<crate::sync::SyncError>,
|
||||
) {
|
||||
@@ -208,7 +239,15 @@ impl External for GitHub {
|
||||
);
|
||||
|
||||
let result: std::result::Result<GithubWebhookApiResponse, _> = self
|
||||
.http_client_request::<_, ()>(&url, Method::GET, workspace_id, db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
workspace_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -295,7 +334,7 @@ impl External for GitHub {
|
||||
fn error_hint(&self, status: StatusCode) -> Option<&'static str> {
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
Some("reconnect the GitHub integration from Workspace settings > Integrations.")
|
||||
Some("reconnect the GitHub account this uses with Reconnect next to it, or ask its owner or an admin to.")
|
||||
}
|
||||
StatusCode::FORBIDDEN => Some(
|
||||
"check that the OAuth app is authorized for the organization and that the \
|
||||
@@ -363,6 +402,7 @@ impl GitHub {
|
||||
async fn get_webhook(
|
||||
&self,
|
||||
w_id: &str,
|
||||
connection_path: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
external_id: &str,
|
||||
@@ -374,7 +414,7 @@ impl GitHub {
|
||||
);
|
||||
|
||||
let response: GithubWebhookApiResponse = self
|
||||
.http_client_request::<_, ()>(&url, Method::GET, w_id, db, None, None)
|
||||
.http_client_request::<_, ()>(&url, Method::GET, w_id, connection_path, db, None, None)
|
||||
.await?;
|
||||
|
||||
Ok(GithubTriggerData {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct GithubOAuthData {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub connection_path: String,
|
||||
}
|
||||
|
||||
/// User-provided configuration for a GitHub webhook trigger.
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::Path, routing::get, Extension, Json, Router};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::get,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use http::Method;
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{error::JsonResult, DB};
|
||||
use windmill_common::{db::UserDB, error::JsonResult, DB};
|
||||
|
||||
use crate::{
|
||||
get_workspace_integration, map_external_error, require_native_integration_use, External,
|
||||
ServiceName,
|
||||
};
|
||||
use crate::{map_external_error, picker_connection, ConnectionQuery, External, ServiceName};
|
||||
|
||||
use super::{GitHub, GithubApiRepoResponse, GithubRepoEntry};
|
||||
|
||||
@@ -19,10 +20,19 @@ async fn list_repos(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<GitHub>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<ConnectionQuery>,
|
||||
) -> JsonResult<Vec<GithubRepoEntry>> {
|
||||
require_native_integration_use(&db, &workspace_id, &authed).await?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?;
|
||||
let connection_path = picker_connection(
|
||||
&authed,
|
||||
&db,
|
||||
user_db,
|
||||
&workspace_id,
|
||||
ServiceName::Github,
|
||||
query.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut all_entries = Vec::new();
|
||||
|
||||
@@ -35,7 +45,15 @@ async fn list_repos(
|
||||
);
|
||||
|
||||
let repos: Vec<GithubApiRepoResponse> = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
&workspace_id,
|
||||
&connection_path,
|
||||
&db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_external_error)?;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ impl External for Google {
|
||||
async fn create(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
webhook_token: &str,
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
@@ -51,8 +51,16 @@ impl External for Google {
|
||||
// At creation time, channel_id also becomes the trigger's external_id
|
||||
// (see external_id_and_metadata_from_response).
|
||||
let channel_id = uuid::Uuid::new_v4().to_string();
|
||||
self.create_watch_channel(w_id, &channel_id, &channel_id, webhook_token, data, db)
|
||||
.await
|
||||
self.create_watch_channel(
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
&channel_id,
|
||||
&channel_id,
|
||||
webhook_token,
|
||||
data,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update(
|
||||
@@ -72,7 +80,15 @@ impl External for Google {
|
||||
// external_id stays the stable routing key used in the webhook URL.
|
||||
let channel_id = uuid::Uuid::new_v4().to_string();
|
||||
let resp = self
|
||||
.create_watch_channel(w_id, external_id, &channel_id, webhook_token, data, db)
|
||||
.create_watch_channel(
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
external_id,
|
||||
&channel_id,
|
||||
webhook_token,
|
||||
data,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.service_config_from_create_response(data, &resp)
|
||||
@@ -86,7 +102,7 @@ impl External for Google {
|
||||
async fn delete(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
@@ -122,7 +138,15 @@ impl External for Google {
|
||||
|
||||
// Stop the channel (ignore errors - channel may have already expired)
|
||||
let result: std::result::Result<serde_json::Value, _> = self
|
||||
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
|
||||
.http_client_request(
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(&stop_request),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
@@ -203,7 +227,7 @@ impl External for Google {
|
||||
fn error_hint(&self, status: http::StatusCode) -> Option<&'static str> {
|
||||
match status {
|
||||
http::StatusCode::UNAUTHORIZED => {
|
||||
Some("reconnect the Google integration from Workspace settings > Integrations.")
|
||||
Some("reconnect the Google account this uses with Reconnect next to it, or ask its owner or an admin to.")
|
||||
}
|
||||
http::StatusCode::FORBIDDEN => Some(
|
||||
"the connected Google account is missing access to this resource or the scope the \
|
||||
@@ -231,6 +255,7 @@ impl Google {
|
||||
async fn create_watch_channel(
|
||||
&self,
|
||||
w_id: &str,
|
||||
connection_path: &str,
|
||||
external_id: &str,
|
||||
channel_id: &str,
|
||||
webhook_token: &str,
|
||||
@@ -263,12 +288,24 @@ impl Google {
|
||||
|
||||
match data.service_config.trigger_type {
|
||||
GoogleTriggerType::Drive => {
|
||||
self.create_drive_watch(w_id, &data.service_config, &watch_request, db)
|
||||
.await
|
||||
self.create_drive_watch(
|
||||
w_id,
|
||||
connection_path,
|
||||
&data.service_config,
|
||||
&watch_request,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
}
|
||||
GoogleTriggerType::Calendar => {
|
||||
self.create_calendar_watch(w_id, &data.service_config, &watch_request, db)
|
||||
.await
|
||||
self.create_calendar_watch(
|
||||
w_id,
|
||||
connection_path,
|
||||
&data.service_config,
|
||||
&watch_request,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +313,7 @@ impl Google {
|
||||
async fn create_drive_watch(
|
||||
&self,
|
||||
w_id: &str,
|
||||
connection_path: &str,
|
||||
config: &GoogleServiceConfig,
|
||||
watch_request: &WatchRequest,
|
||||
db: &DB,
|
||||
@@ -285,14 +323,30 @@ impl Google {
|
||||
// Specific file: use files.watch
|
||||
let url = format!("{}/files/{}/watch", endpoints::DRIVE_API_BASE, resource_id);
|
||||
|
||||
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
|
||||
.await
|
||||
self.http_client_request(
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(watch_request),
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
// All changes: use changes.watch
|
||||
let token_url = format!("{}/changes/startPageToken", endpoints::DRIVE_API_BASE);
|
||||
let token_response: serde_json::Value = self
|
||||
.http_client_request::<_, ()>(&token_url, Method::GET, w_id, db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&token_url,
|
||||
Method::GET,
|
||||
w_id,
|
||||
connection_path,
|
||||
db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let start_page_token = token_response
|
||||
@@ -313,6 +367,7 @@ impl Google {
|
||||
&watch_url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(&watch_body),
|
||||
@@ -325,6 +380,7 @@ impl Google {
|
||||
async fn create_calendar_watch(
|
||||
&self,
|
||||
w_id: &str,
|
||||
connection_path: &str,
|
||||
config: &GoogleServiceConfig,
|
||||
watch_request: &WatchRequest,
|
||||
db: &DB,
|
||||
@@ -339,8 +395,16 @@ impl Google {
|
||||
urlencoding::encode(calendar_id)
|
||||
);
|
||||
|
||||
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
|
||||
.await
|
||||
self.http_client_request(
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(watch_request),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Renew an expiring Google watch channel.
|
||||
@@ -362,6 +426,12 @@ impl Google {
|
||||
.map(|v| serde_json::from_value(v.clone()))
|
||||
.transpose()?
|
||||
.ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?;
|
||||
let connection_path = trigger.connection_path.as_deref().ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"Cannot renew channel {}: the trigger has no Google connection",
|
||||
trigger.external_id
|
||||
))
|
||||
})?;
|
||||
|
||||
let rotated = match crate::rotate_webhook_token(
|
||||
db,
|
||||
@@ -430,7 +500,15 @@ impl Google {
|
||||
}
|
||||
};
|
||||
let result: std::result::Result<serde_json::Value, _> = self
|
||||
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
|
||||
.http_client_request(
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
connection_path,
|
||||
db,
|
||||
None,
|
||||
Some(&stop_request),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
@@ -444,11 +522,11 @@ impl Google {
|
||||
// Create new watch channel with a fresh channel ID
|
||||
let resp = match config.trigger_type {
|
||||
GoogleTriggerType::Drive => {
|
||||
self.create_drive_watch(w_id, &config, &watch_request, db)
|
||||
self.create_drive_watch(w_id, connection_path, &config, &watch_request, db)
|
||||
.await?
|
||||
}
|
||||
GoogleTriggerType::Calendar => {
|
||||
self.create_calendar_watch(w_id, &config, &watch_request, db)
|
||||
self.create_calendar_watch(w_id, connection_path, &config, &watch_request, db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,6 +134,7 @@ pub struct GoogleOAuthData {
|
||||
pub refresh_token: Option<String>,
|
||||
/// When the access token expires
|
||||
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub connection_path: String,
|
||||
}
|
||||
|
||||
/// Google API endpoints
|
||||
|
||||
@@ -10,10 +10,9 @@ use serde::{Deserialize, Serialize};
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::{error::JsonResult, DB};
|
||||
|
||||
use crate::{
|
||||
get_workspace_integration, map_external_error, require_native_integration_use, External,
|
||||
ServiceName,
|
||||
};
|
||||
use windmill_common::db::UserDB;
|
||||
|
||||
use crate::{map_external_error, picker_connection, ConnectionQuery, External, ServiceName};
|
||||
|
||||
use super::Google;
|
||||
|
||||
@@ -85,16 +84,26 @@ pub struct DriveFilesQuery {
|
||||
pub page_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub shared_with_me: bool,
|
||||
pub connection_path: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_calendars(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<ConnectionQuery>,
|
||||
) -> JsonResult<Vec<GoogleCalendarEntry>> {
|
||||
require_native_integration_use(&db, &workspace_id, &authed).await?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
let connection_path = picker_connection(
|
||||
&authed,
|
||||
&db,
|
||||
user_db,
|
||||
&workspace_id,
|
||||
ServiceName::Google,
|
||||
query.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url = format!(
|
||||
"{}/users/me/calendarList",
|
||||
@@ -102,7 +111,15 @@ async fn list_calendars(
|
||||
);
|
||||
|
||||
let response: GoogleCalendarListResponse = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
&workspace_id,
|
||||
&connection_path,
|
||||
&db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_external_error)?;
|
||||
|
||||
@@ -123,11 +140,19 @@ async fn list_drive_files(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<DriveFilesQuery>,
|
||||
) -> JsonResult<GoogleDriveFilesResponse> {
|
||||
require_native_integration_use(&db, &workspace_id, &authed).await?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
let connection_path = picker_connection(
|
||||
&authed,
|
||||
&db,
|
||||
user_db,
|
||||
&workspace_id,
|
||||
ServiceName::Google,
|
||||
query.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let drive_query = if query.shared_with_me {
|
||||
"sharedWithMe = true and trashed = false".to_string()
|
||||
@@ -156,7 +181,15 @@ async fn list_drive_files(
|
||||
}
|
||||
|
||||
let response: DriveApiResponse = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
&workspace_id,
|
||||
&connection_path,
|
||||
&db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_external_error)?;
|
||||
|
||||
@@ -199,10 +232,19 @@ async fn list_shared_drives(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<ConnectionQuery>,
|
||||
) -> JsonResult<Vec<SharedDriveEntry>> {
|
||||
require_native_integration_use(&db, &workspace_id, &authed).await?;
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
let connection_path = picker_connection(
|
||||
&authed,
|
||||
&db,
|
||||
user_db,
|
||||
&workspace_id,
|
||||
ServiceName::Google,
|
||||
query.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url = format!(
|
||||
"{}/drives?pageSize=100&fields=drives(id,name)",
|
||||
@@ -210,7 +252,15 @@ async fn list_shared_drives(
|
||||
);
|
||||
|
||||
let response: SharedDrivesApiResponse = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
&workspace_id,
|
||||
&connection_path,
|
||||
&db,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_external_error)?;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::{
|
||||
classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_token_by_hash,
|
||||
get_native_trigger, list_native_triggers, lock::TriggerLock, map_external_error,
|
||||
map_external_error_with, rotate_webhook_token, set_native_trigger_enabled,
|
||||
store_native_trigger, sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error,
|
||||
get_native_trigger, list_native_triggers, list_usable_connections, lock::TriggerLock,
|
||||
lock_connection, map_external_error, map_external_error_with, resolve_usable_connection,
|
||||
rotate_webhook_token, set_native_trigger_enabled, store_native_trigger,
|
||||
sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error,
|
||||
update_native_trigger_if_runnable_unchanged, webhook_token_label, webhook_token_scopes,
|
||||
External, ExternalReadFailure, NativeTrigger, NativeTriggerConfig, NativeTriggerData,
|
||||
ServiceName,
|
||||
@@ -187,9 +188,21 @@ async fn create_native_trigger<T: External>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
let connection_path = resolve_usable_connection(
|
||||
&db,
|
||||
&mut tx,
|
||||
&workspace_id,
|
||||
service_name.integration_service(),
|
||||
data.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
||||
&db,
|
||||
&workspace_id,
|
||||
service_name.integration_service(),
|
||||
Some(&connection_path),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resp = handler
|
||||
.create(
|
||||
@@ -232,6 +245,23 @@ async fn create_native_trigger<T: External>(
|
||||
webhook_token,
|
||||
};
|
||||
|
||||
lock_connection(&mut tx, &workspace_id, &connection_path).await?;
|
||||
// Through `db`: a member using the workspace connection cannot read its variable.
|
||||
let still_connected = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
|
||||
workspace_id,
|
||||
connection_path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !still_connected {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{connection_path} was disconnected while this trigger was being created. Its \
|
||||
registration on {service_name} may remain; connect an account and create it again."
|
||||
)));
|
||||
}
|
||||
|
||||
store_native_trigger(
|
||||
&mut *tx,
|
||||
&workspace_id,
|
||||
@@ -241,6 +271,7 @@ async fn create_native_trigger<T: External>(
|
||||
service_config,
|
||||
data.summary.as_deref(),
|
||||
data.enabled,
|
||||
&connection_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -282,10 +313,6 @@ async fn update_native_trigger_handler<T: External>(
|
||||
.await?;
|
||||
require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?;
|
||||
|
||||
let integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
|
||||
let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?;
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
@@ -294,6 +321,21 @@ async fn update_native_trigger_handler<T: External>(
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
|
||||
|
||||
// Saving re-registers the webhook as the connection's account, so the editor must be allowed
|
||||
// to act as it, not just to write the runnable.
|
||||
let integration_service = service_name.integration_service();
|
||||
if let Some(path) = existing.connection_path.as_deref() {
|
||||
resolve_usable_connection(&db, &mut tx, &workspace_id, integration_service, Some(path))
|
||||
.await?;
|
||||
}
|
||||
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
||||
&db,
|
||||
&workspace_id,
|
||||
integration_service,
|
||||
existing.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let runnable_changed =
|
||||
existing.script_path != data.script_path || existing.is_flow != data.is_flow;
|
||||
|
||||
@@ -446,9 +488,38 @@ async fn get_native_trigger_handler<T: External>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
// Reading the registration acts as the connection's account, like saving does.
|
||||
let usable = match windmill_trigger.connection_path.as_deref() {
|
||||
Some(path) => list_usable_connections(
|
||||
&db,
|
||||
&mut tx,
|
||||
&workspace_id,
|
||||
service_name.integration_service(),
|
||||
)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|c| c.path == path),
|
||||
None => false,
|
||||
};
|
||||
if !usable {
|
||||
tx.commit().await?;
|
||||
return Ok(Json(FullTriggerResponse {
|
||||
windmill_data: windmill_trigger,
|
||||
external_data: None,
|
||||
external_error: Some(format!(
|
||||
"You cannot use this trigger's {service_name} connection, so its registration was \
|
||||
not read."
|
||||
)),
|
||||
}));
|
||||
}
|
||||
|
||||
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
||||
&db,
|
||||
&workspace_id,
|
||||
service_name.integration_service(),
|
||||
windmill_trigger.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let native_trigger = handler
|
||||
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
|
||||
@@ -554,23 +625,35 @@ async fn delete_native_trigger_handler<T: External>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
|
||||
handler
|
||||
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
map_external_error_with(e, |m| {
|
||||
let end = if m.ends_with(['.', '!', '?']) {
|
||||
""
|
||||
} else {
|
||||
"."
|
||||
};
|
||||
format!("{m}{end} The trigger was kept in Windmill, so it can still fire.")
|
||||
})
|
||||
})?;
|
||||
// A connection can be deleted from under its triggers. Without it the registration cannot be
|
||||
// removed, but deleting the row still revokes the webhook token, so the service's deliveries
|
||||
// stop starting jobs; refusing would leave a trigger nobody can remove.
|
||||
match decrypt_oauth_data::<T::OAuthData>(
|
||||
&db,
|
||||
&workspace_id,
|
||||
service_name.integration_service(),
|
||||
existing.connection_path.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(oauth_data) => handler
|
||||
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
map_external_error_with(e, |m| {
|
||||
let end = if m.ends_with(['.', '!', '?']) {
|
||||
""
|
||||
} else {
|
||||
"."
|
||||
};
|
||||
format!("{m}{end} The trigger was kept in Windmill, so it can still fire.")
|
||||
})
|
||||
})?,
|
||||
Err(e @ (Error::NotFound(_) | Error::BadRequest(_))) => tracing::warn!(
|
||||
"Deleting {service_name} trigger {external_id} without removing its registration: {e}"
|
||||
),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
let deleted =
|
||||
delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?;
|
||||
|
||||
@@ -45,6 +45,7 @@ use std::{collections::HashMap, fmt::Debug};
|
||||
use strum::{EnumIter, IntoEnumIterator};
|
||||
use tokio::task;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{to_anyhow, Error, Result},
|
||||
triggers::TriggerKind,
|
||||
utils::HTTP_CLIENT,
|
||||
@@ -230,6 +231,9 @@ pub struct NativeTrigger {
|
||||
/// its initial value and `setenabled` is its only mutator afterwards, so saving a
|
||||
/// configuration can never silently re-enable a trigger someone paused.
|
||||
pub enabled: bool,
|
||||
/// Path of the connection (the OAuth resource created by the connect flow) the trigger acts
|
||||
/// through on the external service.
|
||||
pub connection_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -253,6 +257,11 @@ pub struct NativeTriggerData<C> {
|
||||
/// An update ignores it: `setenabled` is the only way to change an existing trigger's state.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Honoured on create only: the webhook is registered under this connection's account, so
|
||||
/// switching it would leave the registration owned by an account the trigger no longer uses.
|
||||
/// When omitted, the caller's only readable connection for the service is used.
|
||||
#[serde(default)]
|
||||
pub connection_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
||||
@@ -260,7 +269,6 @@ pub struct WorkspaceIntegration {
|
||||
pub workspace_id: String,
|
||||
pub service_name: ServiceName,
|
||||
pub oauth_data: serde_json::Value,
|
||||
pub resource_path: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_by: String,
|
||||
@@ -399,12 +407,18 @@ pub trait External: Send + Sync + 'static {
|
||||
url: &str,
|
||||
method: Method,
|
||||
workspace_id: &str,
|
||||
connection_path: &str,
|
||||
db: &DB,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<&B>,
|
||||
) -> Result<T> {
|
||||
let oauth_config: OAuthConfig =
|
||||
decrypt_oauth_data(db, workspace_id, Self::SERVICE_NAME).await?;
|
||||
let oauth_config: OAuthConfig = decrypt_oauth_data(
|
||||
db,
|
||||
workspace_id,
|
||||
Self::SERVICE_NAME.integration_service(),
|
||||
Some(connection_path),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = make_http_request(
|
||||
url,
|
||||
@@ -446,14 +460,14 @@ pub trait External: Send + Sync + 'static {
|
||||
task::spawn({
|
||||
let db_clone = db.clone();
|
||||
let workspace_id_clone = workspace_id.to_string();
|
||||
let service_name = Self::SERVICE_NAME;
|
||||
let account_id = refreshed_oauth_config.account_id;
|
||||
let new_access_token = refreshed_oauth_config.access_token.clone();
|
||||
let new_refresh_token = refreshed_oauth_config.refresh_token.clone();
|
||||
async move {
|
||||
update_oauth_token_resource(
|
||||
&db_clone,
|
||||
&workspace_id_clone,
|
||||
service_name,
|
||||
account_id,
|
||||
&new_access_token,
|
||||
new_refresh_token.as_deref(),
|
||||
)
|
||||
@@ -594,6 +608,8 @@ pub struct OAuthConfig {
|
||||
pub refresh_token: Option<String>,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub connection_path: String,
|
||||
pub account_id: i32,
|
||||
}
|
||||
|
||||
pub async fn make_http_request<T: DeserializeOwned + Send, B: Serialize>(
|
||||
@@ -776,51 +792,50 @@ async fn get_instance_oauth_credentials(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Assemble the credentials of one connection: its token from the variable and account at
|
||||
/// `connection_path`, and the OAuth client from the workspace (or instance) configuration.
|
||||
///
|
||||
/// Only an account made by the connect flow is accepted, since token refresh goes through this
|
||||
/// service's OAuth client and any other account would be refreshed against the wrong one.
|
||||
pub async fn decrypt_oauth_data<T: DeserializeOwned>(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
connection_path: Option<&str>,
|
||||
) -> Result<T> {
|
||||
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
|
||||
let oauth_data = integration.oauth_data;
|
||||
|
||||
let resource_path = integration.resource_path.as_deref().ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"No resource_path in {} integration config. Please reconnect the integration.",
|
||||
service_name
|
||||
let connection_path = connection_path.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"This trigger has no {service_name} connection. Delete it and create it again with a connection."
|
||||
))
|
||||
})?;
|
||||
|
||||
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
|
||||
let oauth_data = integration.oauth_data;
|
||||
|
||||
let mc = build_crypt(db, workspace_id).await?;
|
||||
|
||||
let var_row = sqlx::query!(
|
||||
"SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
|
||||
let row = sqlx::query!(
|
||||
"SELECT v.value, a.id AS account_id, a.refresh_token
|
||||
FROM variable v
|
||||
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
|
||||
WHERE v.workspace_id = $1 AND v.path = $2
|
||||
AND a.client = $3 AND a.is_workspace_integration = true",
|
||||
workspace_id,
|
||||
resource_path,
|
||||
connection_path,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"Variable at {} not found for {} integration",
|
||||
resource_path, service_name
|
||||
Error::NotFound(format!(
|
||||
"No {service_name} connection at {connection_path}. Connect an account there or pick another connection."
|
||||
))
|
||||
})?;
|
||||
|
||||
let access_token = decrypt(&mc, var_row.value)
|
||||
let access_token = decrypt(&mc, row.value)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?;
|
||||
|
||||
let refresh_token = if let Some(account_id) = var_row.account {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
workspace_id,
|
||||
account_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let refresh_token = Some(row.refresh_token);
|
||||
|
||||
let (client_id, client_secret) = if oauth_data
|
||||
.get("instance_shared")
|
||||
@@ -853,6 +868,8 @@ pub async fn decrypt_oauth_data<T: DeserializeOwned>(
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"connection_path": connection_path,
|
||||
"account_id": row.account_id,
|
||||
});
|
||||
|
||||
serde_json::from_value(assembled)
|
||||
@@ -978,6 +995,8 @@ pub async fn refresh_oauth_tokens(
|
||||
.or_else(|| oauth_config.refresh_token.clone()),
|
||||
client_id: oauth_config.client_id.clone(),
|
||||
client_secret: oauth_config.client_secret.clone(),
|
||||
connection_path: oauth_config.connection_path.clone(),
|
||||
account_id: oauth_config.account_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -994,61 +1013,48 @@ pub async fn refresh_oauth_tokens(
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_oauth_token_resource(
|
||||
/// Write a refreshed token back to the account it was refreshed from, by that account's id, never
|
||||
/// by service or path: several connections share a service, and a connection's path can be
|
||||
/// reconnected or renamed while the refresh is in flight. A provider that rotates refresh tokens
|
||||
/// invalidates the old one, so writing it onto another account would break that account's next
|
||||
/// refresh.
|
||||
///
|
||||
/// No authorization happens here: it overwrites the account's secrets as the server. Call it only
|
||||
/// with tokens just refreshed from that account's own refresh token.
|
||||
pub async fn update_oauth_token_resource(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
account_id: i32,
|
||||
new_access_token: &str,
|
||||
new_refresh_token: Option<&str>,
|
||||
) {
|
||||
let result = async {
|
||||
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
|
||||
let resource_path = integration.resource_path.ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"No resource_path in {} integration config",
|
||||
service_name
|
||||
))
|
||||
})?;
|
||||
|
||||
let mc = build_crypt(db, workspace_id).await?;
|
||||
let encrypted_token = encrypt(&mc, new_access_token);
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND account = $3",
|
||||
encrypted_token,
|
||||
workspace_id,
|
||||
resource_path,
|
||||
account_id,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
if let Some(refresh_token) = new_refresh_token {
|
||||
sqlx::query!(
|
||||
"UPDATE account SET
|
||||
refresh_token = $1,
|
||||
expires_at = now() + interval '1 hour',
|
||||
refresh_error = NULL
|
||||
WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
|
||||
refresh_token,
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
} else {
|
||||
// Even without a new refresh token, update expires_at to prevent
|
||||
// the background refresh from re-refreshing immediately
|
||||
sqlx::query!(
|
||||
"UPDATE account SET
|
||||
expires_at = now() + interval '1 hour',
|
||||
refresh_error = NULL
|
||||
WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
// Even without a new refresh token, expires_at moves so the background refresh does not
|
||||
// re-refresh immediately.
|
||||
sqlx::query!(
|
||||
"UPDATE account SET
|
||||
refresh_token = COALESCE($1, refresh_token),
|
||||
expires_at = now() + interval '1 hour',
|
||||
refresh_error = NULL
|
||||
WHERE workspace_id = $2 AND id = $3",
|
||||
new_refresh_token,
|
||||
workspace_id,
|
||||
account_id,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
}
|
||||
@@ -1056,8 +1062,8 @@ async fn update_oauth_token_resource(
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::error!(
|
||||
"Failed to update OAuth tokens for {} in workspace {}: {}",
|
||||
service_name,
|
||||
"Failed to update OAuth tokens of account {} in workspace {}: {}",
|
||||
account_id,
|
||||
workspace_id,
|
||||
e
|
||||
);
|
||||
@@ -1183,6 +1189,8 @@ pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Callers must hold [`lock_connection`] on `connection_path` in the same transaction and check
|
||||
/// the connection still exists, or a concurrent disconnect can leave this trigger stranded.
|
||||
pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>, C: Serialize>(
|
||||
db: E,
|
||||
workspace_id: &str,
|
||||
@@ -1192,6 +1200,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
service_config: C,
|
||||
summary: Option<&str>,
|
||||
enabled: bool,
|
||||
connection_path: &str,
|
||||
) -> Result<()> {
|
||||
use windmill_common::auth::hash_token;
|
||||
|
||||
@@ -1211,12 +1220,13 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
webhook_token_hash,
|
||||
service_config,
|
||||
summary,
|
||||
enabled
|
||||
enabled,
|
||||
connection_path
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
)
|
||||
ON CONFLICT (external_id, workspace_id, service_name)
|
||||
DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()
|
||||
DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, connection_path = $10, error = NULL, updated_at = NOW()
|
||||
"#,
|
||||
external_id,
|
||||
workspace_id,
|
||||
@@ -1227,6 +1237,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
sqlx::types::Json(service_config) as _,
|
||||
summary,
|
||||
enabled,
|
||||
connection_path,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
@@ -1424,7 +1435,8 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
created_at,
|
||||
updated_at,
|
||||
summary,
|
||||
enabled
|
||||
enabled,
|
||||
connection_path
|
||||
FROM
|
||||
native_trigger
|
||||
WHERE
|
||||
@@ -1464,7 +1476,8 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P
|
||||
created_at,
|
||||
updated_at,
|
||||
summary,
|
||||
enabled
|
||||
enabled,
|
||||
connection_path
|
||||
FROM
|
||||
native_trigger
|
||||
WHERE
|
||||
@@ -1520,7 +1533,8 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>
|
||||
nt.created_at,
|
||||
nt.updated_at,
|
||||
nt.summary,
|
||||
nt.enabled
|
||||
nt.enabled,
|
||||
nt.connection_path
|
||||
FROM
|
||||
native_trigger nt
|
||||
WHERE
|
||||
@@ -1699,7 +1713,6 @@ pub async fn store_workspace_integration(
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
oauth_data: serde_json::Value,
|
||||
resource_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -1707,23 +1720,20 @@ pub async fn store_workspace_integration(
|
||||
workspace_id,
|
||||
service_name,
|
||||
oauth_data,
|
||||
resource_path,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, now(), now()
|
||||
$1, $2, $3, $4, now(), now()
|
||||
)
|
||||
ON CONFLICT (workspace_id, service_name)
|
||||
DO UPDATE SET
|
||||
oauth_data = $3,
|
||||
resource_path = $4,
|
||||
updated_at = now()
|
||||
"#,
|
||||
workspace_id,
|
||||
service_name as ServiceName,
|
||||
oauth_data,
|
||||
resource_path,
|
||||
authed.username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
@@ -1732,10 +1742,11 @@ pub async fn store_workspace_integration(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorization gate for the integration *use* routes (calendar/drive/repo/event
|
||||
/// pickers). They drive the admin-connected account's upstream API and enumerate its data, so
|
||||
/// they need both the right to manage triggers and the right to write code: a role that only
|
||||
/// manages triggers (the default `operator`) must not browse the admin's Drive or repos.
|
||||
/// Authorization gate for the integration *use* routes (connecting an account, and the
|
||||
/// calendar/drive/repo/event pickers). They drive a connected account's upstream API and
|
||||
/// enumerate its data, so they need both the right to manage triggers and the right to write
|
||||
/// code: a role that only manages triggers (the default `operator`) must not browse a shared
|
||||
/// account's Drive or repos.
|
||||
pub async fn require_native_integration_use(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result<()> {
|
||||
let caps = windmill_common::roles::caller_capabilities(db, w_id, authed).await?;
|
||||
if caps.code && caps.manage_triggers {
|
||||
@@ -1747,6 +1758,141 @@ pub async fn require_native_integration_use(db: &DB, w_id: &str, authed: &ApiAut
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Connection {
|
||||
pub path: String,
|
||||
/// Username of whoever connected the account, when the caller can read its resource.
|
||||
pub owner: Option<String>,
|
||||
/// The workspace connection, which every member may use.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
/// The connections of a service the caller may use. `tx` must be a user transaction: using a
|
||||
/// connection means acting as the account behind it, so it is gated on reading its token
|
||||
/// variable, which the variable's row-level security decides. The one exception is the
|
||||
/// workspace connection an admin shared, read through `db` since members cannot read its token.
|
||||
pub async fn list_usable_connections(
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
) -> Result<Vec<Connection>> {
|
||||
let mut connections = sqlx::query_as!(
|
||||
Connection,
|
||||
r#"SELECT v.path, r.created_by AS "owner?", false AS "workspace!"
|
||||
FROM variable v
|
||||
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
|
||||
LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path
|
||||
WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true
|
||||
ORDER BY v.path"#,
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(shared) = workspace_connection(db, workspace_id, service_name).await? {
|
||||
connections.retain(|c| c.path != shared.path);
|
||||
connections.insert(0, shared);
|
||||
}
|
||||
Ok(connections)
|
||||
}
|
||||
|
||||
/// The connection an admin shared with the whole workspace, if it still exists.
|
||||
pub async fn workspace_connection(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
) -> Result<Option<Connection>> {
|
||||
let connection = sqlx::query_as!(
|
||||
Connection,
|
||||
r#"SELECT v.path, r.created_by AS "owner?", true AS "workspace!"
|
||||
FROM workspace_integrations wi
|
||||
JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path
|
||||
JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account
|
||||
LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path
|
||||
WHERE wi.workspace_id = $1 AND wi.service_name = $2
|
||||
AND a.client = $3 AND a.is_workspace_integration = true"#,
|
||||
workspace_id,
|
||||
service_name as ServiceName,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Pick the connection a new trigger acts through: the one asked for if the caller may use it,
|
||||
/// otherwise the shared account, otherwise the caller's only usable connection. Clients and
|
||||
/// trigger specs that predate connections send none and expect the shared account, so it wins
|
||||
/// over a lone personal one. `tx` must be a user transaction.
|
||||
pub async fn resolve_usable_connection(
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
requested: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let usable = list_usable_connections(db, tx, workspace_id, service_name).await?;
|
||||
match requested {
|
||||
Some(path) if usable.iter().any(|c| c.path == path) => Ok(path.to_string()),
|
||||
Some(path) => Err(Error::NotAuthorized(format!(
|
||||
"No {service_name} connection you can use at {path}. Connect an account at that \
|
||||
path, or ask for read access to the connection there."
|
||||
))),
|
||||
None if usable.first().is_some_and(|c| c.workspace) => Ok(usable[0].path.clone()),
|
||||
None => match usable.as_slice() {
|
||||
[only] => Ok(only.path.clone()),
|
||||
[] => Err(Error::BadRequest(format!(
|
||||
"You have no {service_name} connection. Connect your account first."
|
||||
))),
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"You can use several {service_name} connections; pick one with connection_path."
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize, until `tx` ends, the operations that must agree on whether a connection exists:
|
||||
/// storing a trigger that uses it and disconnecting it. Whichever comes second waits and then
|
||||
/// sees the other's outcome.
|
||||
pub async fn lock_connection(
|
||||
tx: &mut PgConnection,
|
||||
workspace_id: &str,
|
||||
connection_path: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
||||
.bind(format!(
|
||||
"native_connection:{workspace_id}:{connection_path}"
|
||||
))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConnectionQuery {
|
||||
pub connection_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Gate for the pickers that browse the service while configuring a trigger: returns the
|
||||
/// connection to browse through, which the caller must be allowed to use.
|
||||
pub async fn picker_connection(
|
||||
authed: &ApiAuthed,
|
||||
db: &DB,
|
||||
user_db: UserDB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
requested: Option<&str>,
|
||||
) -> Result<String> {
|
||||
require_native_integration_use(db, workspace_id, authed).await?;
|
||||
let mut tx = user_db.begin(authed).await?;
|
||||
let path =
|
||||
resolve_usable_connection(db, &mut tx, workspace_id, service_name, requested).await?;
|
||||
tx.commit().await?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
db: E,
|
||||
workspace_id: &str,
|
||||
@@ -1759,7 +1905,6 @@ pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Post
|
||||
workspace_id,
|
||||
service_name AS "service_name!: ServiceName",
|
||||
oauth_data,
|
||||
resource_path,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by
|
||||
|
||||
@@ -131,11 +131,25 @@ impl External for NextCloud {
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
Some(headers),
|
||||
Some(&full_nextcloud_payload),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Nextcloud lets only its admins register webhooks.
|
||||
if http_error_status(&e) == Some(StatusCode::FORBIDDEN) {
|
||||
Error::BadRequest(
|
||||
"This Nextcloud account isn't a Nextcloud admin, so it can't register \
|
||||
webhooks. Use the shared account, or ask a workspace admin to share a \
|
||||
Nextcloud admin account."
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(ocs_response.ocs.data)
|
||||
}
|
||||
@@ -167,6 +181,7 @@ impl External for NextCloud {
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
Some(headers),
|
||||
Some(&full_nextcloud_payload),
|
||||
@@ -214,7 +229,15 @@ impl External for NextCloud {
|
||||
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
|
||||
|
||||
let ocs_response: OcsResponse<NextCloudTriggerData> = self
|
||||
.http_client_request::<_, ()>(&url, Method::GET, w_id, db, Some(headers), None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
Some(headers),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Some(ocs_response.ocs.data))
|
||||
@@ -239,7 +262,15 @@ impl External for NextCloud {
|
||||
// A webhook already removed in Nextcloud is the outcome this call wants, so only 404 is
|
||||
// swallowed; anything else would leave a live webhook behind while Windmill forgets it.
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, Some(headers), None)
|
||||
.http_client_request::<_, ()>(
|
||||
&url,
|
||||
Method::DELETE,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
Some(headers),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.or_else(|e| match http_error_status(&e) {
|
||||
Some(StatusCode::NOT_FOUND) => Ok(serde_json::Value::Null),
|
||||
@@ -320,13 +351,13 @@ impl External for NextCloud {
|
||||
fn error_hint(&self, status: StatusCode) -> Option<&'static str> {
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
Some("reconnect the Nextcloud integration from Workspace settings > Integrations.")
|
||||
Some("reconnect the Nextcloud account this uses with Reconnect next to it, or ask its owner or an admin to.")
|
||||
}
|
||||
StatusCode::FORBIDDEN => Some(
|
||||
"Nextcloud grants webhook management to administrators only, and a Windmill \
|
||||
admin is a different thing. If the connected Nextcloud account is not an admin \
|
||||
there and holds no delegated admin rights for the Webhooks setting, reconnect \
|
||||
the integration from Workspace settings > Integrations with one that does.",
|
||||
there and holds no delegated admin rights for the Webhooks setting, use one that \
|
||||
does, such as the workspace's shared account.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
@@ -353,6 +384,7 @@ impl NextCloud {
|
||||
&url,
|
||||
Method::GET,
|
||||
w_id,
|
||||
&oauth_data.connection_path,
|
||||
db,
|
||||
Some(headers),
|
||||
None,
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct NextCloudOAuthData {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub connection_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use axum::{extract::Path, routing::get, Extension, Json, Router};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::get,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use http::Method;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult},
|
||||
DB,
|
||||
};
|
||||
@@ -12,16 +17,26 @@ use windmill_api_auth::ApiAuthed;
|
||||
use crate::{
|
||||
get_workspace_integration, map_external_error,
|
||||
nextcloud::{NextCloudEventType, OcsResponse},
|
||||
require_native_integration_use, External, ServiceName,
|
||||
picker_connection, ConnectionQuery, External, ServiceName,
|
||||
};
|
||||
|
||||
async fn list_available_events<T: External>(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<T>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<ConnectionQuery>,
|
||||
) -> JsonResult<Vec<NextCloudEventType>> {
|
||||
require_native_integration_use(&db, &workspace_id, &authed).await?;
|
||||
let connection_path = picker_connection(
|
||||
&authed,
|
||||
&db,
|
||||
user_db,
|
||||
&workspace_id,
|
||||
ServiceName::Nextcloud,
|
||||
query.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?;
|
||||
|
||||
let base_url = integration
|
||||
@@ -43,6 +58,7 @@ async fn list_available_events<T: External>(
|
||||
&url,
|
||||
Method::GET,
|
||||
&workspace_id,
|
||||
&connection_path,
|
||||
&db,
|
||||
Some(headers),
|
||||
None,
|
||||
|
||||
@@ -69,19 +69,8 @@ async fn reregister_service<T: External>(
|
||||
moved: &[&MovedNativeTrigger],
|
||||
handler: T,
|
||||
) {
|
||||
let oauth_data: T::OAuthData =
|
||||
match decrypt_oauth_data(db, w_id, T::SERVICE_NAME.integration_service()).await {
|
||||
Ok(oauth_data) => oauth_data,
|
||||
Err(e) => {
|
||||
for trigger in moved {
|
||||
record_failure::<T>(db, w_id, &trigger.external_id, &e).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for trigger in moved {
|
||||
if let Err(e) = reregister_one(db, authed, w_id, &handler, &oauth_data, trigger).await {
|
||||
if let Err(e) = reregister_one(db, authed, w_id, &handler, trigger).await {
|
||||
record_failure::<T>(db, w_id, &trigger.external_id, &e).await;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +88,6 @@ async fn reregister_one<T: External>(
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
handler: &T,
|
||||
oauth_data: &T::OAuthData,
|
||||
moved: &MovedNativeTrigger,
|
||||
) -> Result<()> {
|
||||
let external_id = moved.external_id.as_str();
|
||||
@@ -136,8 +124,17 @@ async fn reregister_one<T: External>(
|
||||
service_config,
|
||||
summary: trigger.summary.clone(),
|
||||
enabled: trigger.enabled,
|
||||
connection_path: trigger.connection_path.clone(),
|
||||
};
|
||||
|
||||
let oauth_data: T::OAuthData = decrypt_oauth_data(
|
||||
db,
|
||||
w_id,
|
||||
T::SERVICE_NAME.integration_service(),
|
||||
trigger.connection_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The token is scoped to the runnable path and only its hash is kept, so pointing the webhook
|
||||
// at the new path means minting a replacement rather than reusing the old one. Commit it
|
||||
// before handing it out: a service may call back the moment it accepts the new URL.
|
||||
@@ -157,7 +154,7 @@ async fn reregister_one<T: External>(
|
||||
let updated = handler
|
||||
.update(
|
||||
w_id,
|
||||
oauth_data,
|
||||
&oauth_data,
|
||||
external_id,
|
||||
&webhook_token,
|
||||
&data,
|
||||
|
||||
@@ -6,6 +6,9 @@ use serde::Serialize;
|
||||
|
||||
use crate::ServiceName;
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use itertools::Itertools;
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use crate::{
|
||||
decrypt_oauth_data, list_native_triggers, update_native_trigger_error,
|
||||
@@ -238,38 +241,50 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
let mut synced = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Use the integration service for OAuth lookup (e.g., GoogleDrive/GoogleCalendar -> Google)
|
||||
let integration_service = T::SERVICE_NAME.integration_service();
|
||||
// Maintenance runs per connection: each one's triggers are registered under its account, and
|
||||
// Nextcloud reconciles against that account's own list of webhooks.
|
||||
let by_connection = windmill_triggers
|
||||
.into_iter()
|
||||
.into_group_map_by(|t| t.connection_path.clone());
|
||||
|
||||
let oauth_data = {
|
||||
match decrypt_oauth_data(db, workspace_id, integration_service).await {
|
||||
for (connection_path, triggers) in by_connection {
|
||||
let oauth_data = match decrypt_oauth_data(
|
||||
db,
|
||||
workspace_id,
|
||||
T::SERVICE_NAME.integration_service(),
|
||||
connection_path.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(oauth_data) => oauth_data,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to get workspace integration OAuth data for {}: {}",
|
||||
"Failed to get the OAuth data of connection {:?} in {}: {}",
|
||||
connection_path,
|
||||
workspace_id,
|
||||
e
|
||||
);
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!("Failed to get workspace integration OAuth data: {}", e),
|
||||
resource_path: connection_path
|
||||
.unwrap_or_else(|| format!("workspace:{}", workspace_id)),
|
||||
error_message: format!("Failed to get connection OAuth data: {}", e),
|
||||
error_type: "oauth_error".to_string(),
|
||||
});
|
||||
return Ok((Vec::new(), errors));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
handler
|
||||
.maintain_triggers(
|
||||
db,
|
||||
workspace_id,
|
||||
&windmill_triggers,
|
||||
&oauth_data,
|
||||
&mut synced,
|
||||
&mut errors,
|
||||
)
|
||||
.await;
|
||||
handler
|
||||
.maintain_triggers(
|
||||
db,
|
||||
workspace_id,
|
||||
&triggers,
|
||||
&oauth_data,
|
||||
&mut synced,
|
||||
&mut errors,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2224,6 +2224,15 @@ async fn update_resource(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3",
|
||||
npath,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
|
||||
npath,
|
||||
|
||||
@@ -1296,6 +1296,15 @@ async fn update_variable(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3",
|
||||
npath,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
|
||||
npath,
|
||||
|
||||
@@ -281,6 +281,8 @@ export async function pushNativeTrigger(
|
||||
is_flow: localTrigger.is_flow,
|
||||
service_config: localTrigger.service_config,
|
||||
summary: localTrigger.summary,
|
||||
// Used on create only; an update keeps the connection the webhook was registered under.
|
||||
connection_path: localTrigger.connection_path,
|
||||
};
|
||||
|
||||
if (remoteTrigger) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceIntegrationService } from '$lib/gen'
|
||||
import type { NativeServiceName, NativeTriggerConnection } from '$lib/gen/types.gen'
|
||||
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, RefreshCw, Unplug } from 'lucide-svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { connectNativeAccount, getServiceConfig } from './utils'
|
||||
|
||||
interface Props {
|
||||
service: NativeServiceName
|
||||
value: string | undefined
|
||||
/** An existing trigger keeps the connection its webhook was registered under. */
|
||||
locked?: boolean
|
||||
disabled?: boolean
|
||||
/** Select the first connection when none is chosen yet. */
|
||||
autoSelect?: boolean
|
||||
clearable?: boolean
|
||||
/** Suffix the workspace connection's label, where it sits among personal ones. */
|
||||
labelWorkspace?: boolean
|
||||
placeholder?: string
|
||||
/** Called instead of setting `value` directly when the user picks or clears a connection. */
|
||||
onSelect?: (path: string | undefined) => void
|
||||
/** Called after an account is connected or disconnected from the picker. */
|
||||
onConnectionsChange?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
service,
|
||||
value = $bindable(),
|
||||
locked = false,
|
||||
disabled = false,
|
||||
autoSelect = true,
|
||||
clearable = false,
|
||||
labelWorkspace = true,
|
||||
placeholder,
|
||||
onSelect,
|
||||
onConnectionsChange
|
||||
}: Props = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service)
|
||||
const confirmationModal = createAsyncConfirmationModal()
|
||||
|
||||
let connections = $state<NativeTriggerConnection[] | undefined>(undefined)
|
||||
let connecting = $state(false)
|
||||
let connectDrawer: AppConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
connections = await WorkspaceIntegrationService.listNativeTriggerConnections({
|
||||
workspace: $operatingWorkspace!,
|
||||
serviceName: service
|
||||
})
|
||||
} catch (err: any) {
|
||||
connections = []
|
||||
sendUserToast(`Failed to load ${displayName} connections: ${err.body ?? err.message}`, true)
|
||||
}
|
||||
if (autoSelect && !locked && value === undefined && connections.length > 0) {
|
||||
select(connections[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
service
|
||||
$operatingWorkspace
|
||||
load()
|
||||
})
|
||||
|
||||
function select(path: string | undefined) {
|
||||
if (onSelect) onSelect(path)
|
||||
else value = path
|
||||
}
|
||||
|
||||
async function connect(path: string) {
|
||||
connecting = true
|
||||
try {
|
||||
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}?`,
|
||||
confirmationText: 'Disconnect',
|
||||
children: `This deletes every ${displayName} trigger that uses this account, including ones other members created. This action cannot be undone.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await WorkspaceIntegrationService.deleteNativeTriggerConnection({
|
||||
workspace: $operatingWorkspace!,
|
||||
serviceName: service,
|
||||
path
|
||||
})
|
||||
sendUserToast(`${path} disconnected`)
|
||||
if (value === path) select(undefined)
|
||||
await load()
|
||||
onConnectionsChange?.()
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to disconnect ${path}: ${err.body ?? err.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Members cannot write the workspace connection; the server refuses anyone else it does not own.
|
||||
function canDisconnect(c: NativeTriggerConnection): boolean {
|
||||
if ($userStore?.is_admin) return true
|
||||
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,
|
||||
value: c.path
|
||||
}))
|
||||
if (value && !list.some((i) => i.value === value)) {
|
||||
list.push({ label: value, value })
|
||||
}
|
||||
return list
|
||||
})
|
||||
const unusable = $derived(
|
||||
connections !== undefined && !!value && !connections.some((c) => c.path === value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Select
|
||||
{items}
|
||||
bind:value={() => value, (v) => select(v)}
|
||||
onClear={() => select(undefined)}
|
||||
{clearable}
|
||||
disabled={disabled || locked}
|
||||
loading={connections === undefined || connecting}
|
||||
placeholder={placeholder ?? `Connect a ${displayName} account to create this trigger`}
|
||||
noItemsMsg="No {displayName} account connected yet"
|
||||
>
|
||||
{#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"
|
||||
variant="subtle"
|
||||
destructive
|
||||
startIcon={{ icon: Unplug }}
|
||||
onclick={() => (close(), disconnect(connection.path))}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet bottomSnippet({ close })}
|
||||
<div class="flex bg-surface border-t">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
wrapperClasses="flex-1"
|
||||
btnClasses="rounded-none"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={disabled || connecting}
|
||||
onclick={() => (close(), connectDrawer?.open(getServiceConfig(service)?.resourceType))}
|
||||
>
|
||||
Connect a {displayName} account
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Select>
|
||||
{#if locked}
|
||||
{@const current = connections?.find((c) => c.path === value)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-2xs text-secondary">
|
||||
The webhook is registered under this account. To use another one, create a new trigger.
|
||||
</p>
|
||||
{#if current && canReconnect(current)}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
disabled={connecting}
|
||||
onclick={() => reconnect(current)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if unusable}
|
||||
<Alert type="warning" title="You cannot use this connection" size="xs">
|
||||
Saving acts as the account at {value}. Ask its owner for read access to it, or create a new
|
||||
trigger with your own account.
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AppConnectDrawer
|
||||
bind:this={connectDrawer}
|
||||
native={{
|
||||
title: `Connect a ${displayName} account`,
|
||||
defaultName: `native_${service}`,
|
||||
connect
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { WorkspaceIntegrationService } from '$lib/gen'
|
||||
import type { NativeServiceName } from '$lib/gen/types.gen'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { postNativeOAuthResult } from './utils'
|
||||
|
||||
interface Props {
|
||||
/** The exact redirect URI the authorization request was made with. */
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
let { redirectUri }: Props = $props()
|
||||
|
||||
let outcome = $state<{ ok: true; path: string } | { ok: false; error: string } | undefined>()
|
||||
|
||||
// The signed state carries the workspace and service it was issued for, so this page works
|
||||
// whatever workspace is selected and wherever the provider sends the user back.
|
||||
function decodeState(state: string): { workspace: string; service: NativeServiceName } {
|
||||
const payload = atob(state.split(':')[0].replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const [workspace, service] = payload.split(':')
|
||||
return { workspace, service: service as NativeServiceName }
|
||||
}
|
||||
|
||||
async function finish(): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
|
||||
const error = page.url.searchParams.get('error')
|
||||
const code = page.url.searchParams.get('code')
|
||||
const state = page.url.searchParams.get('state')
|
||||
if (error || !code || !state) {
|
||||
return { ok: false, error: error ?? 'The provider returned no authorization code' }
|
||||
}
|
||||
try {
|
||||
const { workspace, service } = decodeState(state)
|
||||
const path = await WorkspaceIntegrationService.nativeTriggerServiceCallback({
|
||||
workspace,
|
||||
serviceName: service,
|
||||
requestBody: { code, state, redirect_uri: redirectUri }
|
||||
})
|
||||
return { ok: true, path }
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err.body ?? err.message ?? String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
outcome = await finish()
|
||||
postNativeOAuthResult(outcome)
|
||||
if (window.opener) {
|
||||
window.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="max-w-lg mx-auto mt-16 px-4">
|
||||
{#if outcome === undefined}
|
||||
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Loader2 class="animate-spin" size={16} />
|
||||
Finishing the connection...
|
||||
</div>
|
||||
{:else if outcome.ok}
|
||||
<Alert type="success" title="Account connected">
|
||||
Saved as {outcome.path}. You can close this window.
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert type="error" title="Could not connect the account">{outcome.error}</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,8 +6,10 @@
|
||||
validateCommonFields,
|
||||
getServiceConfig,
|
||||
getTemplatePath,
|
||||
saveNativeTriggerFromCfg
|
||||
saveNativeTriggerFromCfg,
|
||||
setNativeConnectionContext
|
||||
} from './utils'
|
||||
import NativeConnectionPicker from './NativeConnectionPicker.svelte'
|
||||
import { usedTriggerKinds } from '$lib/stores'
|
||||
import { emptyString, sendUserToast } from '$lib/utils'
|
||||
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
|
||||
@@ -125,6 +127,8 @@
|
||||
let externalError = $state<string | undefined>(undefined)
|
||||
let retryEdit = $state<(() => void) | undefined>(undefined)
|
||||
let enabled = $state(true)
|
||||
let connectionPath = $state<string | undefined>(undefined)
|
||||
setNativeConnectionContext(() => connectionPath)
|
||||
|
||||
export function openNew(
|
||||
nis_flow?: boolean,
|
||||
@@ -156,6 +160,7 @@
|
||||
externalError = undefined
|
||||
retryEdit = undefined
|
||||
enabled = true
|
||||
connectionPath = undefined
|
||||
}
|
||||
|
||||
export function openRecreate(nativeTrigger: ExtendedNativeTrigger) {
|
||||
@@ -185,6 +190,7 @@
|
||||
externalError = undefined
|
||||
retryEdit = undefined
|
||||
enabled = nativeTrigger.enabled
|
||||
connectionPath = nativeTrigger.connection_path ?? undefined
|
||||
}
|
||||
|
||||
export async function openEdit(
|
||||
@@ -219,6 +225,7 @@
|
||||
initialScriptPath = ''
|
||||
summary = ''
|
||||
enabled = true
|
||||
connectionPath = undefined
|
||||
|
||||
try {
|
||||
const fullTrigger = await NativeTriggerService.getNativeTrigger({
|
||||
@@ -236,6 +243,7 @@
|
||||
externalData = fullTrigger.external_data
|
||||
externalError = fullTrigger.external_error ?? undefined
|
||||
enabled = fullTrigger.enabled
|
||||
connectionPath = fullTrigger.connection_path ?? undefined
|
||||
|
||||
// Apply default values if provided (for draft triggers)
|
||||
if (defaultValues) {
|
||||
@@ -263,7 +271,8 @@
|
||||
script_path: scriptPath,
|
||||
is_flow: isFlow,
|
||||
service_config: serviceConfig,
|
||||
summary: summary !== '' ? summary : undefined
|
||||
summary: summary !== '' ? summary : undefined,
|
||||
connection_path: connectionPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +317,8 @@
|
||||
loadingForm ||
|
||||
!can_write ||
|
||||
!hasChanged ||
|
||||
loadError !== undefined
|
||||
loadError !== undefined ||
|
||||
(isNew && !connectionPath)
|
||||
)
|
||||
const saveCfg = $derived.by(getSaveCfg)
|
||||
|
||||
@@ -557,6 +567,17 @@
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
{#if !loadingConfig}
|
||||
<Section label="{serviceInfo?.serviceDisplayName} account">
|
||||
<NativeConnectionPicker
|
||||
{service}
|
||||
bind:value={connectionPath}
|
||||
locked={!isNew}
|
||||
disabled={loading || !can_write}
|
||||
/>
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
{#if loadingConfig}
|
||||
<Section label="{serviceInfo?.serviceDisplayName} configuration">
|
||||
<div class="flex items-center gap-2 text-secondary text-xs">
|
||||
@@ -564,34 +585,39 @@
|
||||
Loading configuration from {serviceInfo?.serviceDisplayName}...
|
||||
</div>
|
||||
</Section>
|
||||
{:else if isNew && !connectionPath}
|
||||
<!-- The pickers browse the service as the chosen account, so there is nothing to
|
||||
configure until one is chosen. -->
|
||||
{:else if ServiceFormComponent}
|
||||
<ServiceFormComponent
|
||||
bind:loading={loadingForm}
|
||||
bind:this={serviceFormRef}
|
||||
bind:serviceConfig
|
||||
bind:errors
|
||||
{externalData}
|
||||
disabled={loading || loadingConfig || !can_write}
|
||||
path={scriptPath}
|
||||
{isFlow}
|
||||
token=""
|
||||
triggerTokens={undefined}
|
||||
scopes={[]}
|
||||
/>
|
||||
{#key connectionPath}
|
||||
<ServiceFormComponent
|
||||
bind:loading={loadingForm}
|
||||
bind:this={serviceFormRef}
|
||||
bind:serviceConfig
|
||||
bind:errors
|
||||
{externalData}
|
||||
disabled={loading || loadingConfig || !can_write}
|
||||
path={scriptPath}
|
||||
{isFlow}
|
||||
token=""
|
||||
triggerTokens={undefined}
|
||||
scopes={[]}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<Section label="{serviceInfo?.serviceDisplayName} configuration">
|
||||
<div class="text-red-500 text-xs space-y-2">
|
||||
<div>Failed to load service configuration component for {service}.</div>
|
||||
<div class="text-secondary">
|
||||
Ensure your workspace has a connected {serviceInfo?.serviceDisplayName} integration.
|
||||
Check that {serviceInfo?.serviceDisplayName} is configured in the native trigger settings.
|
||||
</div>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
href="/workspace_settings?tab=integrations"
|
||||
href="/workspace_settings?tab=native_triggers"
|
||||
target="_blank"
|
||||
>
|
||||
Manage integrations
|
||||
Native trigger settings
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
+4
-1
@@ -7,8 +7,10 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useNativeConnection } from '../../utils'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const connectionPath = useNativeConnection()
|
||||
|
||||
interface Props {
|
||||
serviceConfig: Record<string, any>
|
||||
@@ -66,7 +68,8 @@
|
||||
loading = true
|
||||
try {
|
||||
repos = await NativeTriggerService.listGithubRepos({
|
||||
workspace: $operatingWorkspace
|
||||
workspace: $operatingWorkspace,
|
||||
connectionPath: connectionPath()
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load GitHub repositories:', err)
|
||||
|
||||
+4
-1
@@ -6,8 +6,10 @@
|
||||
import { Loader2, X, RefreshCw } from 'lucide-svelte'
|
||||
import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useNativeConnection } from '../../utils'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const connectionPath = useNativeConnection()
|
||||
|
||||
interface Props {
|
||||
calendarId: string
|
||||
@@ -26,7 +28,8 @@
|
||||
loading = true
|
||||
try {
|
||||
calendars = await NativeTriggerService.listGoogleCalendars({
|
||||
workspace: $operatingWorkspace
|
||||
workspace: $operatingWorkspace,
|
||||
connectionPath: connectionPath()
|
||||
})
|
||||
if (calendarId && !calendarName) {
|
||||
const found = calendars.find((c) => c.id === calendarId)
|
||||
|
||||
+5
-1
@@ -9,8 +9,10 @@
|
||||
import { Debounced, watch } from 'runed'
|
||||
import { untrack } from 'svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useNativeConnection } from '../../utils'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const connectionPath = useNativeConnection()
|
||||
|
||||
interface Props {
|
||||
resourceId: string
|
||||
@@ -53,6 +55,7 @@
|
||||
try {
|
||||
const params: Parameters<typeof NativeTriggerService.listGoogleDriveFiles>[0] = {
|
||||
workspace: $operatingWorkspace,
|
||||
connectionPath: connectionPath(),
|
||||
pageToken
|
||||
}
|
||||
|
||||
@@ -98,7 +101,8 @@
|
||||
|
||||
try {
|
||||
sharedDrives = await NativeTriggerService.listGoogleSharedDrives({
|
||||
workspace: $operatingWorkspace
|
||||
workspace: $operatingWorkspace,
|
||||
connectionPath: connectionPath()
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to load shared drives: ${err.body || err.message}`, true)
|
||||
|
||||
+8
-9
@@ -6,10 +6,11 @@
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { getNextcloudSchema } from '../../utils'
|
||||
import { getNextcloudSchema, useNativeConnection } from '../../utils'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const connectionPath = useNativeConnection()
|
||||
|
||||
interface Props {
|
||||
serviceConfig: Record<string, any>
|
||||
@@ -49,7 +50,8 @@
|
||||
eventsError = undefined
|
||||
try {
|
||||
const events = await NativeTriggerService.listNextCloudEvents({
|
||||
workspace: $operatingWorkspace!
|
||||
workspace: $operatingWorkspace!,
|
||||
connectionPath: connectionPath()
|
||||
})
|
||||
availableEvents = events
|
||||
serviceSchema = getNextcloudSchema(events)
|
||||
@@ -116,16 +118,14 @@
|
||||
{#if eventsError}
|
||||
<div class="break-words">Could not load the available events: {eventsError}</div>
|
||||
{:else}
|
||||
<div
|
||||
>No events available. Please ensure your workspace has a connected Nextcloud integration.</div
|
||||
>
|
||||
<div>No events available. Check that the selected Nextcloud account can reach the server.</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="default" on:click={loadAvailableEvents} {disabled}>
|
||||
Retry loading events
|
||||
</Button>
|
||||
<Button variant="subtle" href="/workspace_settings?tab=integrations" target="_blank">
|
||||
Manage integrations
|
||||
<Button variant="subtle" href="/workspace_settings?tab=native_triggers" target="_blank">
|
||||
Native trigger settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,8 +140,7 @@
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-secondary text-xs">
|
||||
Please ensure Nextcloud workspace integration is connected to load available configuration
|
||||
options.
|
||||
Select a Nextcloud account to load the available configuration options.
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
@@ -2,11 +2,97 @@ import type { NativeServiceName, NativeTrigger, NativeTriggerData } from '$lib/g
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { NativeTriggerService, WorkspaceIntegrationService } from '$lib/gen'
|
||||
import { get, type Writable } from 'svelte/store'
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
const CONNECTION_CONTEXT = Symbol('nativeTriggerConnection')
|
||||
|
||||
/** Makes the connection a trigger editor acts through available to the pickers of its form. */
|
||||
export function setNativeConnectionContext(connectionPath: () => string | undefined) {
|
||||
setContext(CONNECTION_CONTEXT, connectionPath)
|
||||
}
|
||||
|
||||
/** The connection the service pickers browse through; undefined lets the server pick. */
|
||||
export function useNativeConnection(): () => string | undefined {
|
||||
return getContext<(() => string | undefined) | undefined>(CONNECTION_CONTEXT) ?? (() => 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 }
|
||||
|
||||
/** The page the provider redirects to, as registered on the workspace's OAuth apps. */
|
||||
export function nativeOAuthRedirectUri(service: NativeServiceName): string {
|
||||
return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${service}`
|
||||
}
|
||||
|
||||
/** Hands the outcome of a connect popup back to the page that opened it. */
|
||||
export function postNativeOAuthResult(
|
||||
result: { ok: true; path: string } | { ok: false; error: string }
|
||||
) {
|
||||
const message = { type: CONNECT_MESSAGE, ...result } as NativeOAuthMessage
|
||||
window.opener?.postMessage(message, window.location.origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect an account in a popup and resolve with the path of the saved connection. The
|
||||
* connection is saved at `resourcePath`, or at the caller's default path when omitted.
|
||||
*/
|
||||
export async function connectNativeAccount(
|
||||
workspace: string,
|
||||
service: NativeServiceName,
|
||||
resourcePath?: string
|
||||
): Promise<string> {
|
||||
const authUrl = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({
|
||||
workspace,
|
||||
serviceName: service,
|
||||
requestBody: { redirect_uri: nativeOAuthRedirectUri(service), resource_path: resourcePath }
|
||||
})
|
||||
const popup = window.open(authUrl, '_blank', 'popup,width=600,height=750')
|
||||
if (!popup) {
|
||||
throw new Error('The connection window was blocked. Allow popups for this site and retry.')
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (event: MessageEvent<NativeOAuthMessage>) => {
|
||||
if (event.origin !== window.location.origin || event.data?.type !== CONNECT_MESSAGE) return
|
||||
cleanup()
|
||||
if (event.data.ok) resolve(event.data.path)
|
||||
else reject(new Error(event.data.error))
|
||||
}
|
||||
// The callback page posts its result and then closes itself, so a closed window may still
|
||||
// have a message in flight: give it a moment before treating the close as a cancel.
|
||||
const closedPoll = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(closedPoll)
|
||||
setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('The connection window was closed before finishing'))
|
||||
}, 1000)
|
||||
}
|
||||
}, 500)
|
||||
function cleanup() {
|
||||
window.removeEventListener('message', onMessage)
|
||||
clearInterval(closedPoll)
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
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?: {
|
||||
@@ -19,6 +105,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon
|
||||
nextcloud: {
|
||||
serviceDisplayName: 'Nextcloud',
|
||||
serviceKey: 'nextcloud',
|
||||
resourceType: 'nextcloud',
|
||||
supportsSync: true,
|
||||
isCloudCompatible: true,
|
||||
templates: {
|
||||
@@ -30,6 +117,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon
|
||||
google: {
|
||||
serviceDisplayName: 'Google',
|
||||
serviceKey: 'google',
|
||||
resourceType: 'gworkspace',
|
||||
supportsSync: true,
|
||||
isCloudCompatible: true,
|
||||
templates: {
|
||||
@@ -40,6 +128,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon
|
||||
github: {
|
||||
serviceDisplayName: 'GitHub',
|
||||
serviceKey: 'github',
|
||||
resourceType: 'github',
|
||||
supportsSync: true,
|
||||
isCloudCompatible: true,
|
||||
templates: {
|
||||
@@ -216,8 +305,13 @@ export async function saveNativeTriggerFromCfg(
|
||||
service_config: triggerCfg.service_config,
|
||||
summary: triggerCfg.summary
|
||||
}
|
||||
// Only a create can set it: an update ignores the field, and `setenabled` owns it thereafter.
|
||||
const createBody: NativeTriggerData = { ...requestBody, enabled: triggerCfg.enabled ?? true }
|
||||
// Only a create can set these: an update ignores both, `setenabled` owns `enabled` thereafter,
|
||||
// and the connection stays the one the webhook was registered under.
|
||||
const createBody: NativeTriggerData = {
|
||||
...requestBody,
|
||||
enabled: triggerCfg.enabled ?? true,
|
||||
connection_path: triggerCfg.connection_path
|
||||
}
|
||||
|
||||
const serviceName = NATIVE_TRIGGER_SERVICES[service].serviceDisplayName
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { Button, Alert } from '$lib/components/common'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import { Save, ExternalLink } from 'lucide-svelte'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
serviceName: string
|
||||
serviceDisplayName: string
|
||||
existingConfig?: {
|
||||
@@ -16,178 +19,147 @@
|
||||
redirectUri: string
|
||||
onConfigSaved: (oauthData: any) => Promise<void>
|
||||
requiresBaseUrl?: boolean
|
||||
baseUrlLabel?: string
|
||||
baseUrlPlaceholder?: string
|
||||
baseUrlSettingsPath?: string
|
||||
clientIdPlaceholder?: string
|
||||
clientSecretPlaceholder?: string
|
||||
setupInstructions?: string[]
|
||||
/** Shown above the form, for what the service needs before it can be connected. */
|
||||
requirements?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
serviceName,
|
||||
serviceDisplayName,
|
||||
existingConfig,
|
||||
onConfigSaved,
|
||||
redirectUri,
|
||||
requiresBaseUrl = true,
|
||||
baseUrlLabel = `${serviceDisplayName} base URL`,
|
||||
baseUrlPlaceholder = `https://your-${serviceName}.example.com`,
|
||||
baseUrlSettingsPath = '/settings/admin/security',
|
||||
clientIdPlaceholder = `Generated by ${serviceDisplayName} OAuth app`,
|
||||
clientSecretPlaceholder = `Generated by ${serviceDisplayName} OAuth app`,
|
||||
setupInstructions
|
||||
setupInstructions,
|
||||
requirements
|
||||
}: Props = $props()
|
||||
|
||||
let saving = $state(false)
|
||||
let clientId = $state('')
|
||||
let clientSecret = $state('')
|
||||
let baseUrl = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (existingConfig) {
|
||||
clientId = existingConfig.client_id
|
||||
clientSecret = existingConfig.client_secret
|
||||
baseUrl = existingConfig.base_url
|
||||
if (open) {
|
||||
clientId = existingConfig?.client_id ?? ''
|
||||
clientSecret = existingConfig?.client_secret ?? ''
|
||||
baseUrl = existingConfig?.base_url ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
let canSave = $derived(
|
||||
!saving &&
|
||||
!!clientId.trim() &&
|
||||
!!clientSecret.trim() &&
|
||||
(!requiresBaseUrl || !!baseUrl.trim())
|
||||
const baseUrlValid = $derived.by(() => {
|
||||
try {
|
||||
return ['http:', 'https:'].includes(new URL(baseUrl.trim()).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const canSave = $derived(
|
||||
!saving && !!clientId.trim() && !!clientSecret.trim() && (!requiresBaseUrl || baseUrlValid)
|
||||
)
|
||||
|
||||
async function saveConfig() {
|
||||
if (!canSave) {
|
||||
sendUserToast('Please fill in all required fields', true)
|
||||
return
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return
|
||||
saving = true
|
||||
try {
|
||||
const oauthData: Record<string, string> = {
|
||||
await onConfigSaved({
|
||||
client_id: clientId.trim(),
|
||||
client_secret: clientSecret.trim(),
|
||||
base_url: requiresBaseUrl ? baseUrl.trim().replace(/\/+$/, '') : '',
|
||||
redirect_uri: redirectUri.trim()
|
||||
}
|
||||
|
||||
await onConfigSaved(oauthData)
|
||||
resetForm()
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to configure OAuth client: ${err.message}`, true)
|
||||
sendUserToast(`Failed to save the OAuth app: ${err.message}`, true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
clientId = ''
|
||||
clientSecret = ''
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-md font-medium text-primary">{serviceDisplayName} OAuth Client Configuration</h4
|
||||
>
|
||||
</div>
|
||||
<Modal
|
||||
bind:open
|
||||
title="{serviceDisplayName} OAuth app"
|
||||
description="Members connect their {serviceDisplayName} accounts through this app."
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
{@render requirements?.()}
|
||||
|
||||
{#if !existingConfig}
|
||||
<Alert type="warning" title="OAuth client required">
|
||||
<p class="text-sm mb-2">
|
||||
Before you can connect to {serviceDisplayName}, you need to configure an OAuth client.
|
||||
</p>
|
||||
{#if setupInstructions}
|
||||
<ol class="list-decimal list-inside space-y-1 text-sm">
|
||||
{#if !existingConfig && setupInstructions}
|
||||
<Alert type="info" title="Create an OAuth app in {serviceDisplayName}">
|
||||
<ol class="list-decimal list-inside flex flex-col gap-1">
|
||||
{#each setupInstructions as instruction}
|
||||
<li>{@html instruction}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
{#if requiresBaseUrl}
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label={baseUrlLabel} />
|
||||
<p class="text-xs font-normal text-secondary"
|
||||
>The base URL of your {serviceDisplayName} instance (without trailing slash)</p
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={baseUrl}
|
||||
placeholder={baseUrlPlaceholder}
|
||||
class="windmill-input"
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if baseUrl && baseUrlSettingsPath}
|
||||
{#if requiresBaseUrl}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">{serviceDisplayName} URL</div>
|
||||
<div class="text-xs text-secondary">The address of your {serviceDisplayName} server</div>
|
||||
<TextInput
|
||||
bind:value={baseUrl}
|
||||
error={!!baseUrl.trim() && !baseUrlValid}
|
||||
inputProps={{ type: 'url', placeholder: baseUrlPlaceholder, disabled: saving }}
|
||||
/>
|
||||
{#if baseUrl.trim() && !baseUrlValid}
|
||||
<div class="text-2xs text-red-500">Enter a full address, starting with https://</div>
|
||||
{/if}
|
||||
{#if baseUrlValid && baseUrlSettingsPath}
|
||||
<div class="flex">
|
||||
<Button
|
||||
wrapperClasses="self-start"
|
||||
href={`${baseUrl}${baseUrlSettingsPath}`}
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
href={`${baseUrl.replace(/\/+$/, '')}${baseUrlSettingsPath}`}
|
||||
target="_blank"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
{serviceDisplayName} OAuth Settings
|
||||
Open the {serviceDisplayName} OAuth settings
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label="Redirect URI" />
|
||||
<p class="text-xs font-normal text-secondary"
|
||||
>Use this URL when creating the OAuth app in {serviceDisplayName}</p
|
||||
>
|
||||
<ClipboardPanel content={redirectUri} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label="Client ID" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={clientId}
|
||||
placeholder={clientIdPlaceholder}
|
||||
class="windmill-input"
|
||||
disabled={saving}
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Redirect URI</div>
|
||||
<div class="text-xs text-secondary">
|
||||
Register this URL when creating the OAuth app in {serviceDisplayName}
|
||||
</div>
|
||||
<ClipboardPanel content={redirectUri} size="sm" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label="Client secret" />
|
||||
<input
|
||||
type="password"
|
||||
bind:value={clientSecret}
|
||||
placeholder={clientSecretPlaceholder}
|
||||
class="windmill-input"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Client ID</div>
|
||||
<TextInput
|
||||
bind:value={clientId}
|
||||
inputProps={{ placeholder: clientIdPlaceholder, disabled: saving }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
color="blue"
|
||||
onclick={saveConfig}
|
||||
disabled={!canSave}
|
||||
startIcon={{ icon: Save }}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Client secret</div>
|
||||
<TextInput
|
||||
bind:value={clientSecret}
|
||||
inputProps={{ type: 'password', placeholder: clientSecretPlaceholder, disabled: saving }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if existingConfig}
|
||||
<Alert type="success" title="OAuth client configured">
|
||||
<p class="text-sm">
|
||||
OAuth client is configured for {serviceDisplayName}. You can now connect this workspace to
|
||||
{serviceDisplayName}.
|
||||
</p>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="accent" disabled={!canSave} loading={saving} onclick={save}>Save</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -4,22 +4,25 @@
|
||||
import { Button, Alert } from '$lib/components/common'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
|
||||
import { Check, X, ExternalLink, Cog, Plug } from 'lucide-svelte'
|
||||
import { Check, ExternalLink, KeyRound, Pencil, Trash2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
|
||||
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
|
||||
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
|
||||
import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import {
|
||||
WorkspaceIntegrationService,
|
||||
type NativeServiceName,
|
||||
type NativeTriggerConnection
|
||||
} from '$lib/gen'
|
||||
import OAuthClientConfig from './OAuthClientConfig.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import NativeConnectionPicker from '$lib/components/triggers/native/NativeConnectionPicker.svelte'
|
||||
import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils'
|
||||
|
||||
interface WorkspaceIntegration {
|
||||
service_name: string
|
||||
resource_path?: string
|
||||
oauth_data: {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
@@ -44,7 +47,7 @@
|
||||
nextcloud: {
|
||||
name: 'nextcloud',
|
||||
displayName: 'Nextcloud',
|
||||
description: 'Connect to Nextcloud for file operations and webhook triggers',
|
||||
description: 'File events',
|
||||
icon: NextcloudIcon,
|
||||
docsUrl: 'https://www.windmill.dev/docs/integrations/nextcloud',
|
||||
setupInstructions: [
|
||||
@@ -56,7 +59,7 @@
|
||||
google: {
|
||||
name: 'google',
|
||||
displayName: 'Google',
|
||||
description: 'Connect to Google for Drive and Calendar triggers',
|
||||
description: 'Drive and Calendar changes',
|
||||
icon: GoogleIcon,
|
||||
docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#google-triggers',
|
||||
requiresBaseUrl: false,
|
||||
@@ -73,7 +76,7 @@
|
||||
github: {
|
||||
name: 'github',
|
||||
displayName: 'GitHub',
|
||||
description: 'Connect to GitHub for repository webhook triggers',
|
||||
description: 'Repository events',
|
||||
icon: GithubIcon,
|
||||
docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#github-triggers',
|
||||
requiresBaseUrl: false,
|
||||
@@ -89,18 +92,13 @@
|
||||
}
|
||||
|
||||
let integrations = $state<WorkspaceIntegration[]>([])
|
||||
let connections = $state<Record<string, NativeTriggerConnection[]>>({})
|
||||
let loading = $state(false)
|
||||
let connecting = $state<string | null>(null)
|
||||
let showingConfig = $state<string | null>(null)
|
||||
// The service whose OAuth app dialog is open.
|
||||
let configOpen = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(Object.keys(supportedServices).map((s) => [s, false]))
|
||||
)
|
||||
let instanceSharingAvailable = $state<Record<string, boolean>>({})
|
||||
let pendingCallback = $state<{
|
||||
serviceName: NativeServiceName
|
||||
code: string
|
||||
state: string
|
||||
workspace: string
|
||||
} | null>(null)
|
||||
let resourcePath = $state<string | undefined>(undefined)
|
||||
let pathError = $state<string | undefined>(undefined)
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
|
||||
async function loadIntegrations() {
|
||||
@@ -113,9 +111,9 @@
|
||||
})
|
||||
integrations = response.map((item) => ({
|
||||
service_name: item.service_name,
|
||||
resource_path: item.resource_path ?? undefined,
|
||||
oauth_data: item.oauth_data || null
|
||||
}))
|
||||
await Promise.all(Object.keys(supportedServices).map(loadConnections))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load workspace integrations:', err)
|
||||
sendUserToast(`Failed to load integrations: ${err.message}`, true)
|
||||
@@ -124,59 +122,132 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnections(serviceName: string) {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
connections[serviceName] = await WorkspaceIntegrationService.listNativeTriggerConnections({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as NativeServiceName
|
||||
})
|
||||
} catch {
|
||||
connections[serviceName] = []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIntegration(serviceName: string) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: `Disconnect ${displayName}?`,
|
||||
confirmationText: 'Disconnect',
|
||||
children: `This will delete all ${displayName} triggers associated with this integration. This action cannot be undone.`
|
||||
title: `Remove the ${displayName} configuration?`,
|
||||
confirmationText: 'Remove',
|
||||
children: `This deletes the workspace's ${displayName} OAuth app, every ${displayName} account connected through it and all ${displayName} triggers, which stop receiving events. This action cannot be undone.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await WorkspaceIntegrationService.deleteNativeTriggerService({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as any
|
||||
serviceName: serviceName as NativeServiceName
|
||||
})
|
||||
sendUserToast(`${displayName} disconnected successfully`)
|
||||
sendUserToast(`${displayName} integration removed`)
|
||||
loadIntegrations()
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to disconnect ${displayName}: ${err.message}`, true)
|
||||
sendUserToast(`Failed to remove ${displayName}: ${err.body ?? err.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function connectService(serviceName: string, redirectUri: string) {
|
||||
if (!$workspaceStore) return
|
||||
async function setWorkspaceConnection(
|
||||
serviceName: string,
|
||||
path: string | undefined
|
||||
): Promise<boolean> {
|
||||
if (!$workspaceStore) return false
|
||||
|
||||
connecting = serviceName
|
||||
try {
|
||||
const auth_url = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as any,
|
||||
requestBody: { redirect_uri: redirectUri }
|
||||
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
|
||||
if (path) {
|
||||
const owner = connections[serviceName]?.find((c) => c.path === path)?.owner
|
||||
const account = owner ? `this account, connected by ${owner}` : 'this account'
|
||||
const browsable: string | undefined = (
|
||||
{ google: 'its Drive files and calendars', github: 'its repositories' } as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
)[serviceName]
|
||||
const browse = browsable ? `, and browse ${browsable} in the trigger editor` : ''
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: `Share ${path} with the workspace?`,
|
||||
confirmationText: 'Share',
|
||||
children: `Every member will be able to create and edit ${displayName} triggers that act as ${account}${browse}. They will not see its token. It replaces any connection shared before.`
|
||||
})
|
||||
|
||||
if (auth_url) {
|
||||
window.location.href = auth_url
|
||||
}
|
||||
} catch (err: any) {
|
||||
sendUserToast(
|
||||
`Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`,
|
||||
true
|
||||
)
|
||||
connecting = null
|
||||
if (!confirmed) return false
|
||||
} else {
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: 'Stop sharing this account?',
|
||||
confirmationText: 'Stop sharing',
|
||||
children: `Members will no longer be able to create or edit ${displayName} triggers through it, unless they can read it. Its triggers keep running.`
|
||||
})
|
||||
if (!confirmed) return false
|
||||
}
|
||||
|
||||
try {
|
||||
await WorkspaceIntegrationService.setNativeTriggerWorkspaceConnection({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as NativeServiceName,
|
||||
requestBody: { path: path ?? null }
|
||||
})
|
||||
sendUserToast(path ? `${path} is now the shared account` : 'Stopped sharing')
|
||||
await loadConnections(serviceName)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to update the shared account: ${err.body ?? err.message}`, true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function docsItem(config: ServiceConfig): Item[] {
|
||||
return config.docsUrl
|
||||
? [{ displayName: 'Docs', icon: ExternalLink, href: config.docsUrl, hrefTarget: '_blank' }]
|
||||
: []
|
||||
}
|
||||
|
||||
async function openConfig(serviceName: string, leavingInstanceApp: boolean) {
|
||||
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
|
||||
// A refresh token only works with the OAuth app that issued it.
|
||||
if (leavingInstanceApp && (connections[serviceName]?.length ?? 0) > 0) {
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: `Use your own ${displayName} OAuth app?`,
|
||||
confirmationText: 'Continue',
|
||||
children: `Accounts connected with the instance app will have to be connected again, at the same path, before their triggers work again.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
configOpen[serviceName] = true
|
||||
}
|
||||
|
||||
async function createOrUpdateIntegration(serviceName: string, oauthData: any) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
|
||||
const current = getIntegrationByService(serviceName)?.oauth_data
|
||||
// Leaving the instance app is confirmed when the form opens; this covers editing our own app.
|
||||
const clientChanges =
|
||||
!!current?.client_id &&
|
||||
!current.instance_shared &&
|
||||
current.client_id !== (oauthData.client_id ?? '')
|
||||
// A refresh token only works with the OAuth app that issued it.
|
||||
if (clientChanges && (connections[serviceName]?.length ?? 0) > 0) {
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: `Change the ${displayName} OAuth app?`,
|
||||
confirmationText: 'Change',
|
||||
children: `Accounts connected with the current app must be connected again, at the same path, before their triggers work again.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
await WorkspaceIntegrationService.createNativeTriggerService({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as any,
|
||||
serviceName: serviceName as NativeServiceName,
|
||||
requestBody: oauthData
|
||||
})
|
||||
sendUserToast(
|
||||
@@ -207,33 +278,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWithInstanceCredentials(serviceName: string) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
connecting = serviceName
|
||||
try {
|
||||
const redirectUri = getRedirectUri(serviceName)
|
||||
const auth_url = await WorkspaceIntegrationService.generateInstanceConnectUrl({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as NativeServiceName,
|
||||
requestBody: { redirect_uri: redirectUri }
|
||||
})
|
||||
|
||||
if (auth_url) {
|
||||
window.location.href = auth_url
|
||||
}
|
||||
} catch (err: any) {
|
||||
sendUserToast(
|
||||
`Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`,
|
||||
true
|
||||
)
|
||||
connecting = null
|
||||
}
|
||||
}
|
||||
|
||||
function isConfigured(integration: WorkspaceIntegration): boolean {
|
||||
if (integration.oauth_data === null) return false
|
||||
if (integration.oauth_data.instance_shared) return true
|
||||
/** Whether the workspace has its own OAuth app for the service. */
|
||||
function hasWorkspaceApp(integration: WorkspaceIntegration | null): boolean {
|
||||
if (!integration?.oauth_data || integration.oauth_data.instance_shared) return false
|
||||
const serviceConfig = supportedServices[integration.service_name]
|
||||
const needsBaseUrl = serviceConfig?.requiresBaseUrl !== false
|
||||
return (
|
||||
@@ -243,82 +290,10 @@
|
||||
)
|
||||
}
|
||||
|
||||
function isConnected(integration: WorkspaceIntegration): boolean {
|
||||
if (integration.oauth_data?.instance_shared) {
|
||||
return !!integration.resource_path
|
||||
}
|
||||
return isConfigured(integration) && !!integration.resource_path
|
||||
}
|
||||
|
||||
function getIntegrationByService(serviceName: string): WorkspaceIntegration | null {
|
||||
return integrations.find((integration) => integration.service_name === serviceName) || null
|
||||
}
|
||||
|
||||
function handleOAuthCallback(
|
||||
workspace: string,
|
||||
serviceName: NativeServiceName,
|
||||
code: string,
|
||||
state: string
|
||||
) {
|
||||
// Phase 1: store callback params and clean URL — don't call backend yet
|
||||
pendingCallback = { serviceName, code, state, workspace }
|
||||
|
||||
// Pre-populate resource path from existing integration (for reconnect)
|
||||
const integration = getIntegrationByService(serviceName)
|
||||
resourcePath = integration?.resource_path ?? undefined
|
||||
|
||||
const url = new URL(page.url)
|
||||
url.searchParams.delete('code')
|
||||
url.searchParams.delete('state')
|
||||
url.searchParams.delete('service')
|
||||
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true })
|
||||
}
|
||||
|
||||
async function finalizePendingCallback() {
|
||||
if (!pendingCallback) return
|
||||
|
||||
const { serviceName, code, state, workspace } = pendingCallback
|
||||
try {
|
||||
const redirectUri = getRedirectUri(serviceName)
|
||||
await WorkspaceIntegrationService.nativeTriggerServiceCallback({
|
||||
serviceName,
|
||||
workspace,
|
||||
requestBody: {
|
||||
code,
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
resource_path: resourcePath
|
||||
}
|
||||
})
|
||||
sendUserToast(`${supportedServices[serviceName]?.displayName} connected successfully!`)
|
||||
await loadIntegrations()
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to complete OAuth connection: ${err.message}`, true)
|
||||
} finally {
|
||||
pendingCallback = null
|
||||
resourcePath = undefined
|
||||
pathError = undefined
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
page.url.searchParams.has('code') &&
|
||||
page.url.searchParams.has('state') &&
|
||||
page.url.searchParams.has('service') &&
|
||||
$workspaceStore
|
||||
) {
|
||||
const service = page.url.searchParams.get('service')! as NativeServiceName
|
||||
const code = page.url.searchParams.get('code')!
|
||||
const state = page.url.searchParams.get('state')!
|
||||
handleOAuthCallback($workspaceStore, service, code, state)
|
||||
}
|
||||
})
|
||||
|
||||
function getRedirectUri(serviceName: string): string {
|
||||
return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${serviceName}`
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
loadIntegrations()
|
||||
@@ -329,49 +304,12 @@
|
||||
|
||||
<div class="flex flex-col">
|
||||
<SettingsPageHeader
|
||||
title="Native Triggers"
|
||||
description="Connect your workspace to external services for native triggers and enhanced functionality. These connections are shared across all workspace members and are required for native triggers to work."
|
||||
title="Native triggers"
|
||||
description="Configure the OAuth app of each service. Members connect their own account when creating a trigger, or use the shared account, and each trigger acts as the account it was created with."
|
||||
link="https://www.windmill.dev/docs/core_concepts/native_triggers"
|
||||
/>
|
||||
|
||||
{#if pendingCallback}
|
||||
{@const serviceName = pendingCallback.serviceName}
|
||||
{@const config = supportedServices[serviceName]}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
|
||||
<div class="text-sm font-semibold text-emphasis mb-2">
|
||||
Save {config?.displayName ?? serviceName} credentials as resource
|
||||
</div>
|
||||
<div class="text-xs text-secondary mb-3">
|
||||
Choose where to save the OAuth resource. This resource will store the access token for the
|
||||
integration.
|
||||
</div>
|
||||
<Path
|
||||
kind="resource"
|
||||
initialPath={resourcePath ?? ''}
|
||||
namePlaceholder={'native_' + serviceName}
|
||||
bind:path={resourcePath}
|
||||
bind:error={pathError}
|
||||
/>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!resourcePath || !!pathError}
|
||||
onclick={finalizePendingCallback}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
pendingCallback = null
|
||||
resourcePath = undefined
|
||||
pathError = undefined
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
{#each new Array(3) as _}
|
||||
<Skeleton layout={[[6], 0.4]} />
|
||||
@@ -381,164 +319,144 @@
|
||||
<div class="space-y-4">
|
||||
{#each Object.entries(supportedServices) as [serviceName, config]}
|
||||
{@const integration = getIntegrationByService(serviceName)}
|
||||
{@const isConnecting = connecting === serviceName}
|
||||
{@const isOAuthConfigured = integration && isConfigured(integration)}
|
||||
{@const isServiceConnected = integration && isConnected(integration)}
|
||||
{@const isShowingConfig = showingConfig === serviceName}
|
||||
{@const workspaceApp = hasWorkspaceApp(integration)}
|
||||
{@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]}
|
||||
{@const workspaceConnection = connections[serviceName]?.find((c) => c.workspace)?.path}
|
||||
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 flex items-center justify-center">
|
||||
<config.icon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm font-semibold text-emphasis">{config.displayName}</div>
|
||||
<div class="text-xs font-normal text-primary">{config.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isServiceConnected}
|
||||
<div class="flex items-center gap-1 text-green-600 text-xs">
|
||||
<Check size={16} />
|
||||
<span class="font-semibold">Connected</span>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() =>
|
||||
integration?.oauth_data?.instance_shared
|
||||
? connectWithInstanceCredentials(serviceName)
|
||||
: connectService(serviceName, getRedirectUri(serviceName))}
|
||||
disabled={isConnecting}
|
||||
startIcon={{ icon: Plug }}
|
||||
>
|
||||
{isConnecting ? 'Reconnecting...' : 'Reconnect'}
|
||||
</Button>
|
||||
<Button
|
||||
destructive
|
||||
onclick={() => deleteIntegration(serviceName)}
|
||||
startIcon={{ icon: X }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{:else if isOAuthConfigured}
|
||||
<Button
|
||||
variant="accent"
|
||||
onclick={() => connectService(serviceName, getRedirectUri(serviceName))}
|
||||
disabled={isConnecting}
|
||||
startIcon={{ icon: Plug }}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
destructive
|
||||
onclick={() => deleteIntegration(serviceName)}
|
||||
startIcon={{ icon: X }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{:else if instanceSharingAvailable[serviceName]}
|
||||
<Button
|
||||
variant="accent"
|
||||
onclick={() => connectWithInstanceCredentials(serviceName)}
|
||||
disabled={isConnecting}
|
||||
startIcon={{ icon: Plug }}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="accent"
|
||||
onclick={() =>
|
||||
(showingConfig = showingConfig === serviceName ? null : serviceName)}
|
||||
startIcon={{ icon: Cog }}
|
||||
>
|
||||
Configure OAuth
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if config.docsUrl}
|
||||
<Button href={config.docsUrl} target="_blank" startIcon={{ icon: ExternalLink }}>
|
||||
Docs
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="border border-light rounded-md p-4 bg-surface-tertiary flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<config.icon class="w-6 h-6 shrink-0" />
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-emphasis">{config.displayName}</div>
|
||||
<div class="text-xs text-secondary">{config.description}</div>
|
||||
</div>
|
||||
{#if workspaceApp || usesInstanceApp}
|
||||
<DropdownV2
|
||||
items={workspaceApp
|
||||
? [
|
||||
{
|
||||
displayName: 'Edit OAuth app',
|
||||
icon: Pencil,
|
||||
action: () => openConfig(serviceName, false)
|
||||
},
|
||||
...docsItem(config),
|
||||
{
|
||||
displayName: 'Remove configuration',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => deleteIntegration(serviceName)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Use a workspace OAuth app',
|
||||
icon: KeyRound,
|
||||
action: () => openConfig(serviceName, true)
|
||||
},
|
||||
...docsItem(config)
|
||||
]}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
onclick={() => openConfig(serviceName, false)}
|
||||
>
|
||||
Set up
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if instanceSharingAvailable[serviceName] && !isOAuthConfigured}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<Alert type="info" title="Redirect URI required">
|
||||
<p class="text-sm mb-2">
|
||||
Your instance admin has configured Google OAuth for native triggers. Before
|
||||
connecting, ensure the following redirect URI has been added to the
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/credentials"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline">Google Cloud Console</a
|
||||
> by the instance admin:
|
||||
</p>
|
||||
<ClipboardPanel content={getRedirectUri(serviceName)} size="sm" />
|
||||
{#if !workspaceApp && !usesInstanceApp}
|
||||
<div class="text-xs text-primary">
|
||||
Not configured. Members can't create {config.displayName} triggers yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-1.5 text-xs text-primary">
|
||||
<Check size={14} class="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{workspaceApp ? 'Configured in workspace.' : 'Configured by instance.'}
|
||||
Members connect their own {config.displayName} account when they create a trigger.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if serviceName === 'nextcloud' && !workspaceConnection}
|
||||
<Alert
|
||||
type="info"
|
||||
title="Creating Nextcloud triggers requires a Nextcloud admin account"
|
||||
>
|
||||
We recommend connecting one below and sharing it with the workspace.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Shared account</div>
|
||||
<div class="text-xs text-secondary">
|
||||
Members can create triggers as this account and pick from what it can access,
|
||||
without seeing its token.
|
||||
</div>
|
||||
<div class="max-w-md">
|
||||
<!-- Remounted so its list includes an account connected from elsewhere on the page. -->
|
||||
{#key workspaceConnection}
|
||||
<NativeConnectionPicker
|
||||
service={serviceName as NativeServiceName}
|
||||
value={workspaceConnection}
|
||||
autoSelect={false}
|
||||
clearable
|
||||
labelWorkspace={false}
|
||||
placeholder="None"
|
||||
onSelect={(path) => setWorkspaceConnection(serviceName, path)}
|
||||
onConnectionsChange={() => loadConnections(serviceName)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShowingConfig}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<OAuthClientConfig
|
||||
bind:open={configOpen[serviceName]}
|
||||
{serviceName}
|
||||
redirectUri={nativeOAuthRedirectUri(serviceName as NativeServiceName)}
|
||||
serviceDisplayName={config.displayName}
|
||||
existingConfig={workspaceApp ? integration?.oauth_data : null}
|
||||
requiresBaseUrl={config.requiresBaseUrl !== false}
|
||||
clientIdPlaceholder={config.clientIdPlaceholder}
|
||||
clientSecretPlaceholder={config.clientSecretPlaceholder}
|
||||
setupInstructions={config.setupInstructions}
|
||||
onConfigSaved={async (oauthData) => {
|
||||
await createOrUpdateIntegration(serviceName, oauthData)
|
||||
configOpen[serviceName] = false
|
||||
}}
|
||||
>
|
||||
{#snippet requirements()}
|
||||
{#if serviceName === 'nextcloud'}
|
||||
<Alert type="info" title="Requirements" class="mb-4">
|
||||
<p>Nextcloud integration requires:</p>
|
||||
<ul class="list-disc pl-4 mt-2 space-y-1">
|
||||
<Alert type="info" title="Nextcloud requirements">
|
||||
<ul class="list-disc pl-4 flex flex-col gap-1">
|
||||
<li>Nextcloud 33 or later.</li>
|
||||
<li>
|
||||
The <a
|
||||
href="https://apps.nextcloud.com/apps/integration_windmill"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-blue-600"
|
||||
>
|
||||
Windmill integration app
|
||||
</a> to be installed on your Nextcloud instance.
|
||||
class="underline">Windmill integration app</a
|
||||
> installed.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.nextcloud.com/server/latest/admin_manual/installation/source_installation.html#pretty-urls"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-blue-600"
|
||||
>
|
||||
Pretty URLs
|
||||
</a>
|
||||
to be enabled on your Nextcloud instance.
|
||||
class="underline">Pretty URLs</a
|
||||
> enabled.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
{/if}
|
||||
<OAuthClientConfig
|
||||
{serviceName}
|
||||
redirectUri={getRedirectUri(serviceName)}
|
||||
serviceDisplayName={config.displayName}
|
||||
existingConfig={integration?.oauth_data}
|
||||
requiresBaseUrl={config.requiresBaseUrl !== false}
|
||||
clientIdPlaceholder={config.clientIdPlaceholder}
|
||||
clientSecretPlaceholder={config.clientSecretPlaceholder}
|
||||
setupInstructions={config.setupInstructions}
|
||||
onConfigSaved={async (oauthData) => {
|
||||
await createOrUpdateIntegration(serviceName, oauthData)
|
||||
showingConfig = null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</OAuthClientConfig>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if integrations.length === 0}
|
||||
<Alert type="warning" title="No Integrations Connected">
|
||||
Connect to external services above to enable native triggers for your workspace.
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
serviceAvailable = await isServiceAvailable(serviceName, $workspaceStore)
|
||||
if (!serviceAvailable) {
|
||||
sendUserToast(
|
||||
`${serviceName} integration not available, set it in the workspace settings`,
|
||||
`${serviceConfig.serviceDisplayName} isn't configured in this workspace`,
|
||||
true
|
||||
)
|
||||
}
|
||||
@@ -145,7 +145,10 @@
|
||||
}
|
||||
|
||||
if (!serviceAvailable) {
|
||||
sendUserToast('Cannot sync triggers: workspace integration not connected', true)
|
||||
sendUserToast(
|
||||
`Cannot sync: ${serviceConfig.serviceDisplayName} isn't configured in this workspace`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,17 +162,7 @@
|
||||
loadTriggers()
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.body || err.message
|
||||
if (
|
||||
errorMessage.includes('workspace integration') ||
|
||||
errorMessage.includes('not connected')
|
||||
) {
|
||||
sendUserToast(
|
||||
`Cannot sync: ${serviceConfig.serviceDisplayName} workspace integration not connected. Please connect it in workspace settings.`,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
sendUserToast(`Failed to sync triggers: ${errorMessage}`, true)
|
||||
}
|
||||
sendUserToast(`Failed to sync triggers: ${errorMessage}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,8 +230,7 @@
|
||||
type="warning"
|
||||
>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
The workspace doesn't have a {serviceConfig?.serviceDisplayName || serviceName} integration
|
||||
connected
|
||||
{serviceConfig?.serviceDisplayName || serviceName} isn't configured in this workspace yet.
|
||||
<div class="flex gap-2">
|
||||
<Button variant="accent" href="/workspace_settings?tab=native_triggers">
|
||||
Manage native triggers
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
import Trashbin from '$lib/components/settings/Trashbin.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { resource } from 'runed'
|
||||
import NativeOAuthCallback from '$lib/components/triggers/native/NativeOAuthCallback.svelte'
|
||||
import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils'
|
||||
import type { NativeServiceName } from '$lib/gen'
|
||||
import DbtSettings, {
|
||||
convertDbtSettingsFromBackend,
|
||||
type DbtSettingsType
|
||||
@@ -407,6 +410,13 @@
|
||||
// The deploy filters configure what may be promoted into the parent, so they only mean
|
||||
// something for a fork. A root workspace deploys nowhere by lineage.
|
||||
const showDeployToTab = $derived(Boolean(currentWorkspace?.parent_workspace_id))
|
||||
// The page native trigger OAuth providers redirect to. Anyone can connect an account, so the
|
||||
// callback is handled before the admin gate.
|
||||
const nativeOAuthService = $derived(
|
||||
$page.url.searchParams.get('tab') === 'native_triggers' && $page.url.searchParams.has('state')
|
||||
? ($page.url.searchParams.get('service') as NativeServiceName | null)
|
||||
: null
|
||||
)
|
||||
const canAdmin = $derived(($userStore?.is_admin ?? false) || Boolean($superadmin))
|
||||
let yamlMode = $state(false)
|
||||
// A workspace where the user is not an admin hides the toggle, so it must not stay in YAML mode.
|
||||
@@ -1587,7 +1597,9 @@
|
||||
</script>
|
||||
|
||||
<CenteredPage wrapperClasses="pb-0 h-full" handleOverflow={false} class="flex flex-col h-full">
|
||||
{#if canAdmin || isForkOwner}
|
||||
{#if nativeOAuthService}
|
||||
<NativeOAuthCallback redirectUri={nativeOAuthRedirectUri(nativeOAuthService)} />
|
||||
{:else if canAdmin || isForkOwner}
|
||||
<!-- No title of its own: the band already says which workspace's settings these are, and the
|
||||
two controls that sat beside the title are the page's actions. The panes below scroll
|
||||
inside themselves, so the bar keeps its edge at rest rather than only under a scroll. -->
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import NativeOAuthCallback from '$lib/components/triggers/native/NativeOAuthCallback.svelte'
|
||||
</script>
|
||||
|
||||
<!-- Where native trigger connections made with the instance GitHub OAuth app land: that app only
|
||||
redirects below its registered callback, /oauth/callback/github. -->
|
||||
<NativeOAuthCallback redirectUri="{page.url.origin}/oauth/callback/github/native_trigger" />
|
||||
Reference in New Issue
Block a user