mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
Generated
+1
@@ -9802,6 +9802,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ulid",
|
||||
"urlencoding",
|
||||
"uuid 1.8.0",
|
||||
"windmill-audit",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> 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> 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 === ''}
|
||||
|
||||
{: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 === ''}
|
||||
|
||||
{: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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user