mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 00:02:35 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
Ruben Fiszel
parent
3d6a2bcef2
commit
11f2a4c7ff
@@ -1 +1 @@
|
||||
f806a793b07105417dd7a592fbfc93ec2f226d4e
|
||||
5517ff637a675aee66d4e417d3f37266045f0e0c
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user