diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index a9e7794c..9c981fc9 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -4,11 +4,9 @@ export type Environment = { model: "environment", id: string, workspaceId: strin export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; +export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, description: string, name: string, defaultAuthentication: ParentAuthentication, defaultHeaders: Array, sortPriority: number, }; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; @@ -22,6 +20,8 @@ export type HttpResponseState = "initialized" | "connected" | "closed"; export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; +export type ParentAuthentication = { authentication: Record, authenticationType: string | null, }; + export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, defaultAuthentication: ParentAuthentication, defaultHeaders: Array, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-tauri/migrations/20250516182745_default-attrs.sql b/src-tauri/migrations/20250516182745_default-attrs.sql new file mode 100644 index 00000000..4b5691f2 --- /dev/null +++ b/src-tauri/migrations/20250516182745_default-attrs.sql @@ -0,0 +1,15 @@ +-- Auth +ALTER TABLE workspaces + ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE folders + ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE workspaces + ADD COLUMN authentication_type TEXT; +ALTER TABLE folders + ADD COLUMN authentication_type TEXT; + +-- Headers +ALTER TABLE workspaces + ADD COLUMN headers TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE folders + ADD COLUMN headers TEXT NOT NULL DEFAULT '[]'; diff --git a/src-tauri/src/grpc.rs b/src-tauri/src/grpc.rs index 94ab06f4..52556dd5 100644 --- a/src-tauri/src/grpc.rs +++ b/src-tauri/src/grpc.rs @@ -5,6 +5,7 @@ use KeyAndValueRef::{Ascii, Binary}; use tauri::{Manager, Runtime, WebviewWindow}; use yaak_grpc::{KeyAndValueRef, MetadataMap}; use yaak_models::models::GrpcRequest; +use yaak_models::query_manager::QueryManagerExt; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader}; use yaak_plugins::manager::PluginManager; @@ -27,7 +28,8 @@ pub(crate) async fn build_metadata( let mut metadata = BTreeMap::new(); // Add the rest of metadata - for h in request.clone().metadata { + let resolved_metadata = window.db().resolve_metadata_for_grpc_request(&request)?; + for h in resolved_metadata { if h.name.is_empty() && h.value.is_empty() { continue; } @@ -39,8 +41,11 @@ pub(crate) async fn build_metadata( metadata.insert(h.name, h.value); } - if let Some(auth_name) = request.authentication_type.clone() { - let auth = request.authentication.clone(); + let (authentication_type, authentication) = + window.db().resolve_auth_for_grpc_request(&request)?; + + if let Some(auth_name) = authentication_type.clone() { + let auth = authentication.clone(); let plugin_req = CallHttpAuthenticationRequest { context_id: format!("{:x}", md5::compute(request.id.clone())), values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(), diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 0b38c0c8..66f711e2 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -84,7 +84,7 @@ pub async fn send_http_request( } }; - let mut url_string = request.url; + let mut url_string = request.url.clone(); url_string = ensure_proto(&url_string); if !url_string.starts_with("http://") && !url_string.starts_with("https://") { @@ -227,7 +227,9 @@ pub async fn send_http_request( // ); // } - for h in request.headers.clone() { + let resolved_headers = window.db().resolve_headers_for_http_request(&request)?; + + for h in resolved_headers { if h.name.is_empty() && h.value.is_empty() { continue; } @@ -255,7 +257,7 @@ pub async fn send_http_request( } let request_body = request.body.clone(); - if let Some(body_type) = &request.body_type { + if let Some(body_type) = &request.body_type.clone() { if body_type == "graphql" { let query = get_str_h(&request_body, "query"); let variables = get_str_h(&request_body, "variables"); @@ -376,7 +378,7 @@ pub async fn send_http_request( }; } - // Set file path if it is not empty + // Set a file path if it is not empty if !file_path.is_empty() { let filename = PathBuf::from(file_path) .file_name() @@ -426,43 +428,53 @@ pub async fn send_http_request( } }; - // Apply authentication + let (authentication_type, authentication) = + window.db().resolve_auth_for_http_request(&request)?; - if let Some(auth_name) = request.authentication_type.to_owned() { - let req = CallHttpAuthenticationRequest { - context_id: format!("{:x}", md5::compute(request.id)), - values: serde_json::from_value(serde_json::to_value(&request.authentication).unwrap()) - .unwrap(), - url: sendable_req.url().to_string(), - method: sendable_req.method().to_string(), - headers: sendable_req - .headers() - .iter() - .map(|(name, value)| HttpHeader { - name: name.to_string(), - value: value.to_str().unwrap_or_default().to_string(), - }) - .collect(), - }; - let auth_result = plugin_manager.call_http_authentication(&window, &auth_name, req).await; - let plugin_result = match auth_result { - Ok(r) => r, - Err(e) => { - return Ok(response_err( - &app_handle, - &*response.lock().await, - e.to_string(), - &update_source, - )); + match authentication_type { + None => { + // No authentication found. Not even inherited + } + Some(authentication_type) if authentication_type == "none" => { + // Explicitly no authentication + } + Some(authentication_type) => { + let req = CallHttpAuthenticationRequest { + context_id: format!("{:x}", md5::compute(request.id)), + values: serde_json::from_value(serde_json::to_value(&authentication).unwrap()) + .unwrap(), + url: sendable_req.url().to_string(), + method: sendable_req.method().to_string(), + headers: sendable_req + .headers() + .iter() + .map(|(name, value)| HttpHeader { + name: name.to_string(), + value: value.to_str().unwrap_or_default().to_string(), + }) + .collect(), + }; + let auth_result = + plugin_manager.call_http_authentication(&window, &authentication_type, req).await; + let plugin_result = match auth_result { + Ok(r) => r, + Err(e) => { + return Ok(response_err( + &app_handle, + &*response.lock().await, + e.to_string(), + &update_source, + )); + } + }; + + let headers = sendable_req.headers_mut(); + for header in plugin_result.set_headers { + headers.insert( + HeaderName::from_str(&header.name).unwrap(), + HeaderValue::from_str(&header.value).unwrap(), + ); } - }; - - let headers = sendable_req.headers_mut(); - for header in plugin_result.set_headers { - headers.insert( - HeaderName::from_str(&header.name).unwrap(), - HeaderValue::from_str(&header.value).unwrap(), - ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 000d3772..d9752c9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -917,10 +917,10 @@ async fn cmd_call_http_authentication_action( auth_name: &str, action_index: i32, values: HashMap, - request_id: &str, + model_id: &str, ) -> YaakResult<()> { Ok(plugin_manager - .call_http_authentication_action(&window, auth_name, action_index, values, request_id) + .call_http_authentication_action(&window, auth_name, action_index, values, model_id) .await?) } diff --git a/src-tauri/src/render.rs b/src-tauri/src/render.rs index 08f3efb0..6efd6a0e 100644 --- a/src-tauri/src/render.rs +++ b/src-tauri/src/render.rs @@ -2,7 +2,7 @@ use serde_json::Value; use std::collections::{BTreeMap, HashMap}; use yaak_http::apply_path_placeholders; use yaak_models::models::{ - Environment, GrpcMetadataEntry, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter, + Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter, }; use yaak_models::render::make_vars_hashmap; use yaak_templates::{parse_and_render, render_json_value_raw, TemplateCallback}; @@ -37,7 +37,7 @@ pub async fn render_grpc_request( let mut metadata = Vec::new(); for p in r.metadata.clone() { - metadata.push(GrpcMetadataEntry { + metadata.push(HttpRequestHeader { enabled: p.enabled, name: render(p.name.as_str(), vars, cb).await?, value: render(p.value.as_str(), vars, cb).await?, diff --git a/src-tauri/vendored/plugins/auth-oauth2/build/index.js b/src-tauri/vendored/plugins/auth-oauth2/build/index.js index 9669d530..03a8094d 100644 --- a/src-tauri/vendored/plugins/auth-oauth2/build/index.js +++ b/src-tauri/vendored/plugins/auth-oauth2/build/index.js @@ -32,6 +32,7 @@ var import_node_fs = require("node:fs"); async function getAccessToken(ctx, { accessTokenUrl, scope, + audience, params, grantType, credentialsInBody, @@ -56,6 +57,7 @@ async function getAccessToken(ctx, { ] }; if (scope) httpRequest.body.form.push({ name: "scope", value: scope }); + if (scope) httpRequest.body.form.push({ name: "audience", value: audience }); if (credentialsInBody) { httpRequest.body.form.push({ name: "client_id", value: clientId }); httpRequest.body.form.push({ name: "client_secret", value: clientSecret }); @@ -64,10 +66,10 @@ async function getAccessToken(ctx, { httpRequest.headers.push({ name: "Authorization", value }); } const resp = await ctx.httpRequest.send({ httpRequest }); + const body = resp.bodyPath ? (0, import_node_fs.readFileSync)(resp.bodyPath, "utf8") : ""; if (resp.status < 200 || resp.status >= 300) { - throw new Error("Failed to fetch access token with status=" + resp.status); + throw new Error("Failed to fetch access token with status=" + resp.status + " and body=" + body); } - const body = (0, import_node_fs.readFileSync)(resp.bodyPath ?? "", "utf8"); let response; try { response = JSON.parse(body); @@ -168,10 +170,10 @@ async function getOrRefreshAccessToken(ctx, contextId, { await deleteToken(ctx, contextId); return null; } + const body = resp.bodyPath ? (0, import_node_fs2.readFileSync)(resp.bodyPath, "utf8") : ""; if (resp.status < 200 || resp.status >= 300) { - throw new Error("Failed to fetch access token with status=" + resp.status); + throw new Error("Failed to refresh access token with status=" + resp.status + " and body=" + body); } - const body = (0, import_node_fs2.readFileSync)(resp.bodyPath ?? "", "utf8"); let response; try { response = JSON.parse(body); @@ -201,6 +203,7 @@ async function getAuthorizationCode(ctx, contextId, { redirectUri, scope, state, + audience, credentialsInBody, pkce }) { @@ -220,6 +223,7 @@ async function getAuthorizationCode(ctx, contextId, { if (redirectUri) authorizationUrl.searchParams.set("redirect_uri", redirectUri); if (scope) authorizationUrl.searchParams.set("scope", scope); if (state) authorizationUrl.searchParams.set("state", state); + if (audience) authorizationUrl.searchParams.set("audience", audience); if (pkce) { const verifier = pkce.codeVerifier || createPkceCodeVerifier(); const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD; @@ -256,6 +260,7 @@ async function getAuthorizationCode(ctx, contextId, { clientId, clientSecret, scope, + audience, credentialsInBody, params: [ { name: "code", value: code }, @@ -291,6 +296,7 @@ async function getClientCredentials(ctx, contextId, { clientId, clientSecret, scope, + audience, credentialsInBody }) { const token = await getToken(ctx, contextId); @@ -299,6 +305,7 @@ async function getClientCredentials(ctx, contextId, { const response = await getAccessToken(ctx, { grantType: "client_credentials", accessTokenUrl, + audience, clientId, clientSecret, scope, @@ -315,7 +322,8 @@ function getImplicit(ctx, contextId, { clientId, redirectUri, scope, - state + state, + audience }) { return new Promise(async (resolve, reject) => { const token = await getToken(ctx, contextId); @@ -327,6 +335,7 @@ function getImplicit(ctx, contextId, { if (redirectUri) authorizationUrl.searchParams.set("redirect_uri", redirectUri); if (scope) authorizationUrl.searchParams.set("scope", scope); if (state) authorizationUrl.searchParams.set("state", state); + if (audience) authorizationUrl.searchParams.set("audience", audience); if (responseType.includes("id_token")) { authorizationUrl.searchParams.set("nonce", String(Math.floor(Math.random() * 9999999999999) + 1)); } @@ -366,6 +375,7 @@ async function getPassword(ctx, contextId, { username, password, credentialsInBody, + audience, scope }) { const token = await getOrRefreshAccessToken(ctx, contextId, { @@ -383,6 +393,7 @@ async function getPassword(ctx, contextId, { clientId, clientSecret, scope, + audience, grantType: "password", credentialsInBody, params: [ @@ -530,6 +541,12 @@ var plugin = { optional: true, dynamic: hiddenIfNot(["authorization_code", "implicit"]) }, + { + type: "text", + name: "audience", + label: "Audience", + optional: true + }, { type: "checkbox", name: "usePkce", @@ -635,6 +652,7 @@ var plugin = { clientSecret: stringArg(values, "clientSecret"), redirectUri: stringArgOrNull(values, "redirectUri"), scope: stringArgOrNull(values, "scope"), + audience: stringArgOrNull(values, "audience"), state: stringArgOrNull(values, "state"), credentialsInBody, pkce: values.usePkce ? { @@ -650,6 +668,7 @@ var plugin = { redirectUri: stringArgOrNull(values, "redirectUri"), responseType: stringArg(values, "responseType"), scope: stringArgOrNull(values, "scope"), + audience: stringArgOrNull(values, "audience"), state: stringArgOrNull(values, "state") }); } else if (grantType === "client_credentials") { @@ -659,6 +678,7 @@ var plugin = { clientId: stringArg(values, "clientId"), clientSecret: stringArg(values, "clientSecret"), scope: stringArgOrNull(values, "scope"), + audience: stringArgOrNull(values, "audience"), credentialsInBody }); } else if (grantType === "password") { @@ -670,6 +690,7 @@ var plugin = { username: stringArg(values, "username"), password: stringArg(values, "password"), scope: stringArgOrNull(values, "scope"), + audience: stringArgOrNull(values, "audience"), credentialsInBody }); } else { diff --git a/src-tauri/vendored/plugins/importer-curl/build/index.js b/src-tauri/vendored/plugins/importer-curl/build/index.js index 1f94ee39..23d09ea5 100644 --- a/src-tauri/vendored/plugins/importer-curl/build/index.js +++ b/src-tauri/vendored/plugins/importer-curl/build/index.js @@ -542,20 +542,23 @@ function pairsToDataParameters(keyedPairs) { } for (const p of pairs) { if (typeof p !== "string") continue; - const [name, value] = p.split("="); - if (p.startsWith("@")) { - dataParameters.push({ - name: name ?? "", - value: "", - filePath: p.slice(1), - enabled: true - }); - } else { - dataParameters.push({ - name: name ?? "", - value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : value ?? "", - enabled: true - }); + let params = p.split("&"); + for (const param of params) { + const [name, value] = param.split("="); + if (param.startsWith("@")) { + dataParameters.push({ + name: name ?? "", + value: "", + filePath: param.slice(1), + enabled: true + }); + } else { + dataParameters.push({ + name: name ?? "", + value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : value ?? "", + enabled: true + }); + } } } } diff --git a/src-tauri/yaak-git/bindings/gen_models.ts b/src-tauri/yaak-git/bindings/gen_models.ts index 3118c414..b51a6b75 100644 --- a/src-tauri/yaak-git/bindings/gen_models.ts +++ b/src-tauri/yaak-git/bindings/gen_models.ts @@ -4,11 +4,9 @@ export type Environment = { model: "environment", id: string, workspaceId: strin export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; +export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; @@ -20,4 +18,4 @@ export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environ export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 7e5e0e60..bd1b9c84 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -18,7 +18,7 @@ export type Environment = { model: "environment", id: string, workspaceId: strin export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; +export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, }; @@ -28,9 +28,7 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end"; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; @@ -50,6 +48,10 @@ export type ModelChangeEvent = { "type": "upsert" } | { "type": "delete" }; export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, }; +export type ParentAuthentication = { authentication: Record, authenticationType: string | null, }; + +export type ParentHeaders = { headers: Array, }; + export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, }; export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, }; @@ -76,6 +78,6 @@ export type WebsocketMessageType = "text" | "binary"; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 7a5f24ef..f6c7f245 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -219,8 +219,13 @@ pub struct Workspace { pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, - pub name: String, + + #[ts(type = "Record")] + pub authentication: BTreeMap, + pub authentication_type: Option, pub description: String, + pub headers: Vec, + pub name: String, pub encryption_key_challenge: Option, // Settings @@ -261,6 +266,9 @@ impl UpsertModelInfo for Workspace { (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (Name, self.name.trim().into()), + (Authentication, serde_json::to_string(&self.authentication)?.into()), + (AuthenticationType, self.authentication_type.into()), + (Headers, serde_json::to_string(&self.headers)?.into()), (Description, self.description.into()), (EncryptionKeyChallenge, self.encryption_key_challenge.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()), @@ -273,6 +281,9 @@ impl UpsertModelInfo for Workspace { vec![ WorkspaceIden::UpdatedAt, WorkspaceIden::Name, + WorkspaceIden::Authentication, + WorkspaceIden::AuthenticationType, + WorkspaceIden::Headers, WorkspaceIden::Description, WorkspaceIden::EncryptionKeyChallenge, WorkspaceIden::SettingRequestTimeout, @@ -286,6 +297,8 @@ impl UpsertModelInfo for Workspace { where Self: Sized, { + let headers: String = row.get("headers")?; + let authentication: String = row.get("authentication")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -294,6 +307,9 @@ impl UpsertModelInfo for Workspace { name: row.get("name")?, description: row.get("description")?, encryption_key_challenge: row.get("encryption_key_challenge")?, + headers: serde_json::from_str(&headers).unwrap_or_default(), + authentication: serde_json::from_str(&authentication).unwrap_or_default(), + authentication_type: row.get("authentication_type")?, setting_follow_redirects: row.get("setting_follow_redirects")?, setting_request_timeout: row.get("setting_request_timeout")?, setting_validate_certificates: row.get("setting_validate_certificates")?, @@ -581,6 +597,22 @@ pub struct EnvironmentVariable { pub id: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct ParentAuthentication { + #[ts(type = "Record")] + pub authentication: BTreeMap, + pub authentication_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct ParentHeaders { + pub headers: Vec, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] @@ -594,8 +626,12 @@ pub struct Folder { pub workspace_id: String, pub folder_id: Option, - pub name: String, + #[ts(type = "Record")] + pub authentication: BTreeMap, + pub authentication_type: Option, pub description: String, + pub headers: Vec, + pub name: String, pub sort_priority: f32, } @@ -630,8 +666,11 @@ impl UpsertModelInfo for Folder { (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (FolderId, self.folder_id.into()), - (Name, self.name.trim().into()), + (Authentication, serde_json::to_string(&self.authentication)?.into()), + (AuthenticationType, self.authentication_type.into()), + (Headers, serde_json::to_string(&self.headers)?.into()), (Description, self.description.into()), + (Name, self.name.trim().into()), (SortPriority, self.sort_priority.into()), ]) } @@ -640,6 +679,9 @@ impl UpsertModelInfo for Folder { vec![ FolderIden::UpdatedAt, FolderIden::Name, + FolderIden::Authentication, + FolderIden::AuthenticationType, + FolderIden::Headers, FolderIden::Description, FolderIden::FolderId, FolderIden::SortPriority, @@ -650,6 +692,8 @@ impl UpsertModelInfo for Folder { where Self: Sized, { + let headers: String = row.get("headers")?; + let authentication: String = row.get("authentication")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -660,6 +704,9 @@ impl UpsertModelInfo for Folder { folder_id: row.get("folder_id")?, name: row.get("name")?, description: row.get("description")?, + headers: serde_json::from_str(&headers).unwrap_or_default(), + authentication_type: row.get("authentication_type")?, + authentication: serde_json::from_str(&authentication).unwrap_or_default(), }) } } @@ -782,28 +829,28 @@ impl UpsertModelInfo for HttpRequest { ] } - fn from_row(r: &Row) -> rusqlite::Result { - let url_parameters: String = r.get("url_parameters")?; - let body: String = r.get("body")?; - let authentication: String = r.get("authentication")?; - let headers: String = r.get("headers")?; + fn from_row(row: &Row) -> rusqlite::Result { + let url_parameters: String = row.get("url_parameters")?; + let body: String = row.get("body")?; + let authentication: String = row.get("authentication")?; + let headers: String = row.get("headers")?; Ok(Self { - id: r.get("id")?, - model: r.get("model")?, - workspace_id: r.get("workspace_id")?, - created_at: r.get("created_at")?, - updated_at: r.get("updated_at")?, + id: row.get("id")?, + model: row.get("model")?, + workspace_id: row.get("workspace_id")?, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(), - authentication_type: r.get("authentication_type")?, + authentication_type: row.get("authentication_type")?, body: serde_json::from_str(body.as_str()).unwrap_or_default(), - body_type: r.get("body_type")?, - description: r.get("description")?, - folder_id: r.get("folder_id")?, + body_type: row.get("body_type")?, + description: row.get("description")?, + folder_id: row.get("folder_id")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), - method: r.get("method")?, - name: r.get("name")?, - sort_priority: r.get("sort_priority")?, - url: r.get("url")?, + method: row.get("method")?, + name: row.get("name")?, + sort_priority: row.get("sort_priority")?, + url: row.get("url")?, url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(), }) } @@ -992,7 +1039,7 @@ impl UpsertModelInfo for WebsocketRequest { (WorkspaceId, self.workspace_id.into()), (FolderId, self.folder_id.as_ref().map(|s| s.as_str()).into()), (Authentication, serde_json::to_string(&self.authentication)?.into()), - (AuthenticationType, self.authentication_type.as_ref().map(|s| s.as_str()).into()), + (AuthenticationType, self.authentication_type.into()), (Description, self.description.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (Message, self.message.into()), @@ -1295,19 +1342,6 @@ impl UpsertModelInfo for HttpResponse { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] -#[serde(default, rename_all = "camelCase")] -#[ts(export, export_to = "gen_models.ts")] -pub struct GrpcMetadataEntry { - #[serde(default = "default_true")] - #[ts(optional, as = "Option")] - pub enabled: bool, - pub name: String, - pub value: String, - #[ts(optional, as = "Option")] - pub id: Option, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] @@ -1326,7 +1360,7 @@ pub struct GrpcRequest { pub authentication: BTreeMap, pub description: String, pub message: String, - pub metadata: Vec, + pub metadata: Vec, pub method: Option, pub name: String, pub service: Option, diff --git a/src-tauri/yaak-models/src/queries/folders.rs b/src-tauri/yaak-models/src/queries/folders.rs index 1d9e508b..9005ce38 100644 --- a/src-tauri/yaak-models/src/queries/folders.rs +++ b/src-tauri/yaak-models/src/queries/folders.rs @@ -2,10 +2,12 @@ use crate::connection_or_tx::ConnectionOrTx; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ - Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestIden, - WebsocketRequest, WebsocketRequestIden, + Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, + HttpRequestIden, WebsocketRequest, WebsocketRequestIden, }; use crate::util::UpdateSource; +use serde_json::Value; +use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_folder(&self, id: &str) -> Result { @@ -110,4 +112,40 @@ impl<'a> DbContext<'a> { Ok(new_folder) } + + pub fn resolve_auth_for_folder( + &self, + folder: Folder, + ) -> Result<(Option, BTreeMap)> { + if let Some(at) = folder.authentication_type { + return Ok((Some(at), folder.authentication)); + } + + if let Some(folder_id) = folder.folder_id { + let folder = self.get_folder(&folder_id)?; + return self.resolve_auth_for_folder(folder); + } + + let workspace = self.get_workspace(&folder.workspace_id)?; + Ok(self.resolve_auth_for_workspace(&workspace)) + } + + pub fn resolve_headers_for_folder(&self, folder: &Folder) -> Result> { + let mut headers = Vec::new(); + + if let Some(folder_id) = folder.folder_id.clone() { + let parent_folder = self.get_folder(&folder_id)?; + let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; + // NOTE: Add parent headers first, so overrides are logical + headers.append(&mut folder_headers); + } else { + let workspace = self.get_workspace(&folder.workspace_id)?; + let mut workspace_headers = self.resolve_headers_for_workspace(&workspace); + headers.append(&mut workspace_headers); + } + + headers.append(&mut folder.headers.clone()); + + Ok(headers) + } } diff --git a/src-tauri/yaak-models/src/queries/grpc_requests.rs b/src-tauri/yaak-models/src/queries/grpc_requests.rs index 7116fb89..0c8e056b 100644 --- a/src-tauri/yaak-models/src/queries/grpc_requests.rs +++ b/src-tauri/yaak-models/src/queries/grpc_requests.rs @@ -1,7 +1,9 @@ use crate::db_context::DbContext; use crate::error::Result; -use crate::models::{GrpcRequest, GrpcRequestIden}; +use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader}; use crate::util::UpdateSource; +use serde_json::Value; +use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_grpc_request(&self, id: &str) -> Result { @@ -48,4 +50,43 @@ impl<'a> DbContext<'a> { ) -> Result { self.upsert(grpc_request, source) } + + pub fn resolve_auth_for_grpc_request( + &self, + grpc_request: &GrpcRequest, + ) -> Result<(Option, BTreeMap)> { + if let Some(at) = grpc_request.authentication_type.clone() { + return Ok((Some(at), grpc_request.authentication.clone())); + } + + if let Some(folder_id) = grpc_request.folder_id.clone() { + let folder = self.get_folder(&folder_id)?; + return self.resolve_auth_for_folder(folder); + } + + let workspace = self.get_workspace(&grpc_request.workspace_id)?; + Ok(self.resolve_auth_for_workspace(&workspace)) + } + + pub fn resolve_metadata_for_grpc_request( + &self, + grpc_request: &GrpcRequest, + ) -> Result> { + // Resolved headers should be from furthest to closest ancestor, to override logically. + let mut metadata = Vec::new(); + + if let Some(folder_id) = grpc_request.folder_id.clone() { + let parent_folder = self.get_folder(&folder_id)?; + let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; + metadata.append(&mut folder_headers); + } else { + let workspace = self.get_workspace(&grpc_request.workspace_id)?; + let mut workspace_metadata = self.resolve_headers_for_workspace(&workspace); + metadata.append(&mut workspace_metadata); + } + + metadata.append(&mut grpc_request.metadata.clone()); + + Ok(metadata) + } } diff --git a/src-tauri/yaak-models/src/queries/http_requests.rs b/src-tauri/yaak-models/src/queries/http_requests.rs index 777a25b4..5db8beea 100644 --- a/src-tauri/yaak-models/src/queries/http_requests.rs +++ b/src-tauri/yaak-models/src/queries/http_requests.rs @@ -1,7 +1,9 @@ use crate::db_context::DbContext; use crate::error::Result; -use crate::models::{HttpRequest, HttpRequestIden}; +use crate::models::{HttpRequest, HttpRequestHeader, HttpRequestIden}; use crate::util::UpdateSource; +use serde_json::Value; +use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_http_request(&self, id: &str) -> Result { @@ -48,4 +50,43 @@ impl<'a> DbContext<'a> { ) -> Result { self.upsert(http_request, source) } + + pub fn resolve_auth_for_http_request( + &self, + http_request: &HttpRequest, + ) -> Result<(Option, BTreeMap)> { + if let Some(at) = http_request.authentication_type.clone() { + return Ok((Some(at), http_request.authentication.clone())); + } + + if let Some(folder_id) = http_request.folder_id.clone() { + let folder = self.get_folder(&folder_id)?; + return self.resolve_auth_for_folder(folder); + } + + let workspace = self.get_workspace(&http_request.workspace_id)?; + Ok(self.resolve_auth_for_workspace(&workspace)) + } + + pub fn resolve_headers_for_http_request( + &self, + http_request: &HttpRequest, + ) -> Result> { + // Resolved headers should be from furthest to closest ancestor, to override logically. + let mut headers = Vec::new(); + + if let Some(folder_id) = http_request.folder_id.clone() { + let parent_folder = self.get_folder(&folder_id)?; + let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; + headers.append(&mut folder_headers); + } else { + let workspace = self.get_workspace(&http_request.workspace_id)?; + let mut workspace_headers = self.resolve_headers_for_workspace(&workspace); + headers.append(&mut workspace_headers); + } + + headers.append(&mut http_request.headers.clone()); + + Ok(headers) + } } diff --git a/src-tauri/yaak-models/src/queries/websocket_requests.rs b/src-tauri/yaak-models/src/queries/websocket_requests.rs index 3d4784dd..098796f3 100644 --- a/src-tauri/yaak-models/src/queries/websocket_requests.rs +++ b/src-tauri/yaak-models/src/queries/websocket_requests.rs @@ -1,7 +1,9 @@ use crate::db_context::DbContext; use crate::error::Result; -use crate::models::{WebsocketRequest, WebsocketRequestIden}; +use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden}; use crate::util::UpdateSource; +use serde_json::Value; +use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_websocket_request(&self, id: &str) -> Result { @@ -48,4 +50,47 @@ impl<'a> DbContext<'a> { ) -> Result { self.upsert(websocket_request, source) } + + pub fn resolve_auth_for_websocket_request( + &self, + websocket_request: &WebsocketRequest, + ) -> Result<(Option, BTreeMap)> { + if let Some(at) = websocket_request.authentication_type.clone() { + return Ok((Some(at), websocket_request.authentication.clone())); + } + + if let Some(folder_id) = websocket_request.folder_id.clone() { + let folder = self.get_folder(&folder_id)?; + return self.resolve_auth_for_folder(folder); + } + + let workspace = self.get_workspace(&websocket_request.workspace_id)?; + Ok(self.resolve_auth_for_workspace(&workspace)) + } + + pub fn resolve_headers_for_websocket_request( + &self, + websocket_request: &WebsocketRequest, + ) -> Result> { + let workspace = self.get_workspace(&websocket_request.workspace_id)?; + + // Resolved headers should be from furthest to closest ancestor, to override logically. + let mut headers = Vec::new(); + + headers.append(&mut workspace.headers.clone()); + + if let Some(folder_id) = websocket_request.folder_id.clone() { + let parent_folder = self.get_folder(&folder_id)?; + let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; + headers.append(&mut folder_headers); + } else { + let workspace = self.get_workspace(&websocket_request.workspace_id)?; + let mut workspace_headers = self.resolve_headers_for_workspace(&workspace); + headers.append(&mut workspace_headers); + } + + headers.append(&mut websocket_request.headers.clone()); + + Ok(headers) + } } diff --git a/src-tauri/yaak-models/src/queries/workspaces.rs b/src-tauri/yaak-models/src/queries/workspaces.rs index 58f773ad..5ec80b9f 100644 --- a/src-tauri/yaak-models/src/queries/workspaces.rs +++ b/src-tauri/yaak-models/src/queries/workspaces.rs @@ -1,10 +1,12 @@ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ - EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestIden, WebsocketRequestIden, Workspace, - WorkspaceIden, + EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden, + WebsocketRequestIden, Workspace, WorkspaceIden, }; use crate::util::UpdateSource; +use serde_json::Value; +use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_workspace(&self, id: &str) -> Result { @@ -65,4 +67,15 @@ impl<'a> DbContext<'a> { pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result { self.upsert(w, source) } + + pub fn resolve_auth_for_workspace( + &self, + workspace: &Workspace, + ) -> (Option, BTreeMap) { + (workspace.authentication_type.clone(), workspace.authentication.clone()) + } + + pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec { + workspace.headers.clone() + } } diff --git a/src-tauri/yaak-plugins/bindings/gen_models.ts b/src-tauri/yaak-plugins/bindings/gen_models.ts index a9e7794c..8d95d78f 100644 --- a/src-tauri/yaak-plugins/bindings/gen_models.ts +++ b/src-tauri/yaak-plugins/bindings/gen_models.ts @@ -4,11 +4,9 @@ export type Environment = { model: "environment", id: string, workspaceId: strin export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; +export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; @@ -24,4 +22,4 @@ export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index 7190bcd8..fb708a2c 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -537,7 +537,7 @@ impl PluginManager { auth_name: &str, action_index: i32, values: HashMap, - request_id: &str, + model_id: &str, ) -> Result<()> { let results = self.get_http_authentication_summaries(window).await?; let plugin = results @@ -545,7 +545,7 @@ impl PluginManager { .find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None }) .ok_or(PluginNotFoundErr(auth_name.into()))?; - let context_id = format!("{:x}", md5::compute(request_id.to_string())); + let context_id = format!("{:x}", md5::compute(model_id.to_string())); self.send_to_plugin_and_wait( &PluginWindowContext::new(window), &plugin, diff --git a/src-tauri/yaak-sync/bindings/gen_models.ts b/src-tauri/yaak-sync/bindings/gen_models.ts index 8a77ba00..3828f880 100644 --- a/src-tauri/yaak-sync/bindings/gen_models.ts +++ b/src-tauri/yaak-sync/bindings/gen_models.ts @@ -4,11 +4,9 @@ export type Environment = { model: "environment", id: string, workspaceId: strin export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, }; +export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; -export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; @@ -22,4 +20,4 @@ export type SyncState = { model: "sync_state", id: string, workspaceId: string, export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, name: string, description: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; diff --git a/src-tauri/yaak-ws/src/commands.rs b/src-tauri/yaak-ws/src/commands.rs index c1de4f80..11429a45 100644 --- a/src-tauri/yaak-ws/src/commands.rs +++ b/src-tauri/yaak-ws/src/commands.rs @@ -206,9 +206,28 @@ pub(crate) async fn connect( ) .await?; + let (authentication_type, authentication) = + window.db().resolve_auth_for_websocket_request(&request)?; + let mut headers = HeaderMap::new(); - if let Some(auth_name) = request.authentication_type.clone() { - let auth = request.authentication.clone(); + + let resolved_headers = window.db().resolve_headers_for_websocket_request(&request)?; + for h in resolved_headers { + if h.name.is_empty() && h.value.is_empty() { + continue; + } + + if !h.enabled { + continue; + } + headers.insert( + HeaderName::from_str(&h.name).unwrap(), + HeaderValue::from_str(&h.value).unwrap(), + ); + } + + if let Some(auth_name) = authentication_type.clone() { + let auth = authentication.clone(); let plugin_req = CallHttpAuthenticationRequest { context_id: format!("{:x}", md5::compute(request_id.to_string())), values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(), diff --git a/src-web/commands/openFolderSettings.tsx b/src-web/commands/openFolderSettings.tsx new file mode 100644 index 00000000..06ab98cb --- /dev/null +++ b/src-web/commands/openFolderSettings.tsx @@ -0,0 +1,14 @@ +import type { FolderSettingsTab } from '../components/FolderSettingsDialog'; +import { FolderSettingsDialog } from '../components/FolderSettingsDialog'; +import { showDialog } from '../lib/dialog'; + +export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) { + showDialog({ + id: 'folder-settings', + title: 'Folder Settings', + size: 'lg', + className: 'h-[50rem]', + noPadding: true, + render: () => , + }); +} diff --git a/src-web/commands/openWorkspaceSettings.tsx b/src-web/commands/openWorkspaceSettings.tsx index 336e5eb0..48ae0682 100644 --- a/src-web/commands/openWorkspaceSettings.tsx +++ b/src-web/commands/openWorkspaceSettings.tsx @@ -1,20 +1,22 @@ -import { WorkspaceSettingsDialog } from '../components/WorkspaceSettingsDialog'; +import type { + WorkspaceSettingsTab} from '../components/WorkspaceSettingsDialog'; +import { + WorkspaceSettingsDialog +} from '../components/WorkspaceSettingsDialog'; import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; -import { createFastMutation } from '../hooks/useFastMutation'; import { showDialog } from '../lib/dialog'; import { jotaiStore } from '../lib/jotai'; -export const openWorkspaceSettings = createFastMutation({ - mutationKey: ['open_workspace_settings'], - async mutationFn() { - const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); - showDialog({ - id: 'workspace-settings', - title: 'Workspace Settings', - size: 'md', - render({ hide }) { - return ; - }, - }); - }, -}); +export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) { + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + showDialog({ + id: 'workspace-settings', + title: 'Workspace Settings', + size: 'lg', + className: 'h-[50rem]', + noPadding: true, + render({ hide }) { + return ; + }, + }); +} diff --git a/src-web/components/FolderSettingsDialog.tsx b/src-web/components/FolderSettingsDialog.tsx index a6b65dfe..0d9ff6b5 100644 --- a/src-web/components/FolderSettingsDialog.tsx +++ b/src-web/components/FolderSettingsDialog.tsx @@ -1,36 +1,91 @@ import { foldersAtom, patchModel } from '@yaakapp-internal/models'; import { useAtomValue } from 'jotai'; +import { useMemo, useState } from 'react'; +import { useAuthTab } from '../hooks/useAuthTab'; +import { useHeadersTab } from '../hooks/useHeadersTab'; +import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { Input } from './core/Input'; import { VStack } from './core/Stacks'; +import type { TabItem } from './core/Tabs/Tabs'; +import { TabContent, Tabs } from './core/Tabs/Tabs'; +import { HeadersEditor } from './HeadersEditor'; +import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { MarkdownEditor } from './MarkdownEditor'; interface Props { folderId: string | null; + tab?: FolderSettingsTab; } -export function FolderSettingsDialog({ folderId }: Props) { +const TAB_AUTH = 'auth'; +const TAB_HEADERS = 'headers'; +const TAB_GENERAL = 'general'; + +export type FolderSettingsTab = typeof TAB_AUTH | typeof TAB_HEADERS | typeof TAB_GENERAL; + +export function FolderSettingsDialog({ folderId, tab }: Props) { const folders = useAtomValue(foldersAtom); - const folder = folders.find((f) => f.id === folderId); + const folder = folders.find((f) => f.id === folderId) ?? null; + const [activeTab, setActiveTab] = useState(tab ?? TAB_GENERAL); + const authTab = useAuthTab(TAB_AUTH, folder); + const headersTab = useHeadersTab(TAB_HEADERS, folder); + const inheritedHeaders = useInheritedHeaders(folder); + + const tabs = useMemo(() => { + if (folder == null) return []; + + return [ + { + value: TAB_GENERAL, + label: 'General', + }, + ...authTab, + ...headersTab, + ]; + }, [authTab, folder, headersTab]); if (folder == null) return null; return ( - - patchModel(folder, { name })} - stateKey={`name.${folder.id}`} - /> + + + + + + + patchModel(folder, { name })} + stateKey={`name.${folder.id}`} + /> - patchModel(folder, { description })} - /> - + patchModel(folder, { description })} + /> + + + + patchModel(folder, { headers })} + stateKey={`headers.${folder.id}`} + /> + + ); } diff --git a/src-web/components/GitDropdown.tsx b/src-web/components/GitDropdown.tsx index 2d692949..63550712 100644 --- a/src-web/components/GitDropdown.tsx +++ b/src-web/components/GitDropdown.tsx @@ -343,9 +343,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) color: 'success', label: 'Open Workspace Settings', leftSlot: , - onSelect() { - openWorkspaceSettings.mutate(); - }, + onSelect: openWorkspaceSettings, }, { type: 'separator' }, { diff --git a/src-web/components/GrpcRequestPane.tsx b/src-web/components/GrpcRequestPane.tsx index eb011a31..6b14bc2a 100644 --- a/src-web/components/GrpcRequestPane.tsx +++ b/src-web/components/GrpcRequestPane.tsx @@ -1,10 +1,12 @@ -import { type GrpcMetadataEntry, type GrpcRequest, patchModel } from '@yaakapp-internal/models'; +import { type GrpcRequest, type HttpRequestHeader, patchModel } from '@yaakapp-internal/models'; import classNames from 'classnames'; import type { CSSProperties } from 'react'; import React, { useCallback, useMemo, useRef } from 'react'; +import { useAuthTab } from '../hooks/useAuthTab'; import { useContainerSize } from '../hooks/useContainerQuery'; import type { ReflectResponseService } from '../hooks/useGrpc'; -import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication'; +import { useHeadersTab } from '../hooks/useHeadersTab'; +import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useKeyValue } from '../hooks/useKeyValue'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { resolvedModelName } from '../lib/resolvedModelName'; @@ -12,13 +14,13 @@ import { Button } from './core/Button'; import { CountBadge } from './core/CountBadge'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; -import { PairOrBulkEditor } from './core/PairOrBulkEditor'; import { PlainInput } from './core/PlainInput'; import { RadioDropdown } from './core/RadioDropdown'; import { HStack, VStack } from './core/Stacks'; import type { TabItem } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs'; import { GrpcEditor } from './GrpcEditor'; +import { HeadersEditor } from './HeadersEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { MarkdownEditor } from './MarkdownEditor'; import { UrlBar } from './UrlBar'; @@ -64,7 +66,9 @@ export function GrpcRequestPane({ onCancel, onSend, }: Props) { - const authentication = useHttpAuthenticationSummaries(); + const authTab = useAuthTab(TAB_AUTH, activeRequest); + const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata'); + const inheritedHeaders = useInheritedHeaders(activeRequest); const { value: activeTabs, set: setActiveTabs } = useKeyValue>({ namespace: 'no_sync', key: 'grpcRequestActiveTabs', @@ -130,42 +134,15 @@ export function GrpcRequestPane({ const tabs: TabItem[] = useMemo( () => [ { value: TAB_MESSAGE, label: 'Message' }, - { - value: TAB_AUTH, - label: 'Auth', - options: { - value: activeRequest.authenticationType, - items: [ - ...authentication.map((a) => ({ - label: a.label || 'UNKNOWN', - shortLabel: a.shortLabel, - value: a.name, - })), - { type: 'separator' }, - { label: 'No Authentication', shortLabel: 'Auth', value: null }, - ], - onChange: async (authenticationType) => { - let authentication: GrpcRequest['authentication'] = activeRequest.authentication; - if (activeRequest.authenticationType !== authenticationType) { - authentication = { - // Reset auth if changing types - }; - } - await patchModel(activeRequest, { - authenticationType, - authentication, - }); - }, - }, - }, - { value: TAB_METADATA, label: 'Metadata' }, + ...metadataTab, + ...authTab, { value: TAB_DESCRIPTION, label: 'Info', rightSlot: activeRequest.description && , }, ], - [activeRequest, authentication], + [activeRequest.description, authTab, metadataTab], ); const activeTab = activeTabs?.[activeRequest.id]; @@ -177,7 +154,7 @@ export function GrpcRequestPane({ ); const handleMetadataChange = useCallback( - (metadata: GrpcMetadataEntry[]) => patchModel(activeRequest, { metadata }), + (metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }), [activeRequest], ); @@ -307,17 +284,15 @@ export function GrpcRequestPane({ /> - + - diff --git a/src-web/components/HeadersEditor.tsx b/src-web/components/HeadersEditor.tsx index 8d8d025d..5023e46c 100644 --- a/src-web/components/HeadersEditor.tsx +++ b/src-web/components/HeadersEditor.tsx @@ -5,36 +5,81 @@ import { connections } from '../lib/data/connections'; import { encodings } from '../lib/data/encodings'; import { headerNames } from '../lib/data/headerNames'; import { mimeTypes } from '../lib/data/mimetypes'; +import { Banner } from './core/Banner'; +import { CountBadge } from './core/CountBadge'; import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import type { InputProps } from './core/Input'; import type { Pair, PairEditorProps } from './core/PairEditor'; +import { ensurePairId, PairEditorRow } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor'; +import { HStack } from './core/Stacks'; type Props = { forceUpdateKey: string; headers: HttpRequestHeader[]; + inheritedHeaders?: HttpRequestHeader[]; stateKey: string; onChange: (headers: HttpRequestHeader[]) => void; + label?: string; }; -export function HeadersEditor({ stateKey, headers, onChange, forceUpdateKey }: Props) { +export function HeadersEditor({ + stateKey, + headers, + inheritedHeaders, + onChange, + forceUpdateKey, +}: Props) { + const validInheritedHeaders = + inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? []; return ( - +
+ {validInheritedHeaders.length > 0 ? ( + +
+ + + Inherited + + +
+ {validInheritedHeaders?.map((pair, i) => ( + {}} + onEnd={() => {}} + onMove={() => {}} + pair={ensurePairId(pair)} + stateKey={null} + /> + ))} +
+
+
+ ) : ( + + )} + +
); } @@ -51,14 +96,14 @@ const headerOptionsMap: Record = { const valueType = (pair: Pair): InputProps['type'] => { const name = pair.name.toLowerCase().trim(); if ( - name.includes('authorization') || - name.includes('api-key') || - name.includes('access-token') || - name.includes('auth') || - name.includes('secret') || - name.includes('token') || - name === 'cookie' || - name === 'set-cookie' + name.includes('authorization') || + name.includes('api-key') || + name.includes('access-token') || + name.includes('auth') || + name.includes('secret') || + name.includes('token') || + name === 'cookie' || + name === 'set-cookie' ) { return 'password'; } else { diff --git a/src-web/components/HttpAuthenticationEditor.tsx b/src-web/components/HttpAuthenticationEditor.tsx index 9ea2422a..2fbe85fc 100644 --- a/src-web/components/HttpAuthenticationEditor.tsx +++ b/src-web/components/HttpAuthenticationEditor.tsx @@ -1,34 +1,84 @@ -import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; +import type { + Folder, + GrpcRequest, + HttpRequest, + WebsocketRequest, + Workspace, +} from '@yaakapp-internal/models'; import { patchModel } from '@yaakapp-internal/models'; import React, { useCallback } from 'react'; +import { openFolderSettings } from '../commands/openFolderSettings'; +import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig'; +import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication'; +import { resolvedModelName } from '../lib/resolvedModelName'; import { Checkbox } from './core/Checkbox'; import type { DropdownItem } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; +import { InlineCode } from './core/InlineCode'; import { HStack } from './core/Stacks'; import { DynamicForm } from './DynamicForm'; import { EmptyStateText } from './EmptyStateText'; interface Props { - request: HttpRequest | GrpcRequest | WebsocketRequest; + model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace; } -export function HttpAuthenticationEditor({ request }: Props) { +export function HttpAuthenticationEditor({ model }: Props) { + const inheritedAuth = useInheritedAuthentication(model); const authConfig = useHttpAuthenticationConfig( - request.authenticationType, - request.authentication, - request.id, + model.authenticationType, + model.authentication, + model.id, ); const handleChange = useCallback( - (authentication: Record) => patchModel(request, { authentication }), - [request], + async (authentication: Record) => await patchModel(model, { authentication }), + [model], ); - if (authConfig.data == null) { - return No Authentication {request.authenticationType}; + if (model.authenticationType === 'none') { + return No authentication; + } + + if (model.authenticationType != null && authConfig.data == null) { + return ( + + Unknown authentication {authConfig.data} + + ); + } + + if (inheritedAuth == null) { + return Authentication not configured; + } + + if (inheritedAuth.authenticationType === 'none') { + return No authentication; + } + + const wasAuthInherited = inheritedAuth?.id !== model.id; + if (wasAuthInherited) { + const name = resolvedModelName(inheritedAuth); + const cta = inheritedAuth.model === 'workspace' ? 'Workspace' : name; + return ( + +

+ Inherited from{' '} + +

+
+ ); } return ( @@ -36,17 +86,17 @@ export function HttpAuthenticationEditor({ request }: Props) { handleChange({ ...request.authentication, disabled: !disabled })} + checked={!model.authentication.disabled} + onChange={(disabled) => handleChange({ ...model.authentication, disabled: !disabled })} title="Enabled" /> - {authConfig.data.actions && authConfig.data.actions.length > 0 && ( + {authConfig.data?.actions && authConfig.data.actions.length > 0 && ( ({ label: a.label, leftSlot: a.icon ? : null, - onSelect: () => a.call(request), + onSelect: () => a.call(model), }), )} > @@ -55,12 +105,12 @@ export function HttpAuthenticationEditor({ request }: Props) { )} diff --git a/src-web/components/HttpRequestPane.tsx b/src-web/components/HttpRequestPane.tsx index 3506434c..4b1551f5 100644 --- a/src-web/components/HttpRequestPane.tsx +++ b/src-web/components/HttpRequestPane.tsx @@ -6,13 +6,15 @@ import { atom, useAtomValue } from 'jotai'; import type { CSSProperties } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; +import { allRequestsAtom } from '../hooks/useAllRequests'; +import { useAuthTab } from '../hooks/useAuthTab'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; -import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication'; +import { useHeadersTab } from '../hooks/useHeadersTab'; import { useImportCurl } from '../hooks/useImportCurl'; +import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useKeyValue } from '../hooks/useKeyValue'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; -import { allRequestsAtom } from '../hooks/useAllRequests'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { deepEqualAtom } from '../lib/atoms'; @@ -44,12 +46,12 @@ import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { FormMultipartEditor } from './FormMultipartEditor'; import { FormUrlencodedEditor } from './FormUrlencodedEditor'; +import { GraphQLEditor } from './GraphQLEditor'; import { HeadersEditor } from './HeadersEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { MarkdownEditor } from './MarkdownEditor'; import { UrlBar } from './UrlBar'; import { UrlParametersEditor } from './UrlParameterEditor'; -import { GraphQLEditor } from './GraphQLEditor'; interface Props { style: CSSProperties; @@ -85,7 +87,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const contentType = getContentTypeFromHeaders(activeRequest.headers); - const authentication = useHttpAuthenticationSummaries(); + const authTab = useAuthTab(TAB_AUTH, activeRequest); + const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); + const inheritedHeaders = useInheritedHeaders(activeRequest); const handleContentTypeChange = useCallback( async (contentType: string | null, patch: Partial> = {}) => { @@ -214,42 +218,21 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: rightSlot: , label: 'Params', }, - { - value: TAB_HEADERS, - label: 'Headers', - rightSlot: h.name).length} />, - }, - { - value: TAB_AUTH, - label: 'Auth', - options: { - value: activeRequest.authenticationType, - items: [ - ...authentication.map((a) => ({ - label: a.label || 'UNKNOWN', - shortLabel: a.shortLabel, - value: a.name, - })), - { type: 'separator' }, - { label: 'No Authentication', shortLabel: 'Auth', value: null }, - ], - onChange: async (authenticationType) => { - let authentication: HttpRequest['authentication'] = activeRequest.authentication; - if (activeRequest.authenticationType !== authenticationType) { - authentication = { - // Reset auth if changing types - }; - } - await patchModel(activeRequest, { authenticationType, authentication }); - }, - }, - }, + ...headersTab, + ...authTab, { value: TAB_DESCRIPTION, label: 'Info', }, ], - [activeRequest, authentication, handleContentTypeChange, numParams, urlParameterPairs.length], + [ + activeRequest, + authTab, + handleContentTypeChange, + headersTab, + numParams, + urlParameterPairs.length, + ], ); const { mutate: sendRequest } = useSendAnyHttpRequest(); @@ -372,10 +355,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: tabListClassName="mt-2 !mb-1.5" > - + {message} {stack && ( -
+
Stack Trace
{stack}
diff --git a/src-web/components/WebsocketRequestPane.tsx b/src-web/components/WebsocketRequestPane.tsx index 5a390be9..c41c7e50 100644 --- a/src-web/components/WebsocketRequestPane.tsx +++ b/src-web/components/WebsocketRequestPane.tsx @@ -1,4 +1,4 @@ -import type { HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; +import type { WebsocketRequest } from '@yaakapp-internal/models'; import { patchModel } from '@yaakapp-internal/models'; import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import { closeWebsocket, connectWebsocket, sendWebsocket } from '@yaakapp-internal/ws'; @@ -9,13 +9,15 @@ import React, { useCallback, useMemo } from 'react'; import { getActiveCookieJar } from '../hooks/useActiveCookieJar'; import { getActiveEnvironment } from '../hooks/useActiveEnvironment'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; +import { allRequestsAtom } from '../hooks/useAllRequests'; +import { useAuthTab } from '../hooks/useAuthTab'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; -import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication'; +import { useHeadersTab } from '../hooks/useHeadersTab'; +import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useKeyValue } from '../hooks/useKeyValue'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; -import {allRequestsAtom} from "../hooks/useAllRequests"; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { deepEqualAtom } from '../lib/atoms'; import { languageFromContentType } from '../lib/contentType'; @@ -69,7 +71,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque }); const forceUpdateKey = useRequestUpdateKey(activeRequest.id); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); - const authentication = useHttpAuthenticationSummaries(); + const authTab = useAuthTab(TAB_AUTH, activeRequest); + const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); + const inheritedHeaders = useInheritedHeaders(activeRequest); const { urlParameterPairs, urlParametersKey } = useMemo(() => { const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( @@ -99,45 +103,14 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque rightSlot: , label: 'Params', }, - { - value: TAB_HEADERS, - label: 'Headers', - rightSlot: h.name).length} />, - }, - { - value: TAB_AUTH, - label: 'Auth', - options: { - value: activeRequest.authenticationType, - items: [ - ...authentication.map((a) => ({ - label: a.label || 'UNKNOWN', - shortLabel: a.shortLabel, - value: a.name, - })), - { type: 'separator' }, - { label: 'No Authentication', shortLabel: 'Auth', value: null }, - ], - onChange: async (authenticationType) => { - let authentication: HttpRequest['authentication'] = activeRequest.authentication; - if (activeRequest.authenticationType !== authenticationType) { - authentication = { - // Reset auth if changing types - }; - } - await patchModel(activeRequest, { - authenticationType, - authentication, - }); - }, - }, - }, + ...headersTab, + ...authTab, { value: TAB_DESCRIPTION, label: 'Info', }, ]; - }, [activeRequest, authentication, urlParameterPairs.length]); + }, [authTab, headersTab, urlParameterPairs.length]); const { activeResponse } = usePinnedHttpResponse(activeRequestId); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); @@ -266,10 +239,11 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque tabListClassName="mt-2 !mb-1.5" > - + , hotKeyAction: 'workspace_settings.show', - onSelect: () => openWorkspaceSettings.mutate(), + onSelect: openWorkspaceSettings, }, { label: revealInFinderText, diff --git a/src-web/components/WorkspaceSettingsDialog.tsx b/src-web/components/WorkspaceSettingsDialog.tsx index cdde48ed..9e6a9fea 100644 --- a/src-web/components/WorkspaceSettingsDialog.tsx +++ b/src-web/components/WorkspaceSettingsDialog.tsx @@ -1,5 +1,9 @@ import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models'; import { useAtomValue } from 'jotai'; +import { useState } from 'react'; +import { useAuthTab } from '../hooks/useAuthTab'; +import { useHeadersTab } from '../hooks/useHeadersTab'; +import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { router } from '../lib/router'; import { Banner } from './core/Banner'; @@ -8,6 +12,9 @@ import { InlineCode } from './core/InlineCode'; import { PlainInput } from './core/PlainInput'; import { Separator } from './core/Separator'; import { HStack, VStack } from './core/Stacks'; +import { TabContent, Tabs } from './core/Tabs/Tabs'; +import { HeadersEditor } from './HeadersEditor'; +import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { MarkdownEditor } from './MarkdownEditor'; import { SyncToFilesystemSetting } from './SyncToFilesystemSetting'; import { WorkspaceEncryptionSetting } from './WorkspaceEncryptionSetting'; @@ -15,11 +22,22 @@ import { WorkspaceEncryptionSetting } from './WorkspaceEncryptionSetting'; interface Props { workspaceId: string | null; hide: () => void; + tab?: WorkspaceSettingsTab; } -export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) { +const TAB_AUTH = 'auth'; +const TAB_HEADERS = 'headers'; +const TAB_GENERAL = 'general'; + +export type WorkspaceSettingsTab = typeof TAB_AUTH | typeof TAB_HEADERS | typeof TAB_GENERAL; + +export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) { const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId); const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId); + const [activeTab, setActiveTab] = useState(tab ?? TAB_GENERAL); + const authTab = useAuthTab(TAB_AUTH, workspace ?? null); + const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null); + const inheritedHeaders = useInheritedHeaders(workspace ?? null); if (workspace == null) { return ( @@ -37,53 +55,76 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) { ); return ( - - patchModel(workspace, { name })} - /> + + + + + + patchModel(workspace, { headers })} + stateKey={`headers.${workspace.id}`} + /> + + + + patchModel(workspace, { name })} + /> - patchModel(workspace, { description })} - heightMode="auto" - /> + patchModel(workspace, { description })} + heightMode="auto" + /> - patchModel(workspaceMeta, { settingSyncDir: filePath })} - /> - + patchModel(workspaceMeta, { settingSyncDir: filePath })} + /> + - + - - - {workspaceId} - - + + + {workspaceId} + + + + ); } diff --git a/src-web/components/core/Checkbox.tsx b/src-web/components/core/Checkbox.tsx index 9d453f60..b074c199 100644 --- a/src-web/components/core/Checkbox.tsx +++ b/src-web/components/core/Checkbox.tsx @@ -52,6 +52,7 @@ export function Checkbox({
diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index c533bd29..88457b75 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -290,6 +290,8 @@ type PairEditorRowProps = { onFocus?: (pair: PairWithId) => void; onSubmit?: (pair: PairWithId) => void; isLast?: boolean; + disabled?: boolean; + disableDrag?: boolean; index: number; } & Pick< PairEditorProps, @@ -311,21 +313,23 @@ type PairEditorRowProps = { | 'valueValidate' >; -function PairEditorRow({ +export function PairEditorRow({ allowFileValues, allowMultilineValues, className, - forcedEnvironmentId, + disableDrag, + disabled, forceFocusNamePairId, forceFocusValuePairId, forceUpdateKey, + forcedEnvironmentId, index, isLast, nameAutocomplete, - namePlaceholder, - nameValidate, nameAutocompleteFunctions, nameAutocompleteVariables, + namePlaceholder, + nameValidate, onChange, onDelete, onEnd, @@ -461,12 +465,12 @@ function PairEditorRow({ - {!isLast ? ( + {!isLast && !disableDrag ? (
{pair.isFile ? ( - + ) : isLast ? ( // Use PlainInput for last ones because there's a unique bug where clicking below // the Codemirror input focuses it. )} diff --git a/src-web/components/core/RadioDropdown.tsx b/src-web/components/core/RadioDropdown.tsx index 9e03a02d..4572c2a4 100644 --- a/src-web/components/core/RadioDropdown.tsx +++ b/src-web/components/core/RadioDropdown.tsx @@ -8,7 +8,7 @@ export type RadioDropdownItem = | { type?: 'default'; label: ReactNode; - shortLabel?: string; + shortLabel?: ReactNode; value: T; rightSlot?: ReactNode; } diff --git a/src-web/components/sidebar/SidebarItemContextMenu.tsx b/src-web/components/sidebar/SidebarItemContextMenu.tsx index 6a79a22a..ee6a8f3c 100644 --- a/src-web/components/sidebar/SidebarItemContextMenu.tsx +++ b/src-web/components/sidebar/SidebarItemContextMenu.tsx @@ -1,6 +1,7 @@ import { duplicateModelById, getModel, workspacesAtom } from '@yaakapp-internal/models'; import { useAtomValue } from 'jotai'; import React, { useMemo } from 'react'; +import { openFolderSettings } from '../../commands/openFolderSettings'; import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems'; import { useHttpRequestActions } from '../../hooks/useHttpRequestActions'; import { useMoveToWorkspace } from '../../hooks/useMoveToWorkspace'; @@ -8,13 +9,11 @@ import { useSendAnyHttpRequest } from '../../hooks/useSendAnyHttpRequest'; import { useSendManyRequests } from '../../hooks/useSendManyRequests'; import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm'; -import { showDialog } from '../../lib/dialog'; import { duplicateRequestAndNavigate } from '../../lib/duplicateRequestAndNavigate'; import { renameModelWithPrompt } from '../../lib/renameModelWithPrompt'; import type { DropdownItem } from '../core/Dropdown'; import { ContextMenu } from '../core/Dropdown'; import { Icon } from '../core/Icon'; -import { FolderSettingsDialog } from '../FolderSettingsDialog'; import type { SidebarTreeNode } from './Sidebar'; interface Props { @@ -44,13 +43,7 @@ export function SidebarItemContextMenu({ child, show, close }: Props) { { label: 'Settings', leftSlot: , - onSelect: () => - showDialog({ - id: 'folder-settings', - title: 'Folder Settings', - size: 'md', - render: () => , - }), + onSelect: () => openFolderSettings(child.id), }, { label: 'Duplicate', diff --git a/src-web/hooks/useAuthTab.tsx b/src-web/hooks/useAuthTab.tsx new file mode 100644 index 00000000..c72d7063 --- /dev/null +++ b/src-web/hooks/useAuthTab.tsx @@ -0,0 +1,64 @@ +import type { Folder } from '@yaakapp-internal/models'; +import { patchModel } from '@yaakapp-internal/models'; +import { useMemo } from 'react'; +import { IconTooltip } from '../components/core/IconTooltip'; +import { HStack } from '../components/core/Stacks'; +import type { TabItem } from '../components/core/Tabs/Tabs'; +import { useHttpAuthenticationSummaries } from './useHttpAuthentication'; +import type { AuthenticatedModel} from './useInheritedAuthentication'; +import { useInheritedAuthentication } from './useInheritedAuthentication'; + +export function useAuthTab(tabValue: T, model: AuthenticatedModel | null) { + const authentication = useHttpAuthenticationSummaries(); + const inheritedAuth = useInheritedAuthentication(model); + + return useMemo(() => { + if (model == null) return []; + + const tab: TabItem = { + value: tabValue, + label: 'Auth', + options: { + value: model.authenticationType, + items: [ + ...authentication.map((a) => ({ + label: a.label || 'UNKNOWN', + shortLabel: a.shortLabel, + value: a.name, + })), + { type: 'separator' }, + { + label: 'Inherit from Parent', + shortLabel: + inheritedAuth != null && inheritedAuth.authenticationType != 'none' ? ( + + {authentication.find((a) => a.name === inheritedAuth.authenticationType) + ?.shortLabel ?? 'UNKNOWN'} + + + ) : ( + 'Auth' + ), + value: null, + }, + { label: 'No Auth', shortLabel: 'No Auth', value: 'none' }, + ], + onChange: async (authenticationType) => { + let authentication: Folder['authentication'] = model.authentication; + if (model.authenticationType !== authenticationType) { + authentication = { + // Reset auth if changing types + }; + } + await patchModel(model, { authentication, authenticationType }); + }, + }, + }; + + return [tab]; + }, [authentication, inheritedAuth, model, tabValue]); +} diff --git a/src-web/hooks/useHeadersTab.tsx b/src-web/hooks/useHeadersTab.tsx new file mode 100644 index 00000000..e01f285f --- /dev/null +++ b/src-web/hooks/useHeadersTab.tsx @@ -0,0 +1,31 @@ +import React, { useMemo } from 'react'; +import { CountBadge } from '../components/core/CountBadge'; +import type { TabItem } from '../components/core/Tabs/Tabs'; +import type { HeaderModel } from './useInheritedHeaders'; +import { useInheritedHeaders } from './useInheritedHeaders'; + +export function useHeadersTab( + tabValue: T, + model: HeaderModel | null, + label?: string, +) { + const inheritedHeaders = useInheritedHeaders(model); + + return useMemo(() => { + if (model == null) return []; + + const allHeaders = [ + ...inheritedHeaders, + ...(model.model === 'grpc_request' ? model.metadata : model.headers), + ]; + const numHeaders = allHeaders.filter((h) => h.name).length; + + const tab: TabItem = { + value: tabValue, + label: label ?? 'Headers', + rightSlot: , + }; + + return [tab]; + }, [inheritedHeaders, label, model, tabValue]); +} diff --git a/src-web/hooks/useHttpAuthenticationConfig.ts b/src-web/hooks/useHttpAuthenticationConfig.ts index a2178b0e..c37a2d9a 100644 --- a/src-web/hooks/useHttpAuthenticationConfig.ts +++ b/src-web/hooks/useHttpAuthenticationConfig.ts @@ -1,5 +1,11 @@ import { useQuery } from '@tanstack/react-query'; -import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; +import type { + Folder, + GrpcRequest, + HttpRequest, + WebsocketRequest, + Workspace, +} from '@yaakapp-internal/models'; import { httpResponsesAtom } from '@yaakapp-internal/models'; import type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from '@yaakapp-internal/plugins'; import { useAtomValue } from 'jotai'; @@ -49,13 +55,15 @@ export function useHttpAuthenticationConfig( ...config, actions: config.actions?.map((a, i) => ({ ...a, - call: async ({ id: requestId }: HttpRequest | GrpcRequest | WebsocketRequest) => { + call: async ({ + id: modelId, + }: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace) => { await invokeCmd('cmd_call_http_authentication_action', { pluginRefId: config.pluginRefId, actionIndex: i, authName, values, - requestId, + modelId, }); // Ensure the config is refreshed after the action is done diff --git a/src-web/hooks/useInheritedAuthentication.ts b/src-web/hooks/useInheritedAuthentication.ts new file mode 100644 index 00000000..86923436 --- /dev/null +++ b/src-web/hooks/useInheritedAuthentication.ts @@ -0,0 +1,50 @@ +import type { + Folder, + GrpcRequest, + HttpRequest, + WebsocketRequest, + Workspace, +} from '@yaakapp-internal/models'; +import { foldersAtom, workspacesAtom } from '@yaakapp-internal/models'; +import { atom, useAtomValue } from 'jotai'; + +const ancestorsAtom = atom(function (get) { + return [...get(foldersAtom), ...get(workspacesAtom)]; +}); + +export type AuthenticatedModel = HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace; + +export function useInheritedAuthentication( + baseModel: AuthenticatedModel | null, +) { + const parents = useAtomValue(ancestorsAtom); + + if (baseModel == null) return null; + + const next = (child: AuthenticatedModel) => { + // We hit the top + if (child.model === 'workspace') { + return child.authenticationType == null ? null : child; + } + + // Has valid auth + if (child.authenticationType !== null) { + return child; + } + + // Recurse up the tree + const parent = parents.find((p) => { + if (child.folderId) return p.id === child.folderId; + else return p.id === child.workspaceId; + }); + + // Failed to find parent (should never happen) + if (parent == null) { + return null; + } + + return next(parent); + }; + + return next(baseModel); +} diff --git a/src-web/hooks/useInheritedHeaders.ts b/src-web/hooks/useInheritedHeaders.ts new file mode 100644 index 00000000..400463c5 --- /dev/null +++ b/src-web/hooks/useInheritedHeaders.ts @@ -0,0 +1,46 @@ +import type { + Folder, + GrpcRequest, + HttpRequest, + HttpRequestHeader, + WebsocketRequest, + Workspace, +} from '@yaakapp-internal/models'; +import { foldersAtom, workspacesAtom } from '@yaakapp-internal/models'; +import { atom, useAtomValue } from 'jotai'; + +const ancestorsAtom = atom(function (get) { + return [...get(foldersAtom), ...get(workspacesAtom)]; +}); + +export type HeaderModel = HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace; + +export function useInheritedHeaders(baseModel: HeaderModel | null) { + const parents = useAtomValue(ancestorsAtom); + + if (baseModel == null) return []; + if (baseModel.model === 'workspace') return []; + + const next = (child: HeaderModel): HttpRequestHeader[] => { + // Short-circuit + if (child.model === 'workspace') { + return []; + } + + // Recurse up the tree + const parent = parents.find((p) => { + if (child.folderId) return p.id === child.folderId; + else return p.id === child.workspaceId; + }); + + // Failed to find parent (should never happen) + if (parent == null) { + return []; + } + + const headers = next(parent); + return [...headers, ...parent.headers]; + }; + + return next(baseModel); +}