feat(oauth): support per-provider sandbox URLs (#9358)

* feat(oauth): support per-provider sandbox URLs in registry + instance settings

* fix(oauth): polish sandbox review nits (cc lookup, header label, ee ref)

* refactor(oauth): drop dead build_oauth_clients duplicate in windmill-oauth

* refactor(oauth): derive sandbox-capable provider list from registry

* chore(docker): copy oauth_connect.json into frontend build stage

* test(oauth): cover sandbox helpers (as_sandbox, canonical_name, resolve)

* chore: update ee-repo-ref to 9297d8f790346e6a6ad540c7bca1a67f91ec11a2

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

Previous ee-repo-ref: 3ab3eca9ac15ebab6db991e7964bc5e48ce21f42

New ee-repo-ref: 9297d8f790346e6a6ad540c7bca1a67f91ec11a2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-05-29 00:33:44 +02:00
committed by GitHub
parent 889101b7f0
commit 2bf11dcb15
10 changed files with 282 additions and 225 deletions
+1
View File
@@ -66,6 +66,7 @@ RUN npm ci
COPY frontend .
RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
+1 -1
View File
@@ -1 +1 @@
a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd
9297d8f790346e6a6ad540c7bca1a67f91ec11a2
+5 -1
View File
@@ -176,6 +176,10 @@
"token_url": "https://account.docusign.com/oauth/token",
"scopes": [
"signature"
]
],
"sandbox": {
"auth_url": "https://account-d.docusign.com/oauth/auth",
"token_url": "https://account-d.docusign.com/oauth/token"
}
}
}
@@ -586,6 +586,21 @@ pub struct OAuthConfig {
pub req_body_auth: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,
/// Optional URL overrides for the provider's sandbox environment.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<OAuthSandboxOverride>,
}
/// URL overrides for an OAuth provider's sandbox environment.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
pub struct OAuthSandboxOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_url: Option<String>,
}
// ---------------------------------------------------------------------------
+188 -205
View File
@@ -18,9 +18,7 @@ use std::collections::HashMap;
use std::fmt::Debug;
use anyhow::anyhow;
use base64::Engine;
use hmac::Mac;
use itertools::Itertools;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sqlx::{Postgres, Transaction};
use tower_cookies::{Cookie, Cookies};
@@ -89,6 +87,76 @@ pub struct OAuthConfig {
pub req_body_auth: Option<bool>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
/// Optional URL overrides for the provider's sandbox environment. When
/// present and the admin has configured a `<name>_sandbox` credentials
/// entry, `build_oauth_clients` registers a second client under that key.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<OAuthSandboxOverride>,
}
/// URL overrides for an OAuth provider's sandbox environment. Inherits
/// scopes, extra_params, etc. from the parent [`OAuthConfig`].
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct OAuthSandboxOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_url: Option<String>,
}
impl OAuthConfig {
/// Returns a copy of this config with sandbox URL overrides applied and
/// the nested `sandbox` field cleared. Returns `None` if no overrides are
/// set.
pub fn as_sandbox(&self) -> Option<OAuthConfig> {
let sb = self.sandbox.as_ref()?;
let mut out = self.clone();
out.sandbox = None;
if let Some(u) = &sb.auth_url {
out.auth_url = u.clone();
}
if let Some(u) = &sb.token_url {
out.token_url = u.clone();
}
if sb.userinfo_url.is_some() {
out.userinfo_url = sb.userinfo_url.clone();
}
Some(out)
}
}
/// Suffix appended to a provider name to identify its sandbox variant in the
/// instance credentials map and in `account.client`.
pub const SANDBOX_SUFFIX: &str = "_sandbox";
/// Strips [`SANDBOX_SUFFIX`] from a client name, returning the canonical
/// provider name. Returns the input unchanged if no suffix is present.
pub fn canonical_provider_name(client_name: &str) -> &str {
client_name
.strip_suffix(SANDBOX_SUFFIX)
.unwrap_or(client_name)
}
/// Resolves a registry [`OAuthConfig`] for `client_name`, transparently
/// applying the `sandbox` override block when the name carries the sandbox
/// suffix (e.g. `docusign_sandbox` resolves to `docusign` with sandbox URLs
/// applied). Used so callers don't need to know whether a name is a sandbox
/// variant before looking it up.
pub fn resolve_registry_config(
static_configs: &HashMap<String, OAuthConfig>,
client_name: &str,
) -> Option<OAuthConfig> {
if let Some(cfg) = static_configs.get(client_name) {
return Some(cfg.clone());
}
if client_name.ends_with(SANDBOX_SUFFIX) {
return static_configs
.get(canonical_provider_name(client_name))
.and_then(|cfg| cfg.as_sandbox());
}
None
}
/// OAuth client credentials
@@ -181,181 +249,6 @@ pub struct OAuthCallback {
pub state: String,
}
/// Build all OAuth clients from configuration
pub async fn build_oauth_clients(
base_url: &str,
oauths_from_config: Option<HashMap<String, OAuthClient>>,
connect_configs_json: &str,
login_configs_json: &str,
) -> anyhow::Result<AllClients> {
let connect_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)?;
let login_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(login_configs_json)?;
let oauths = if let Some(oauths) = oauths_from_config {
tracing::info!("Using OAuth clients from config: {oauths:?}");
oauths
} else {
let path = "./oauth.json";
let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") {
std::str::from_utf8(
&base64::engine::general_purpose::STANDARD
.decode(e)
.map_err(to_anyhow)?,
)?
.to_string()
} else if std::path::Path::new(path).exists() {
std::fs::read_to_string(path).map_err(to_anyhow)?
} else {
tracing::warn!("oauth.json not found, no OAuth clients loaded");
return Ok(AllClients {
logins: HashMap::new(),
connects: HashMap::new(),
slack: None,
});
};
if content.is_empty() {
tracing::warn!("oauth.json is empty, no OAuth clients loaded");
return Ok(AllClients {
logins: HashMap::new(),
connects: HashMap::new(),
slack: None,
});
};
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
Ok(clients) => clients,
Err(e) => {
tracing::error!("deserializing oauth.json: {e}");
HashMap::new()
}
}
.into_iter()
.collect()
};
tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", "));
let logins = login_configs
.into_iter()
.filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1))))
.chain(oauths.iter().filter_map(|x| {
x.1.login_config
.as_ref()
.map(|c| (x.0.clone(), (x.1, c.clone())))
}))
.filter_map(|(k, (client_params, config))| {
let named_client = build_basic_client(
k.clone(),
config.clone(),
client_params.clone(),
true,
base_url,
None,
);
named_client
.map(|named_client| {
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: client_params.allowed_domains.clone(),
userinfo_url: config.userinfo_url,
display_name: client_params.display_name.clone(),
grant_types: client_params.grant_types.clone(),
},
)
})
.map_err(|e| {
tracing::error!("Error building oauth client {k}: {e}");
e
})
.ok()
})
.collect();
let connects = connect_configs
.into_iter()
.filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1))))
.chain(oauths.iter().filter_map(|x| {
x.1.connect_config
.as_ref()
.map(|c| (x.0.clone(), (x.1, c.clone())))
}))
.filter_map(|(k, (client_params, config))| {
let named_client = build_basic_client(
k.clone(),
config.clone(),
client_params.clone(),
false,
base_url,
if k == "supabase_wizard" {
Some(format!("{base_url}/oauth/callback_supabase"))
} else {
None
},
);
named_client
.map(|named_client| {
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: None,
userinfo_url: None,
display_name: client_params.display_name.clone(),
grant_types: client_params.grant_types.clone(),
},
)
})
.map_err(|e| {
tracing::error!("Error building oauth client {k}: {e}");
e
})
.ok()
})
.collect();
let slack = oauths
.get("slack")
.map(|v| {
build_basic_client(
"slack".to_string(),
OAuthConfig {
auth_url: "https://slack.com/oauth/v2/authorize".to_string(),
token_url: "https://slack.com/api/oauth.v2.access".to_string(),
userinfo_url: None,
scopes: None,
extra_params: None,
extra_params_callback: None,
req_body_auth: None,
grant_types: vec!["authorization_code".to_string()],
},
v.clone(),
false,
base_url,
Some(format!("{base_url}/oauth/callback_slack")),
)
.map(|x| x.1)
.map_err(|e| {
tracing::error!("Error building oauth slack client: {e}");
e
})
.ok()
})
.flatten();
let all_clients = AllClients { logins, connects, slack };
tracing::debug!("Final oauth config: {all_clients:#?}");
Ok(all_clients)
}
/// Build a basic OAuth client from configuration
pub fn build_basic_client(
name: String,
@@ -433,38 +326,29 @@ pub async fn build_client_credentials_oauth_client(
let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone())
.map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?;
let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config {
if !config.auth_url.is_empty() && !config.token_url.is_empty() {
config.clone()
} else {
let static_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
.map_err(|e| {
error::Error::InternalErr(format!(
"Failed to parse oauth_connect.json: {}",
e
))
})?;
static_configs.get(client_name).cloned().ok_or_else(|| {
error::Error::BadRequest(format!(
"OAuth configuration not found for '{}' in either global settings or static config",
client_name
))
})?
}
} else {
let static_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(
|e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)),
)?;
static_configs.get(client_name).cloned().ok_or_else(|| {
let parse_static_configs = || {
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e))
})
};
let resolve_from_registry = |client_name: &str| -> error::Result<OAuthConfig> {
let static_configs = parse_static_configs()?;
resolve_registry_config(&static_configs, client_name).ok_or_else(|| {
error::Error::BadRequest(format!(
"OAuth configuration not found for '{}' in either global settings or static config",
client_name
))
})?
})
};
let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config {
if !config.auth_url.is_empty() && !config.token_url.is_empty() {
config.clone()
} else {
resolve_from_registry(client_name)?
}
} else {
resolve_from_registry(client_name)?
};
if let Some(override_url) = cc_token_url_override {
@@ -905,4 +789,103 @@ mod tests {
let verifier = SlackVerifier::new("test_secret").unwrap();
assert!(verifier.verify("123", "body", "wrong_sig").is_err());
}
#[test]
fn canonical_provider_name_strips_sandbox_suffix() {
assert_eq!(canonical_provider_name("docusign_sandbox"), "docusign");
assert_eq!(canonical_provider_name("docusign"), "docusign");
assert_eq!(canonical_provider_name(""), "");
// Only strips the suffix once; trailing suffix on already-canonical name.
assert_eq!(
canonical_provider_name("foo_sandbox_sandbox"),
"foo_sandbox"
);
}
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()),
scopes: Some(vec!["signature".to_string()]),
extra_params: None,
extra_params_callback: None,
req_body_auth: None,
grant_types: default_grant_types(),
sandbox: with_sandbox.then(|| OAuthSandboxOverride {
auth_url: Some("https://account-d.example.com/oauth/auth".to_string()),
token_url: Some("https://account-d.example.com/oauth/token".to_string()),
userinfo_url: None,
}),
}
}
#[test]
fn as_sandbox_returns_none_when_no_override() {
assert!(sample_oauth_config(false).as_sandbox().is_none());
}
#[test]
fn as_sandbox_overlays_urls_and_inherits_rest() {
let resolved = sample_oauth_config(true).as_sandbox().unwrap();
// URLs overridden by sandbox block
assert_eq!(
resolved.auth_url,
"https://account-d.example.com/oauth/auth"
);
assert_eq!(
resolved.token_url,
"https://account-d.example.com/oauth/token"
);
// userinfo_url not in override → inherits from parent
assert_eq!(
resolved.userinfo_url,
Some("https://account.example.com/userinfo".to_string())
);
// Scopes/grant_types inherited from parent
assert_eq!(resolved.scopes, Some(vec!["signature".to_string()]));
assert_eq!(resolved.grant_types, default_grant_types());
// Nested sandbox field cleared on the resolved config
assert!(resolved.sandbox.is_none());
}
#[test]
fn resolve_registry_config_direct_lookup() {
let mut registry = HashMap::new();
registry.insert("docusign".to_string(), sample_oauth_config(true));
let resolved = resolve_registry_config(&registry, "docusign").unwrap();
assert_eq!(resolved.auth_url, "https://account.example.com/oauth/auth");
// Direct lookup returns the entry as-is (sandbox block still attached).
assert!(resolved.sandbox.is_some());
}
#[test]
fn resolve_registry_config_sandbox_fallback() {
let mut registry = HashMap::new();
registry.insert("docusign".to_string(), sample_oauth_config(true));
let resolved = resolve_registry_config(&registry, "docusign_sandbox").unwrap();
// Sandbox-suffixed lookup resolves to parent's sandbox-overlaid config.
assert_eq!(
resolved.auth_url,
"https://account-d.example.com/oauth/auth"
);
assert!(resolved.sandbox.is_none());
}
#[test]
fn resolve_registry_config_missing_returns_none() {
let registry: HashMap<String, OAuthConfig> = HashMap::new();
assert!(resolve_registry_config(&registry, "docusign").is_none());
assert!(resolve_registry_config(&registry, "docusign_sandbox").is_none());
}
#[test]
fn resolve_registry_config_sandbox_without_block_returns_none() {
let mut registry = HashMap::new();
// Parent exists but has no sandbox override.
registry.insert("docusign".to_string(), sample_oauth_config(false));
assert!(resolve_registry_config(&registry, "docusign_sandbox").is_none());
}
}
+1
View File
@@ -30,6 +30,7 @@ RUN npm ci
COPY frontend .
RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
+1
View File
@@ -30,6 +30,7 @@ RUN npm ci
COPY frontend .
RUN mkdir /backend
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
@@ -74,6 +74,16 @@
let value: string = $state('')
let valueToken: TokenResponse | undefined = undefined
let connects: string[] | undefined = $state(undefined)
const SANDBOX_SUFFIX = '_sandbox'
function stripSandboxSuffix(name: string): string {
return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name
}
// `resourceType` is always the canonical type (e.g. `docusign`) so resource
// rows are uniform. `connectClient` carries the suffixed OAuth client name
// (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime
// and stored on `account.client` so token refresh hits the right endpoint.
let connectClient: string = $state('')
let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined =
$state(undefined)
let args: any = $state({})
@@ -152,7 +162,9 @@
description = ''
labels = undefined
wsSpecific = false
resourceType = rt ?? ''
const rawRt = rt ?? ''
connectClient = rawRt
resourceType = stripSandboxSuffix(rawRt)
valueToken = undefined
// Reset client credentials state
@@ -163,7 +175,7 @@
tokenUrl = ''
await loadConnects()
manual = !connects?.includes(resourceType)
manual = !connects?.includes(connectClient)
if (manual && express) {
dispatch('error', 'Express OAuth setup is not available for non OAuth resource types')
return
@@ -312,7 +324,8 @@
sendUserToast(data.error, true)
step = 2
} else if (data.type === 'success') {
resourceType = data.resource_type
connectClient = data.resource_type
resourceType = stripSandboxSuffix(connectClient)
value = data.res.access_token!
valueToken = data.res
responseExtra = data.extra ?? {}
@@ -325,7 +338,7 @@
}
async function getScopesAndParams() {
const connect = await OauthService.getOauthConnect({ client: resourceType })
const connect = await OauthService.getOauthConnect({ client: connectClient })
scopes = connect.scopes ?? []
extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][]
@@ -401,7 +414,7 @@
}
const tokenResponse = await OauthService.connectClientCredentials({
client: resourceType,
client: connectClient,
requestBody
})
@@ -428,7 +441,7 @@
* Requires user interaction and consent
* Opens popup for user to authenticate with OAuth provider
*/
const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin)
const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin)
url.searchParams.append('scopes', scopes.join('+'))
if (extra_params.length > 0) {
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
@@ -490,7 +503,7 @@
const accountData: any = {
refresh_token: valueToken.refresh_token ?? '',
expires_in: valueToken.expires_in,
client: resourceType,
client: connectClient,
grant_type: valueToken.grant_type || 'authorization_code'
}
@@ -602,6 +615,7 @@
)
step = 1
resourceType = ''
connectClient = ''
}
}
@@ -660,10 +674,11 @@
<Button
unifiedSize="md"
variant="default"
selected={key === resourceType}
selected={key === connectClient}
on:click={() => {
manual = false
resourceType = key
connectClient = key
resourceType = stripSandboxSuffix(key)
next()
}}
>
@@ -703,6 +718,7 @@
selected={key === resourceType}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
@@ -725,6 +741,7 @@
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
@@ -26,6 +26,7 @@
import { tick } from 'svelte'
import { Popover } from './meltComponents'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import oauthConnectRegistry from '$oauth_connect_registry'
interface Props {
snowflakeAccountIdentifier?: string
@@ -59,7 +60,7 @@
}
})
const windmillBuiltins = [
const windmillBuiltinsBase = [
'azure_oauth',
'github',
'gitlab',
@@ -82,7 +83,21 @@
'teams',
'zoho',
'xero',
'apify'
'apify',
'docusign'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
// entry and is treated as a builtin so we don't render the custom-URL form
// — the URLs come from the registry sandbox block. Derived at build time
// from the registry so adding a sandbox to a provider needs no frontend
// change.
const windmillBuiltinsWithSandbox = Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'sandbox' in cfg)
.map(([name]) => name)
const windmillBuiltins = [
...windmillBuiltinsBase,
...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`)
]
let showCustomOAuthForm = $state(false)
@@ -175,26 +190,29 @@
}
function getOAuthProviderIcon(name: string) {
// Sandbox variants share the parent provider's icon.
const lookup = name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
// Handle special cases
if (name === 'teams') {
if (lookup === 'teams') {
return APP_TO_ICON_COMPONENT.ms_teams_webhook
}
if (name === 'snowflake_oauth') {
if (lookup === 'snowflake_oauth') {
return APP_TO_ICON_COMPONENT.snowflake
}
if (name === 'azure_oauth') {
if (lookup === 'azure_oauth') {
return APP_TO_ICON_COMPONENT.azure
}
// Try direct mapping, fallback to Circle icon if not found
return APP_TO_ICON_COMPONENT[name as keyof typeof APP_TO_ICON_COMPONENT] || Circle
return APP_TO_ICON_COMPONENT[lookup as keyof typeof APP_TO_ICON_COMPONENT] || Circle
}
function generateOAuthDropdownItems(): Item[] {
const items: Item[] = []
// Add built-in providers that are not already configured
windmillBuiltins.forEach((name) => {
windmillBuiltinsBase.forEach((name) => {
// Only show providers that are not already in the oauths object
if (!oauths || !oauths[name]) {
const icon = getOAuthProviderIcon(name)
@@ -206,6 +224,19 @@
}
})
// Add sandbox variants for providers that have sandbox URLs in the registry
windmillBuiltinsWithSandbox.forEach((name) => {
const sandboxKey = `${name}_sandbox`
if (!oauths || !oauths[sandboxKey]) {
const icon = getOAuthProviderIcon(name)
items.push({
displayName: `${capitalize(name)} (sandbox)`,
action: () => createOAuthClient(sandboxKey),
icon: icon
})
}
})
// Add custom option
items.push({
displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`,
@@ -370,11 +401,14 @@
{#if oauths[k] && !(oauths[k] && 'login_config' in oauths[k])}
{#if !['slack', 'teams'].includes(k) && oauths[k]}
{@const IconComponent = getOAuthProviderIcon(k) as any}
{@const headerLabel = k.endsWith('_sandbox')
? `${k.slice(0, -'_sandbox'.length)} (sandbox)`
: k}
<div class="flex flex-col gap-2 pb-6">
<div class="flex flex-row items-center gap-2">
<IconComponent size={24} width="24" height="24" class="shrink-0" />
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis">{k}</label>
<label class="text-xs font-semibold text-emphasis">{headerLabel}</label>
<Button
variant="subtle"
destructive
+2 -1
View File
@@ -28,7 +28,8 @@ const config = {
base: process.env.VITE_BASE_URL ?? ''
},
alias: {
'$system_prompts': '../system_prompts/auto-generated'
$system_prompts: '../system_prompts/auto-generated',
$oauth_connect_registry: '../backend/oauth_connect.json'
}
},