From 8a80e7b8332813f18793f2c069456e47035ce82d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 26 Nov 2025 11:05:07 -0800 Subject: [PATCH] Add ability to conditionally disable auth (#309) --- plugins/auth-oauth2/src/index.ts | 1 - src-tauri/src/lib.rs | 6 +- src-tauri/src/render.rs | 73 +++++++-- src-tauri/yaak-crypto/src/manager.rs | 1 - src-tauri/yaak-ws/src/render.rs | 39 ++++- .../components/HttpAuthenticationEditor.tsx | 128 ++++++++++++--- src-web/components/MarkdownEditor.tsx | 2 + src-web/components/TemplateFunctionDialog.tsx | 13 +- src-web/components/core/SegmentedControl.tsx | 153 ++++++++++++------ src-web/components/core/Select.tsx | 1 + src-web/hooks/useRenderTemplate.ts | 39 +++-- 11 files changed, 346 insertions(+), 110 deletions(-) diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index 10c1d1f2..324ad9b0 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -131,7 +131,6 @@ export const plugin: PluginDefinition = { type: 'select', name: 'grantType', label: 'Grant Type', - hideLabel: true, defaultValue: defaultGrantType, options: grantTypes, }, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f59018af..1650b40d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -118,6 +118,7 @@ async fn cmd_render_template( workspace_id: &str, environment_id: Option<&str>, purpose: Option, + ignore_error: Option, ) -> YaakResult { let environment_chain = app_handle.db().resolve_environments(workspace_id, None, environment_id)?; @@ -130,7 +131,10 @@ async fn cmd_render_template( purpose.unwrap_or(RenderPurpose::Preview), ), &RenderOptions { - error_behavior: RenderErrorBehavior::Throw, + error_behavior: match ignore_error { + Some(true) => RenderErrorBehavior::ReturnEmpty, + _ => RenderErrorBehavior::Throw, + }, }, ) .await?; diff --git a/src-tauri/src/render.rs b/src-tauri/src/render.rs index 3895a35e..9c3d7030 100644 --- a/src-tauri/src/render.rs +++ b/src-tauri/src/render.rs @@ -1,3 +1,4 @@ +use log::info; use serde_json::Value; use std::collections::BTreeMap; use yaak_http::path_placeholders::apply_path_placeholders; @@ -5,7 +6,7 @@ use yaak_models::models::{ Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter, }; use yaak_models::render::make_vars_hashmap; -use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions, TemplateCallback}; +use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; pub async fn render_template( template: &str, @@ -45,10 +46,37 @@ pub async fn render_grpc_request( }) } - let mut authentication = BTreeMap::new(); - for (k, v) in r.authentication.clone() { - authentication.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); - } + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + match r.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; + } + Some(Value::String(tmpl)) => { + disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt) + .await + .unwrap_or_default() + .is_empty(); + info!( + "Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\"" + ); + } + _ => {} + } + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (k, v) in r.authentication.clone() { + if k == "disabled" { + auth.insert(k, Value::Bool(false)); + } else { + auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); + } + } + } + auth + }; let url = parse_and_render(r.url.as_str(), vars, cb, &opt).await?; @@ -99,10 +127,37 @@ pub async fn render_http_request( body.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); } - let mut authentication = BTreeMap::new(); - for (k, v) in r.authentication.clone() { - authentication.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); - } + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + match r.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; + } + Some(Value::String(tmpl)) => { + disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt) + .await + .unwrap_or_default() + .is_empty(); + info!( + "Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\"" + ); + } + _ => {} + } + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (k, v) in r.authentication.clone() { + if k == "disabled" { + auth.insert(k, Value::Bool(false)); + } else { + auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); + } + } + } + auth + }; let url = parse_and_render(r.url.clone().as_str(), vars, cb, &opt).await?; diff --git a/src-tauri/yaak-crypto/src/manager.rs b/src-tauri/yaak-crypto/src/manager.rs index b2c2ec86..af419ec9 100644 --- a/src-tauri/yaak-crypto/src/manager.rs +++ b/src-tauri/yaak-crypto/src/manager.rs @@ -155,7 +155,6 @@ impl EncryptionManager { let raw_key = mkey .decrypt(decoded_key.as_slice()) .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?; - info!("Got existing workspace key for {workspace_id}"); let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice()); Ok(wkey) diff --git a/src-tauri/yaak-ws/src/render.rs b/src-tauri/yaak-ws/src/render.rs index 36cbac03..06757477 100644 --- a/src-tauri/yaak-ws/src/render.rs +++ b/src-tauri/yaak-ws/src/render.rs @@ -1,8 +1,10 @@ use crate::error::Result; +use log::info; +use serde_json::Value; use std::collections::BTreeMap; use yaak_models::models::{Environment, HttpRequestHeader, HttpUrlParameter, WebsocketRequest}; use yaak_models::render::make_vars_hashmap; -use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions, TemplateCallback}; +use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; pub async fn render_websocket_request( r: &WebsocketRequest, @@ -32,10 +34,37 @@ pub async fn render_websocket_request( }) } - let mut authentication = BTreeMap::new(); - for (k, v) in r.authentication.clone() { - authentication.insert(k, render_json_value_raw(v, vars, cb, opt).await?); - } + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + match r.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; + } + Some(Value::String(tmpl)) => { + disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt) + .await + .unwrap_or_default() + .is_empty(); + info!( + "Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\"" + ); + } + _ => {} + } + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (k, v) in r.authentication.clone() { + if k == "disabled" { + auth.insert(k, Value::Bool(false)); + } else { + auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); + } + } + } + auth + }; let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?; diff --git a/src-web/components/HttpAuthenticationEditor.tsx b/src-web/components/HttpAuthenticationEditor.tsx index e7e58af7..ccf46370 100644 --- a/src-web/components/HttpAuthenticationEditor.tsx +++ b/src-web/components/HttpAuthenticationEditor.tsx @@ -11,14 +11,15 @@ import { openFolderSettings } from '../commands/openFolderSettings'; import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig'; import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication'; +import { useRenderTemplate } from '../hooks/useRenderTemplate'; import { resolvedModelName } from '../lib/resolvedModelName'; -import { Checkbox } from './core/Checkbox'; -import type { DropdownItem } from './core/Dropdown'; -import { Dropdown } from './core/Dropdown'; +import { Dropdown, type DropdownItem } from './core/Dropdown'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { InlineCode } from './core/InlineCode'; +import { Input, type InputProps } from './core/Input'; import { Link } from './core/Link'; +import { SegmentedControl } from './core/SegmentedControl'; import { HStack } from './core/Stacks'; import { DynamicForm } from './DynamicForm'; import { EmptyStateText } from './EmptyStateText'; @@ -36,7 +37,7 @@ export function HttpAuthenticationEditor({ model }: Props) { ); const handleChange = useCallback( - async (authentication: Record) => await patchModel(model, { authentication }), + async (authentication: Record) => await patchModel(model, { authentication }), [model], ); @@ -98,30 +99,64 @@ export function HttpAuthenticationEditor({ model }: Props) { } return ( -
- - handleChange({ ...model.authentication, disabled: !disabled })} - title="Enabled" - /> - {authConfig.data?.actions && authConfig.data.actions.length > 0 && ( - ({ - label: a.label, - leftSlot: a.icon ? : null, - onSelect: () => a.call(model), - }), - )} - > - - +
+
+ + { + let disabled: boolean | string; + if (enabled === '__TRUE__') { + disabled = false; + } else if (enabled === '__FALSE__') { + disabled = true; + } else { + disabled = ''; + } + await handleChange({ ...model.authentication, disabled }); + }} + /> + {authConfig.data?.actions && authConfig.data.actions.length > 0 && ( + ({ + label: a.label, + leftSlot: a.icon ? : null, + onSelect: () => a.call(model), + }), + )} + > + + + )} + + {typeof model.authentication.disabled === 'string' && ( +
+ handleChange({ ...model.authentication, disabled: v })} + /> +
)} - +
); } + +function AuthenticationDisabledInput({ + value, + onChange, + stateKey, + className, +}: { + value: string; + onChange: InputProps['onChange']; + stateKey: string; + className?: string; +}) { + const rendered = useRenderTemplate({ + template: value, + enabled: true, + purpose: 'preview', + refreshKey: value, + }); + + return ( + +
+ {rendered.isPending ? 'loading' : rendered.data ? 'enabled' : 'disabled'} +
+
+ } + autocompleteFunctions + autocompleteVariables + onChange={onChange} + stateKey={stateKey} + /> + ); +} diff --git a/src-web/components/MarkdownEditor.tsx b/src-web/components/MarkdownEditor.tsx index 8b068e20..dfebaa65 100644 --- a/src-web/components/MarkdownEditor.tsx +++ b/src-web/components/MarkdownEditor.tsx @@ -67,8 +67,10 @@ export function MarkdownEditor({
(null); - const rendered = useRenderTemplate( - debouncedTagText, - previewType !== 'none', - previewType === 'click' ? 'send' : 'preview', - previewType === 'live' ? renderKey + debouncedTagText : renderKey, - ); + const rendered = useRenderTemplate({ + template: debouncedTagText, + enabled: previewType !== 'none', + purpose: previewType === 'click' ? 'send' : 'preview', + refreshKey: previewType === 'live' ? renderKey + debouncedTagText : renderKey, + ignoreError: false, + }); const tooLarge = rendered.data ? rendered.data.length > 10000 : false; // biome-ignore lint/correctness/useExhaustiveDependencies: Only update this on rendered data change to keep secrets hidden on input change diff --git a/src-web/components/core/SegmentedControl.tsx b/src-web/components/core/SegmentedControl.tsx index a9030553..b75060f3 100644 --- a/src-web/components/core/SegmentedControl.tsx +++ b/src-web/components/core/SegmentedControl.tsx @@ -1,75 +1,124 @@ import classNames from 'classnames'; -import { useRef } from 'react'; +import { type ReactNode, useRef } from 'react'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; +import { generateId } from '../../lib/generateId'; +import { Button } from './Button'; import type { IconProps } from './Icon'; -import { IconButton } from './IconButton'; +import { IconButton, type IconButtonProps } from './IconButton'; +import { Label } from './Label'; import { HStack } from './Stacks'; interface Props { - options: { value: T; label: string; icon: IconProps['icon'] }[]; + options: { value: T; label: string; icon?: IconProps['icon'] }[]; onChange: (value: T) => void; value: T; name: string; + size?: IconButtonProps['size']; + label: string; className?: string; + hideLabel?: boolean; + labelClassName?: string; + help?: ReactNode; } export function SegmentedControl({ value, onChange, options, + size = 'xs', + label, + hideLabel, + labelClassName, + help, className, }: Props) { const [selectedValue, setSelectedValue] = useStateWithDeps(value, [value]); const containerRef = useRef(null); + const id = useRef(`input-${generateId()}`); + return ( - { - const selectedIndex = options.findIndex((o) => o.value === selectedValue); - if (e.key === 'ArrowRight') { - const newIndex = Math.abs((selectedIndex + 1) % options.length); - options[newIndex] && setSelectedValue(options[newIndex].value); - const child = containerRef.current?.children[newIndex] as HTMLButtonElement; - child.focus(); - } else if (e.key === 'ArrowLeft') { - const newIndex = Math.abs((selectedIndex - 1) % options.length); - options[newIndex] && setSelectedValue(options[newIndex].value); - const child = containerRef.current?.children[newIndex] as HTMLButtonElement; - child.focus(); - } - }} - > - {options.map((o) => { - const isSelected = selectedValue === o.value; - const isActive = value === o.value; - return ( - onChange(o.value)} - /> - ); - })} - +
+ + { + const selectedIndex = options.findIndex((o) => o.value === selectedValue); + if (e.key === 'ArrowRight') { + e.preventDefault(); + const newIndex = Math.abs((selectedIndex + 1) % options.length); + options[newIndex] && setSelectedValue(options[newIndex].value); + const child = containerRef.current?.children[newIndex] as HTMLButtonElement; + child.focus(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + const newIndex = Math.abs((selectedIndex - 1) % options.length); + options[newIndex] && setSelectedValue(options[newIndex].value); + const child = containerRef.current?.children[newIndex] as HTMLButtonElement; + child.focus(); + } + }} + > + {options.map((o) => { + const isSelected = selectedValue === o.value; + const isActive = value === o.value; + if (o.icon == null) { + return ( + + ); + } else { + return ( + onChange(o.value)} + /> + ); + } + })} + +
); } diff --git a/src-web/components/core/Select.tsx b/src-web/components/core/Select.tsx index 319c1eea..32217c4e 100644 --- a/src-web/components/core/Select.tsx +++ b/src-web/components/core/Select.tsx @@ -90,6 +90,7 @@ export function Select({ onBlur={() => setFocused(false)} className={classNames( 'pr-7 w-full outline-none bg-transparent disabled:opacity-disabled', + 'leading-[1]', // Center the text better vertically )} disabled={disabled} > diff --git a/src-web/hooks/useRenderTemplate.ts b/src-web/hooks/useRenderTemplate.ts index d19a8125..1fe294b4 100644 --- a/src-web/hooks/useRenderTemplate.ts +++ b/src-web/hooks/useRenderTemplate.ts @@ -6,20 +6,33 @@ import { invokeCmd } from '../lib/tauri'; import { useActiveEnvironment } from './useActiveEnvironment'; import { activeWorkspaceIdAtom } from './useActiveWorkspace'; -export function useRenderTemplate( - template: string, - enabled: boolean, - purpose: RenderPurpose, - refreshKey: string | null, -) { +export function useRenderTemplate({ + template, + enabled, + purpose, + refreshKey, + ignoreError, + preservePreviousValue, +}: { + template: string; + enabled: boolean; + purpose: RenderPurpose; + refreshKey?: string | null; + ignoreError?: boolean; + preservePreviousValue?: boolean; +}) { const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? 'n/a'; const environmentId = useActiveEnvironment()?.id ?? null; return useQuery({ refetchOnWindowFocus: false, enabled, - queryKey: ['render_template', workspaceId, environmentId, refreshKey, purpose], + placeholderData: preservePreviousValue ? (prev) => prev : undefined, + queryKey: ['render_template', workspaceId, environmentId, refreshKey, purpose, ignoreError], queryFn: () => - minPromiseMillis(renderTemplate({ template, workspaceId, environmentId, purpose }), 300), + minPromiseMillis( + renderTemplate({ template, workspaceId, environmentId, purpose, ignoreError }), + 300, + ), }); } @@ -28,13 +41,21 @@ export async function renderTemplate({ workspaceId, environmentId, purpose, + ignoreError, }: { template: string; workspaceId: string; environmentId: string | null; purpose: RenderPurpose; + ignoreError?: boolean; }): Promise { - return invokeCmd('cmd_render_template', { template, workspaceId, environmentId, purpose }); + return invokeCmd('cmd_render_template', { + template, + workspaceId, + environmentId, + purpose, + ignoreError, + }); } export async function decryptTemplate({