feat: fill docusign, salesforce and quickbooks fields on connect (#11960)

* feat: fill docusign account_id and base_uri on connect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: fill salesforce and quickbooks connect fields through connect_fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: note connect_fields source trust and pin sandbox inheritance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 5517ff637a675aee66d4e417d3f37266045f0e0c

This commit updates the EE repository reference after PR #913 was merged in windmill-ee-private.

Previous ee-repo-ref: ba98803b236f018ed45199344f744ea1387afe2d

New ee-repo-ref: 5517ff637a675aee66d4e417d3f37266045f0e0c

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>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2026-10-23 18:01:13 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot] Ruben Fiszel
parent 3d6a2bcef2
commit 11f2a4c7ff
6 changed files with 129 additions and 10 deletions
+1 -1
View File
@@ -1 +1 @@
f806a793b07105417dd7a592fbfc93ec2f226d4e
5517ff637a675aee66d4e417d3f37266045f0e0c
+16 -1
View File
@@ -343,6 +343,10 @@
"quickbooks": {
"auth_url": "https://appcenter.intuit.com/connect/oauth2",
"token_url": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
"connect_fields": {
"realmId": "callback.realmId"
},
"resource_fields": ["realmId", "isSandBox"],
"scopes": ["com.intuit.quickbooks.accounting"]
},
"visma": {
@@ -468,15 +472,26 @@
"docusign": {
"auth_url": "https://account.docusign.com/oauth/auth",
"token_url": "https://account.docusign.com/oauth/token",
"userinfo_url": "https://account.docusign.com/oauth/userinfo",
"connect_fields": {
"account_id": "userinfo.accounts[is_default=true].account_id",
"base_uri": "userinfo.accounts[is_default=true].base_uri"
},
"resource_fields": ["account_id", "base_uri"],
"scopes": ["signature"],
"sandbox": {
"auth_url": "https://account-d.docusign.com/oauth/auth",
"token_url": "https://account-d.docusign.com/oauth/token"
"token_url": "https://account-d.docusign.com/oauth/token",
"userinfo_url": "https://account-d.docusign.com/oauth/userinfo"
}
},
"salesforce": {
"auth_url": "https://login.salesforce.com/services/oauth2/authorize",
"token_url": "https://login.salesforce.com/services/oauth2/token",
"connect_fields": {
"instance_url": "token.instance_url"
},
"resource_fields": ["instance_url"],
"scopes": ["api", "refresh_token", "offline_access"],
"sandbox": {
"auth_url": "https://test.salesforce.com/services/oauth2/authorize",
+12
View File
@@ -8837,6 +8837,11 @@ paths:
type: string
state:
type: string
params:
type: object
description: The callback URL's other query params, which the provider's `connect_fields` may read (QuickBooks' `realmId`)
additionalProperties:
type: string
required:
- code
- state
@@ -37929,6 +37934,13 @@ components:
type: string
grant_type:
type: string
resource_fields:
type: object
additionalProperties: {}
description: >
Resource fields the provider's `connect_fields` picked on connect
(Salesforce's instance_url, Docusign's account_id and base_uri,
QuickBooks' realmId), for the connect dialog to prefill.
required:
- access_token
+96
View File
@@ -66,6 +66,8 @@ pub struct ClientWithScopes {
pub extra_params_callback: Option<HashMap<String, String>>,
pub allowed_domains: Option<Vec<String>>,
pub userinfo_url: Option<String>,
/// See [`OAuthConfig::connect_fields`].
pub connect_fields: Option<HashMap<String, String>>,
pub grant_types: Vec<String>,
pub token_response_path: Option<String>,
/// Resolved token endpoint, exposed so the connect dialog can prefill and
@@ -88,6 +90,15 @@ pub struct OAuthConfig {
#[serde(default = "empty_string")]
pub token_url: String,
pub userinfo_url: Option<String>,
/// Resource fields the connect callback fills from what the provider returns,
/// as field -> path (see [`connect_resource_fields`]). A path starts with its
/// source: `token.` (the raw token response, before `token_response_path`
/// unwraps it), `userinfo.` (`userinfo_url`'s response, fetched only when a
/// path needs it) or `callback.` (the callback URL's query params). The browser
/// relays `callback.` values, so they only prefill the user's own resource:
/// nothing server-side may trust them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_fields: Option<HashMap<String, String>>,
/// The registry JSON may also carry frontend-only keys for the connect
/// dialog, deliberately not modelled here: `scope_options`, a scope pick
/// list, `resource_fields`, the fields of the resource type the dialog
@@ -353,6 +364,10 @@ pub struct SlackTeam {
pub struct OAuthCallback {
pub code: String,
pub state: String,
/// The callback URL's other query params (QuickBooks' `realmId`), read by
/// `connect_fields` paths under `callback.`.
#[serde(default)]
pub params: HashMap<String, String>,
}
/// Build a basic OAuth client from configuration
@@ -902,6 +917,48 @@ pub fn parse_token_response(
})
}
/// What the connect callback returns: the token, and the resource fields its
/// `connect_fields` picked, which the connect dialog prefills.
#[derive(Debug, Serialize)]
pub struct ConnectCallbackResponse {
#[serde(flatten)]
pub token: TokenResponse,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub resource_fields: HashMap<String, serde_json::Value>,
}
/// Picks each field's value out of `sources` (`{token, userinfo, callback}`). A path
/// is dot-separated keys, and a `key[k=v]` segment takes the element of array `key`
/// whose `k` is `v` (`userinfo.accounts[is_default=true].base_uri`). Only the listed
/// paths leave the server, and fields whose path misses are left out.
pub fn connect_resource_fields(
sources: &serde_json::Value,
fields: &HashMap<String, String>,
) -> HashMap<String, serde_json::Value> {
fn get<'a>(v: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
path.split('.')
.try_fold(v, |v, seg| match seg.split_once('[') {
Some((key, filter)) => {
let (k, want) = filter.strip_suffix(']')?.split_once('=')?;
v.get(key)?.as_array()?.iter().find(|e| match e.get(k) {
Some(serde_json::Value::String(s)) => s == want,
Some(other) => other.to_string() == want,
None => false,
})
}
None => v.get(seg),
})
}
fields
.iter()
.filter_map(|(field, path)| {
get(sources, path)
.filter(|v| !v.is_null())
.map(|v| (field.clone(), v.clone()))
})
.collect()
}
/// Pre-fetched account fields needed for token refresh.
pub struct OAuthAccountInfo {
pub client: String,
@@ -1260,11 +1317,50 @@ mod tests {
assert_eq!(&*token.access_token, "xoxe.xoxp-new");
}
#[test]
fn connect_fields_pick_from_each_source() {
let registry: HashMap<String, OAuthConfig> =
serde_json::from_str(include_str!("../../oauth_connect.json")).unwrap();
let fields = |p: &str| registry[p].connect_fields.clone().unwrap();
let docusign = serde_json::json!({"userinfo": {"accounts": [
{"account_id": "a1", "is_default": false, "base_uri": "https://na2.docusign.net"},
{"account_id": "a2", "is_default": true, "base_uri": "https://demo.docusign.net"}
]}});
let got = connect_resource_fields(&docusign, &fields("docusign"));
assert_eq!(got["account_id"], "a2");
assert_eq!(got["base_uri"], "https://demo.docusign.net");
let no_account = serde_json::json!({"userinfo": {"accounts": []}});
assert!(connect_resource_fields(&no_account, &fields("docusign")).is_empty());
let salesforce = serde_json::json!({"token": {
"access_token": "t", "instance_url": "https://acme.my.salesforce.com", "id_token": "x"
}});
let want = HashMap::from([(
"instance_url".to_string(),
serde_json::json!("https://acme.my.salesforce.com"),
)]);
assert_eq!(
connect_resource_fields(&salesforce, &fields("salesforce")),
want
);
let sandbox = registry["salesforce"].as_sandbox().unwrap();
assert_eq!(sandbox.connect_fields, registry["salesforce"].connect_fields);
let quickbooks = serde_json::json!({"callback": {"realmId": "9130"}});
let got = connect_resource_fields(&quickbooks, &fields("quickbooks"));
assert_eq!(
got,
HashMap::from([("realmId".to_string(), serde_json::json!("9130"))])
);
}
fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig {
OAuthConfig {
auth_url: "https://account.example.com/oauth/auth".to_string(),
token_url: "https://account.example.com/oauth/token".to_string(),
userinfo_url: Some("https://account.example.com/userinfo".to_string()),
connect_fields: None,
scopes: Some(vec!["signature".to_string()]),
cc_scopes: None,
extra_params: None,
@@ -185,7 +185,6 @@
* toggling back from client-credentials can restore them. */
let instanceScopes: string[] = $state([])
let extra_params: [string, string][] = []
let responseExtra: Record<string, string> = $state({})
let path: string = $state('')
let description = $state('')
let labels: string[] | undefined = $state(undefined)
@@ -672,7 +671,7 @@
resourceType = stripSandboxSuffix(connectClient)
value = data.res.access_token!
valueToken = data.res
responseExtra = data.extra ?? {}
Object.assign(args, data.res.resource_fields ?? {})
fillGeneratedDescription()
step = 4
// `fillPath` decides the path as surely as express does, so neither stops here.
@@ -959,9 +958,6 @@
}
}
}
if (resourceType === 'quickbooks' && responseExtra['realmId']) {
args['realmId'] = responseExtra['realmId']
}
let accountData: any = undefined
if (valueToken?.expires_in != undefined) {
@@ -29,16 +29,16 @@
// goto('/resources')
} else if (code && state) {
try {
const extraParams = Object.fromEntries(
const params = Object.fromEntries(
Array.from(Object.entries(urlParamsToObject(page.url.searchParams))).filter(
([key]) => key !== 'code' && key !== 'state' && key !== 'error'
)
)
const res = await OauthService.connectCallback({
clientName: client_name ?? '',
requestBody: { code, state }
requestBody: { code, state, params }
})
const message = { type: 'success', res, resource_type: client_name, extra: extraParams }
const message = { type: 'success', res, resource_type: client_name }
sendUserToast('successful', false)
if (window.opener) {
console.log('Sending oauth popup message')