mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
Merge branch 'di/better-ws-storage-settings' into di/playwright
This commit is contained in:
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Resolve _ee.rs symlinks to actual files so Claude can read them
|
||||
# This script runs before each user prompt is processed
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Find all _ee.rs symlinks and store their targets
|
||||
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
|
||||
target=$(readlink -f "$symlink" 2>/dev/null) || continue
|
||||
|
||||
# Only process if target file exists
|
||||
if [[ -f "$target" ]]; then
|
||||
# Store symlink path and target in manifest
|
||||
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
|
||||
|
||||
# Replace symlink with actual file content
|
||||
rm "$symlink"
|
||||
cp "$target" "$symlink"
|
||||
fi
|
||||
done
|
||||
|
||||
# Atomically replace manifest
|
||||
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
|
||||
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Restore _ee.rs symlinks after Claude finishes processing
|
||||
# This script runs when Claude stops
|
||||
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
|
||||
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
|
||||
|
||||
# Check if manifest exists
|
||||
if [[ ! -f "$MANIFEST_FILE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read manifest and restore symlinks
|
||||
while IFS='|' read -r symlink target; do
|
||||
if [[ -n "$symlink" && -n "$target" ]]; then
|
||||
# If the file exists (not a symlink) and target exists, copy changes back
|
||||
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
|
||||
# Copy the potentially modified file back to the target
|
||||
cp "$symlink" "$target"
|
||||
fi
|
||||
|
||||
# Remove the regular file (which was a copy)
|
||||
rm -f "$symlink" 2>/dev/null || true
|
||||
|
||||
# Recreate the symlink
|
||||
ln -s "$target" "$symlink" 2>/dev/null || true
|
||||
fi
|
||||
done < "$MANIFEST_FILE"
|
||||
|
||||
# Clean up manifest
|
||||
rm -f "$MANIFEST_FILE"
|
||||
|
||||
exit 0
|
||||
+41
-6
@@ -1,7 +1,41 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(**/*.rs)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cat:*)",
|
||||
@@ -56,10 +90,11 @@
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git merge:*)",
|
||||
"Bash(git rebase:*)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"../windmill-ee-private/"
|
||||
]
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledPlugins": {
|
||||
"rust-analyzer-lsp@claude-plugins-official": true,
|
||||
"typescript-lsp@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ jobs:
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -60,19 +60,18 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
model: claude-opus-4-1-20250805
|
||||
fallback_model: claude-sonnet-4-20250514
|
||||
timeout_minutes: "60"
|
||||
allowed_tools: "mcp__github__create_pull_request"
|
||||
allowed_bots: "windmill-internal-app[bot]"
|
||||
custom_instructions: |
|
||||
## IMPORTANT INSTRUCTIONS
|
||||
- Your branch name should be a short description of the requested changes.
|
||||
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
|
||||
|
||||
## Available Tools
|
||||
- mcp__github__create_pull_request: Create PRs from branches
|
||||
trigger_phrase: "/ai-fast"
|
||||
plugins: "rust-analyzer-lsp@claude-plugins-official,typescript-lsp@claude-plugins-official"
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"SQLX_OFFLINE": "true"
|
||||
}
|
||||
}
|
||||
claude_args: |
|
||||
--allowedTools "Bash,WebFetch,WebSearch"
|
||||
--model opus
|
||||
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -95,8 +95,9 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: 'windmill-internal-app[bot]'
|
||||
trigger_phrase: '/ai'
|
||||
allowed_bots: "windmill-internal-app[bot]"
|
||||
trigger_phrase: "/ai"
|
||||
plugins: "rust-analyzer-lsp@claude-plugins-official,typescript-lsp@claude-plugins-official"
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [1.603.0](https://github.com/windmill-labs/windmill/compare/v1.602.0...v1.603.0) (2026-01-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add password reset flow using configured SMTP settings ([#7525](https://github.com/windmill-labs/windmill/issues/7525)) ([6f7cf2f](https://github.com/windmill-labs/windmill/commit/6f7cf2fb1645bb784af3a68760abc44013bd81f8))
|
||||
|
||||
## [1.602.0](https://github.com/windmill-labs/windmill/compare/v1.601.1...v1.602.0) (2026-01-08)
|
||||
|
||||
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM magic_link WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9"
|
||||
}
|
||||
Generated
+258
-220
File diff suppressed because it is too large
Load Diff
+12
-8
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.602.0"
|
||||
version = "1.603.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -11,11 +11,13 @@ members = [
|
||||
"./windmill-queue",
|
||||
"./windmill-worker",
|
||||
"./windmill-common",
|
||||
"./windmill-mcp",
|
||||
"./windmill-audit",
|
||||
"./windmill-git-sync",
|
||||
"./windmill-autoscaling",
|
||||
"./windmill-indexer",
|
||||
"./windmill-macros",
|
||||
"./windmill-oauth",
|
||||
"./parsers/windmill-parser",
|
||||
"./parsers/windmill-parser-ts",
|
||||
"./parsers/windmill-parser-go",
|
||||
@@ -33,7 +35,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.602.0"
|
||||
version = "1.603.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -56,7 +58,6 @@ enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmi
|
||||
enterprise_saml = ["windmill-api/enterprise_saml", "oauth2"]
|
||||
stripe = ["windmill-api/stripe"]
|
||||
benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark"]
|
||||
loki = ["windmill-common/loki"]
|
||||
embedding = ["windmill-api/embedding"]
|
||||
parquet = ["windmill-api/parquet", "windmill-common/parquet", "windmill-worker/parquet", "dep:object_store"]
|
||||
prometheus = ["windmill-common/prometheus", "windmill-api/prometheus", "windmill-worker/prometheus", "windmill-queue/prometheus", "dep:prometheus"]
|
||||
@@ -75,7 +76,7 @@ dind = ["windmill-worker/dind"]
|
||||
websocket = ["windmill-api/websocket"]
|
||||
http_trigger = ["windmill-api/http_trigger"]
|
||||
postgres_trigger = ["windmill-api/postgres_trigger"]
|
||||
mcp = ["windmill-api/mcp"]
|
||||
mcp = ["windmill-api/mcp", "windmill-worker/mcp"]
|
||||
mqtt_trigger = ["windmill-api/mqtt_trigger"]
|
||||
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
|
||||
gcp_trigger = ["windmill-api/gcp_trigger"]
|
||||
@@ -102,7 +103,7 @@ ruby = ["windmill-worker/ruby"]
|
||||
all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql", "bigquery", "csharp", "nu", "php", "java", "ruby"]
|
||||
# For windows we have another set of languages enabled
|
||||
all_languages_windows = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql", "bigquery", "csharp", "nu", "php", "java"]
|
||||
all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "loki", "embedding", "parquet", "prometheus", "flow_testing",
|
||||
all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embedding", "parquet", "prometheus", "flow_testing",
|
||||
"openidconnect", "cloud", "jemalloc", "tantivy", "sqlx", "kafka", "nats", "otel", "dind", "websocket", "http_trigger",
|
||||
"postgres_trigger", "mcp", "mqtt_trigger", "sqs_trigger", "gcp_trigger", "smtp", "stripe",
|
||||
"license", "oauth2", "zip", "static_frontend", "scoped_cache", "agent_worker_server"]
|
||||
@@ -190,6 +191,8 @@ windmill-audit = { path = "./windmill-audit" }
|
||||
windmill-git-sync = { path = "./windmill-git-sync" }
|
||||
windmill-autoscaling = { path = "./windmill-autoscaling" }
|
||||
windmill-indexer = {path = "./windmill-indexer"}
|
||||
windmill-mcp = {path = "./windmill-mcp"}
|
||||
windmill-oauth = {path = "./windmill-oauth"}
|
||||
windmill-macros = {path = "./windmill-macros"}
|
||||
windmill-parser = { path = "./parsers/windmill-parser" }
|
||||
windmill-parser-ts = { path = "./parsers/windmill-parser-ts" }
|
||||
@@ -256,13 +259,13 @@ mail-send = { version = "0.4.0", features = ["builder"], default-features=false
|
||||
urlencoding = "^2"
|
||||
url = { version = "^2" , features = ["serde"]}
|
||||
async-oauth2 = "0.5.1"
|
||||
reqwest = { version = "=0.12.24", features = ["json", "stream", "gzip", "multipart"] }
|
||||
reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart", "query", "form"] }
|
||||
eventsource-stream = "0.2.3"
|
||||
time = "^0"
|
||||
serde_urlencoded = "^0"
|
||||
astral-tokio-tar = "^0.5.6"
|
||||
tempfile = "^3"
|
||||
tokio-util = { version = "^0", features = ["io"] }
|
||||
tokio-util = { version = "=0.7.17", features = ["io"] }
|
||||
json-pointer = "^0"
|
||||
itertools = "^0.14.0"
|
||||
regex = "^1"
|
||||
@@ -379,7 +382,8 @@ async-nats = "0.38.0"
|
||||
nkeys = "0.4.4"
|
||||
nu-parser = { version = "0.101.0", default-features = false }
|
||||
globset = "0.4.16"
|
||||
|
||||
croner = "2.2.0"
|
||||
rmcp = { version = "^0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
|
||||
process-wrap = { version = "8.2.1", features = ["tokio1"] }
|
||||
|
||||
datafusion = "47.0.0"
|
||||
|
||||
@@ -1 +1 @@
|
||||
cf96b45aa1183f15b3cc1b971035de5e37a68849
|
||||
c8e8a6df19203acc2cef1aebd1bd4157f2439cbf
|
||||
@@ -2080,7 +2080,6 @@ async fn test_flow_lock_all(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.get_flow_by_path("test-workspace", "g/all/flow_lock_all", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.open_flow
|
||||
.value
|
||||
.modules;
|
||||
@@ -2363,7 +2362,6 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()>
|
||||
no_flow_overlap: None,
|
||||
summary: None,
|
||||
tag: None,
|
||||
paused_until: None,
|
||||
cron_version: None,
|
||||
description: None,
|
||||
};
|
||||
@@ -2434,7 +2432,6 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()>
|
||||
summary: None,
|
||||
no_flow_overlap: None,
|
||||
tag: None,
|
||||
paused_until: None,
|
||||
cron_version: None,
|
||||
description: None,
|
||||
},
|
||||
@@ -2520,7 +2517,6 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
no_flow_overlap: None,
|
||||
summary: None,
|
||||
tag: None,
|
||||
paused_until: None,
|
||||
cron_version: None,
|
||||
description: None,
|
||||
};
|
||||
@@ -2592,7 +2588,6 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
summary: None,
|
||||
no_flow_overlap: None,
|
||||
tag: None,
|
||||
paused_until: None,
|
||||
cron_version: None,
|
||||
description: None,
|
||||
},
|
||||
|
||||
@@ -10,13 +10,7 @@ path = "./src/lib.rs"
|
||||
|
||||
|
||||
[dependencies]
|
||||
progenitor-client = { git = "https://github.com/oxidecomputer/progenitor", rev = "3d96016ae8d422e90513b2d34fb5b63eeab30b01" }
|
||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
serde_json.workspace = true
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
openapiv3 = "=1.0.2"
|
||||
|
||||
urlencoding = "2"
|
||||
|
||||
-2059
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "windmill-api-client-build"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "windmill_api_client_build"
|
||||
path = "./main.rs"
|
||||
|
||||
|
||||
[dependencies]
|
||||
prettyplease = "0.1.25"
|
||||
progenitor = { git = "https://github.com/oxidecomputer/progenitor", rev = "3d96016ae8d422e90513b2d34fb5b63eeab30b01" }
|
||||
serde_json = "1.0"
|
||||
syn = "1.0"
|
||||
openapiv3 = "=1.0.2"
|
||||
|
||||
[workspace]
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
npx swagger-cli bundle ../../windmill-api/openapi.yaml > bundled.json
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
path::Path,
|
||||
process::Command,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
Command::new("sh").args(&["./bundle.sh"]).status().unwrap();
|
||||
let file = File::open("./bundled.json").unwrap();
|
||||
let mut spec: openapiv3::OpenAPI = serde_json::from_reader(file).unwrap();
|
||||
spec.paths.paths.retain(|key, _| {
|
||||
[
|
||||
"/w/{workspace}/flows/create",
|
||||
"/w/{workspace}/flows/get/{path}",
|
||||
"/w/{workspace}/scripts/create",
|
||||
"/workspaces/list",
|
||||
"/w/{workspace}/schedules/create",
|
||||
"/w/{workspace}/schedules/update/{path}",
|
||||
]
|
||||
.contains(&key.as_str())
|
||||
});
|
||||
|
||||
let mut generator = progenitor::Generator::default();
|
||||
let tokens = generator.generate_tokens(&spec).unwrap();
|
||||
let ast = syn::parse2(tokens).unwrap();
|
||||
let content = prettyplease::unparse(&ast);
|
||||
|
||||
let mut out_file = Path::new("../src").to_path_buf();
|
||||
out_file.push("codegen.rs");
|
||||
|
||||
fs::write(out_file, content).unwrap();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,753 @@
|
||||
include!("./codegen.rs");
|
||||
//! Minimal Windmill API client for tests
|
||||
//!
|
||||
//! This is a handwritten minimal client that provides just enough functionality
|
||||
//! for the integration tests. It replaces the auto-generated progenitor client.
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Client for Windmill API
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
pub baseurl: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client with an existing reqwest::Client
|
||||
pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
baseurl: baseurl.to_string(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base URL
|
||||
pub fn baseurl(&self) -> &String {
|
||||
&self.baseurl
|
||||
}
|
||||
|
||||
/// Get the internal reqwest::Client
|
||||
pub fn client(&self) -> &reqwest::Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Create a script
|
||||
pub async fn create_script(
|
||||
&self,
|
||||
workspace: &str,
|
||||
body: &types::NewScript,
|
||||
) -> Result<String, Error> {
|
||||
let url = format!(
|
||||
"{}/w/{}/scripts/create",
|
||||
self.baseurl,
|
||||
urlencoding::encode(workspace)
|
||||
);
|
||||
let response = self.client.post(&url).json(body).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.text().await?)
|
||||
} else {
|
||||
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a flow
|
||||
pub async fn create_flow(
|
||||
&self,
|
||||
workspace: &str,
|
||||
body: &types::CreateFlowBody,
|
||||
) -> Result<String, Error> {
|
||||
let url = format!(
|
||||
"{}/w/{}/flows/create",
|
||||
self.baseurl,
|
||||
urlencoding::encode(workspace)
|
||||
);
|
||||
let response = self.client.post(&url).json(body).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.text().await?)
|
||||
} else {
|
||||
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get flow by path
|
||||
pub async fn get_flow_by_path(
|
||||
&self,
|
||||
workspace: &str,
|
||||
path: &str,
|
||||
with_starred_info: Option<bool>,
|
||||
) -> Result<types::Flow, Error> {
|
||||
let url = format!(
|
||||
"{}/w/{}/flows/get/{}",
|
||||
self.baseurl,
|
||||
urlencoding::encode(workspace),
|
||||
urlencoding::encode(path)
|
||||
);
|
||||
|
||||
let mut request = self.client.get(&url);
|
||||
if let Some(starred) = with_starred_info {
|
||||
request = request.query(&[("with_starred_info", starred.to_string())]);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json().await?)
|
||||
} else {
|
||||
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a schedule
|
||||
pub async fn create_schedule(
|
||||
&self,
|
||||
workspace: &str,
|
||||
body: &types::NewSchedule,
|
||||
) -> Result<String, Error> {
|
||||
let url = format!(
|
||||
"{}/w/{}/schedules/create",
|
||||
self.baseurl,
|
||||
urlencoding::encode(workspace)
|
||||
);
|
||||
let response = self.client.post(&url).json(body).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.text().await?)
|
||||
} else {
|
||||
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a schedule
|
||||
pub async fn update_schedule(
|
||||
&self,
|
||||
workspace: &str,
|
||||
path: &str,
|
||||
body: &types::EditSchedule,
|
||||
) -> Result<String, Error> {
|
||||
let url = format!(
|
||||
"{}/w/{}/schedules/update/{}",
|
||||
self.baseurl,
|
||||
urlencoding::encode(workspace),
|
||||
urlencoding::encode(path)
|
||||
);
|
||||
let response = self.client.post(&url).json(body).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.text().await?)
|
||||
} else {
|
||||
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// List workspaces
|
||||
pub async fn list_workspaces(&self) -> Result<Vec<types::Workspace>, Error> {
|
||||
let url = format!("{}/workspaces/list", self.baseurl);
|
||||
let response = self.client.get(&url).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json().await?)
|
||||
} else {
|
||||
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with bearer token authentication
|
||||
pub fn create_client(base_url: &str, token: String) -> Client {
|
||||
let mut val = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.expect("header creation");
|
||||
let mut val = HeaderValue::from_str(&format!("Bearer {token}")).expect("header creation");
|
||||
val.set_sensitive(true);
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, val);
|
||||
let client = reqwest::ClientBuilder::new()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.expect("client build");
|
||||
Client::new_with_client(&format!("{}/api", base_url.trim_end_matches('/')), client)
|
||||
}
|
||||
|
||||
/// Error type for API client
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Request error
|
||||
Request(reqwest::Error),
|
||||
/// Unexpected response status
|
||||
UnexpectedResponse(u16, String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
Error::Request(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Request(e) => write!(f, "Request error: {}", e),
|
||||
Error::UnexpectedResponse(status, body) => {
|
||||
write!(f, "Unexpected response ({}): {}", status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// API types
|
||||
pub mod types {
|
||||
use super::*;
|
||||
|
||||
/// Script language
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
pub enum ScriptLang {
|
||||
#[serde(rename = "python3")]
|
||||
Python3,
|
||||
#[serde(rename = "deno")]
|
||||
Deno,
|
||||
#[serde(rename = "go")]
|
||||
Go,
|
||||
#[serde(rename = "bash")]
|
||||
Bash,
|
||||
#[serde(rename = "powershell")]
|
||||
Powershell,
|
||||
#[serde(rename = "postgresql")]
|
||||
Postgresql,
|
||||
#[serde(rename = "mysql")]
|
||||
Mysql,
|
||||
#[serde(rename = "bigquery")]
|
||||
Bigquery,
|
||||
#[serde(rename = "snowflake")]
|
||||
Snowflake,
|
||||
#[serde(rename = "mssql")]
|
||||
Mssql,
|
||||
#[serde(rename = "oracledb")]
|
||||
Oracledb,
|
||||
#[serde(rename = "graphql")]
|
||||
Graphql,
|
||||
#[serde(rename = "nativets")]
|
||||
Nativets,
|
||||
#[serde(rename = "bun")]
|
||||
Bun,
|
||||
#[serde(rename = "php")]
|
||||
Php,
|
||||
#[serde(rename = "rust")]
|
||||
Rust,
|
||||
#[serde(rename = "ansible")]
|
||||
Ansible,
|
||||
#[serde(rename = "csharp")]
|
||||
Csharp,
|
||||
#[serde(rename = "nu")]
|
||||
Nu,
|
||||
#[serde(rename = "java")]
|
||||
Java,
|
||||
#[serde(rename = "ruby")]
|
||||
Ruby,
|
||||
#[serde(rename = "duckdb")]
|
||||
Duckdb,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ScriptLang {
|
||||
type Err = &'static str;
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"python3" => Ok(Self::Python3),
|
||||
"deno" => Ok(Self::Deno),
|
||||
"go" => Ok(Self::Go),
|
||||
"bash" => Ok(Self::Bash),
|
||||
"powershell" => Ok(Self::Powershell),
|
||||
"postgresql" => Ok(Self::Postgresql),
|
||||
"mysql" => Ok(Self::Mysql),
|
||||
"bigquery" => Ok(Self::Bigquery),
|
||||
"snowflake" => Ok(Self::Snowflake),
|
||||
"mssql" => Ok(Self::Mssql),
|
||||
"oracledb" => Ok(Self::Oracledb),
|
||||
"graphql" => Ok(Self::Graphql),
|
||||
"nativets" => Ok(Self::Nativets),
|
||||
"bun" => Ok(Self::Bun),
|
||||
"php" => Ok(Self::Php),
|
||||
"rust" => Ok(Self::Rust),
|
||||
"ansible" => Ok(Self::Ansible),
|
||||
"csharp" => Ok(Self::Csharp),
|
||||
"nu" => Ok(Self::Nu),
|
||||
"java" => Ok(Self::Java),
|
||||
"ruby" => Ok(Self::Ruby),
|
||||
"duckdb" => Ok(Self::Duckdb),
|
||||
_ => Err("invalid script language"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw script language (for flow modules)
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
pub enum RawScriptLanguage {
|
||||
#[serde(rename = "python3")]
|
||||
Python3,
|
||||
#[serde(rename = "deno")]
|
||||
Deno,
|
||||
#[serde(rename = "go")]
|
||||
Go,
|
||||
#[serde(rename = "bash")]
|
||||
Bash,
|
||||
#[serde(rename = "powershell")]
|
||||
Powershell,
|
||||
#[serde(rename = "postgresql")]
|
||||
Postgresql,
|
||||
#[serde(rename = "mysql")]
|
||||
Mysql,
|
||||
#[serde(rename = "bigquery")]
|
||||
Bigquery,
|
||||
#[serde(rename = "snowflake")]
|
||||
Snowflake,
|
||||
#[serde(rename = "mssql")]
|
||||
Mssql,
|
||||
#[serde(rename = "oracledb")]
|
||||
Oracledb,
|
||||
#[serde(rename = "graphql")]
|
||||
Graphql,
|
||||
#[serde(rename = "nativets")]
|
||||
Nativets,
|
||||
#[serde(rename = "bun")]
|
||||
Bun,
|
||||
#[serde(rename = "php")]
|
||||
Php,
|
||||
#[serde(rename = "rust")]
|
||||
Rust,
|
||||
#[serde(rename = "ansible")]
|
||||
Ansible,
|
||||
#[serde(rename = "csharp")]
|
||||
Csharp,
|
||||
#[serde(rename = "nu")]
|
||||
Nu,
|
||||
#[serde(rename = "java")]
|
||||
Java,
|
||||
#[serde(rename = "ruby")]
|
||||
Ruby,
|
||||
#[serde(rename = "duckdb")]
|
||||
Duckdb,
|
||||
}
|
||||
|
||||
/// New script request body
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct NewScript {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_ttl: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codebase: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrency_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrency_time_window_s: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrent_limit: Option<i64>,
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedicated_worker: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_message: Option<String>,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub envs: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub has_preprocessor: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_template: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
pub language: ScriptLang,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lock: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub no_main_func: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_behalf_of_email: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_hash: Option<String>,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub restart_unless_cancelled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub schema: HashMap<String, serde_json::Value>,
|
||||
pub summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visible_to_runner_only: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Script arguments (used in schedules)
|
||||
pub type ScriptArgs = HashMap<String, serde_json::Value>;
|
||||
|
||||
/// New schedule request body
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct NewSchedule {
|
||||
pub args: ScriptArgs,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cron_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
pub is_flow: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub no_flow_overlap: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure_exact: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure_extra_args: Option<ScriptArgs>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure_times: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_recovery: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_recovery_extra_args: Option<ScriptArgs>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_recovery_times: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_success: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_success_extra_args: Option<ScriptArgs>,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry: Option<serde_json::Value>,
|
||||
pub schedule: String,
|
||||
pub script_path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
pub timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Edit schedule request body
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct EditSchedule {
|
||||
pub args: ScriptArgs,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cron_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub no_flow_overlap: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure_exact: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure_extra_args: Option<ScriptArgs>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_failure_times: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_recovery: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_recovery_extra_args: Option<ScriptArgs>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_recovery_times: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_success: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_success_extra_args: Option<ScriptArgs>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry: Option<serde_json::Value>,
|
||||
pub schedule: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
pub timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Open flow definition
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct OpenFlow {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub schema: HashMap<String, serde_json::Value>,
|
||||
pub summary: String,
|
||||
pub value: FlowValue,
|
||||
}
|
||||
|
||||
/// Flow value containing modules
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct FlowValue {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_ttl: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrency_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrency_time_window_s: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrent_limit: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub early_return: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure_module: Option<FlowModule>,
|
||||
pub modules: Vec<FlowModule>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preprocessor_module: Option<FlowModule>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub same_worker: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_expr: Option<String>,
|
||||
}
|
||||
|
||||
/// Flow module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct FlowModule {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_ttl: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub continue_on_error: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delete_after_use: Option<bool>,
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mock: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_if: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sleep: Option<InputTransform>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stop_after_all_iters_if: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stop_after_if: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub suspend: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<InputTransform>,
|
||||
pub value: FlowModuleValue,
|
||||
}
|
||||
|
||||
/// Input transform
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum InputTransform {
|
||||
Static {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
value: serde_json::Value
|
||||
},
|
||||
Javascript {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
expr: String
|
||||
},
|
||||
}
|
||||
|
||||
/// Flow module value (the actual module content)
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum FlowModuleValue {
|
||||
RawScript(RawScript),
|
||||
Script(ScriptModule),
|
||||
Flow(FlowModule2),
|
||||
ForLoop(ForLoopModule),
|
||||
WhileLoop(WhileLoopModule),
|
||||
BranchOne(BranchOneModule),
|
||||
BranchAll(BranchAllModule),
|
||||
Identity(IdentityModule),
|
||||
Other(serde_json::Value),
|
||||
}
|
||||
|
||||
/// Raw script module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RawScript {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrency_time_window_s: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub concurrent_limit: Option<f64>,
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_concurrency_key: Option<String>,
|
||||
pub input_transforms: HashMap<String, InputTransform>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_trigger: Option<bool>,
|
||||
pub language: RawScriptLanguage,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lock: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
}
|
||||
|
||||
impl RawScript {
|
||||
pub fn new(content: String, language: RawScriptLanguage) -> Self {
|
||||
Self {
|
||||
assets: vec![],
|
||||
concurrency_time_window_s: None,
|
||||
concurrent_limit: None,
|
||||
content,
|
||||
custom_concurrency_key: None,
|
||||
input_transforms: HashMap::new(),
|
||||
is_trigger: None,
|
||||
language,
|
||||
lock: None,
|
||||
path: None,
|
||||
tag: None,
|
||||
type_: "rawscript".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Script module reference
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ScriptModule {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub path: String,
|
||||
pub input_transforms: HashMap<String, InputTransform>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Flow module reference
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct FlowModule2 {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub path: String,
|
||||
pub input_transforms: HashMap<String, InputTransform>,
|
||||
}
|
||||
|
||||
/// For loop module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ForLoopModule {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub iterator: InputTransform,
|
||||
pub modules: Vec<FlowModule>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parallel: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parallelism: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_failures: Option<bool>,
|
||||
}
|
||||
|
||||
/// While loop module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct WhileLoopModule {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub modules: Vec<FlowModule>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_failures: Option<bool>,
|
||||
}
|
||||
|
||||
/// Branch one module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BranchOneModule {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub branches: Vec<serde_json::Value>,
|
||||
pub default: Vec<FlowModule>,
|
||||
}
|
||||
|
||||
/// Branch all module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BranchAllModule {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub branches: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parallel: Option<bool>,
|
||||
}
|
||||
|
||||
/// Identity module
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct IdentityModule {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow: Option<bool>,
|
||||
}
|
||||
|
||||
/// Open flow with path
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct OpenFlowWPath {
|
||||
#[serde(flatten)]
|
||||
pub open_flow: OpenFlow,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dedicated_worker: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_behalf_of_email: Option<String>,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visible_to_runner_only: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Create flow request body
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CreateFlowBody {
|
||||
#[serde(flatten)]
|
||||
pub open_flow_w_path: OpenFlowWPath,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_message: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draft_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// Flow response type
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Flow {
|
||||
pub path: String,
|
||||
#[serde(flatten)]
|
||||
pub open_flow: OpenFlow,
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Workspace
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Workspace {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub owner: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ websocket = ["dep:tokio-tungstenite"]
|
||||
smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp"]
|
||||
license = ["dep:rsa"]
|
||||
zip = ["dep:async_zip"]
|
||||
oauth2 = ["dep:async-oauth2"]
|
||||
oauth2 = ["dep:windmill-oauth"]
|
||||
http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_eq"]
|
||||
static_frontend = ["dep:rust-embed"]
|
||||
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"]
|
||||
@@ -36,11 +36,11 @@ sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws
|
||||
deno_core = ["dep:deno_core", "dep:deno_error"]
|
||||
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
|
||||
cloud = ["windmill-common/cloud"]
|
||||
mcp = ["dep:rmcp"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server"]
|
||||
python = []
|
||||
|
||||
[dependencies]
|
||||
rmcp = { version = "0.12.0", features=["transport-streamable-http-server", "transport-streamable-http-server-session", "transport-worker"], optional = true }
|
||||
windmill-mcp = { workspace = true, optional = true }
|
||||
windmill-queue.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-audit.workspace = true
|
||||
@@ -67,7 +67,7 @@ itertools.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
sqlx.workspace = true
|
||||
async-oauth2 = { workspace = true, optional = true }
|
||||
windmill-oauth = { workspace = true, optional = true }
|
||||
tracing.workspace = true
|
||||
sql-builder.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.602.0
|
||||
version: 1.603.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -230,6 +230,83 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/auth/is_smtp_configured:
|
||||
get:
|
||||
security: []
|
||||
summary: check if SMTP is configured for password reset
|
||||
operationId: isSmtpConfigured
|
||||
tags:
|
||||
- user
|
||||
responses:
|
||||
"200":
|
||||
description: returns true if SMTP is configured
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/auth/request_password_reset:
|
||||
post:
|
||||
security: []
|
||||
summary: request password reset email
|
||||
operationId: requestPasswordReset
|
||||
tags:
|
||||
- user
|
||||
requestBody:
|
||||
description: email to send password reset link to
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
responses:
|
||||
"200":
|
||||
description: password reset email sent (if user exists)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PasswordResetResponse"
|
||||
"400":
|
||||
description: SMTP not configured
|
||||
|
||||
/auth/reset_password:
|
||||
post:
|
||||
security: []
|
||||
summary: reset password using token
|
||||
operationId: resetPassword
|
||||
tags:
|
||||
- user
|
||||
requestBody:
|
||||
description: token and new password
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
- new_password
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
new_password:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: password reset successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PasswordResetResponse"
|
||||
"400":
|
||||
description: invalid or expired token
|
||||
|
||||
/w/{workspace}/users/get/{username}:
|
||||
get:
|
||||
summary: get user (require admin privilege)
|
||||
@@ -17457,6 +17534,14 @@ components:
|
||||
- email
|
||||
- password
|
||||
|
||||
PasswordResetResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
|
||||
EditWorkspaceUser:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -9,19 +9,20 @@ use std::sync::Arc;
|
||||
use std::{borrow::Cow, time::Duration};
|
||||
|
||||
use axum::body::to_bytes;
|
||||
use rmcp::{
|
||||
handler::server::ServerHandler,
|
||||
model::*,
|
||||
service::{RequestContext, RoleServer},
|
||||
transport::StreamableHttpServerConfig,
|
||||
ErrorData,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::try_join;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::{utils::StripPath, DB};
|
||||
use windmill_mcp::server::{
|
||||
Annotated, CallToolRequestParam, CallToolResult, Content, ErrorData, Implementation,
|
||||
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, LocalSessionManager, PaginatedRequestParam,
|
||||
ProtocolVersion, RawContent, RawTextContent, RequestContext, RoleServer, ServerCapabilities,
|
||||
ServerHandler, ServerInfo, StreamableHttpServerConfig, StreamableHttpService, Tool,
|
||||
ToolAnnotations,
|
||||
};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::jobs::{
|
||||
@@ -47,9 +48,6 @@ use super::utils::{
|
||||
use axum::{
|
||||
extract::Path, http::Request, middleware::Next, response::Response, routing::get, Json, Router,
|
||||
};
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
session::local::LocalSessionManager, StreamableHttpService,
|
||||
};
|
||||
use windmill_common::error::JsonResult;
|
||||
|
||||
/// MCP Server Runner - implements the core MCP protocol handlers
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
//! them to MCP tools and handling HTTP calls to Windmill API endpoints.
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use rmcp::{model::Tool, ErrorData};
|
||||
use std::sync::Arc;
|
||||
use windmill_common::db::Authed;
|
||||
use windmill_common::{auth::create_jwt_token, BASE_INTERNAL_URL};
|
||||
use windmill_mcp::server::{ErrorData, Tool, ToolAnnotations};
|
||||
|
||||
// Import the auto-generated tools
|
||||
use super::auto_generated_endpoints;
|
||||
@@ -66,7 +66,7 @@ pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
|
||||
}
|
||||
|
||||
/// Create appropriate annotations for endpoint tools based on HTTP method
|
||||
fn create_endpoint_annotations(tool: &EndpointTool) -> rmcp::model::ToolAnnotations {
|
||||
fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations {
|
||||
let method = tool.method.as_ref();
|
||||
|
||||
// Determine characteristics based on HTTP method
|
||||
@@ -79,7 +79,7 @@ fn create_endpoint_annotations(tool: &EndpointTool) -> rmcp::model::ToolAnnotati
|
||||
_ => (false, true, false, true), // Default: assume can modify and be destructive
|
||||
};
|
||||
|
||||
rmcp::model::ToolAnnotations {
|
||||
ToolAnnotations {
|
||||
title: Some(format!("{} {}", method, tool.path)),
|
||||
read_only_hint: Some(read_only),
|
||||
destructive_hint: Some(destructive),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Contains all database query functions and database-related utilities
|
||||
//! used by the MCP server implementation.
|
||||
|
||||
use rmcp::ErrorData;
|
||||
use windmill_mcp::server::ErrorData;
|
||||
use sql_builder::prelude::*;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Contains functions for transforming Windmill schemas into MCP-compatible formats,
|
||||
//! including resource enrichment and schema conversion utilities.
|
||||
|
||||
use rmcp::ErrorData;
|
||||
use windmill_mcp::server::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::db::UserDB;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Contains utilities for parsing and matching MCP token scopes to determine
|
||||
//! which scripts, flows, and endpoints a token has access to.
|
||||
|
||||
use rmcp::ErrorData;
|
||||
use windmill_mcp::server::ErrorData;
|
||||
|
||||
/// Configuration for MCP scopes parsed from token scopes
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
||||
@@ -21,7 +21,7 @@ use hmac::Mac;
|
||||
#[cfg(all(feature = "oauth2", not(feature = "private")))]
|
||||
use itertools::Itertools;
|
||||
#[cfg(all(feature = "oauth2", not(feature = "private")))]
|
||||
use oauth2::{Client as OClient, *};
|
||||
use windmill_oauth::{OClient, AccessToken, RefreshToken, Scope, helpers};
|
||||
#[cfg(not(feature = "private"))]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(not(feature = "private"))]
|
||||
|
||||
@@ -48,7 +48,7 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
let router = Router::new()
|
||||
.route("/list", get(list_resources))
|
||||
.route("/list_search", get(list_search_resources))
|
||||
.route("/list_names/:type", get(list_names))
|
||||
@@ -75,8 +75,12 @@ pub fn workspaced_service() -> Router {
|
||||
"/file_resource_type_to_file_ext_map",
|
||||
get(file_resource_ext_to_resource_type),
|
||||
)
|
||||
.route("/type/create", post(create_resource_type))
|
||||
.route("/mcp_tools/*path", get(get_mcp_tools))
|
||||
.route("/type/create", post(create_resource_type));
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
let router = router.route("/mcp_tools/*path", get(get_mcp_tools));
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
pub fn public_service() -> Router {
|
||||
@@ -1400,6 +1404,7 @@ where
|
||||
}
|
||||
|
||||
/// Get list of tools from an MCP resource
|
||||
#[cfg(feature = "mcp")]
|
||||
async fn get_mcp_tools(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1431,11 +1436,11 @@ async fn get_mcp_tools(
|
||||
|
||||
// Parse MCP resource
|
||||
let mcp_resource =
|
||||
serde_json::from_str::<windmill_common::mcp_client::McpResource>(resource_value.0.get())
|
||||
serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
|
||||
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
|
||||
|
||||
// Create MCP client connection
|
||||
let client = windmill_common::mcp_client::McpClient::from_resource(mcp_resource, &db, &w_id)
|
||||
let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))?;
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ use windmill_common::users::COOKIE_NAME;
|
||||
use windmill_common::users::{truncate_token, username_to_permissioned_as};
|
||||
use windmill_common::utils::paginate;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
use windmill_common::BASE_URL;
|
||||
use windmill_common::{
|
||||
auth::{get_folders_for_user, get_groups_for_user},
|
||||
db::UserDB,
|
||||
@@ -125,6 +126,9 @@ pub fn make_unauthed_service() -> Router {
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout).get(logout))
|
||||
.route("/is_first_time_setup", get(is_first_time_setup))
|
||||
.route("/request_password_reset", post(request_password_reset))
|
||||
.route("/reset_password", post(reset_password))
|
||||
.route("/is_smtp_configured", get(is_smtp_configured))
|
||||
}
|
||||
|
||||
pub async fn maybe_refresh_folders(
|
||||
@@ -3081,3 +3085,197 @@ async fn update_username_in_workpsace<'c>(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Password Reset Types
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordReset {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPassword {
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PasswordResetResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// Password Reset Functions
|
||||
|
||||
/// Check if SMTP is configured
|
||||
async fn is_smtp_configured(Extension(db): Extension<DB>) -> JsonResult<bool> {
|
||||
let smtp = windmill_common::server::load_smtp_config(&db).await?;
|
||||
Ok(Json(smtp.is_some()))
|
||||
}
|
||||
|
||||
/// Request a password reset email
|
||||
async fn request_password_reset(
|
||||
Extension(db): Extension<DB>,
|
||||
Json(req): Json<RequestPasswordReset>,
|
||||
) -> Result<Json<PasswordResetResponse>> {
|
||||
let email = req.email.to_lowercase();
|
||||
|
||||
// Check if SMTP is configured
|
||||
let smtp = windmill_common::server::load_smtp_config(&db).await?;
|
||||
let smtp = smtp.ok_or_else(|| {
|
||||
Error::BadRequest("SMTP is not configured. Password reset is not available.".to_string())
|
||||
})?;
|
||||
|
||||
// Check if user exists with password login type
|
||||
let user_exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')",
|
||||
&email
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
// But only send email if user exists
|
||||
if user_exists {
|
||||
// Generate a secure token
|
||||
let token = rd_string(32);
|
||||
|
||||
// Delete any existing tokens for this email
|
||||
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Insert new token with 1 hour expiration
|
||||
sqlx::query!(
|
||||
"INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')",
|
||||
&email,
|
||||
&token
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Get the base URL for the reset link
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let base_url = if base_url.is_empty() {
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string())
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
|
||||
let reset_link = format!("{}/user/reset-password?token={}", base_url, token);
|
||||
|
||||
// Send the email
|
||||
let subject = "Windmill Password Reset";
|
||||
let content = format!(
|
||||
"You have requested a password reset for your Windmill account.\n\n\
|
||||
Click the link below to reset your password:\n\
|
||||
{}\n\n\
|
||||
This link will expire in 1 hour.\n\n\
|
||||
If you did not request this password reset, you can safely ignore this email.",
|
||||
reset_link
|
||||
);
|
||||
|
||||
// Send the email - don't fail the request if email fails
|
||||
if let Err(e) = windmill_common::email_oss::send_email_plain_text(
|
||||
subject,
|
||||
&content,
|
||||
vec![email.clone()],
|
||||
smtp,
|
||||
Some(Duration::from_secs(10)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send password reset email to {}: {:?}", email, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
Ok(Json(PasswordResetResponse {
|
||||
message: "If an account with that email exists, a password reset link has been sent."
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reset password using a token
|
||||
async fn reset_password(
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
Json(req): Json<ResetPassword>,
|
||||
) -> Result<Json<PasswordResetResponse>> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Find the token and verify it's not expired
|
||||
let magic_link = sqlx::query!(
|
||||
"SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()",
|
||||
&req.token
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let email = match magic_link {
|
||||
Some(link) => link.email,
|
||||
None => {
|
||||
return Err(Error::BadRequest(
|
||||
"Invalid or expired password reset token".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Hash the new password
|
||||
let password_hash = crate::users_oss::hash_password(argon2, req.new_password)?;
|
||||
|
||||
// Update the password
|
||||
let rows_updated = sqlx::query!(
|
||||
"UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'",
|
||||
&password_hash,
|
||||
&email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows_updated == 0 {
|
||||
return Err(Error::BadRequest(
|
||||
"Unable to update password. User may not exist or may use a different login method."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete the used token and any other tokens for this email
|
||||
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Invalidate all existing sessions for this user
|
||||
sqlx::query!(
|
||||
"DELETE FROM token WHERE email = $1 AND label = 'session'",
|
||||
&email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Audit log
|
||||
let audit_author = AuditAuthor {
|
||||
email: email.clone(),
|
||||
username: email.clone(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&audit_author,
|
||||
"users.password_reset",
|
||||
ActionKind::Update,
|
||||
"global",
|
||||
Some(&email),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(PasswordResetResponse {
|
||||
message: "Password has been reset successfully. You can now log in with your new password."
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ pub async fn set_password(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn hash_password(_argon2: Arc<Argon2<'_>>, _password: String) -> Result<String> {
|
||||
Err(Error::internal_err(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn send_email_if_possible(_subject: &str, _content: &str, _to: &str) {
|
||||
tracing::warn!(
|
||||
@@ -70,7 +77,6 @@ pub struct OnboardingData {
|
||||
pub use_case: String,
|
||||
}
|
||||
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn submit_onboarding_data(
|
||||
_authed: ApiAuthed,
|
||||
@@ -80,4 +86,4 @@ pub async fn submit_onboarding_data(
|
||||
Err(Error::internal_err(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ private = ["dep:aws-sdk-rds"]
|
||||
jemalloc = ["dep:tikv-jemalloc-ctl"]
|
||||
tantivy = []
|
||||
prometheus = ["dep:prometheus"]
|
||||
loki = ["dep:tracing-loki"]
|
||||
benchmark = []
|
||||
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"]
|
||||
aws_auth = ["dep:aws-sdk-sts"]
|
||||
@@ -59,7 +58,6 @@ itertools.workspace = true
|
||||
regex.workspace = true
|
||||
git-version.workspace = true
|
||||
cron.workspace = true
|
||||
tracing-loki = { version = "^0", optional = true }
|
||||
magic-crypt.workspace = true
|
||||
object_store = { workspace = true, optional = true }
|
||||
prometheus = { workspace = true, optional = true }
|
||||
@@ -100,7 +98,7 @@ async-recursion.workspace = true
|
||||
pep440_rs.workspace = true
|
||||
|
||||
semver.workspace = true
|
||||
croner = "2.2.0"
|
||||
croner.workspace = true
|
||||
quick_cache.workspace = true
|
||||
pin-project-lite.workspace = true
|
||||
futures.workspace = true
|
||||
@@ -108,7 +106,6 @@ tempfile.workspace = true
|
||||
systemstat.workspace = true
|
||||
size.workspace = true
|
||||
globset.workspace = true
|
||||
rmcp = { version = "0.12.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
|
||||
|
||||
opentelemetry-semantic-conventions = { workspace = true, optional = true }
|
||||
opentelemetry-otlp = { workspace = true, optional = true }
|
||||
|
||||
@@ -64,7 +64,6 @@ pub mod git_sync_ee;
|
||||
pub mod git_sync_oss;
|
||||
pub mod jobs;
|
||||
pub mod jwt;
|
||||
pub mod mcp_client;
|
||||
pub mod more_serde;
|
||||
pub mod oauth2;
|
||||
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "windmill-mcp"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "windmill_mcp"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker"]
|
||||
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
anyhow.workspace = true
|
||||
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
rmcp.workspace = true
|
||||
@@ -1,10 +1,19 @@
|
||||
use crate::variables::get_secret_value_as_admin;
|
||||
use crate::DB;
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use windmill_common::variables::get_secret_value_as_admin;
|
||||
use windmill_common::DB;
|
||||
|
||||
// Re-export rmcp types for client usage
|
||||
pub use rmcp::model::Tool as McpTool;
|
||||
use rmcp::{
|
||||
model::{
|
||||
@@ -18,6 +27,26 @@ use rmcp::{
|
||||
RoleClient, ServiceExt,
|
||||
};
|
||||
|
||||
// Re-export rmcp server types when server feature is enabled
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server {
|
||||
//! Re-exports of rmcp server types for MCP server implementations
|
||||
|
||||
pub use rmcp::handler::server::ServerHandler;
|
||||
pub use rmcp::model::{
|
||||
Annotated, CallToolRequestParam, CallToolResult, Content, Implementation,
|
||||
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParam, ProtocolVersion, RawContent,
|
||||
RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
|
||||
};
|
||||
pub use rmcp::service::{RequestContext, RoleServer};
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
session::local::LocalSessionManager, StreamableHttpService,
|
||||
};
|
||||
pub use rmcp::transport::StreamableHttpServerConfig;
|
||||
pub use rmcp::ErrorData;
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "windmill-oauth"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "windmill_oauth"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
|
||||
async-oauth2.workspace = true
|
||||
axum.workspace = true
|
||||
tower-cookies.workspace = true
|
||||
# Note: We use reqwest 0.12 via async-oauth2, not the workspace reqwest 0.13
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
hex.workspace = true
|
||||
|
||||
chrono.workspace = true
|
||||
itertools.workspace = true
|
||||
anyhow.workspace = true
|
||||
lazy_static.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -0,0 +1,856 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! OAuth2 client and token management for Windmill.
|
||||
//!
|
||||
//! This crate provides OAuth2 functionality including:
|
||||
//! - OAuth2 client configuration and building
|
||||
//! - Token exchange and refresh
|
||||
//! - Slack OAuth integration
|
||||
//! - Client credentials flow support
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use base64::Engine;
|
||||
use hmac::Mac;
|
||||
use itertools::Itertools;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use tokio::sync::RwLock;
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
use windmill_common::error::{self, to_anyhow, Error};
|
||||
use windmill_common::more_serde::maybe_number_opt;
|
||||
use windmill_common::oauth2::*;
|
||||
use windmill_common::utils::now_from_db;
|
||||
use windmill_common::variables::{build_crypt, encrypt};
|
||||
|
||||
pub type DB = sqlx::Pool<sqlx::Postgres>;
|
||||
|
||||
// Re-export oauth2 types that consumers need (also used internally)
|
||||
pub use oauth2::{
|
||||
AccessToken, AuthType, Client as OClient, RefreshToken, Scope, State, Url,
|
||||
helpers,
|
||||
};
|
||||
|
||||
// Re-export reqwest Client (version 0.12 compatible with async-oauth2)
|
||||
pub use reqwest::Client as HttpClient;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
|
||||
pub static ref IS_SECURE: Arc<RwLock<bool>> = Arc::new(RwLock::new(false));
|
||||
pub static ref COOKIE_DOMAIN: Option<String> = std::env::var("COOKIE_DOMAIN").ok();
|
||||
|
||||
/// HTTP client for OAuth operations (reqwest 0.12, compatible with async-oauth2)
|
||||
pub static ref OAUTH_HTTP_CLIENT: reqwest::Client = reqwest::ClientBuilder::new()
|
||||
.user_agent("windmill/oauth")
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create OAuth HTTP client");
|
||||
}
|
||||
|
||||
/// OAuth client with associated scopes and configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientWithScopes {
|
||||
pub display_name: Option<String>,
|
||||
pub client: OClient,
|
||||
pub scopes: Vec<String>,
|
||||
pub extra_params: Option<HashMap<String, String>>,
|
||||
pub extra_params_callback: Option<HashMap<String, String>>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub userinfo_url: Option<String>,
|
||||
pub grant_types: Vec<String>,
|
||||
}
|
||||
|
||||
/// Map of OAuth client names to their configurations
|
||||
pub type BasicClientsMap = HashMap<String, ClientWithScopes>;
|
||||
|
||||
/// OAuth provider configuration
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
#[serde(default = "empty_auth")]
|
||||
pub auth_url: String,
|
||||
#[serde(default = "empty_string")]
|
||||
pub token_url: String,
|
||||
pub userinfo_url: Option<String>,
|
||||
pub scopes: Option<Vec<String>>,
|
||||
pub extra_params: Option<HashMap<String, String>>,
|
||||
pub extra_params_callback: Option<HashMap<String, String>>,
|
||||
pub req_body_auth: Option<bool>,
|
||||
#[serde(default = "default_grant_types")]
|
||||
pub grant_types: Vec<String>,
|
||||
}
|
||||
|
||||
/// OAuth client credentials
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OAuthClient {
|
||||
#[serde(default = "empty_string")]
|
||||
pub id: String,
|
||||
#[serde(default = "empty_string")]
|
||||
pub secret: String,
|
||||
#[serde(default, deserialize_with = "windmill_common::utils::empty_as_none")]
|
||||
pub display_name: Option<String>,
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub connect_config: Option<OAuthConfig>,
|
||||
pub login_config: Option<OAuthConfig>,
|
||||
pub tenant: Option<String>,
|
||||
#[serde(default = "default_grant_types")]
|
||||
pub grant_types: Vec<String>,
|
||||
}
|
||||
|
||||
fn empty_string() -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
fn empty_auth() -> String {
|
||||
"https://missing-auth-url".to_string()
|
||||
}
|
||||
|
||||
fn default_grant_types() -> Vec<String> {
|
||||
vec!["authorization_code".to_string()]
|
||||
}
|
||||
|
||||
/// Container for all OAuth clients (login, connect, and slack)
|
||||
#[derive(Debug)]
|
||||
pub struct AllClients {
|
||||
pub logins: BasicClientsMap,
|
||||
pub connects: BasicClientsMap,
|
||||
pub slack: Option<OClient>,
|
||||
}
|
||||
|
||||
/// Slack token response from OAuth flow
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SlackTokenResponse {
|
||||
pub access_token: AccessToken,
|
||||
pub team_id: String,
|
||||
pub team_name: String,
|
||||
#[serde(rename = "scope")]
|
||||
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
|
||||
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub scopes: Option<Vec<Scope>>,
|
||||
pub bot: SlackBotToken,
|
||||
}
|
||||
|
||||
/// Standard OAuth token response
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: AccessToken,
|
||||
#[serde(deserialize_with = "maybe_number_opt")]
|
||||
#[serde(default)]
|
||||
pub expires_in: Option<u64>,
|
||||
pub refresh_token: Option<RefreshToken>,
|
||||
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
|
||||
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
|
||||
#[serde(default)]
|
||||
pub scope: Option<Vec<Scope>>,
|
||||
}
|
||||
|
||||
/// Slack bot token from OAuth response
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SlackBotToken {
|
||||
pub bot_access_token: String,
|
||||
}
|
||||
|
||||
/// OAuth callback parameters
|
||||
#[derive(Deserialize)]
|
||||
pub struct OAuthCallback {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Build all OAuth clients from configuration
|
||||
pub async fn build_oauth_clients(
|
||||
base_url: &str,
|
||||
oauths_from_config: Option<HashMap<String, OAuthClient>>,
|
||||
connect_configs_json: &str,
|
||||
login_configs_json: &str,
|
||||
) -> anyhow::Result<AllClients> {
|
||||
let connect_configs =
|
||||
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)?;
|
||||
let login_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(login_configs_json)?;
|
||||
|
||||
let oauths = if let Some(oauths) = oauths_from_config {
|
||||
tracing::info!("Using OAuth clients from config: {oauths:?}");
|
||||
oauths
|
||||
} else {
|
||||
let path = "./oauth.json";
|
||||
let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") {
|
||||
std::str::from_utf8(
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(e)
|
||||
.map_err(to_anyhow)?,
|
||||
)?
|
||||
.to_string()
|
||||
} else if std::path::Path::new(path).exists() {
|
||||
std::fs::read_to_string(path).map_err(to_anyhow)?
|
||||
} else {
|
||||
tracing::warn!("oauth.json not found, no OAuth clients loaded");
|
||||
return Ok(AllClients {
|
||||
logins: HashMap::new(),
|
||||
connects: HashMap::new(),
|
||||
slack: None,
|
||||
});
|
||||
};
|
||||
|
||||
if content.is_empty() {
|
||||
tracing::warn!("oauth.json is empty, no OAuth clients loaded");
|
||||
return Ok(AllClients {
|
||||
logins: HashMap::new(),
|
||||
connects: HashMap::new(),
|
||||
slack: None,
|
||||
});
|
||||
};
|
||||
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
|
||||
Ok(clients) => clients,
|
||||
Err(e) => {
|
||||
tracing::error!("deserializing oauth.json: {e}");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"OAuth loaded clients: {}",
|
||||
oauths.keys().join(", ")
|
||||
);
|
||||
|
||||
let logins = login_configs
|
||||
.into_iter()
|
||||
.filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1))))
|
||||
.chain(oauths.iter().filter_map(|x| {
|
||||
x.1.login_config
|
||||
.as_ref()
|
||||
.map(|c| (x.0.clone(), (x.1, c.clone())))
|
||||
}))
|
||||
.filter_map(|(k, (client_params, config))| {
|
||||
let named_client = build_basic_client(
|
||||
k.clone(),
|
||||
config.clone(),
|
||||
client_params.clone(),
|
||||
true,
|
||||
base_url,
|
||||
None,
|
||||
);
|
||||
named_client
|
||||
.map(|named_client| {
|
||||
(
|
||||
named_client.0,
|
||||
ClientWithScopes {
|
||||
client: named_client.1,
|
||||
scopes: config.scopes.unwrap_or(vec![]),
|
||||
extra_params: config.extra_params,
|
||||
extra_params_callback: config.extra_params_callback,
|
||||
allowed_domains: client_params.allowed_domains.clone(),
|
||||
userinfo_url: config.userinfo_url,
|
||||
display_name: client_params.display_name.clone(),
|
||||
grant_types: client_params.grant_types.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error building oauth client {k}: {e}");
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let connects = connect_configs
|
||||
.into_iter()
|
||||
.filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1))))
|
||||
.chain(oauths.iter().filter_map(|x| {
|
||||
x.1.connect_config
|
||||
.as_ref()
|
||||
.map(|c| (x.0.clone(), (x.1, c.clone())))
|
||||
}))
|
||||
.filter_map(|(k, (client_params, config))| {
|
||||
let named_client = build_basic_client(
|
||||
k.clone(),
|
||||
config.clone(),
|
||||
client_params.clone(),
|
||||
false,
|
||||
base_url,
|
||||
if k == "supabase_wizard" {
|
||||
Some(format!("{base_url}/oauth/callback_supabase"))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
named_client
|
||||
.map(|named_client| {
|
||||
(
|
||||
named_client.0,
|
||||
ClientWithScopes {
|
||||
client: named_client.1,
|
||||
scopes: config.scopes.unwrap_or(vec![]),
|
||||
extra_params: config.extra_params,
|
||||
extra_params_callback: config.extra_params_callback,
|
||||
allowed_domains: None,
|
||||
userinfo_url: None,
|
||||
display_name: client_params.display_name.clone(),
|
||||
grant_types: client_params.grant_types.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error building oauth client {k}: {e}");
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let slack = oauths
|
||||
.get("slack")
|
||||
.map(|v| {
|
||||
build_basic_client(
|
||||
"slack".to_string(),
|
||||
OAuthConfig {
|
||||
auth_url: "https://slack.com/oauth/authorize".to_string(),
|
||||
token_url: "https://slack.com/api/oauth.access".to_string(),
|
||||
userinfo_url: None,
|
||||
scopes: None,
|
||||
extra_params: None,
|
||||
extra_params_callback: None,
|
||||
req_body_auth: None,
|
||||
grant_types: vec!["authorization_code".to_string()],
|
||||
},
|
||||
v.clone(),
|
||||
false,
|
||||
base_url,
|
||||
Some(format!("{base_url}/oauth/callback_slack")),
|
||||
)
|
||||
.map(|x| x.1)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error building oauth slack client: {e}");
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
|
||||
let all_clients = AllClients { logins, connects, slack };
|
||||
tracing::debug!("Final oauth config: {all_clients:#?}");
|
||||
Ok(all_clients)
|
||||
}
|
||||
|
||||
/// Build a basic OAuth client from configuration
|
||||
pub fn build_basic_client(
|
||||
name: String,
|
||||
config: OAuthConfig,
|
||||
client_params: OAuthClient,
|
||||
login: bool,
|
||||
base_url: &str,
|
||||
override_callback: Option<String>,
|
||||
) -> error::Result<(String, OClient)> {
|
||||
let auth_url = Url::parse(&config.auth_url)
|
||||
.map_err(|e| anyhow!("Invalid authorization endpoint URL: {e}"))?;
|
||||
let token_url =
|
||||
Url::parse(&config.token_url).map_err(|e| anyhow!("Invalid token endpoint URL: {e}"))?;
|
||||
|
||||
let redirect_url = if login {
|
||||
format!("{base_url}/user/login_callback/{name}")
|
||||
} else if let Some(callback) = override_callback {
|
||||
callback
|
||||
} else {
|
||||
format!("{base_url}/oauth/callback/{name}")
|
||||
};
|
||||
|
||||
let mut client = OClient::new(client_params.id, auth_url, token_url);
|
||||
if config.req_body_auth.unwrap_or(false) {
|
||||
client.set_auth_type(AuthType::RequestBody);
|
||||
}
|
||||
client.set_client_secret(client_params.secret.clone());
|
||||
client.set_redirect_url(
|
||||
Url::parse(&redirect_url).map_err(|e| anyhow!("Invalid redirect URL: {e}"))?,
|
||||
);
|
||||
|
||||
Ok((name.to_string(), client))
|
||||
}
|
||||
|
||||
/// Build a Slack OAuth client with custom credentials
|
||||
pub async fn build_slack_client(
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
_workspace_id: &str,
|
||||
) -> error::Result<OClient> {
|
||||
let auth_url = Url::parse("https://slack.com/oauth/authorize")
|
||||
.map_err(|e| anyhow!("Invalid Slack authorization URL: {e}"))?;
|
||||
let token_url = Url::parse("https://slack.com/api/oauth.access")
|
||||
.map_err(|e| anyhow!("Invalid Slack token URL: {e}"))?;
|
||||
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let redirect_url = format!("{}/oauth/callback_slack", base_url);
|
||||
|
||||
let mut client = OClient::new(client_id.to_string(), auth_url, token_url);
|
||||
client.set_client_secret(client_secret.to_string());
|
||||
client.set_redirect_url(
|
||||
Url::parse(&redirect_url).map_err(|e| anyhow!("Invalid redirect URL: {e}"))?,
|
||||
);
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Build OAuth client for client credentials flow with resource-level credentials
|
||||
pub async fn build_client_credentials_oauth_client(
|
||||
db: &DB,
|
||||
client_name: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
cc_token_url_override: Option<&str>,
|
||||
connect_configs_json: &str,
|
||||
) -> error::Result<(OClient, OAuthClient)> {
|
||||
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
|
||||
|
||||
let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?;
|
||||
let oauths = oauths.unwrap_or_default();
|
||||
let oauth_config = oauths
|
||||
.get(client_name)
|
||||
.ok_or_else(|| error::Error::BadRequest("OAuth configuration not found".to_string()))?;
|
||||
|
||||
let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone())
|
||||
.map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?;
|
||||
|
||||
let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config {
|
||||
if !config.auth_url.is_empty() && !config.token_url.is_empty() {
|
||||
config.clone()
|
||||
} else {
|
||||
let static_configs =
|
||||
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
|
||||
.map_err(|e| {
|
||||
error::Error::InternalErr(format!(
|
||||
"Failed to parse oauth_connect.json: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
static_configs.get(client_name).cloned().ok_or_else(|| {
|
||||
error::Error::BadRequest(format!(
|
||||
"OAuth configuration not found for '{}' in either global settings or static config",
|
||||
client_name
|
||||
))
|
||||
})?
|
||||
}
|
||||
} else {
|
||||
let static_configs =
|
||||
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(
|
||||
|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e))
|
||||
},
|
||||
)?;
|
||||
|
||||
static_configs.get(client_name).cloned().ok_or_else(|| {
|
||||
error::Error::BadRequest(format!(
|
||||
"OAuth configuration not found for '{}' in either global settings or static config",
|
||||
client_name
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
if let Some(override_url) = cc_token_url_override {
|
||||
connect_config.token_url = override_url.to_string();
|
||||
}
|
||||
|
||||
let resource_oauth_client = OAuthClient {
|
||||
id: client_id.to_string(),
|
||||
secret: client_secret.to_string(),
|
||||
allowed_domains: oauth_client_config.allowed_domains.clone(),
|
||||
connect_config: Some(connect_config.clone()),
|
||||
login_config: oauth_client_config.login_config.clone(),
|
||||
display_name: oauth_client_config.display_name.clone(),
|
||||
grant_types: oauth_client_config.grant_types.clone(),
|
||||
tenant: oauth_client_config.tenant.clone(),
|
||||
};
|
||||
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let (_, client) = build_basic_client(
|
||||
client_name.to_string(),
|
||||
connect_config,
|
||||
resource_oauth_client,
|
||||
false,
|
||||
&base_url,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok((client, oauth_client_config))
|
||||
}
|
||||
|
||||
/// Exchange authorization code for tokens
|
||||
pub async fn exchange_code<T: DeserializeOwned>(
|
||||
callback: OAuthCallback,
|
||||
cookies: &Cookies,
|
||||
client: OClient,
|
||||
extra_params: Option<HashMap<String, String>>,
|
||||
http_client: &reqwest::Client,
|
||||
) -> error::Result<T> {
|
||||
let name = if COOKIE_DOMAIN.is_some() {
|
||||
"csrf_domain"
|
||||
} else {
|
||||
"csrf"
|
||||
};
|
||||
let csrf_state = cookies
|
||||
.get(name)
|
||||
.map(|x| x.value().to_string())
|
||||
.unwrap_or("".to_string());
|
||||
if callback.state != csrf_state {
|
||||
return Err(error::Error::BadRequest("csrf did not match".to_string()));
|
||||
}
|
||||
|
||||
let mut token_url = client.exchange_code(callback.code);
|
||||
|
||||
if let Some(extra_params) = extra_params {
|
||||
for (key, value) in extra_params {
|
||||
token_url = token_url.param(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
token_url
|
||||
.with_client(http_client)
|
||||
.execute::<T>()
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(format!("{:?}", e)))
|
||||
}
|
||||
|
||||
/// Internal token exchange implementation
|
||||
pub async fn exchange_token(
|
||||
client: OClient,
|
||||
refresh_token: &str,
|
||||
grant_type: &str,
|
||||
oauth_client_info: Option<&ClientWithScopes>,
|
||||
http_client: &reqwest::Client,
|
||||
) -> Result<TokenResponse, Error> {
|
||||
let token_json = match grant_type {
|
||||
"authorization_code" => {
|
||||
client
|
||||
.exchange_refresh_token(&RefreshToken::from(refresh_token))
|
||||
.with_client(http_client)
|
||||
.execute::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
}
|
||||
"client_credentials" => {
|
||||
let mut token_request = client.exchange_client_credentials();
|
||||
|
||||
if let Some(oauth_info) = oauth_client_info {
|
||||
if let Some(extra_params) = oauth_info.extra_params_callback.as_ref() {
|
||||
for (key, value) in extra_params.iter() {
|
||||
token_request = token_request.param(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token_request
|
||||
.with_client(http_client)
|
||||
.execute::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
}
|
||||
"" | _ if grant_type.is_empty() => {
|
||||
client
|
||||
.exchange_refresh_token(&RefreshToken::from(refresh_token))
|
||||
.with_client(http_client)
|
||||
.execute::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Unsupported grant type: {}",
|
||||
grant_type
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let token = serde_json::from_value::<TokenResponse>(token_json.clone()).map_err(|e| {
|
||||
Error::BadConfig(format!(
|
||||
"Error deserializing response as a new token: {e}\nresponse:{token_json}"
|
||||
))
|
||||
})?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Refresh an OAuth token and update the database
|
||||
pub async fn refresh_token<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
path: &str,
|
||||
w_id: &str,
|
||||
id: i32,
|
||||
db: &DB,
|
||||
oauth_clients: &AllClients,
|
||||
http_client: &reqwest::Client,
|
||||
connect_configs_json: &str,
|
||||
) -> error::Result<String> {
|
||||
let account = sqlx::query!(
|
||||
"SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
w_id,
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let account = windmill_common::utils::not_found_if_none(account, "Account", &id.to_string())?;
|
||||
let oauth_client_info = oauth_clients
|
||||
.connects
|
||||
.get(&account.client)
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
||||
.clone();
|
||||
|
||||
let mut client = if account.grant_type == "client_credentials" {
|
||||
match (&account.cc_client_id, &account.cc_client_secret) {
|
||||
(Some(client_id), Some(client_secret)) => {
|
||||
let (client, _) = build_client_credentials_oauth_client(
|
||||
db,
|
||||
&account.client,
|
||||
client_id,
|
||||
client_secret,
|
||||
account.cc_token_url.as_deref(),
|
||||
connect_configs_json,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
}
|
||||
_ => {
|
||||
return Err(error::Error::BadRequest(
|
||||
"client_credentials flow requires cc_client_id and cc_client_secret to be stored in account".to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
oauth_client_info.client.to_owned()
|
||||
};
|
||||
|
||||
if account.grant_type == "client_credentials" {
|
||||
for scope in oauth_client_info.scopes.iter() {
|
||||
client.add_scope(scope);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
grant_type = %account.grant_type,
|
||||
client = %account.client,
|
||||
workspace_id = %w_id,
|
||||
account_id = %id,
|
||||
"Refreshing OAuth token"
|
||||
);
|
||||
|
||||
let token = exchange_token(
|
||||
client,
|
||||
&account.refresh_token,
|
||||
&account.grant_type,
|
||||
Some(&oauth_client_info),
|
||||
http_client,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(token_err) = token {
|
||||
sqlx::query!(
|
||||
"UPDATE account SET refresh_error = $1 WHERE workspace_id = $2 AND id = $3",
|
||||
token_err.alt(),
|
||||
w_id,
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Error refreshing token: {}",
|
||||
token_err.alt()
|
||||
)));
|
||||
};
|
||||
|
||||
let token = token.unwrap();
|
||||
|
||||
let expires_at = now_from_db(&mut *tx).await?
|
||||
+ chrono::Duration::try_seconds(
|
||||
token
|
||||
.expires_in
|
||||
.ok_or_else(|| Error::InternalErr("expires_in expected and not found".to_string()))?
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
sqlx::query!(
|
||||
"UPDATE account SET refresh_token = $1, expires_at = $2, refresh_error = NULL WHERE workspace_id = $3 AND id = $4",
|
||||
token
|
||||
.refresh_token
|
||||
.map(|x| x.to_string())
|
||||
.unwrap_or(account.refresh_token),
|
||||
expires_at,
|
||||
w_id,
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let token_str = token.access_token.to_string();
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
let encrypted_token = encrypt(&mc, token_str.as_str());
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
encrypted_token,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
grant_type = %account.grant_type,
|
||||
client = %account.client,
|
||||
workspace_id = %w_id,
|
||||
account_id = %id,
|
||||
"OAuth token refreshed successfully"
|
||||
);
|
||||
|
||||
Ok(token_str)
|
||||
}
|
||||
|
||||
/// Generate OAuth redirect URL with CSRF protection
|
||||
pub fn oauth_redirect(
|
||||
clients: &HashMap<String, ClientWithScopes>,
|
||||
client_name: String,
|
||||
cookies: Cookies,
|
||||
scopes: Option<Vec<String>>,
|
||||
extra_params: Option<HashMap<String, String>>,
|
||||
is_secure: bool,
|
||||
) -> error::Result<axum::response::Redirect> {
|
||||
let client_w_scopes = clients
|
||||
.get(&client_name)
|
||||
.ok_or_else(|| error::Error::BadRequest("client not found".to_string()))?;
|
||||
let state = State::new_random();
|
||||
let mut client = client_w_scopes.client.clone();
|
||||
let scopes_iter = if let Some(scopes) = scopes {
|
||||
scopes
|
||||
} else {
|
||||
client_w_scopes.scopes.clone()
|
||||
};
|
||||
|
||||
for scope in scopes_iter.iter() {
|
||||
client.add_scope(scope);
|
||||
}
|
||||
|
||||
let mut auth_url = client.authorize_url(&state);
|
||||
|
||||
if let Some(extra_params) = extra_params {
|
||||
let mut query_string = auth_url.query_pairs_mut();
|
||||
for (key, value) in extra_params {
|
||||
query_string.append_pair(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
set_csrf_cookie(&state, cookies, is_secure);
|
||||
Ok(axum::response::Redirect::to(auth_url.as_str()))
|
||||
}
|
||||
|
||||
/// Set CSRF cookie for OAuth state verification
|
||||
pub fn set_csrf_cookie(state: &State, cookies: Cookies, is_secure: bool) {
|
||||
let csrf = state.to_base64();
|
||||
let name = if COOKIE_DOMAIN.is_some() {
|
||||
"csrf_domain".to_string()
|
||||
} else {
|
||||
"csrf".to_string()
|
||||
};
|
||||
let mut cookie = Cookie::new(name, csrf);
|
||||
cookie.set_secure(is_secure);
|
||||
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_path("/");
|
||||
if COOKIE_DOMAIN.is_some() {
|
||||
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
|
||||
}
|
||||
cookies.add(cookie);
|
||||
}
|
||||
|
||||
/// Slack signature verifier for webhook authentication
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SlackVerifier {
|
||||
mac: HmacSha256,
|
||||
}
|
||||
|
||||
impl SlackVerifier {
|
||||
pub fn new<S: AsRef<[u8]>>(secret: S) -> anyhow::Result<SlackVerifier> {
|
||||
HmacSha256::new_from_slice(secret.as_ref())
|
||||
.map(|mac| SlackVerifier { mac })
|
||||
.map_err(|_| anyhow::anyhow!("invalid secret"))
|
||||
}
|
||||
|
||||
pub fn verify(&self, ts: &str, body: &str, exp_sig: &str) -> anyhow::Result<()> {
|
||||
let basestring = format!("v0:{}:{}", ts, body);
|
||||
let mut mac = self.mac.clone();
|
||||
|
||||
mac.update(basestring.as_bytes());
|
||||
let sig = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
|
||||
if sig != exp_sig {
|
||||
Err(anyhow::anyhow!("signature mismatch"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch user info from OAuth provider
|
||||
pub async fn http_get_user_info<T: DeserializeOwned>(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
) -> error::Result<T> {
|
||||
let res = http_client
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(to_anyhow)
|
||||
.map_err(|e| error::Error::InternalErr(format!("failed to fetch user info: {}", e)))?;
|
||||
if !res.status().is_success() {
|
||||
tracing::debug!(
|
||||
"The bearer token of the failed oauth user info exchange is: {}",
|
||||
token
|
||||
);
|
||||
return Err(error::Error::BadConfig(format!(
|
||||
"The user info endpoint responded with non 200: {}\n{}\n{}",
|
||||
res.status(),
|
||||
res.headers()
|
||||
.iter()
|
||||
.map(|x| format!("{}: {}", x.0.as_str(), x.1.to_str().unwrap_or_default()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
res.text().await.unwrap_or_default(),
|
||||
)));
|
||||
}
|
||||
Ok(res
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(to_anyhow)
|
||||
.map_err(|e| error::Error::InternalErr(format!("failed to decode json from user info: {}", e)))?)
|
||||
}
|
||||
|
||||
/// GitHub email info response
|
||||
#[derive(Deserialize)]
|
||||
pub struct GHEmailInfo {
|
||||
pub email: String,
|
||||
pub verified: bool,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_slack_verifier() {
|
||||
let verifier = SlackVerifier::new("test_secret").unwrap();
|
||||
assert!(verifier.verify("123", "body", "wrong_sig").is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
private = []
|
||||
mcp = ["dep:windmill-mcp"]
|
||||
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util"]
|
||||
mssql = ["dep:tiberius"]
|
||||
@@ -40,6 +41,7 @@ duckdb = ["dep:libloading"]
|
||||
windmill-queue.workspace = true
|
||||
windmill-audit.workspace = true # there isn't really a reason for audit-worth actions to happen in the worker.
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-mcp = { workspace = true, optional = true }
|
||||
windmill-macros.workspace = true
|
||||
windmill-parser.workspace = true
|
||||
windmill-parser-ts.workspace = true
|
||||
|
||||
@@ -22,7 +22,16 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::flows::InputTransform;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_common::mcp_client::{McpClient, McpToolSource};
|
||||
use crate::ai::types::McpToolSource;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
use windmill_mcp::McpClient;
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
pub struct McpClientStub;
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
type McpClient = McpClientStub;
|
||||
use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
|
||||
@@ -3,7 +3,17 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::mcp_client::McpToolSource;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub use windmill_mcp::McpToolSource;
|
||||
|
||||
/// Stub type when mcp feature is not enabled
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct McpToolSource {
|
||||
pub name: String,
|
||||
pub tool_name: String,
|
||||
pub resource_path: String,
|
||||
}
|
||||
use windmill_common::{
|
||||
ai_providers::AIProvider, db::DB, error::Error, flow_status::AgentAction, flows::FlowModule,
|
||||
s3_helpers::S3Object,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::ai::types::{ToolDef, ToolDefFunction};
|
||||
pub use crate::ai::types::McpToolSource;
|
||||
use crate::ai::types::ToolDef;
|
||||
use anyhow::Context;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::types::Json;
|
||||
@@ -7,6 +8,7 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::flows::FlowModuleValue;
|
||||
use windmill_common::{
|
||||
ai_providers::AIProvider,
|
||||
db::DB,
|
||||
@@ -18,10 +20,8 @@ use windmill_common::{
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
worker::to_raw_value,
|
||||
};
|
||||
use windmill_common::{
|
||||
flows::FlowModuleValue,
|
||||
mcp_client::{McpClient, McpResource, McpTool, McpToolSource},
|
||||
};
|
||||
#[cfg(feature = "mcp")]
|
||||
use windmill_mcp::{McpClient, McpResource, McpTool};
|
||||
use windmill_queue::{flow_status::get_step_of_flow_status, MiniPulledJob};
|
||||
|
||||
use crate::{ai::types::*, parse_sig_of_lang};
|
||||
@@ -322,6 +322,7 @@ pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) ->
|
||||
}
|
||||
|
||||
/// Cleanup MCP clients by gracefully shutting down connections
|
||||
#[cfg(feature = "mcp")]
|
||||
pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
|
||||
if mcp_clients.is_empty() {
|
||||
return;
|
||||
@@ -351,6 +352,7 @@ pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
|
||||
}
|
||||
|
||||
/// Convert raw MCP tools to Windmill Tool format with source tracking
|
||||
#[cfg(feature = "mcp")]
|
||||
fn convert_mcp_tools_to_windmill_tools(
|
||||
mcp_tools: &[McpTool],
|
||||
resource_name: &str,
|
||||
@@ -396,6 +398,7 @@ fn convert_mcp_tools_to_windmill_tools(
|
||||
}
|
||||
|
||||
/// Configuration for loading tools from an MCP server resource
|
||||
#[cfg(feature = "mcp")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpResourceConfig {
|
||||
pub resource_path: String,
|
||||
@@ -408,6 +411,7 @@ pub struct McpResourceConfig {
|
||||
/// - If include_tools is Some and non-empty: whitelist approach (keep only listed tools)
|
||||
/// - Else if exclude_tools is Some and non-empty: blacklist approach (remove listed tools)
|
||||
/// - Otherwise: no filtering (keep all tools)
|
||||
#[cfg(feature = "mcp")]
|
||||
fn apply_tool_filters(
|
||||
tools: Vec<Tool>,
|
||||
include_tools: &Option<Vec<String>>,
|
||||
@@ -449,6 +453,7 @@ fn apply_tool_filters(
|
||||
|
||||
/// Load tools from MCP servers and return both the clients and tools
|
||||
/// Returns a map of resource name -> client, and a vector of tools
|
||||
#[cfg(feature = "mcp")]
|
||||
pub async fn load_mcp_tools(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
@@ -517,6 +522,7 @@ pub async fn load_mcp_tools(
|
||||
}
|
||||
|
||||
/// Execute an MCP tool by routing the call to the appropriate MCP client
|
||||
#[cfg(feature = "mcp")]
|
||||
pub async fn execute_mcp_tool(
|
||||
mcp_clients: &HashMap<String, Arc<McpClient>>,
|
||||
mcp_source: &McpToolSource,
|
||||
@@ -539,6 +545,39 @@ pub async fn execute_mcp_tool(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Stub implementations when mcp feature is not enabled
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
pub struct McpResourceConfig {}
|
||||
|
||||
/// Stub for cleanup_mcp_clients when mcp is not enabled
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
pub async fn cleanup_mcp_clients<T>(_mcp_clients: HashMap<String, Arc<T>>) {
|
||||
// No-op when MCP is disabled
|
||||
}
|
||||
|
||||
/// Stub for load_mcp_tools when mcp is not enabled
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
pub async fn load_mcp_tools<T>(
|
||||
_db: &DB,
|
||||
_workspace_id: &str,
|
||||
_mcp_configs: Vec<McpResourceConfig>,
|
||||
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
|
||||
Ok((HashMap::new(), Vec::new()))
|
||||
}
|
||||
|
||||
/// Stub for execute_mcp_tool when mcp is not enabled
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
pub async fn execute_mcp_tool<T>(
|
||||
_mcp_clients: &HashMap<String, Arc<T>>,
|
||||
mcp_source: &McpToolSource,
|
||||
_arguments_str: &str,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
Err(Error::internal_err(format!(
|
||||
"MCP support is not enabled. Cannot execute MCP tool: {}",
|
||||
mcp_source.tool_name
|
||||
)))
|
||||
}
|
||||
|
||||
/// Check if any tool's input transforms reference previous_result
|
||||
pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
|
||||
tools.iter().any(|tool| {
|
||||
|
||||
@@ -13,7 +13,11 @@ use regex::Regex;
|
||||
use serde_json::value::RawValue;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::mcp_client::McpClient;
|
||||
#[cfg(feature = "mcp")]
|
||||
use windmill_mcp::McpClient;
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
use crate::ai::tools::McpClientStub as McpClient;
|
||||
use windmill_common::{
|
||||
ai_providers::AIProvider,
|
||||
cache,
|
||||
@@ -149,24 +153,34 @@ pub async fn handle_ai_agent_job(
|
||||
|
||||
// Separate Windmill tools from MCP tools, websearch, and extract MCP resource configs
|
||||
let mut windmill_modules: Vec<FlowModule> = Vec::new();
|
||||
#[allow(unused_mut)]
|
||||
let mut mcp_configs: Vec<crate::ai::utils::McpResourceConfig> = Vec::new();
|
||||
let mut has_websearch = false;
|
||||
|
||||
for tool in tools {
|
||||
match &tool.value {
|
||||
#[allow(unused_variables)]
|
||||
ToolValue::Mcp(mcp_config) => {
|
||||
// This is an MCP tool - extract config
|
||||
tracing::debug!(
|
||||
"MCP server module: path={}, include={:?}, exclude={:?}",
|
||||
mcp_config.resource_path,
|
||||
mcp_config.include_tools,
|
||||
mcp_config.exclude_tools
|
||||
);
|
||||
mcp_configs.push(crate::ai::utils::McpResourceConfig {
|
||||
resource_path: mcp_config.resource_path.clone(),
|
||||
include_tools: Some(mcp_config.include_tools.clone()),
|
||||
exclude_tools: Some(mcp_config.exclude_tools.clone()),
|
||||
});
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
// This is an MCP tool - extract config
|
||||
tracing::debug!(
|
||||
"MCP server module: path={}, include={:?}, exclude={:?}",
|
||||
mcp_config.resource_path,
|
||||
mcp_config.include_tools,
|
||||
mcp_config.exclude_tools
|
||||
);
|
||||
mcp_configs.push(crate::ai::utils::McpResourceConfig {
|
||||
resource_path: mcp_config.resource_path.clone(),
|
||||
include_tools: Some(mcp_config.include_tools.clone()),
|
||||
exclude_tools: Some(mcp_config.exclude_tools.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
tracing::warn!("MCP tool detected but MCP feature is not enabled");
|
||||
}
|
||||
}
|
||||
ToolValue::FlowModule(_) => {
|
||||
// Regular Windmill flow module (script, flow, etc.) - convert to FlowModule
|
||||
@@ -299,6 +313,7 @@ pub async fn handle_ai_agent_job(
|
||||
|
||||
// Load MCP tools if configured
|
||||
let mut tools = tools;
|
||||
|
||||
let mcp_clients = if !mcp_configs.is_empty() {
|
||||
let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs).await?;
|
||||
tools.extend(mcp_tools);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.602.0";
|
||||
export const VERSION = "v1.603.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export {
|
||||
// }
|
||||
// });
|
||||
|
||||
export const VERSION = "1.602.0";
|
||||
export const VERSION = "1.603.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
@@ -68,6 +68,8 @@ services:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- MODE=worker
|
||||
- WORKER_GROUP=default
|
||||
# If running with non-root/non-windmill UID (e.g., user: "1001:1001"),
|
||||
# add: - HOME=/tmp
|
||||
# Uncomment to enable PID namespace isolation (requires privileged: true above)
|
||||
# - ENABLE_UNSHARE_PID=true
|
||||
depends_on:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.602.0",
|
||||
"version": "1.603.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.602.0",
|
||||
"version": "1.603.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.602.0",
|
||||
"version": "1.603.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
let showPassword = $state(false)
|
||||
let logins: OAuthLogin[] | undefined = $state(undefined)
|
||||
let saml: string | undefined = $state(undefined)
|
||||
let smtpConfigured: boolean | undefined = $state(undefined)
|
||||
|
||||
type OAuthLogin = {
|
||||
type: string
|
||||
@@ -194,6 +195,17 @@
|
||||
|
||||
loadLogins()
|
||||
|
||||
async function checkSmtpConfigured() {
|
||||
try {
|
||||
smtpConfigured = await UserService.isSmtpConfigured()
|
||||
} catch (err) {
|
||||
console.error('Could not check if SMTP is configured', err)
|
||||
smtpConfigured = false
|
||||
}
|
||||
}
|
||||
|
||||
checkSmtpConfigured()
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
|
||||
@@ -372,6 +384,16 @@
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{#if smtpConfigured}
|
||||
<div class="text-right pt-1">
|
||||
<a
|
||||
href="{base}/user/forgot-password"
|
||||
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
|
||||
import MenuButton, { sidebarClasses } from './MenuButton.svelte'
|
||||
import MenuLink from './MenuLink.svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
|
||||
let darkMode: boolean = $state(false)
|
||||
|
||||
interface Props {
|
||||
@@ -78,7 +78,13 @@
|
||||
)
|
||||
)
|
||||
|
||||
let secondMenuLinks = $derived(
|
||||
type SecondMenuLink = { label: string; id: string; href: string }
|
||||
function filterLink(link: SecondMenuLink) {
|
||||
if (!$userWorkspaces || !$workspaceStore) return false
|
||||
let userWorkspace = $userWorkspaces.find((_) => _.id === $workspaceStore)
|
||||
return userWorkspace?.operator_settings?.[link.id] === true
|
||||
}
|
||||
let secondMenuLinks: SecondMenuLink[] = $derived(
|
||||
[
|
||||
{
|
||||
label: 'Resources',
|
||||
@@ -95,6 +101,25 @@
|
||||
id: 'assets',
|
||||
href: `${base}/assets`
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
id: 'groups',
|
||||
href: `${base}/groups`
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
id: 'folders',
|
||||
href: `${base}/folders`
|
||||
},
|
||||
{
|
||||
label: 'Workers',
|
||||
id: 'workers',
|
||||
href: `${base}/workers`
|
||||
}
|
||||
].filter(filterLink)
|
||||
)
|
||||
let secondMenuTriggerLinks = $derived(
|
||||
[
|
||||
{
|
||||
label: 'Custom HTTP routes',
|
||||
id: 'triggers',
|
||||
@@ -144,52 +169,15 @@
|
||||
label: 'Audit logs',
|
||||
id: 'audit_logs',
|
||||
href: `${base}/audit_logs`
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
id: 'groups',
|
||||
href: `${base}/groups`
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
id: 'folders',
|
||||
href: `${base}/folders`
|
||||
},
|
||||
{
|
||||
label: 'Workers',
|
||||
id: 'workers',
|
||||
href: `${base}/workers`
|
||||
}
|
||||
].filter((link) => {
|
||||
if (!$userWorkspaces || !$workspaceStore) return false
|
||||
return (
|
||||
$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] === true
|
||||
)
|
||||
})
|
||||
].filter(filterLink)
|
||||
)
|
||||
|
||||
let moreOpen = $state(false)
|
||||
let moreOpenTimeout: number | undefined = $state()
|
||||
|
||||
function debouncedSetMoreOpen(value: boolean) {
|
||||
if (moreOpenTimeout) {
|
||||
clearTimeout(moreOpenTimeout)
|
||||
}
|
||||
moreOpenTimeout = setTimeout(() => {
|
||||
moreOpen = value
|
||||
}, 150) // 150ms debounce
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (moreOpenTimeout) {
|
||||
clearTimeout(moreOpenTimeout)
|
||||
}
|
||||
})
|
||||
let showMore = $state(false)
|
||||
</script>
|
||||
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
<Menu {createMenu} usePointerDownOutside on:close={() => (showMore = false)}>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
@@ -305,8 +293,8 @@
|
||||
onClick={() => logout()}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
|
||||
'text-secondary text-xs',
|
||||
'hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'text-primary text-xs',
|
||||
'hover:bg-surface-hover cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
@@ -315,57 +303,42 @@
|
||||
Sign out
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div
|
||||
onmouseenter={() => debouncedSetMoreOpen(true)}
|
||||
onmouseleave={() => debouncedSetMoreOpen(false)}
|
||||
role="none"
|
||||
>
|
||||
<MenuItem
|
||||
onFocusIn={() => debouncedSetMoreOpen(true)}
|
||||
onFocusOut={() => debouncedSetMoreOpen(false)}
|
||||
{item}
|
||||
>
|
||||
{#if !moreOpen || secondMenuLinks.length === 0}
|
||||
<div class="px-2 py-2 text-primary text-2xs">More...</div>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{#if moreOpen && secondMenuLinks.length > 0}
|
||||
{#each secondMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<div>
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
onFocusIn={() => debouncedSetMoreOpen(true)}
|
||||
onFocusOut={() => debouncedSetMoreOpen(false)}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/each}
|
||||
<div onmouseleave={() => (showMore = false)} role="none">
|
||||
{#if secondMenuLinks.length}
|
||||
<ResizeTransitionWrapper vertical innerClass="w-full">
|
||||
{#if !showMore}
|
||||
<div onmouseenter={() => (showMore = true)} role="none">
|
||||
<MenuItem {item}>
|
||||
<div class="px-2 py-2 text-primary text-2xs">More...</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])}
|
||||
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
<div class="divide-y">
|
||||
<div>{@render renderSecondMenuLinks(secondMenuLinks)}</div>
|
||||
<div>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
{/if}
|
||||
{#if $enterpriseLicense}
|
||||
<MultiplayerMenu />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if $enterpriseLicense}
|
||||
<div
|
||||
onmouseenter={() => {
|
||||
if (moreOpenTimeout) {
|
||||
setTimeout(() => {
|
||||
clearTimeout(moreOpenTimeout)
|
||||
}, 15)
|
||||
}
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
debouncedSetMoreOpen(false)
|
||||
}}
|
||||
role="none"
|
||||
>
|
||||
<MultiplayerMenu />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
neverShowLoader?: boolean
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
containerClass?: string
|
||||
children?: import('svelte').Snippet
|
||||
emptyMessage?: import('svelte').Snippet
|
||||
}
|
||||
@@ -59,6 +60,7 @@
|
||||
neverShowLoader = false,
|
||||
loading = false,
|
||||
loadingMore = false,
|
||||
containerClass = '',
|
||||
children,
|
||||
emptyMessage
|
||||
}: Props = $props()
|
||||
@@ -119,7 +121,8 @@
|
||||
class={twMerge(
|
||||
'h-full',
|
||||
rounded ? 'rounded-md overflow-hidden' : '',
|
||||
noBorder ? 'border-0' : 'border'
|
||||
noBorder ? 'border-0' : 'border',
|
||||
containerClass
|
||||
)}
|
||||
bind:clientHeight={tableHeight}
|
||||
>
|
||||
|
||||
@@ -352,18 +352,12 @@
|
||||
class="cursor-not-allowed"
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<ExploreAssetButton
|
||||
class="h-9"
|
||||
asset={{ kind: 'ducklake', path: ducklake.name }}
|
||||
{dbManagerDrawer}
|
||||
disabled
|
||||
/>
|
||||
<ExploreAssetButton asset={{ kind: 'ducklake', path: '' }} disabled />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content">Please save settings first</svelte:fragment>
|
||||
</Popover>
|
||||
{:else}
|
||||
<ExploreAssetButton
|
||||
class="h-9"
|
||||
asset={{ kind: 'ducklake', path: ducklake.name }}
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores'
|
||||
import { emptyString, sendUserToast } from '$lib/utils'
|
||||
import { emptyString, pick, sendUserToast } from '$lib/utils'
|
||||
import { ChevronDown, Plus, Shield } from 'lucide-svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import Tab from '../common/tabs/Tab.svelte'
|
||||
import Tabs from '../common/tabs/Tabs.svelte'
|
||||
import Description from '../Description.svelte'
|
||||
import ResourcePicker from '../ResourcePicker.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
@@ -25,11 +23,22 @@
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import DataTable from '../table/DataTable.svelte'
|
||||
import Head from '../table/Head.svelte'
|
||||
import Cell from '../table/Cell.svelte'
|
||||
import Row from '../table/Row.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import ExploreAssetButton from '../ExploreAssetButton.svelte'
|
||||
|
||||
let {
|
||||
s3ResourceSettings = $bindable(),
|
||||
s3ResourceSavedSettings,
|
||||
onSave = undefined
|
||||
}: { s3ResourceSettings: S3ResourceSettings; onSave?: () => void } = $props()
|
||||
}: {
|
||||
s3ResourceSettings: S3ResourceSettings
|
||||
s3ResourceSavedSettings: S3ResourceSettings
|
||||
onSave?: () => void
|
||||
} = $props()
|
||||
|
||||
let s3FileViewer: S3FilePicker | undefined = $state()
|
||||
|
||||
@@ -45,6 +54,43 @@
|
||||
sendUserToast(`Large file storage settings changed`)
|
||||
onSave?.()
|
||||
}
|
||||
let tableHeadNames = ['Name', 'Storage resource', '', ''] as const
|
||||
let tableHeadTooltips: Partial<Record<(typeof tableHeadNames)[number], string | undefined>> = {
|
||||
'Storage resource':
|
||||
'Which resource the workspace storage will point to. Note that all users of the workspace will be able to access the workspace storage regardless of the resource visibility.'
|
||||
}
|
||||
|
||||
let tableRows: [string | null, S3ResourceSettingsItem][] = $derived([
|
||||
[null, s3ResourceSettings],
|
||||
...(s3ResourceSettings.secondaryStorage ?? [])
|
||||
])
|
||||
let secondaryStorageIsDirty: Record<string, boolean> = $derived(
|
||||
Object.fromEntries(
|
||||
s3ResourceSettings.secondaryStorage?.map((d) => {
|
||||
const saved = s3ResourceSavedSettings.secondaryStorage?.find((saved) => saved[0] === d[0])
|
||||
return [d[0], !deepEqual(saved?.[1], d[1])] as const
|
||||
}) ?? []
|
||||
)
|
||||
)
|
||||
let primaryStorageIsDirty: boolean = $derived(
|
||||
!deepEqual(
|
||||
pick(s3ResourceSavedSettings, [
|
||||
'resourcePath',
|
||||
'resourceType',
|
||||
'publicResource',
|
||||
'advancedPermissions'
|
||||
]),
|
||||
pick(s3ResourceSettings, [
|
||||
'resourcePath',
|
||||
'resourceType',
|
||||
'publicResource',
|
||||
'advancedPermissions'
|
||||
])
|
||||
)
|
||||
)
|
||||
function isDirty(name: string | null): boolean {
|
||||
return name === null ? primaryStorageIsDirty : secondaryStorageIsDirty[name]
|
||||
}
|
||||
</script>
|
||||
|
||||
<Portal name="workspace-settings">
|
||||
@@ -82,140 +128,127 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if s3ResourceSettings}
|
||||
<div class="mt-5">
|
||||
<div class="w-full">
|
||||
<!-- this can be removed once parent moves to runes -->
|
||||
<!-- svelte-ignore binding_property_non_reactive -->
|
||||
<Tabs bind:selected={s3ResourceSettings.resourceType}>
|
||||
<Tab exact label="S3" value="s3" />
|
||||
<Tab value="azure_blob" label="Azure Blob" />
|
||||
<Tab exact value="s3_aws_oidc" label="AWS OIDC" />
|
||||
<Tab value="azure_workload_identity" label="Azure Workload Identity" />
|
||||
<Tab exact value="gcloud_storage" label="Google Cloud Storage" />
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="w-full flex gap-1 mt-4 whitespace-nowrap">
|
||||
<!-- this can be removed once parent moves to runes -->
|
||||
<!-- svelte-ignore binding_property_non_reactive -->
|
||||
<ResourcePicker
|
||||
resourceType={s3ResourceSettings.resourceType}
|
||||
bind:value={s3ResourceSettings.resourcePath}
|
||||
/>
|
||||
{@render permissionBtn(s3ResourceSettings)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={emptyString(s3ResourceSettings.resourcePath)}
|
||||
on:click={async () => {
|
||||
if ($workspaceStore) {
|
||||
s3FileViewer?.open?.(undefined)
|
||||
}
|
||||
}}>Browse content (save first)</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable containerClass="mt-4">
|
||||
<Head>
|
||||
<tr>
|
||||
{#each tableHeadNames as name, i}
|
||||
<Cell head first={i == 0} last={i == tableHeadNames.length - 1}>
|
||||
{name}
|
||||
{#if tableHeadTooltips[name]}
|
||||
<Tooltip>{@html tableHeadTooltips[name]}</Tooltip>
|
||||
{/if}
|
||||
</Cell>
|
||||
{/each}
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#each tableRows as tableRow, idx}
|
||||
<Row>
|
||||
<Cell first class="w-48 relative">
|
||||
{#if tableRow[0] === null}
|
||||
<TextInput inputProps={{ placeholder: 'Primary storage', disabled: true }} />
|
||||
{:else}
|
||||
<TextInput bind:value={tableRow[0]} inputProps={{ placeholder: 'Name' }} />
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<Select
|
||||
items={[
|
||||
{ value: 's3', label: 'S3' },
|
||||
{ value: 'azure_blob', label: 'Azure Blob' },
|
||||
{ value: 's3_aws_oidc', label: 'AWS OIDC' },
|
||||
{ value: 'azure_workload_identity', label: 'Azure Workload Identity' },
|
||||
{ value: 'gcloud_storage', label: 'Google Cloud Storage' }
|
||||
]}
|
||||
bind:value={tableRow[1].resourceType}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1">
|
||||
<ResourcePicker
|
||||
class="flex-1"
|
||||
bind:value={tableRow[1].resourcePath}
|
||||
resourceType={tableRow[1].resourceType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="flex mt-2 flex-col gap-y-4 max-w-5xl">
|
||||
{#each s3ResourceSettings.secondaryStorage ?? [] as _, idx}
|
||||
<div class="flex gap-1 relative whitespace-nowrap">
|
||||
<TextInput
|
||||
class="max-w-[200px]"
|
||||
inputProps={{ type: 'text', placeholder: 'Storage name' }}
|
||||
bind:value={
|
||||
() => s3ResourceSettings.secondaryStorage?.[idx]?.[0] || '',
|
||||
(v) => {
|
||||
if (s3ResourceSettings.secondaryStorage?.[idx]) {
|
||||
s3ResourceSettings.secondaryStorage[idx][0] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
class="max-w-[125px]"
|
||||
inputClass="h-full"
|
||||
bind:value={
|
||||
() => s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourceType || 's3',
|
||||
(v) => {
|
||||
if (s3ResourceSettings.secondaryStorage?.[idx]) {
|
||||
s3ResourceSettings.secondaryStorage[idx][1].resourceType = v
|
||||
}
|
||||
}
|
||||
}
|
||||
items={[
|
||||
{ value: 's3', label: 'S3' },
|
||||
{ value: 'azure_blob', label: 'Azure Blob' },
|
||||
{ value: 's3_aws_oidc', label: 'AWS OIDC' },
|
||||
{ value: 'azure_workload_identity', label: 'Azure Workload Identity' },
|
||||
{ value: 'gcloud_storage', label: 'Google Cloud Storage' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<ResourcePicker
|
||||
resourceType={s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourceType || 's3'}
|
||||
bind:value={
|
||||
() => s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourcePath || undefined,
|
||||
(v) => {
|
||||
if (s3ResourceSettings.secondaryStorage?.[idx]) {
|
||||
s3ResourceSettings.secondaryStorage[idx][1].resourcePath = v
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
{@render permissionBtn(s3ResourceSettings.secondaryStorage![idx][1])}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
disabled={emptyString(s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourcePath)}
|
||||
on:click={async () => {
|
||||
if ($workspaceStore) {
|
||||
s3FileViewer?.open?.({
|
||||
s3: '',
|
||||
storage: s3ResourceSettings.secondaryStorage?.[idx]?.[0] || ''
|
||||
})
|
||||
}
|
||||
}}>Browse content (save first)</Button
|
||||
>
|
||||
<CloseButton
|
||||
class="my-auto"
|
||||
small
|
||||
on:close={() => {
|
||||
if (s3ResourceSettings.secondaryStorage) {
|
||||
s3ResourceSettings.secondaryStorage.splice(idx, 1)
|
||||
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Cell class="w-12">
|
||||
<div class="flex gap-2">
|
||||
{@render permissionBtn(tableRow[1])}
|
||||
{#if emptyString(tableRow[1].resourcePath) || isDirty(tableRow[0])}
|
||||
<Popover
|
||||
openOnHover
|
||||
contentClasses="p-2 text-sm text-secondary italic"
|
||||
class="cursor-not-allowed"
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<ExploreAssetButton asset={{ kind: 's3object', path: '' }} disabled />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content">Please save settings first</svelte:fragment>
|
||||
</Popover>
|
||||
{:else}
|
||||
<ExploreAssetButton
|
||||
asset={{ kind: 's3object', path: (tableRow[0] ?? '') + '/' }}
|
||||
s3FilePicker={s3FileViewer}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="w-12">
|
||||
{#if tableRow[0] !== null}
|
||||
<CloseButton
|
||||
small
|
||||
on:close={() => {
|
||||
if (s3ResourceSettings.secondaryStorage) {
|
||||
s3ResourceSettings.secondaryStorage.splice(idx - 1, 1)
|
||||
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (s3ResourceSettings.secondaryStorage === undefined) {
|
||||
s3ResourceSettings.secondaryStorage = []
|
||||
}
|
||||
s3ResourceSettings.secondaryStorage.push([
|
||||
`storage_${s3ResourceSettings.secondaryStorage.length + 1}`,
|
||||
{
|
||||
resourcePath: '',
|
||||
resourceType: 's3',
|
||||
publicResource: false,
|
||||
advancedPermissions: defaultS3AdvancedPermissions(!!$enterpriseLicense)
|
||||
}
|
||||
])
|
||||
s3ResourceSettings.secondaryStorage = s3ResourceSettings.secondaryStorage
|
||||
}}><Plus size={14} />Add secondary storage</Button
|
||||
>
|
||||
<Tooltip>
|
||||
Secondary storage is a feature that allows you to read and write from storage that isn't
|
||||
your main storage by specifying it in the s3 object as "secondary_storage" with the name
|
||||
of it
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row class="!border-0">
|
||||
<Cell colspan={tableHeadNames.length} class="pt-0 pb-2">
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
btnClasses="max-w-fit"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (s3ResourceSettings.secondaryStorage === undefined) {
|
||||
s3ResourceSettings.secondaryStorage = []
|
||||
}
|
||||
s3ResourceSettings.secondaryStorage.push([
|
||||
`storage_${s3ResourceSettings.secondaryStorage.length + 1}`,
|
||||
{
|
||||
resourcePath: '',
|
||||
resourceType: 's3',
|
||||
publicResource: false,
|
||||
advancedPermissions: defaultS3AdvancedPermissions(!!$enterpriseLicense)
|
||||
}
|
||||
])
|
||||
s3ResourceSettings.secondaryStorage = s3ResourceSettings.secondaryStorage
|
||||
}}
|
||||
>
|
||||
<Plus /> Add secondary storage
|
||||
<Tooltip>
|
||||
Secondary storage is a feature that allows you to read and write from storage that
|
||||
isn't your main storage by specifying it in the s3 object as "secondary_storage"
|
||||
with the name of it
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
<div class="flex mt-5 mb-5 gap-1">
|
||||
<Button
|
||||
variant="accent"
|
||||
@@ -228,10 +261,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet permissionBtn(storage: NonNullable<S3ResourceSettings['secondaryStorage']>[number][1])}
|
||||
{#snippet permissionBtn(storage: S3ResourceSettingsItem)}
|
||||
<Popover closeOnOtherPopoverOpen placement="left">
|
||||
<svelte:fragment slot="trigger">
|
||||
<Button variant="default" wrapperClasses="h-full" btnClasses="px-2.5" size="sm">
|
||||
<Button variant="default" btnClasses="px-2.5" size="sm">
|
||||
<Shield size={16} /> Permissions <ChevronDown size={14} />
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -1960,3 +1960,28 @@ export function countChars(str: string, char: string): number {
|
||||
export function onlyAlphaNumAndUnderscore(str: string): string {
|
||||
return str.replace(/[^a-zA-Z0-9_]/g, '')
|
||||
}
|
||||
|
||||
export function buildReactiveObj<T extends object>(fields: {
|
||||
[name in keyof T]: [() => T[name], (v: T[name]) => void]
|
||||
}): T {
|
||||
const obj = {} as T
|
||||
for (const key in fields) {
|
||||
Object.defineProperty(obj, key, {
|
||||
get: fields[key][0],
|
||||
set: fields[key][1],
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
const result = {} as Pick<T, K>
|
||||
for (const key of keys) {
|
||||
if (key in obj) {
|
||||
result[key] = obj[key]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
publicResource: undefined,
|
||||
secondaryStorage: undefined
|
||||
})
|
||||
let initialS3ResourceSettings: S3ResourceSettings = $state({
|
||||
let s3ResourceSavedSettings: S3ResourceSettings = $state({
|
||||
resourceType: 's3',
|
||||
resourcePath: undefined,
|
||||
publicResource: undefined,
|
||||
@@ -353,7 +353,7 @@
|
||||
settings.large_file_storage,
|
||||
!!$enterpriseLicense
|
||||
)
|
||||
initialS3ResourceSettings = clone(s3ResourceSettings)
|
||||
s3ResourceSavedSettings = clone(s3ResourceSettings)
|
||||
dataTableSettings = convertDataTableSettingsFromBackend(settings.datatable)
|
||||
ducklakeSettings = convertDucklakeSettingsFromBackend(settings.ducklake)
|
||||
ducklakeSavedSettings = clone(ducklakeSettings)
|
||||
@@ -580,7 +580,7 @@
|
||||
}
|
||||
|
||||
const savedValue = {
|
||||
s3ResourceSettings: initialS3ResourceSettings,
|
||||
s3ResourceSettings: s3ResourceSavedSettings,
|
||||
ducklakeSettings: ducklakeSavedSettings
|
||||
}
|
||||
|
||||
@@ -594,7 +594,7 @@
|
||||
|
||||
// Function to discard unsaved storage settings changes
|
||||
function discardStorageSettingsChanges() {
|
||||
s3ResourceSettings = clone(initialS3ResourceSettings)
|
||||
s3ResourceSettings = clone(s3ResourceSavedSettings)
|
||||
ducklakeSettings = clone(ducklakeSavedSettings)
|
||||
}
|
||||
|
||||
@@ -1203,8 +1203,9 @@
|
||||
{:else if tab == 'windmill_lfs'}
|
||||
<StorageSettings
|
||||
bind:s3ResourceSettings
|
||||
{s3ResourceSavedSettings}
|
||||
onSave={() => {
|
||||
initialS3ResourceSettings = clone(s3ResourceSettings)
|
||||
s3ResourceSavedSettings = clone(s3ResourceSettings)
|
||||
}}
|
||||
/>
|
||||
<DucklakeSettings
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$lib/navigation'
|
||||
import { WindmillIcon } from '$lib/components/icons'
|
||||
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { UserService } from '$lib/gen'
|
||||
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
|
||||
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
|
||||
|
||||
let email = $state('')
|
||||
let loading = $state(false)
|
||||
let submitted = $state(false)
|
||||
|
||||
async function requestPasswordReset() {
|
||||
if (!email) {
|
||||
sendUserToast('Please enter your email address', true)
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
await UserService.requestPasswordReset({ requestBody: { email } })
|
||||
submitted = true
|
||||
sendUserToast('If an account with that email exists, a password reset link has been sent.')
|
||||
} catch (err: any) {
|
||||
if (err?.body?.includes('SMTP is not configured')) {
|
||||
sendUserToast('Password reset is not available. SMTP is not configured.', true)
|
||||
} else {
|
||||
sendUserToast('An error occurred. Please try again later.', true)
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
requestPasswordReset()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
|
||||
>
|
||||
<LoginPageHeader />
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="mx-auto flex justify-center">
|
||||
{#if !$enterpriseLicense || !$whitelabelNameStore}
|
||||
<WindmillIcon height="80px" width="80px" spin="slow" />
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
|
||||
Reset password
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-xs text-secondary">
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
|
||||
<div class="flex justify-end">
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
|
||||
{#if submitted}
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-secondary">
|
||||
If an account with that email exists, we've sent a password reset link.
|
||||
</p>
|
||||
<p class="text-secondary text-sm">
|
||||
Please check your email and follow the instructions to reset your password.
|
||||
</p>
|
||||
<div class="pt-4">
|
||||
<Button variant="accent" on:click={() => goto('/user/login')}>Back to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<label for="email" class="block text-xs font-semibold text-emphasis">Email</label>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
id="email"
|
||||
autocomplete="email"
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 flex flex-col gap-2">
|
||||
<Button on:click={requestPasswordReset} variant="accent" disabled={!email || loading}>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</Button>
|
||||
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { WindmillIcon } from '$lib/components/icons'
|
||||
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { UserService } from '$lib/gen'
|
||||
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
|
||||
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
|
||||
|
||||
const token = $page.url.searchParams.get('token') ?? ''
|
||||
|
||||
let newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let loading = $state(false)
|
||||
let success = $state(false)
|
||||
|
||||
async function resetPassword() {
|
||||
if (!token) {
|
||||
sendUserToast('Invalid or missing reset token', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
sendUserToast('Please fill in both password fields', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
sendUserToast('Passwords do not match', true)
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
await UserService.resetPassword({
|
||||
requestBody: {
|
||||
token,
|
||||
new_password: newPassword
|
||||
}
|
||||
})
|
||||
success = true
|
||||
sendUserToast('Password has been reset successfully!')
|
||||
} catch (err: any) {
|
||||
console.error('Could not reset password', err)
|
||||
sendUserToast('Could not reset password: ' + err, true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
resetPassword()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
|
||||
>
|
||||
<LoginPageHeader />
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="mx-auto flex justify-center">
|
||||
{#if !$enterpriseLicense || !$whitelabelNameStore}
|
||||
<WindmillIcon height="80px" width="80px" spin="slow" />
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
|
||||
{success ? 'Password Reset' : 'Set New Password'}
|
||||
</h2>
|
||||
{#if !success}
|
||||
<p class="mt-2 text-center text-xs text-secondary"> Enter your new password below </p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
|
||||
<div class="flex justify-end">
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
|
||||
{#if !token}
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-red-500">Invalid or missing reset token.</p>
|
||||
<div class="pt-4">
|
||||
<Button variant="accent" on:click={() => goto('/user/forgot-password')}>
|
||||
Request New Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if success}
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-secondary"> Your password has been reset successfully. </p>
|
||||
<p class="text-secondary text-sm"> You can now log in with your new password. </p>
|
||||
<div class="pt-4">
|
||||
<Button variant="accent" on:click={() => goto('/user/login')}>Go to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<label for="new-password" class="block text-xs font-semibold text-emphasis">
|
||||
New Password
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
id="new-password"
|
||||
autocomplete="new-password"
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="confirm-password" class="block text-xs font-semibold text-emphasis">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
id="confirm-password"
|
||||
autocomplete="new-password"
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 flex flex-col gap-2">
|
||||
<Button
|
||||
on:click={resetPassword}
|
||||
variant="accent"
|
||||
disabled={!newPassword || !confirmPassword || loading}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset password'}
|
||||
</Button>
|
||||
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+2
-2
@@ -4,8 +4,8 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.602.0"
|
||||
wmill_pg = ">=1.602.0"
|
||||
wmill = ">=1.603.0"
|
||||
wmill_pg = ">=1.603.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.3'
|
||||
|
||||
info:
|
||||
version: 1.602.0
|
||||
version: 1.603.0
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
RootModule = 'WindmillClient.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '1.602.0'
|
||||
ModuleVersion = '1.603.0'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill"
|
||||
version = "1.602.0"
|
||||
version = "1.603.0"
|
||||
description = "A client library for accessing Windmill server wrapping the Windmill client API"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill-pg"
|
||||
version = "1.602.0"
|
||||
version = "1.603.0"
|
||||
description = "An extension client for the wmill client library focused on pg"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill/windmill",
|
||||
"version": "1.602.0",
|
||||
"version": "1.603.0",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-client",
|
||||
"description": "Windmill SDK client for browsers and Node.js",
|
||||
"version": "1.602.0",
|
||||
"version": "1.603.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache 2.0",
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.602.0
|
||||
1.603.0
|
||||
|
||||
Reference in New Issue
Block a user