feat: allow user resources in apps with a toggle (#3821)

* all

* all

* all

* all

* all

* all

* all

* all

* all

* all

* nits

* nits
This commit is contained in:
Ruben Fiszel
2024-05-27 07:51:49 +02:00
committed by GitHub
parent 1dfe4bf9fa
commit 3db4e3bab1
38 changed files with 1521 additions and 1070 deletions
+1
View File
@@ -9802,6 +9802,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"ulid",
"urlencoding",
"uuid 1.8.0",
"windmill-audit",
+2 -1
View File
@@ -89,4 +89,5 @@ openidconnect = { workspace = true, optional = true}
pin-project.workspace = true
crc.workspace = true
http.workspace = true
async-stream.workspace = true
async-stream.workspace = true
ulid.workspace = true
+41 -10
View File
@@ -2578,16 +2578,41 @@ paths:
content:
application/json:
schema:
additionalProperties:
type: object
properties:
extra_params:
additionalProperties:
type: string
scopes:
type: array
items:
type: string
type: array
items:
type: string
/oauth/get_connect/{client}:
get:
summary: get oauth connect
operationId: getOAuthConnect
tags:
- oauth
parameters:
- name: client
description: client name
in: path
required: true
schema:
type: string
responses:
"200":
description: get
content:
application/json:
schema:
type: object
properties:
extra_params:
additionalProperties:
type: string
scopes:
type: array
items:
type: string
/w/{workspace}/resources/create:
post:
@@ -4921,6 +4946,8 @@ paths:
type: string
path:
type: string
lock:
type: string
cache_ttl:
type: integer
required:
@@ -4930,6 +4957,10 @@ paths:
type: object
force_viewer_one_of_fields:
type: object
force_viewer_allow_user_resources:
type: array
items:
type: string
required:
- args
- component
+121 -22
View File
@@ -9,7 +9,9 @@ use std::collections::HashMap;
*/
use crate::{
db::{ApiAuthed, DB},
resources::get_resource_value_interpolated_internal,
users::{require_owner_of_path, OptAuthed},
variables::encrypt,
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
@@ -38,7 +40,7 @@ use windmill_common::{
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, Pagination, StripPath,
},
variables::build_crypt,
variables::{build_crypt, build_crypt_with_key_suffix},
worker::to_raw_value,
HUB_BASE_URL,
};
@@ -145,6 +147,7 @@ pub struct AppHistoryUpdate {
pub type StaticFields = HashMap<String, Box<RawValue>>;
pub type OneOfFields = HashMap<String, Vec<Box<RawValue>>>;
pub type AllowUserResources = Vec<String>;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
#[serde(rename_all = "lowercase")]
@@ -158,6 +161,7 @@ pub enum ExecutionMode {
pub struct PolicyTriggerableInputs {
static_inputs: StaticFields,
one_of_inputs: OneOfFields,
allow_user_resources: AllowUserResources,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -940,6 +944,7 @@ pub struct ExecuteApp {
// if set, the app is executed as viewer with the given static fields
pub force_viewer_static_fields: Option<StaticFields>,
pub force_viewer_one_of_fields: Option<OneOfFields>,
pub force_viewer_allow_user_resources: Option<AllowUserResources>,
}
fn digest(code: &str) -> String {
@@ -952,6 +957,7 @@ fn digest(code: &str) -> String {
async fn execute_component(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(payload): Json<ExecuteApp>,
@@ -976,6 +982,7 @@ async fn execute_component(
ExecuteApp {
force_viewer_static_fields: Some(static_fields),
force_viewer_one_of_fields: Some(one_of_fields),
force_viewer_allow_user_resources: Some(allow_user_resources),
..
} => {
let mut hm = HashMap::new();
@@ -986,6 +993,7 @@ async fn execute_component(
PolicyTriggerableInputs {
static_inputs: static_fields,
one_of_inputs: one_of_fields,
allow_user_resources,
},
);
} else {
@@ -998,6 +1006,7 @@ async fn execute_component(
PolicyTriggerableInputs {
static_inputs: static_fields,
one_of_inputs: one_of_fields,
allow_user_resources,
},
);
}
@@ -1027,22 +1036,31 @@ async fn execute_component(
let (username, permissioned_as, email) = match policy.execution_mode {
ExecutionMode::Anonymous => {
let username = opt_authed
.map(|a| a.username)
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Publisher => {
let username = opt_authed.map(|a| a.username).ok_or_else(|| {
Error::BadRequest("publisher execution mode requires authentication".to_string())
})?;
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.ok_or_else(|| {
Error::BadRequest(
"publisher execution mode requires authentication".to_string(),
)
})?;
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Viewer => {
let (username, email) = opt_authed.map(|a| (a.username, a.email)).ok_or_else(|| {
Error::BadRequest("Required to be authed in viewer mode".to_string())
})?;
let (username, email) = opt_authed
.as_ref()
.map(|a| (a.username.clone(), a.email.clone()))
.ok_or_else(|| {
Error::BadRequest("Required to be authed in viewer mode".to_string())
})?;
(
username.clone(),
username_to_permissioned_as(&username),
@@ -1051,17 +1069,37 @@ async fn execute_component(
}
};
let (job_payload, args, tag) = match payload {
let (job_payload, (args, job_id), tag) = match payload {
ExecuteApp { args, component, raw_code: Some(raw_code), path: None, .. } => {
let content = &raw_code.content;
let payload = JobPayload::Code(raw_code.clone());
let path = digest(content);
let args = build_args(policy, &component, path, args)?;
let args = build_args(
policy,
&component,
path,
args,
opt_authed.as_ref(),
&user_db,
&db,
&w_id,
)
.await?;
(payload, args, None)
}
ExecuteApp { args, component, raw_code: None, path: Some(path), .. } => {
let (payload, tag) = get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?;
let args = build_args(policy, &component, path.to_string(), args)?;
let args = build_args(
policy,
&component,
path.to_string(),
args,
opt_authed.as_ref(),
&user_db,
&db,
&w_id,
)
.await?;
(payload, args, tag)
}
_ => unreachable!(),
@@ -1081,7 +1119,7 @@ async fn execute_component(
None,
None,
None,
None,
job_id,
false,
false,
None,
@@ -1150,16 +1188,21 @@ async fn exists_app(
Ok(Json(exists))
}
fn build_args(
async fn build_args(
policy: Policy,
component: &str,
path: String,
args: HashMap<String, Box<RawValue>>,
) -> Result<PushArgs> {
authed: Option<&ApiAuthed>,
user_db: &UserDB,
db: &DB,
w_id: &str,
) -> Result<(PushArgs, Option<Uuid>)> {
let mut job_id: Option<Uuid> = None;
let key = format!("{}:{}", component, &path);
let (static_inputs, one_of_inputs) = match policy {
let (static_inputs, one_of_inputs, allow_user_resources) = match policy {
Policy { triggerables_v2: Some(t), .. } => {
let PolicyTriggerableInputs { static_inputs, one_of_inputs } = t
let PolicyTriggerableInputs { static_inputs, one_of_inputs, allow_user_resources } = t
.get(&key)
.or_else(|| t.get(&path))
.map(|x| x.clone())
@@ -1168,6 +1211,7 @@ fn build_args(
Some(PolicyTriggerableInputs {
static_inputs: HashMap::new(),
one_of_inputs: HashMap::new(),
allow_user_resources: Vec::new(),
})
} else {
None
@@ -1177,7 +1221,7 @@ fn build_args(
Error::BadRequest(format!("path {} is not allowed in the app policy", path))
})?;
(static_inputs, one_of_inputs)
(static_inputs, one_of_inputs, allow_user_resources)
}
Policy { triggerables: Some(t), .. } => {
let static_inputs = t
@@ -1195,7 +1239,7 @@ fn build_args(
Error::BadRequest(format!("path {} is not allowed in the app policy", path))
})?;
(static_inputs, HashMap::new())
(static_inputs, HashMap::new(), Vec::new())
}
_ => Err(Error::BadRequest(format!(
"Policy is missing triggerables for {}",
@@ -1206,7 +1250,58 @@ fn build_args(
let mut args = args.clone();
let mut safe_args = HashMap::<String, Box<RawValue>>::new();
// tracing::error!("{:?}", allow_user_resources);
for k in allow_user_resources.iter() {
if let Some(arg_val) = args.get(k) {
let key = serde_json::from_str::<String>(arg_val.get()).ok();
if let Some(path) =
key.and_then(|x| x.clone().strip_prefix("$res:").map(|x| x.to_string()))
{
if let Some(authed) = authed {
let res = get_resource_value_interpolated_internal(
authed,
Some(user_db.clone()),
db,
w_id,
&path,
None,
"",
)
.await?;
if res.is_none() {
return Err(Error::BadRequest(format!(
"Resource {} not found or not allowed for viewer",
path
)));
}
let job_id = if let Some(job_id) = job_id {
job_id
} else {
job_id = Some(ulid::Ulid::new().into());
job_id.unwrap()
};
let mut tx = db.begin().await?;
let mc =
build_crypt_with_key_suffix(&mut tx, &w_id, &job_id.to_string()).await?;
let encrypted = encrypt(&mc, to_raw_value(&res.unwrap()).get());
tx.commit().await?;
safe_args.insert(
k.to_string(),
to_raw_value(&format!("$encrypted:{encrypted}")),
);
} else {
return Err(Error::BadRequest(
"User resources are not allowed without being logged in".to_string(),
));
}
}
}
}
for (k, v) in one_of_inputs {
if safe_args.contains_key(&k) {
continue;
}
if let Some(arg_val) = args.get(&k) {
let arg_str = arg_val.get();
@@ -1239,9 +1334,13 @@ fn build_args(
}
for (k, v) in args {
let arg_str = serde_json::to_string(&v).unwrap_or_else(|_| "".to_string());
if safe_args.contains_key(&k) {
continue;
}
if !arg_str.contains("$var:") && !arg_str.contains("$res:") {
let arg_str = v.get();
if !arg_str.contains("\"$var:") && !arg_str.contains("\"$res:") {
safe_args.insert(k.to_string(), v);
} else {
safe_args.insert(
@@ -1254,7 +1353,7 @@ fn build_args(
)
.replace(
"$res:",
"The following resource has been omitted for security reasons: ",
"The following resource has been omitted for security reasons, to allow it, toggle: 'Allow resources from users' on that field input: ",
),
)
.map_err(|e| {
@@ -1270,5 +1369,5 @@ fn build_args(
for (k, v) in static_inputs {
extra.insert(k.to_string(), v.to_owned());
}
Ok(PushArgs { extra, args: safe_args })
Ok((PushArgs { extra, args: safe_args }, job_id))
}
+2 -1
View File
@@ -97,9 +97,10 @@ async fn list_variables(
from variable
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $1
LEFT JOIN resource ON resource.path = variable.path AND resource.workspace_id = $1
WHERE variable.workspace_id = $1 ORDER BY path",
WHERE variable.workspace_id = $1 AND variable.path NOT LIKE 'u/' || $2 || '/secret_arg/%' ORDER BY path",
)
.bind(&w_id)
.bind(&authed.username)
.fetch_all(&mut *tx)
.await?;
+2 -2
View File
@@ -2578,9 +2578,9 @@ async fn tarball_workspace(
if !skip_variables.unwrap_or(false) {
let variables =
sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) {
"SELECT * FROM variable WHERE workspace_id = $1"
"SELECT * FROM variable WHERE workspace_id = $1 AND path NOT LIKE 'u/%/secret_arg/%'"
} else {
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false"
"SELECT * FROM variable WHERE workspace_id = $1 AND is_secret = false AND path NOT LIKE 'u/%/secret_arg/%'"
})
.bind(&w_id)
.fetch_all(&mut *tx)
+24 -3
View File
@@ -78,6 +78,20 @@ pub async fn build_crypt<'c>(
Ok(magic_crypt::new_magic_crypt!(crypt_key, 256))
}
pub async fn build_crypt_with_key_suffix<'c>(
db: &mut Transaction<'c, Postgres>,
w_id: &str,
key_suffix: &str,
) -> crate::error::Result<MagicCrypt256> {
let key = get_workspace_key(w_id, db).await?;
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}{}", key, salt, key_suffix)
} else {
format!("{}{}", key, key_suffix)
};
Ok(magic_crypt::new_magic_crypt!(crypt_key, 256))
}
pub async fn get_workspace_key<'c>(
w_id: &str,
db: &mut Transaction<'c, Postgres>,
@@ -118,9 +132,7 @@ pub async fn get_secret_value_as_admin(
let mut tx = db.begin().await?;
let mc = build_crypt(&mut tx, &w_id).await?;
tx.commit().await?;
mc.decrypt_base64_to_string(value)
.map_err(|e| crate::Error::InternalErr(e.to_string()))?
decrypt_value_with_mc(value, mc).await?
} else {
"".to_string()
}
@@ -131,6 +143,15 @@ pub async fn get_secret_value_as_admin(
Ok(r)
}
pub async fn decrypt_value_with_mc(
value: String,
mc: MagicCrypt256,
) -> Result<String, crate::error::Error> {
Ok(mc
.decrypt_base64_to_string(value)
.map_err(|e| crate::Error::InternalErr(e.to_string()))?)
}
pub async fn get_reserved_variables(
db: &DB,
w_id: &str,
+16 -1
View File
@@ -26,6 +26,7 @@ use windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS;
use windmill_common::s3_helpers::{
get_etag_or_empty, LargeFileStorage, ObjectStoreResource, S3Object,
};
use windmill_common::variables::{build_crypt_with_key_suffix, decrypt_value_with_mc};
use windmill_common::worker::{CLOUD_HOSTED, TMP_DIR, WORKER_CONFIG};
use windmill_common::{
error::{self, Error},
@@ -146,7 +147,7 @@ pub async fn write_file_binary(dir: &str, path: &str, content: &[u8]) -> error::
}
lazy_static::lazy_static! {
static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|res)\:"#).unwrap();
static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|res|encrypted)\:"#).unwrap();
}
pub async fn transform_json<'a>(
@@ -274,6 +275,20 @@ pub async fn transform_json_value(
Error::NotFound(format!("Resource {path} not found for `{name}`: {e:#}"))
})
}
Value::String(y) if y.starts_with("$encrypted:") => {
let encrypted = y.strip_prefix("$encrypted:").unwrap();
let mut tx = db.begin().await?;
let mc = build_crypt_with_key_suffix(&mut tx, &job.workspace_id, &job.id.to_string())
.await?;
tx.commit().await?;
decrypt_value_with_mc(encrypted.to_string(), mc)
.await
.and_then(|x| {
serde_json::from_str(&x).map_err(|e| Error::InternalErr(e.to_string()))
})
// let path = y.strip_prefix("$res:").unwrap();
}
Value::String(y) if y.starts_with("$") => {
let flow_path = if let Some(uuid) = job.parent_job {
sqlx::query_scalar!("SELECT script_path FROM queue WHERE id = $1", uuid)
@@ -37,9 +37,8 @@
notFound = true
}
}
$: {
$workspaceStore && loadSchema()
}
$: $workspaceStore && loadSchema()
$: notFound && rawCode && parseJson()
function parseJson() {
@@ -1,631 +0,0 @@
<script lang="ts" context="module">
const apiTokenApps: Record<
string,
{ img?: string; instructions: string[]; linkedSecret?: string }
> = {
airtable: {
img: '/airtable_connect.png',
instructions: [
'Go to <a href="https://airtable.com/create/tokens" target="_blank" rel=”noopener noreferrer”>https://airtable.com/create/tokens</a>',
'Click on "Create new token"',
'Set a name, specify the scopes or the access level and click on "Create token"',
'Copy the token'
]
},
discord_webhook: {
img: '/discord_webhook.png',
instructions: ['Click on Server Settings', 'Click on Integration', 'Find "Webhooks"'],
linkedSecret: 'webhook_url'
},
toggl: {
img: '/toggl_connect.png',
instructions: [
'Go to <a href="https://track.toggl.com/profile" target="_blank" rel=”noopener noreferrer”>https://track.toggl.com/profile</a>',
'Find "API Token"'
]
},
mailchimp: {
img: '/mailchimp_connect.png',
instructions: [
'Go to <a href="https://admin.mailchimp.com/account/api" target="_blank" rel=”noopener noreferrer”>https://admin.mailchimp.com/account/api</a>',
'Find "Your API Keys"'
]
},
sendgrid: {
img: '/sendgrid_connect.png',
instructions: [
'Go to <a href="https://app.sendgrid.com/settings/api_keys" target="_blank" rel=”noopener noreferrer”>https://app.sendgrid.com/settings/api_keys</a>',
'Create an API key',
'Copy your key'
]
},
supabase: {
img: '/supabase_connect.png',
instructions: ['Go to the API Settings of your app to find the project URL and key']
},
square: {
img: '/square_connect.gif',
instructions: [
'Go to <a href="https://developer.squareup.com/apps" target="_blank" rel=”noopener noreferrer”>https://developer.squareup.com/apps</a>',
'In the left pane, choose Credentials',
'At the top of the page, choose Production mode for a production access token or Sandbox mode for a Sandbox access token.'
]
}
}
</script>
<script lang="ts">
import { oauthStore, workspaceStore } from '$lib/stores'
import IconedResourceType from './IconedResourceType.svelte'
import {
OauthService,
ResourceService,
VariableService,
type TokenResponse,
type ResourceType
} from '$lib/gen'
import { emptyString, truncateRev, urlize } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import Path from './Path.svelte'
import { Button, Drawer, Skeleton } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte'
import WhitelistIp from './WhitelistIp.svelte'
import { sendUserToast } from '$lib/toast'
import OauthScopes from './OauthScopes.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import Markdown from 'svelte-exmarkdown'
import autosize from '$lib/autosize'
import Required from './Required.svelte'
import Toggle from './Toggle.svelte'
import { Pen } from 'lucide-svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
export let newPageOAuth = false
const nativeLanguagesCategory = [
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'graphql'
]
let filter = ''
let manual = false
let value: string = ''
let valueToken: TokenResponse | undefined = undefined
let connects:
| Record<string, { scopes: string[]; extra_params?: Record<string, string> }>
| undefined = undefined
let connectsManual:
| [string, { img?: string; instructions: string[]; key?: string }][]
| undefined = undefined
let args: any = {}
let renderDescription = true
$: linkedSecretCandidates = apiTokenApps[resourceType]?.linkedSecret
? ([apiTokenApps[resourceType]?.linkedSecret] as string[])
: args != undefined
? Object.keys(args).filter((x) =>
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
)
: undefined
function linkedSecretValue(x: string) {
let r = 0
let lowerCasedX = x.toLowerCase()
if (lowerCasedX.includes('secret')) {
r += 10
}
if (lowerCasedX.includes('password')) {
r += 5
}
if (lowerCasedX.includes('private')) {
r += 4
}
if (lowerCasedX.includes('key')) {
r += 3
}
if (lowerCasedX.includes('token')) {
r += 2
}
if (lowerCasedX.includes('pass')) {
r += 1
}
return r
}
function forceSecretValue(resourceType: string): string | undefined {
if (resourceType == 'git_repository') {
return 'url'
}
}
$: linkedSecret =
forceSecretValue(resourceType) ??
linkedSecretCandidates?.sort((ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua))?.[0]
let scopes: string[] = []
let extra_params: [string, string][] = []
let path: string
let description = ''
let drawer: Drawer
let resourceType = ''
let resourceTypeInfo: ResourceType | undefined = undefined
let step = 1
let no_back = false
let pathError = ''
export async function open(rt?: string) {
step = 1
value = ''
description = ''
no_back = false
resourceType = rt ?? ''
valueToken = undefined
await loadConnects()
const connect = connects?.[resourceType]
if (connect) {
scopes = connect.scopes
extra_params = Object.entries(connect.extra_params ?? {})
} else {
manual = true
if (rt) {
next()
}
}
drawer.openDrawer?.()
}
export function openFromOauth(rt: string) {
resourceType = rt
value = $oauthStore?.access_token!
valueToken = $oauthStore!
$oauthStore = undefined
manual = false
step = 3
no_back = true
drawer.openDrawer?.()
}
async function loadConnects() {
const nconnects = (await OauthService.listOauthConnects()) as any
if (nconnects['supabase_wizard']) {
delete nconnects['supabase_wizard']
}
connects = nconnects
}
const connectAndManual = ['gitlab']
async function loadResources() {
await loadConnects()
const availableRts = await ResourceService.listResourceTypeNames({
workspace: $workspaceStore!
})
connectsManual = availableRts
.filter((x) => connectAndManual.includes(x) || !Object.keys(connects ?? {}).includes(x))
.map((x) => [
x,
apiTokenApps[x] ?? {
instructions: '',
img: undefined,
linkedSecret: undefined
}
])
const filteredNativeLanguages = filteredConnectsManual?.filter(
(o) => nativeLanguagesCategory?.includes(o[0]) ?? false
)
try {
filteredConnectsManual = [
...(filteredNativeLanguages ?? []),
...(filteredConnectsManual ?? []).filter(
([key, _]) => !nativeLanguagesCategory.includes(key)
)
]
} catch (e) {}
}
async function next() {
if (step == 1 && manual) {
resourceTypeInfo = await ResourceService.getResourceType({
workspace: $workspaceStore!,
path: resourceType
})
step += 1
args = {}
} else if (step == 1 && !manual) {
const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin)
url.searchParams.append('scopes', scopes.join('+'))
if (extra_params.length > 0) {
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
}
if (!newPageOAuth) {
window.location.href = url.toString()
} else {
window.open(url.toString(), '_blank')
drawer.closeDrawer()
}
} else {
let exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
path
})
if (exists) {
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
}
exists = await ResourceService.existsResource({
workspace: $workspaceStore!,
path
})
if (exists) {
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
let account: number | undefined = undefined
if (valueToken?.expires_in != undefined) {
account = Number(
await OauthService.createAccount({
workspace: $workspaceStore!,
requestBody: {
refresh_token: valueToken.refresh_token ?? '',
expires_in: valueToken.expires_in,
client: resourceType
}
})
)
}
const resourceValue = args
let saveVariable = false
if (!manual || linkedSecret != undefined) {
let v = manual ? args[linkedSecret ?? ''] : value
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
saveVariable = true
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value: v,
is_secret: true,
description: emptyString(description)
? `${manual ? 'Token' : 'OAuth token'} for ${resourceType}`
: description,
is_oauth: !manual,
account: account
}
})
resourceValue[linkedSecret ?? 'token'] = `$var:${path}`
}
}
await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type: resourceType,
path,
value: resourceValue,
description
}
})
dispatch('refresh', path)
sendUserToast(`Saved resource${saveVariable ? ' and variable' : ''} path: ${path}`)
drawer.closeDrawer?.()
}
}
async function back() {
if (step > 1) {
step -= 1
}
}
const dispatch = createEventDispatcher()
$: isGoogleSignin =
step == 1 &&
(resourceType == 'google' ||
resourceType == 'gmail' ||
resourceType == 'gcal' ||
resourceType == 'gdrive' ||
resourceType == 'gsheets')
$: disabled =
(step == 1 && resourceType == '') ||
(step == 2 &&
value == '' &&
args &&
args['token'] == '' &&
args['password'] == '' &&
args['api_key'] == '' &&
args['key'] == '' &&
linkedSecret != undefined) ||
(step == 3 && pathError != '') ||
!isValid
let isValid = true
let filteredConnects: [string, { scopes: string[]; extra_params?: Record<string, string> }][] = []
let filteredConnectsManual: [string, { img?: string; instructions: string[]; key?: string }][] =
[]
let darkMode: boolean = false
</script>
<DarkModeObserver bind:darkMode />
<SearchItems
{filter}
items={connects ? Object.entries(connects).sort((a, b) => a[0].localeCompare(b[0])) : undefined}
bind:filteredItems={filteredConnects}
f={(x) => x[0]}
/>
<SearchItems
{filter}
items={connectsManual?.sort((a, b) => a[0].localeCompare(b[0]))}
bind:filteredItems={filteredConnectsManual}
f={(x) => x[0]}
/>
<Drawer
bind:this={drawer}
on:close={() => {
dispatch('close')
}}
on:open={() => {
loadResources()
}}
size="800px"
>
<DrawerContent
title="Add a Resource"
on:close={drawer.closeDrawer}
tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs."
documentationLink="https://www.windmill.dev/docs/integrations/integrations_on_windmill"
>
{#if step == 1}
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
<input
type="text"
placeholder="Search resource type"
bind:value={filter}
class="text-2xl grow"
/>
</div>
<h2 class="mb-4">OAuth APIs</h2>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnects}
{#each filteredConnects as [key, values]}
<Button
size="sm"
variant="border"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = false
resourceType = key
scopes = values.scopes
extra_params = Object.entries(values.extra_params ?? {})
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/each}
{:else}
{#each new Array(3) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
{#if connects && Object.keys(connects).length == 0}
<div class="text-secondary text-sm w-full"
>No OAuth APIs has been setup on the instance. To add oauth APIs, first sync the resource
types with the hub, then add oauth configuration. See <a
href="https://www.windmill.dev/docs/misc/setup_oauth">documentation</a
>
</div>
{/if}
{#if manual == false && resourceType != ''}
<h3>Scopes</h3>
{#if !manual && resourceType != ''}
<OauthScopes bind:scopes />
{/if}
{/if}
<h2 class="mt-8 mb-4">Others</h2>
{#if connectsManual && connectsManual?.length < 10}
<div class="text-secondary p-2">
Resource Types have not been synced with the hub. Go to the admins workspace to sync them
(and add a schedule to do daily):
<p class="mt-4"
>1. Go to the "admins" workspaces:
<img src="/sync_resource_types.png" alt="sync resource types" class="mt-2" />
</p>
<p class="mt-4">
2: Run the synchronization script:
<img src="/sync_resource_types2.png" alt="sync resource types" class="mt-2" />
</p>
</div>
{/if}
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if nativeLanguagesCategory.includes(key)}
<Button
size="sm"
variant="border"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
manual = true
resourceType = key
next()
dispatch('click')
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{/if}
</div>
<div class="mt-8 mb-4" />
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if !nativeLanguagesCategory.includes(key)}
<!-- Exclude specific items -->
<Button
size="sm"
variant="border"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
manual = true
resourceType = key
next()
dispatch('click')
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{:else}
{#each new Array(9) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
{:else if step == 2 && manual}
<Path
bind:error={pathError}
bind:path
initialPath=""
namePlaceholder={resourceType}
kind="resource"
/>
{#if apiTokenApps[resourceType]}
<h2 class="mt-4 mb-2">Instructions</h2>
<div class="pl-10">
<ol class="list-decimal">
{#each apiTokenApps[resourceType].instructions as step}
<li>
{@html step}
</li>
{/each}
</ol>
</div>
{#if apiTokenApps[resourceType].img}
<div class="mt-4 w-full overflow-hidden">
<img class="m-auto max-h-60" alt="connect" src={apiTokenApps[resourceType].img} />
</div>
{/if}
{:else if !emptyString(resourceTypeInfo?.description)}
<h4 class="mt-8 mb-2">{resourceTypeInfo?.name} description</h4>
<div class="text-sm">
<Markdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} />
</div>
{/if}
{#if resourceType == 'postgresql' || resourceType == 'mysql' || resourceType == 'mongodb'}
<WhitelistIp />
{/if}
<h4 class="mt-8 inline-flex items-center gap-4"
>Resource description <Required required={false} />
<div class="flex gap-1 items-center">
<Toggle size="xs" bind:checked={renderDescription} />
<Pen size={14} />
</div>
</h4>
{#if renderDescription}
<div>
<div class="flex flex-row-reverse text-2xs text-tertiary -mt-1">GH Markdown</div>
<textarea use:autosize bind:value={description} placeholder={'Resource description'} />
</div>
{:else if description == undefined || description == ''}
<div class="text-sm text-tertiary">No description provided</div>
{:else}
<div class="mt-2" />
<GfmMarkdown md={description} />
{/if}
<div class="mt-12">
{#key resourceTypeInfo}
<ApiConnectForm
{linkedSecret}
{linkedSecretCandidates}
{resourceType}
{resourceTypeInfo}
bind:args
bind:isValid
/>
{/key}
</div>
{:else}
<Path
initialPath=""
namePlaceholder={resourceType}
bind:error={pathError}
bind:path
kind="resource"
/>
{#if apiTokenApps[resourceType] || !manual}
<ul class="mt-10">
<li>
1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ??
'token'}
<span class="font-bold">{truncateRev(value, 5, '*****')}</span>
will be stored a
<span class="font-mono whitespace-nowrap">{path}</span>.
</li>
<li class="mt-4">
2. The resource containing that token will be stored at the same path <span
class="font-mono whitespace-nowrap">{path}</span
>. The Variable and Resource will be "linked together", they will be deleted and renamed
together.
</li></ul
>
{/if}
{/if}
<div slot="actions" class="flex gap-1">
{#if step > 1 && !no_back}
<Button variant="border" on:click={back}>Back</Button>
{/if}
{#if isGoogleSignin}
<button {disabled} on:click={next}>
<img
class="h-10 w-auto object-contain"
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
alt="Google sign-in"
/>
</button>
{:else}
<Button {disabled} on:click={next}>
{#if step == 1 && !manual}
Connect
{:else if step == 1 && manual}
Next
{:else}
Save
{/if}
</Button>
{/if}
</div>
</DrawerContent>
</Drawer>
@@ -0,0 +1,82 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import AppConnectInner from './AppConnectInner.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
let drawer: Drawer
let resourceType = ''
let step = 1
let disabled = false
let isGoogleSignin = false
let manual = false
let appConnectInner: AppConnectInner | undefined = undefined
let rtToLoad: string | undefined = ''
export async function open(rt?: string) {
rtToLoad = rt
drawer.openDrawer?.()
}
$: appConnectInner?.open(rtToLoad)
const dispatch = createEventDispatcher()
let darkMode: boolean = false
</script>
<DarkModeObserver bind:darkMode />
<Drawer
bind:this={drawer}
on:close={() => {
step = 1
dispatch('close')
}}
size="800px"
>
<DrawerContent
title="Add a Resource"
on:close={drawer.closeDrawer}
tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs."
documentationLink="https://www.windmill.dev/docs/integrations/integrations_on_windmill"
>
<AppConnectInner
bind:this={appConnectInner}
bind:step
bind:resourceType
bind:isGoogleSignin
bind:disabled
bind:manual
on:close={drawer?.closeDrawer}
on:refresh
/>
<div slot="actions" class="flex gap-1">
{#if step > 1}
<Button variant="border" on:click={appConnectInner?.back}>Back</Button>
{/if}
{#if isGoogleSignin}
<button {disabled} on:click={appConnectInner?.next}>
<img
class="h-10 w-auto object-contain"
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
alt="Google sign-in"
/>
</button>
{:else}
<Button {disabled} on:click={appConnectInner?.next}>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
Next
{:else}
Save
{/if}
</Button>
{/if}
</div>
</DrawerContent>
</Drawer>
@@ -0,0 +1,550 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import IconedResourceType from './IconedResourceType.svelte'
import {
OauthService,
ResourceService,
VariableService,
type TokenResponse,
type ResourceType
} from '$lib/gen'
import { emptyString, truncateRev, urlize } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import Path from './Path.svelte'
import { Button, Skeleton } from './common'
import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte'
import WhitelistIp from './WhitelistIp.svelte'
import { sendUserToast } from '$lib/toast'
import OauthScopes from './OauthScopes.svelte'
import Markdown from 'svelte-exmarkdown'
import autosize from '$lib/autosize'
import Required from './Required.svelte'
import Toggle from './Toggle.svelte'
import { Pen } from 'lucide-svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect'
export let step = 1
export let resourceType = ''
export let isGoogleSignin = false
export let disabled = false
export let manual = false
let isValid = true
const nativeLanguagesCategory = [
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'graphql'
]
let filter = ''
let value: string = ''
let valueToken: TokenResponse | undefined = undefined
let connects: string[] | undefined = undefined
let connectsManual:
| [string, { img?: string; instructions: string[]; key?: string }][]
| undefined = undefined
let args: any = {}
let renderDescription = true
$: linkedSecretCandidates = apiTokenApps[resourceType]?.linkedSecret
? ([apiTokenApps[resourceType]?.linkedSecret] as string[])
: args != undefined
? Object.keys(args).filter((x) =>
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
)
: undefined
$: linkedSecret =
forceSecretValue(resourceType) ??
linkedSecretCandidates?.sort((ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua))?.[0]
let scopes: string[] = []
let extra_params: [string, string][] = []
let path: string
let description = ''
let resourceTypeInfo: ResourceType | undefined = undefined
let pathError = ''
export async function open(rt?: string) {
step = 1
value = ''
description = ''
resourceType = rt ?? ''
valueToken = undefined
await loadConnects()
const isConnect = connects?.includes(resourceType)
if (isConnect) {
manual = false
next()
} else {
manual = true
if (rt) {
next()
} else {
loadResourceTypes()
}
}
}
async function loadConnects() {
if (!connects) {
connects = await OauthService.listOauthConnects()
}
}
const connectAndManual = ['gitlab']
$: isGoogleSignin =
step == 1 &&
(resourceType == 'google' ||
resourceType == 'gmail' ||
resourceType == 'gcal' ||
resourceType == 'gdrive' ||
resourceType == 'gsheets')
$: disabled =
(step == 1 && resourceType == '') ||
(step == 2 &&
value == '' &&
args &&
args['token'] == '' &&
args['password'] == '' &&
args['api_key'] == '' &&
args['key'] == '' &&
linkedSecret != undefined) ||
step == 3 ||
(step == 4 && pathError != '') ||
!isValid
export async function loadResourceTypes() {
if (connectsManual) {
return
}
const availableRts = await ResourceService.listResourceTypeNames({
workspace: $workspaceStore!
})
connectsManual = availableRts
.filter((x) => connectAndManual.includes(x) || !Object.keys(connects ?? {}).includes(x))
.map((x) => [
x,
apiTokenApps[x] ?? {
instructions: '',
img: undefined,
linkedSecret: undefined
}
])
const filteredNativeLanguages = filteredConnectsManual?.filter(
(o) => nativeLanguagesCategory?.includes(o[0]) ?? false
)
try {
filteredConnectsManual = [
...(filteredNativeLanguages ?? []),
...(filteredConnectsManual ?? []).filter(
([key, _]) => !nativeLanguagesCategory.includes(key)
)
]
} catch (e) {}
}
function popupListener(event) {
let data = event.data
if (event.origin !== window.location.origin) {
return
}
if (data.type === 'error') {
sendUserToast(event.data.error, true)
step = 2
} else if (data.type === 'success') {
resourceType = data.resource_type
value = data.res.access_token!
valueToken = data.res
step = 4
}
}
async function getScopesAndParams() {
const connect = await OauthService.getOauthConnect({ client: resourceType })
scopes = connect.scopes ?? []
extra_params = Object.entries(connect.extra_params ?? {})
}
async function getResourceTypeInfo() {
resourceTypeInfo = await ResourceService.getResourceType({
workspace: $workspaceStore!,
path: resourceType
})
}
export async function next() {
if (step == 1) {
if (manual) {
await getResourceTypeInfo()
args = {}
} else {
await Promise.all([getScopesAndParams(), getResourceTypeInfo()])
}
step += 1
} else if (step == 2 && !manual) {
const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin)
url.searchParams.append('scopes', scopes.join('+'))
if (extra_params.length > 0) {
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
}
// if (!newPageOAuth) {
// window.location.href = url.toString()
// } else {
window.addEventListener('message', popupListener, { once: true })
window.open(url.toString(), '_blank', 'popup=true')
step += 1
// dispatch('close')
// }
} else {
let exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
path
})
if (exists) {
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
}
exists = await ResourceService.existsResource({
workspace: $workspaceStore!,
path
})
if (exists) {
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
let account: number | undefined = undefined
if (valueToken?.expires_in != undefined) {
account = Number(
await OauthService.createAccount({
workspace: $workspaceStore!,
requestBody: {
refresh_token: valueToken.refresh_token ?? '',
expires_in: valueToken.expires_in,
client: resourceType
}
})
)
}
const resourceValue = args
let saveVariable = false
if (!manual || linkedSecret != undefined) {
let v = manual ? args[linkedSecret ?? ''] : value
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
saveVariable = true
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value: v,
is_secret: true,
description: emptyString(description)
? `${manual ? 'Token' : 'OAuth token'} for ${resourceType}`
: description,
is_oauth: !manual,
account: account
}
})
resourceValue[linkedSecret ?? 'token'] = `$var:${path}`
}
}
await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type: resourceType,
path,
value: resourceValue,
description
}
})
dispatch('refresh', path)
dispatch('close')
sendUserToast(`Saved resource${saveVariable ? ' and variable' : ''} path: ${path}`)
step = 1
resourceType = ''
}
}
export async function back() {
if (step == 4) {
step -= 2
} else if (step > 1) {
step -= 1
}
if (step == 1) {
loadConnects()
loadResourceTypes()
}
}
const dispatch = createEventDispatcher()
let filteredConnects: string[] = []
let filteredConnectsManual: [string, { img?: string; instructions: string[]; key?: string }][] =
[]
let editScopes = false
</script>
<SearchItems
{filter}
items={connects ? connects.sort((a, b) => a.localeCompare(b)) : undefined}
bind:filteredItems={filteredConnects}
f={(x) => x}
/>
<SearchItems
{filter}
items={connectsManual?.sort((a, b) => a[0].localeCompare(b[0]))}
bind:filteredItems={filteredConnectsManual}
f={(x) => x[0]}
/>
{#if step == 1}
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
<input
type="text"
placeholder="Search resource type"
bind:value={filter}
class="text-2xl grow"
/>
</div>
<h2 class="mb-4">OAuth APIs</h2>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnects}
{#each filteredConnects as key}
<Button
size="sm"
variant="border"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = false
resourceType = key
next()
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/each}
{:else}
{#each new Array(3) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
{#if connects && connects.length == 0}
<div class="text-secondary text-sm w-full"
>No OAuth APIs has been setup on the instance. To add oauth APIs, first sync the resource
types with the hub, then add oauth configuration. See <a
href="https://www.windmill.dev/docs/misc/setup_oauth">documentation</a
>
</div>
{/if}
<h2 class="mt-8 mb-4">Others</h2>
{#if connectsManual && connectsManual?.length < 10}
<div class="text-secondary p-2">
Resource Types have not been synced with the hub. Go to the admins workspace to sync them (and
add a schedule to do daily):
<p class="mt-4"
>1. Go to the "admins" workspaces:
<img src="/sync_resource_types.png" alt="sync resource types" class="mt-2" />
</p>
<p class="mt-4">
2: Run the synchronization script:
<img src="/sync_resource_types2.png" alt="sync resource types" class="mt-2" />
</p>
</div>
{/if}
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if nativeLanguagesCategory.includes(key)}
<Button
size="sm"
variant="border"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = true
resourceType = key
next()
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{/if}
</div>
<div class="mt-8 mb-4" />
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
{#if !nativeLanguagesCategory.includes(key) && key != 'supabase_wizard'}
<!-- Exclude specific items -->
<Button
size="sm"
variant="border"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = true
resourceType = key
next()
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{:else}
{#each new Array(9) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
{:else if step == 2 && manual}
<Path
bind:error={pathError}
bind:path
initialPath=""
namePlaceholder={resourceType}
kind="resource"
/>
{#if apiTokenApps[resourceType]}
<h2 class="mt-4 mb-2">Instructions</h2>
<div class="pl-10">
<ol class="list-decimal">
{#each apiTokenApps[resourceType].instructions as step}
<li>
{@html step}
</li>
{/each}
</ol>
</div>
{#if apiTokenApps[resourceType].img}
<div class="mt-4 w-full overflow-hidden">
<img class="m-auto max-h-60" alt="connect" src={apiTokenApps[resourceType].img} />
</div>
{/if}
{:else if !emptyString(resourceTypeInfo?.description)}
<h4 class="mt-8 mb-2">{resourceTypeInfo?.name} description</h4>
<div class="text-sm">
<Markdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} />
</div>
{/if}
{#if resourceType == 'postgresql' || resourceType == 'mysql' || resourceType == 'mongodb'}
<WhitelistIp />
{/if}
<h4 class="mt-8 inline-flex items-center gap-4"
>Resource description <Required required={false} />
<div class="flex gap-1 items-center">
<Toggle size="xs" bind:checked={renderDescription} />
<Pen size={14} />
</div>
</h4>
{#if renderDescription}
<div>
<div class="flex flex-row-reverse text-2xs text-tertiary -mt-1">GH Markdown</div>
<textarea use:autosize bind:value={description} placeholder={'Resource description'} />
</div>
{:else if description == undefined || description == ''}
<div class="text-sm text-tertiary">No description provided</div>
{:else}
<div class="mt-2" />
<GfmMarkdown md={description} />
{/if}
<div class="mt-12">
{#key resourceTypeInfo}
<ApiConnectForm
{linkedSecret}
{linkedSecretCandidates}
{resourceType}
{resourceTypeInfo}
bind:args
bind:isValid
/>
{/key}
</div>
{:else if step == 2 && !manual}
{#if manual == false && resourceType != ''}
<h1 class="mb-4">{resourceType}</h1>
<div class="my-4 text-secondary"
>Click connect to create a resource backed by an oauth connection, whose token is fetched from
the external services and refreshed automatically if needed before expiration (using its
refresh token)</div
>
<h4 class="mb-2">Description</h4>
<div class="text-sm mb-8">
<Markdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} />
</div>
<h3 class="mb-4 flex gap-4"
>Scopes <button
on:click={() => {
editScopes = !editScopes
}}><Pen size={14} /></button
></h3
>
{#if editScopes}
<OauthScopes bind:scopes />
{:else}
<div class="flex flex-col gap-1">
{#each scopes as scope}
<div class="py-0.5 pl-2 text-xs">- {scope}</div>
{/each}
</div>
{/if}
{/if}
{:else if step == 3 && !manual}
Finish connection in popup window
{:else}
<Path
initialPath=""
namePlaceholder={resourceType}
bind:error={pathError}
bind:path
kind="resource"
/>
{#if apiTokenApps[resourceType] || !manual}
<ul class="mt-10">
<li>
1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? 'token'}
<span class="font-bold">{truncateRev(value, 5, '*****')}</span>
will be stored a
<span class="font-mono whitespace-nowrap">{path}</span>.
</li>
<li class="mt-4">
2. The resource containing that token will be stored at the same path <span
class="font-mono whitespace-nowrap">{path}</span
>. The Variable and Resource will be "linked together", they will be deleted and renamed
together.
</li></ul
>
{/if}
{/if}
@@ -230,7 +230,6 @@
}}
readOnlyMode={false}
/>
<!-- svelte-ignore a11y-autofocus -->
<div class="flex flex-col w-full {minW ? 'min-w-[250px]' : ''}">
<div>
@@ -67,8 +67,8 @@
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Payload <Tooltip
>Auth is passed in query most commonly. LinkedIn is an example of OAuth using
x-www-form-urlencoded
>Auth (client id/client secret) is passed as basic auth most commonly but can be passed in the
body x-www-form-urlencoded. Some LinkedIn is an example of OAuth using x-www-form-urlencoded
</Tooltip></span
>
<div>
@@ -674,7 +674,7 @@
}}
>
<span class="truncate font-mono">
# Retry {j + 1}: {failedRetry}
# Attempt {j + 1}: {failedRetry}
</span>
</Button>
@@ -48,8 +48,6 @@
export let extra: Record<string, any> = {}
export let displayType: boolean = true
export let customErrorMessage: string | undefined = undefined
export let hideResourceInput: boolean = false
export let resourceInputUnsupported: boolean = false
export let render = true
export let title: string | undefined = undefined
export let placeholder: string | undefined = undefined
@@ -177,313 +175,306 @@
</script>
{#if render}
{#if !(hideResourceInput && inputCat === 'resource-object')}
<div class="flex flex-col w-full min-w-[250px]">
<div>
{#if displayHeader}
<FieldHeader
prettify={emptyString(title)}
label={title && !emptyString(title) ? title : label}
{required}
{type}
{contentEncoding}
{format}
{displayType}
labelClass={css?.label?.class}
/>
{/if}
<div class="flex flex-col w-full min-w-[250px]">
<div>
{#if displayHeader}
<FieldHeader
prettify={emptyString(title)}
label={title && !emptyString(title) ? title : label}
{required}
{type}
{contentEncoding}
{format}
{displayType}
labelClass={css?.label?.class}
/>
{/if}
{#if description}
<div class={twMerge('text-xs italic pb-1', css?.description?.class)}>
<pre class="font-main whitespace-normal">{description}</pre>
</div>
{/if}
{#if description}
<div class={twMerge('text-xs italic pb-1', css?.description?.class)}>
<pre class="font-main whitespace-normal">{description}</pre>
</div>
{/if}
<div class="flex space-x-1">
{#if inputCat == 'number'}
{#if extra['min'] != undefined && extra['max'] != undefined}
<div class="flex w-full gap-1">
<span>{extra['min']}</span>
<div class="grow">
<Range bind:value min={extra['min']} max={extra['max']} />
</div>
<span>{extra['max']}</span>
<span class="mx-2"><Badge large color="blue">{value}</Badge></span>
<div class="flex space-x-1">
{#if inputCat == 'number'}
{#if extra['min'] != undefined && extra['max'] != undefined}
<div class="flex w-full gap-1">
<span>{extra['min']}</span>
<div class="grow">
<Range bind:value min={extra['min']} max={extra['max']} />
</div>
{:else if extra?.currency}
<CurrencyInput
inputClasses={{
formatted: 'px-2 w-full py-1.5 text-black dark:text-white',
wrapper: 'w-full windmillapp',
formattedZero: 'text-black dark:text-white'
}}
noColor
bind:value
currency={extra?.currency}
locale={extra?.currencyLocale ?? 'en-US'}
/>
{:else}
<input
on:focus={(e) => {
dispatch('focus')
}}
type="number"
class={twMerge(
valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'
)}
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
min={extra['min']}
max={extra['max']}
/>
{/if}
{:else if inputCat == 'boolean'}
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
class={valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'}
bind:checked={value}
/>
{#if type == 'boolean' && value == undefined}
<span>&nbsp; Not set</span>
{/if}
{:else if inputCat == 'list'}
<div class="w-full">
{#if Array.isArray(itemsType?.multiselect) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'!bg-surface-secondary'}
bind:selected={value}
options={itemsType?.multiselect ?? []}
selectedOptionsDraggable={true}
/>
</div>
{:else if Array.isArray(itemsType?.enum) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'!bg-surface-secondary'}
bind:selected={value}
options={itemsType?.enum ?? []}
selectedOptionsDraggable={true}
/>
</div>
{:else if Array.isArray(enum_) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'!bg-surface-secondary'}
bind:selected={value}
options={enum_ ?? []}
selectedOptionsDraggable={true}
/>
</div>
{:else}
<div class="w-full">
{#if Array.isArray(value)}
{#each value ?? [] as v, i}
<div class="flex flex-row max-w-md mt-1 w-full">
{#if itemsType?.type == 'number'}
<input type="number" bind:value={v} />
{:else if itemsType?.type == 'string' && itemsType?.contentEncoding == 'base64'}
<input
type="file"
class="my-6"
on:change={(x) => fileChanged(x, (val) => (value[i] = val))}
multiple={false}
/>
{:else if Array.isArray(itemsType?.enum)}
<select
on:focus={(e) => {
dispatch('focus')
}}
class="px-6"
bind:value={v}
>
{#each itemsType?.enum ?? [] as e}
<option>{e}</option>
{/each}
</select>
{:else}
<input type="text" bind:value={v} />
{/if}
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
on:click={() => {
value = value.filter((el) => el != v)
if (value.length == 0) {
value = undefined
}
}}
>
<X size={14} />
</button>
</div>
{/each}
{:else if value != undefined}
List is not an array
{/if}
</div>
<div class="flex my-2">
<Button
variant="border"
color="light"
size="sm"
btnClasses="mt-1"
on:click={() => {
if (value == undefined || !Array.isArray(value)) {
value = []
}
value = value.concat('')
}}
startIcon={{ icon: Plus }}
>
Add
</Button>
</div>
<span class="ml-2">
{(value ?? []).length} item{(value ?? []).length != 1 ? 's' : ''}
</span>
{/if}
<span>{extra['max']}</span>
<span class="mx-2"><Badge large color="blue">{value}</Badge></span>
</div>
{:else if inputCat == 'resource-object'}
<LightweightObjectResourceInput
{format}
{:else if extra?.currency}
<CurrencyInput
inputClasses={{
formatted: 'px-2 w-full py-1.5 text-black dark:text-white',
wrapper: 'w-full windmillapp',
formattedZero: 'text-black dark:text-white'
}}
noColor
bind:value
unsupported={resourceInputUnsupported}
currency={extra?.currency}
locale={extra?.currencyLocale ?? 'en-US'}
/>
{:else if inputCat == 'object'}
{#if properties && Object.keys(properties).length > 0}
<div class="p-4 pl-8 border rounded w-full">
<LightweightSchemaForm
schema={{
properties,
$schema: '',
required: nestedRequired ?? [],
type: 'object'
}}
bind:args={value}
/>
</div>
{:else}
<textarea
bind:this={el}
on:focus={(e) => {
dispatch('focus')
}}
use:autosize
style="min-height: 5px;"
class="col-span-10 {valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'}"
placeholder={defaultValue ? JSON.stringify(defaultValue, null, 4) : ''}
bind:value={rawValue}
/>
{/if}
{:else if inputCat == 'enum'}
<select
{:else}
<input
on:focus={(e) => {
dispatch('focus')
}}
class="px-6"
bind:value
>
{#each enum_ ?? [] as e}
<option>{e}</option>
{/each}
</select>
{:else if inputCat == 'date'}
{#if format === 'date'}
<DateInput bind:value dateFormat={extra['dateFormat']} />
{:else}
<DateTimeInput useDropdown bind:value />
{/if}
{:else if inputCat == 'base64'}
<div class="flex flex-col my-6 w-full">
<input
type="file"
on:change={(x) => fileChanged(x, (val) => (value = val))}
multiple={false}
/>
{#if value?.length}
<div class="text-2xs text-tertiary mt-1"
>File length: {value.length} base64 chars</div
>
{/if}
</div>
{:else if inputCat == 'resource-string'}
<div class="flex flex-row gap-x-1 w-full">
<LightweightResourcePicker
bind:value
resourceType={format.split('-').length > 1
? format.substring('resource-'.length)
: undefined}
/>
</div>
{:else if inputCat == 'email'}
<input
on:focus
type="email"
class={valid
? ''
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-3'}
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
/>
{:else if inputCat == 'currency'}
<input
type="number"
class={valid
? ''
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-3'}
class={twMerge(
valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'
)}
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
min={extra['min']}
max={extra['max']}
/>
{:else if inputCat == 'string'}
<div class="flex flex-col w-full">
<div class="flex flex-row w-full items-center justify-between">
{#if extra?.['password'] == true}
<Password bind:password={value} />
{:else}
<textarea
rows={extra?.['rows'] || 1}
bind:this={el}
on:focus={(e) => {
dispatch('focus')
}}
use:autosize
class="col-span-10 {valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'}"
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
on:pointerdown|stopPropagation={(e) => {
dispatch('inputClicked', e)
}}
/>
{/if}
{:else if inputCat == 'boolean'}
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
class={valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'}
bind:checked={value}
/>
{#if type == 'boolean' && value == undefined}
<span>&nbsp; Not set</span>
{/if}
{:else if inputCat == 'list'}
<div class="w-full">
{#if Array.isArray(itemsType?.multiselect) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'!bg-surface-secondary'}
bind:selected={value}
options={itemsType?.multiselect ?? []}
selectedOptionsDraggable={true}
/>
</div>
{:else if Array.isArray(itemsType?.enum) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'!bg-surface-secondary'}
bind:selected={value}
options={itemsType?.enum ?? []}
selectedOptionsDraggable={true}
/>
</div>
{:else if Array.isArray(enum_) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'!bg-surface-secondary'}
bind:selected={value}
options={enum_ ?? []}
selectedOptionsDraggable={true}
/>
</div>
{:else}
<div class="w-full">
{#if Array.isArray(value)}
{#each value ?? [] as v, i}
<div class="flex flex-row max-w-md mt-1 w-full">
{#if itemsType?.type == 'number'}
<input type="number" bind:value={v} />
{:else if itemsType?.type == 'string' && itemsType?.contentEncoding == 'base64'}
<input
type="file"
class="my-6"
on:change={(x) => fileChanged(x, (val) => (value[i] = val))}
multiple={false}
/>
{:else if Array.isArray(itemsType?.enum)}
<select
on:focus={(e) => {
dispatch('focus')
}}
class="px-6"
bind:value={v}
>
{#each itemsType?.enum ?? [] as e}
<option>{e}</option>
{/each}
</select>
{:else}
<input type="text" bind:value={v} />
{/if}
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
on:click={() => {
value = value.filter((el) => el != v)
if (value.length == 0) {
value = undefined
}
}}
>
<X size={14} />
</button>
</div>
{/each}
{:else if value != undefined}
List is not an array
{/if}
</div>
</div>
{/if}
<slot name="actions" />
</div>
{#if error && error != ''}
<div class="text-right text-xs text-red-600 dark:text-red-400">
{#if error === ''}
&nbsp;
{:else}
{error}
<div class="flex my-2">
<Button
variant="border"
color="light"
size="sm"
btnClasses="mt-1"
on:click={() => {
if (value == undefined || !Array.isArray(value)) {
value = []
}
value = value.concat('')
}}
startIcon={{ icon: Plus }}
>
Add
</Button>
</div>
<span class="ml-2">
{(value ?? []).length} item{(value ?? []).length != 1 ? 's' : ''}
</span>
{/if}
</div>
{:else if inputCat == 'resource-object'}
<LightweightObjectResourceInput {format} bind:value />
{:else if inputCat == 'object'}
{#if properties && Object.keys(properties).length > 0}
<div class="p-4 pl-8 border rounded w-full">
<LightweightSchemaForm
schema={{
properties,
$schema: '',
required: nestedRequired ?? [],
type: 'object'
}}
bind:args={value}
/>
</div>
{:else}
<textarea
bind:this={el}
on:focus={(e) => {
dispatch('focus')
}}
use:autosize
style="min-height: 5px;"
class="col-span-10 {valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'}"
placeholder={defaultValue ? JSON.stringify(defaultValue, null, 4) : ''}
bind:value={rawValue}
/>
{/if}
{:else if inputCat == 'enum'}
<select
on:focus={(e) => {
dispatch('focus')
}}
class="px-6"
bind:value
>
{#each enum_ ?? [] as e}
<option>{e}</option>
{/each}
</select>
{:else if inputCat == 'date'}
{#if format === 'date'}
<DateInput bind:value dateFormat={extra['dateFormat']} />
{:else}
<DateTimeInput useDropdown bind:value />
{/if}
{:else if inputCat == 'base64'}
<div class="flex flex-col my-6 w-full">
<input
type="file"
on:change={(x) => fileChanged(x, (val) => (value = val))}
multiple={false}
/>
{#if value?.length}
<div class="text-2xs text-tertiary mt-1">File length: {value.length} base64 chars</div
>
{/if}
</div>
{:else if inputCat == 'resource-string'}
<div class="flex flex-row gap-x-1 w-full">
<LightweightResourcePicker
bind:value
resourceType={format.split('-').length > 1
? format.substring('resource-'.length)
: undefined}
/>
</div>
{:else if inputCat == 'email'}
<input
on:focus
type="email"
class={valid
? ''
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-3'}
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
/>
{:else if inputCat == 'currency'}
<input
type="number"
class={valid
? ''
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-3'}
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
/>
{:else if inputCat == 'string'}
<div class="flex flex-col w-full">
<div class="flex flex-row w-full items-center justify-between">
{#if extra?.['password'] == true}
<Password bind:password={value} />
{:else}
<textarea
rows={extra?.['rows'] || 1}
bind:this={el}
on:focus={(e) => {
dispatch('focus')
}}
use:autosize
class="col-span-10 {valid && error == ''
? ''
: 'border !border-red-700 !border-opacity-70 focus:!border-red-700 focus:!border-opacity-30'}"
placeholder={placeholder ?? defaultValue ?? ''}
bind:value
on:pointerdown|stopPropagation={(e) => {
dispatch('inputClicked', e)
}}
/>
{/if}
</div>
</div>
{/if}
<slot name="actions" />
</div>
{#if error && error != ''}
<div class="text-right text-xs text-red-600 dark:text-red-400">
{#if error === ''}
&nbsp;
{:else}
{error}
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style>
@@ -4,7 +4,6 @@
export let format: string
export let value: any
export let disablePortal = false
export let unsupported = false
function isString(value: any) {
return typeof value === 'string' || value instanceof String
@@ -34,20 +33,13 @@
</script>
<div class="flex flex-row w-full flex-wrap gap-x-2 gap-y-0.5">
{#if unsupported}
<div class=" text-xs text-yellow-600 dark:text-yellow-500">
Resource argument is unsupported for security reasons and won't be displayed, use the resource
select component instead.
</div>
{:else}
<LightweightResourcePicker
{disablePortal}
on:change={(e) => {
path = e.detail
resourceToValue()
}}
bind:value={path}
resourceType={format.split('-').length > 1 ? format.substring('resource-'.length) : undefined}
/>
{/if}
<LightweightResourcePicker
{disablePortal}
on:change={(e) => {
path = e.detail
resourceToValue()
}}
bind:value={path}
resourceType={format.split('-').length > 1 ? format.substring('resource-'.length) : undefined}
/>
</div>
@@ -6,6 +6,8 @@
import { SELECT_INPUT_DEFAULT_STYLE } from '../defaults'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Button, Drawer, DrawerContent } from './common'
import { Plus } from 'lucide-svelte'
const dispatch = createEventDispatcher()
@@ -20,7 +22,7 @@
value: value ?? initialValue,
label: value ?? initialValue
}
: undefined
: ''
let collection = [valueSelect]
@@ -42,34 +44,82 @@
collection = nc
}
$: {
if ($workspaceStore) {
loadResources(resourceType)
}
}
$: $workspaceStore && loadResources(resourceType)
$: dispatch('change', value)
let darkMode: boolean = false
let drawer: Drawer | undefined = undefined
function processEvent(event: MessageEvent) {
if (event.origin !== window.location.origin) {
return
}
if (event.data.type === 'refresh') {
value = event.data.detail
valueSelect = { value, label: value }
drawer?.closeDrawer?.()
}
}
</script>
<DarkModeObserver bind:darkMode />
<Select
portal={!disablePortal}
value={valueSelect}
on:change={(e) => {
value = e.detail.value
valueSelect = e.detail
}}
on:clear={() => {
value = undefined
valueSelect = undefined
}}
items={collection}
class="text-clip grow min-w-0"
placeholder="{resourceType ?? 'any'} resource"
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
/>
<Drawer bind:this={drawer} size="800px">
<DrawerContent
title="Add a Resource"
on:close={drawer.closeDrawer}
tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs."
documentationLink="https://www.windmill.dev/docs/integrations/integrations_on_windmill"
>
<iframe
title="App connection"
class="w-full h-full"
src="/embed_connect?resource_type={resourceType}"
/>
</DrawerContent>
</Drawer>
<div class="flex flex-col w-full items-start">
<div class="flex flex-row gap-x-1 w-full">
<Select
portal={!disablePortal}
value={valueSelect}
on:change={(e) => {
value = e.detail.value
valueSelect = e.detail
}}
on:clear={() => {
value = undefined
valueSelect = ''
}}
items={collection}
class="text-clip grow min-w-0"
placeholder="{resourceType ?? 'any'} resource"
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
/>
{#if resourceType}
<Button
color="light"
variant="border"
size="xs"
on:click={() => {
window.removeEventListener('message', processEvent)
window.addEventListener('message', processEvent, {
once: true
})
drawer?.openDrawer?.()
}}
startIcon={{ icon: Plus }}
iconOnly
/>
{/if}
</div>
</div>
@@ -14,8 +14,6 @@
export let isValid: boolean = true
export let defaultValues: Record<string, any> = {}
export let dynamicEnums: Record<string, any> = {}
export let hideResourceInput: boolean = false
export let resourceInputUnsupported: boolean = false
let inputCheck: { [id: string]: boolean } = {}
let errors: { [id: string]: string } = {}
@@ -91,8 +89,6 @@
on:inputClicked
{displayType}
{css}
{hideResourceInput}
{resourceInputUnsupported}
/>
{/if}
{/each}
@@ -8,7 +8,7 @@
export let disabled: boolean
let path = ''
let password = ''
let password = value && !value.startsWith('$var:') ? value : ''
async function generateValue() {
let npath =
@@ -4,7 +4,7 @@
import { createEventDispatcher } from 'svelte'
import Select from './apps/svelte-select/lib/index'
import { SELECT_INPUT_DEFAULT_STYLE } from '../defaults'
import AppConnect from './AppConnect.svelte'
import AppConnect from './AppConnectDrawer.svelte'
import { Button } from './common'
import ResourceEditor from './ResourceEditor.svelte'
import DBSchemaExplorer from './DBSchemaExplorer.svelte'
@@ -58,11 +58,8 @@
}
}
$: {
if ($workspaceStore) {
loadResources(resourceType)
}
}
$: $workspaceStore && loadResources(resourceType)
$: dispatch('change', value)
let appConnect: AppConnect
@@ -84,7 +81,6 @@
type: valueType ?? ''
}
}}
newPageOAuth
bind:this={appConnect}
/>
@@ -18,8 +18,10 @@
import { flip } from 'svelte/animate'
import Portal from 'svelte-portal'
import { Plus } from 'lucide-svelte'
import type VariableEditor from './VariableEditor.svelte'
export let isFlowInput = false
export let variableEditor: VariableEditor | undefined = undefined
const dispatch = createEventDispatcher()
@@ -382,5 +384,6 @@
bind:editing
bind:oldArgName
propsNames={Object.keys(schema.properties ?? {})}
{variableEditor}
/>
</Portal>
+43 -3
View File
@@ -92,13 +92,20 @@
import SimpleEditor from './SimpleEditor.svelte'
import Label from './Label.svelte'
import { shouldDisplayPlaceholder } from '$lib/utils'
import type VariableEditor from './VariableEditor.svelte'
import ItemPicker from './ItemPicker.svelte'
import { Plus } from 'lucide-svelte'
import { VariableService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
export let error = ''
export let editing = false
export let oldArgName: string | undefined = undefined
export let isFlowInput = false
export let propsNames: string[] = []
export let variableEditor: VariableEditor | undefined = undefined
let itemPicker: ItemPicker | undefined = undefined
const dispatch = createEventDispatcher()
let drawer: Drawer
@@ -275,20 +282,21 @@
<div>
<div class="flex flex-row gap-x-4 items-center">
<ArgInput
{itemPicker}
resourceTypes={getResourceTypesFromFormat(property.format)}
label="Default"
bind:value={property.default}
type={property.selectedType}
type={property.password ? 'string' : property.selectedType}
pattern={property.pattern}
customErrorMessage={property.customErrorMessage}
itemsType={property.items}
contentEncoding={property.contentEncoding}
format={property.format}
extra={property}
disabled={property.password}
extra={property.password ? {} : property}
nullable={property.nullable}
title={property.title}
placeholder={property.placeholder}
{variableEditor}
/>
<div>
<Toggle
@@ -432,3 +440,35 @@
</svelte:fragment>
</DrawerContent>
</Drawer>
{#if isFlowInput}
<ItemPicker
bind:this={itemPicker}
pickCallback={(path, _) => {
if (property) {
property.default = '$var:' + path
}
}}
itemName="Variable"
tooltip="Variables are dynamic values that have a key associated to them and can be retrieved during the execution of a Script or Flow."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
extraField="path"
loadItems={async () =>
(await VariableService.listVariable({ workspace: $workspaceStore ?? '' })).map((x) => ({
name: x.path,
...x
}))}
>
<div slot="submission">
<Button
variant="border"
color="blue"
size="sm"
startIcon={{ icon: Plus }}
on:click={() => variableEditor?.initNew?.()}
>
New Variable
</Button>
</div>
</ItemPicker>
{/if}
@@ -0,0 +1,84 @@
export const apiTokenApps: Record<
string,
{ img?: string; instructions: string[]; linkedSecret?: string }
> = {
airtable: {
img: '/airtable_connect.png',
instructions: [
'Go to <a href="https://airtable.com/create/tokens" target="_blank" rel=”noopener noreferrer”>https://airtable.com/create/tokens</a>',
'Click on "Create new token"',
'Set a name, specify the scopes or the access level and click on "Create token"',
'Copy the token'
]
},
discord_webhook: {
img: '/discord_webhook.png',
instructions: ['Click on Server Settings', 'Click on Integration', 'Find "Webhooks"'],
linkedSecret: 'webhook_url'
},
toggl: {
img: '/toggl_connect.png',
instructions: [
'Go to <a href="https://track.toggl.com/profile" target="_blank" rel=”noopener noreferrer”>https://track.toggl.com/profile</a>',
'Find "API Token"'
]
},
mailchimp: {
img: '/mailchimp_connect.png',
instructions: [
'Go to <a href="https://admin.mailchimp.com/account/api" target="_blank" rel=”noopener noreferrer”>https://admin.mailchimp.com/account/api</a>',
'Find "Your API Keys"'
]
},
sendgrid: {
img: '/sendgrid_connect.png',
instructions: [
'Go to <a href="https://app.sendgrid.com/settings/api_keys" target="_blank" rel=”noopener noreferrer”>https://app.sendgrid.com/settings/api_keys</a>',
'Create an API key',
'Copy your key'
]
},
supabase: {
img: '/supabase_connect.png',
instructions: ['Go to the API Settings of your app to find the project URL and key']
},
square: {
img: '/square_connect.gif',
instructions: [
'Go to <a href="https://developer.squareup.com/apps" target="_blank" rel=”noopener noreferrer”>https://developer.squareup.com/apps</a>',
'In the left pane, choose Credentials',
'At the top of the page, choose Production mode for a production access token or Sandbox mode for a Sandbox access token.'
]
}
}
export function linkedSecretValue(x: string) {
let r = 0
let lowerCasedX = x.toLowerCase()
if (lowerCasedX.includes('secret')) {
r += 10
}
if (lowerCasedX.includes('password')) {
r += 5
}
if (lowerCasedX.includes('private')) {
r += 4
}
if (lowerCasedX.includes('key')) {
r += 3
}
if (lowerCasedX.includes('token')) {
r += 2
}
if (lowerCasedX.includes('pass')) {
r += 1
}
return r
}
export function forceSecretValue(resourceType: string): string | undefined {
if (resourceType == 'git_repository') {
return 'url'
}
}
@@ -25,7 +25,7 @@
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'schemaformcomponent'> | undefined = undefined
const { worldStore, connectingInput, app, selectedComponent, componentControl, mode } =
const { worldStore, connectingInput, app, selectedComponent, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
const iterContext = getContext<ListContext>('ListWrapperContext')
const listInputs: ListInputs | undefined = getContext<ListInputs>('ListInputs')
@@ -127,8 +127,6 @@
displayType={Boolean(resolvedConfig.displayType)}
largeGap={Boolean(resolvedConfig.largeGap)}
{css}
hideResourceInput={$mode === 'preview'}
resourceInputUnsupported={$mode === 'dnd'}
/>
</div>
{:else}
@@ -3,7 +3,7 @@
import Alert from '$lib/components/common/alert/Alert.svelte'
import LightweightSchemaForm from '$lib/components/LightweightSchemaForm.svelte'
import Popover from '$lib/components/Popover.svelte'
import { AppService } from '$lib/gen'
import { AppService, type ExecuteComponentData } from '$lib/gen'
import { classNames, defaultIfEmptyString, emptySchema, sendUserToast } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import { Bug } from 'lucide-svelte'
@@ -325,26 +325,39 @@
jobId = await resultJobLoader?.abstractRun(async () => {
const nonStaticRunnableInputs = dynamicArgsOverride ?? {}
const staticRunnableInputs = {}
const allowUserResources: string[] = []
for (const k of Object.keys(fields ?? {})) {
let field = fields[k]
if (field?.type == 'static' && fields[k]) {
staticRunnableInputs[k] = field.value
if (isEditor) {
staticRunnableInputs[k] = field.value
}
} else if (field?.type == 'user') {
nonStaticRunnableInputs[k] = args?.[k]
if (isEditor && field.allowUserResources) {
allowUserResources.push(k)
}
} else if (field?.type == 'eval' || (field?.type == 'evalv2' && inputValues[k])) {
nonStaticRunnableInputs[k] = await inputValues[k]?.computeExpr()
if (isEditor && field?.type == 'evalv2' && field.allowUserResources) {
allowUserResources.push(k)
}
} else {
if (isEditor && field?.type == 'connected' && field.allowUserResources) {
allowUserResources.push(k)
}
nonStaticRunnableInputs[k] = runnableInputValues[k]
}
}
const oneOfRunnableInputs = collectOneOfFields(fields, $app)
const oneOfRunnableInputs = isEditor ? collectOneOfFields(fields, $app) : {}
const requestBody = {
const requestBody: ExecuteComponentData['requestBody'] = {
args: nonStaticRunnableInputs,
component: id,
force_viewer_static_fields: !isEditor ? undefined : staticRunnableInputs,
force_viewer_one_of_fields: !isEditor ? undefined : oneOfRunnableInputs
force_viewer_one_of_fields: !isEditor ? undefined : oneOfRunnableInputs,
force_viewer_allow_user_resources: !isEditor ? undefined : allowUserResources
}
if (runnable?.type === 'runnableByName') {
@@ -355,7 +368,7 @@
if (inlineScript) {
requestBody['raw_code'] = {
content: inlineScript.content,
language: inlineScript.language,
language: inlineScript.language ?? '',
path: inlineScript.path,
lock: inlineScript.lock,
cache_ttl: inlineScript.cache_ttl
@@ -174,8 +174,15 @@
})
)
}
type TriggerableV2 = {
static_inputs: Record<string, any>
one_of_inputs?: Record<string, any[] | undefined>
allow_user_resources?: string[]
}
async function computeTriggerables() {
const allTriggers = (await Promise.all(
const allTriggers: ([string, TriggerableV2] | undefined)[] = (await Promise.all(
allItems($app.grid, $app.subgrids)
.flatMap((x) => {
let c = x.data as AppComponent
@@ -257,39 +264,56 @@
}
})
return processed
return processed as Promise<[string, TriggerableV2] | undefined>[]
})
.concat(
Object.values($app.hiddenInlineScripts ?? {}).map(async (v, i) => {
return await processRunnable(BG_PREFIX + i, v, v.fields)
})
}) as Promise<[string, TriggerableV2] | undefined>[]
)
)) as ([string, Record<string, any>] | undefined)[]
)) as ([string, TriggerableV2] | undefined)[]
delete policy.triggerables
policy.triggerables_v2 = Object.fromEntries(
allTriggers.filter(Boolean) as [string, Record<string, any>][]
const ntriggerables: Record<string, TriggerableV2> = Object.fromEntries(
allTriggers.filter(Boolean) as [string, TriggerableV2][]
)
console.log(ntriggerables)
policy.triggerables_v2 = ntriggerables
}
async function processRunnable(
id: string,
runnable: Runnable,
fields: Record<string, any>
): Promise<[string, Record<string, any>] | undefined> {
): Promise<[string, TriggerableV2] | undefined> {
const staticInputs = collectStaticFields(fields)
const oneOfInputs = collectOneOfFields(fields, $app)
if (runnable?.type == 'runnableByName') {
console.log('processRunnable:content', runnable.inlineScript?.content)
const allowUserResources: string[] = Object.entries(fields)
.map(([k, v]) => {
return v['allowUserResources'] ? k : undefined
})
.filter(Boolean) as string[]
if (runnable?.type == 'runnableByName') {
let hex = await hash(runnable.inlineScript?.content)
console.log('hex', hex, id)
return [`${id}:rawscript/${hex}`, { static_inputs: staticInputs, one_of_inputs: oneOfInputs }]
return [
`${id}:rawscript/${hex}`,
{
static_inputs: staticInputs,
one_of_inputs: oneOfInputs,
allow_user_resources: allowUserResources
}
]
} else if (runnable?.type == 'runnableByPath') {
let prefix = runnable.runType !== 'hubscript' ? runnable.runType : 'script'
return [
`${id}:${prefix}/${runnable.path}`,
{ static_inputs: staticInputs, one_of_inputs: oneOfInputs }
{
static_inputs: staticInputs,
one_of_inputs: oneOfInputs,
allow_user_resources: allowUserResources
}
]
}
}
@@ -918,7 +918,7 @@ export function recursivelyFilterKeyInJSON(
return filteredJSON
}
export function collectOneOfFields(fields: AppInputs, app: App) {
export function collectOneOfFields(fields: AppInputs, app: App): Record<string, any[]> {
return Object.fromEntries(
Object.entries(fields ?? {})
.filter(([k, v]) => v.type == 'evalv2')
@@ -975,6 +975,7 @@ export function collectOneOfFields(fields: AppInputs, app: App) {
return [k, undefined]
})
.filter(([k, v]) => v !== undefined)
)
}
@@ -29,7 +29,7 @@
</div>
{#if collapsed}
<div class="flex flex-row gap-1">
<div class="flex flex-row gap-1 flex-wrap">
{#each componentControls as control}
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium border">
{control.title}
@@ -16,6 +16,7 @@
import { fieldTypeToTsType } from '../../utils'
import EvalV2InputEditor from './inputEditor/EvalV2InputEditor.svelte'
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
export let id: string
export let componentInput: RichConfiguration
@@ -119,7 +120,7 @@
}}
>
<ToggleButton value="static" icon={Pen} iconOnly tooltip="Static" />
{#if userInputEnabled && !format?.startsWith('resource-')}
{#if userInputEnabled}
<ToggleButton value="user" icon={User} iconOnly tooltip="User Input" />
{/if}
{#if fileUpload}
@@ -190,5 +191,19 @@
{:else if componentInput?.type === 'user'}
<span class="text-2xs italic text-tertiary">Field's value is set by the user</span>
{/if}
{#if (componentInput?.type === 'evalv2' || componentInput?.type === 'connected' || componentInput?.type === 'user') && fieldType == 'object' && format?.startsWith('resource-')}
<div class="flex flex-row">
<Toggle
size="xs"
bind:checked={componentInput.allowUserResources}
options={{ right: 'Allow resources from users' }}
/>
<Tooltip
>Apps are executed on behalf of publishers and by default cannot access viewer's
resources. If you use a non-static resource picker and connect it here, you need to toggle
this.</Tooltip
>
</div>
{/if}
</div>
{/if}
@@ -57,12 +57,14 @@ export type InputConnectionEval = {
export type ConnectedInput = {
type: 'connected'
connection: InputConnection | undefined
allowUserResources?: boolean
}
// User input, set by the user in the app
export type UserInput<U> = {
type: 'user'
value: U | undefined
allowUserResources?: boolean
}
// Input can be uploaded with a file selector
@@ -81,6 +83,7 @@ export type EvalInputV2 = {
expr: string
connections: InputConnectionEval[]
onDemandOnly?: boolean
allowUserResources?: boolean
}
export type RowInput = {
+1 -1
View File
@@ -58,7 +58,7 @@ export function schemaToInputsSpec(
const property = schema.properties[key]
accu[key] = {
type: defaultUserInput && !property.format?.startsWith('resource-') ? 'user' : 'static',
type: defaultUserInput ? 'user' : 'static',
value: property.default,
fieldType: property.type,
format: property.format
@@ -385,10 +385,12 @@ type Field = {
type: 'static' | 'connected'
value: any
fieldType?: string
format?: string
connection?: {
componentId: string
path: string
}
allowUserResources?: boolean
}
function convertSchemaToFields(schema: Record<string, any> | undefined): { [key: string]: Field } {
@@ -403,6 +405,8 @@ function convertSchemaToFields(schema: Record<string, any> | undefined): { [key:
type: 'connected',
value: fieldInfo.default,
fieldType: fieldInfo.type,
format: fieldInfo.format,
allowUserResources: true,
connection: {
componentId: 'c',
path: `values.${fieldName}`
@@ -1,10 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sendUserToast } from '$lib/toast'
import { onMount } from 'svelte'
import { OauthService } from '$lib/gen'
import { oauthStore } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import { Loader2 } from 'lucide-svelte'
@@ -16,24 +13,34 @@
onMount(async () => {
if (error) {
sendUserToast(`Error trying to add ${client_name} connection: ${error}`, true)
goto('/resources')
window.opener.postMessage(
{ type: 'error', error: `Error trying to add ${client_name} connection: ${error}` },
'*'
)
// goto('/resources')
} else if (code && state) {
try {
const res = await OauthService.connectCallback({
clientName: client_name,
requestBody: { code, state }
})
$oauthStore = res
goto(`/resources?resource_type=${client_name}`)
window.opener.postMessage({ type: 'success', res, resource_type: client_name }, '*')
// goto(`/resources?resource_type=${client_name}`)
} catch (e) {
sendUserToast(`Error parsing the response token, ${e.body}`, true)
goto('/resources')
window.opener.postMessage(
{ type: 'error', error: `Error parsing the response token, ${e.body}` },
'*'
)
// goto('/resources')
}
} else {
sendUserToast('Missing code or state as query params', true)
goto('/resources')
window.opener.postMessage(
{ type: 'error', error: 'Missing code or state as query params' },
'*'
)
// goto('/resources')
}
close()
})
</script>
@@ -4,7 +4,7 @@
import { sendUserToast } from '$lib/toast'
import { onMount } from 'svelte'
import { OauthService } from '$lib/gen'
import { workspaceStore, oauthStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
@@ -17,11 +17,10 @@
if (error) {
sendUserToast(`Error trying to add slack connection: ${error}`, true)
} else if (code && state) {
const token = await OauthService.connectSlackCallback({
await OauthService.connectSlackCallback({
workspace: $workspaceStore!,
requestBody: { code, state }
})
oauthStore.set({ access_token: token })
sendUserToast(
'Slack workspace connected to your Windmill workspace and slack token saved in the folder `slack_bot` at `f/slack_bot/bot_token`.'
)
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores'
import AppConnect from '$lib/components/AppConnect.svelte'
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, Skeleton, Tab } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
@@ -31,7 +31,7 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import type { ResourceType } from '$lib/gen'
import { OauthService, ResourceService, type ListableResource } from '$lib/gen'
import { oauthStore, userStore, workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { canWrite, classNames, emptySchema, removeMarkdown, truncate } from '$lib/utils'
import { convert } from '@redocly/json-to-json-schema'
@@ -265,11 +265,6 @@
}
onMount(() => {
let resource_type = $page.url.searchParams.get('resource_type')
if ($oauthStore && resource_type) {
appConnect.openFromOauth?.(resource_type)
}
const callback = $page.url.searchParams.get('callback')
if (callback == 'supabase_wizard') {
supabaseConnect.open?.()
@@ -695,7 +690,7 @@
</div>
{#if is_oauth}
<div class="w-10">
<div class="w-10 pt-1.5">
{#if refresh_error}
<Popover>
<Circle
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'App Connection' }
}
}
@@ -0,0 +1,67 @@
<script lang="ts">
import { page } from '$app/stores'
import AppConnectInner from '$lib/components/AppConnectInner.svelte'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Button } from '$lib/components/common'
import { onMount } from 'svelte'
let resourceType = $page.url.searchParams.get('resource_type') ?? undefined
let step = 1
let disabled = false
let isGoogleSignin = false
let manual = false
let appConnect: AppConnectInner | undefined = undefined
let darkMode: boolean = false
onMount(async () => {
if (resourceType) {
appConnect?.open(resourceType)
}
})
</script>
<DarkModeObserver bind:darkMode />
<div>
<div class="flex flex-row-reverse w-full">
<div class="flex gap-2">
{#if step > 2}
<Button variant="border" on:click={appConnect?.back}>Back</Button>
{/if}
{#if isGoogleSignin}
<button {disabled} on:click={appConnect?.next}>
<img
class="h-10 w-auto object-contain"
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
alt="Google sign-in"
/>
</button>
{:else}
<Button {disabled} on:click={appConnect?.next}>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
Next
{:else}
Save
{/if}
</Button>
{/if}
</div>
</div>
<AppConnectInner
bind:this={appConnect}
bind:step
bind:resourceType
bind:isGoogleSignin
bind:disabled
bind:manual
on:refresh={(e) => {
window?.parent?.postMessage({ type: 'refresh', detail: e.detail }, '*')
}}
/>
</div>