Conditionally disable auth

This commit is contained in:
Gregory Schier
2025-11-26 10:30:16 -08:00
parent dfa6f1c5b4
commit 79dd50474d
11 changed files with 348 additions and 117 deletions

View File

@@ -131,7 +131,6 @@ export const plugin: PluginDefinition = {
type: 'select', type: 'select',
name: 'grantType', name: 'grantType',
label: 'Grant Type', label: 'Grant Type',
hideLabel: true,
defaultValue: defaultGrantType, defaultValue: defaultGrantType,
options: grantTypes, options: grantTypes,
}, },

View File

@@ -118,6 +118,7 @@ async fn cmd_render_template<R: Runtime>(
workspace_id: &str, workspace_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
purpose: Option<RenderPurpose>, purpose: Option<RenderPurpose>,
ignore_error: bool,
) -> YaakResult<String> { ) -> YaakResult<String> {
let environment_chain = let environment_chain =
app_handle.db().resolve_environments(workspace_id, None, environment_id)?; app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
@@ -130,7 +131,11 @@ async fn cmd_render_template<R: Runtime>(
purpose.unwrap_or(RenderPurpose::Preview), purpose.unwrap_or(RenderPurpose::Preview),
), ),
&RenderOptions { &RenderOptions {
error_behavior: RenderErrorBehavior::Throw, error_behavior: if ignore_error {
RenderErrorBehavior::ReturnEmpty
} else {
RenderErrorBehavior::Throw
},
}, },
) )
.await?; .await?;

View File

@@ -1,3 +1,4 @@
use log::info;
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use yaak_http::path_placeholders::apply_path_placeholders; use yaak_http::path_placeholders::apply_path_placeholders;
@@ -5,7 +6,7 @@ use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter, Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
}; };
use yaak_models::render::make_vars_hashmap; 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<T: TemplateCallback>( pub async fn render_template<T: TemplateCallback>(
template: &str, template: &str,
@@ -45,10 +46,37 @@ pub async fn render_grpc_request<T: TemplateCallback>(
}) })
} }
let mut authentication = BTreeMap::new(); let authentication = {
for (k, v) in r.authentication.clone() { let mut disabled = false;
authentication.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); 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?; let url = parse_and_render(r.url.as_str(), vars, cb, &opt).await?;
@@ -99,10 +127,37 @@ pub async fn render_http_request<T: TemplateCallback>(
body.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); body.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
} }
let mut authentication = BTreeMap::new(); let authentication = {
for (k, v) in r.authentication.clone() { let mut disabled = false;
authentication.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); 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?; let url = parse_and_render(r.url.clone().as_str(), vars, cb, &opt).await?;

View File

@@ -155,7 +155,6 @@ impl EncryptionManager {
let raw_key = mkey let raw_key = mkey
.decrypt(decoded_key.as_slice()) .decrypt(decoded_key.as_slice())
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?; .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()); let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());
Ok(wkey) Ok(wkey)

View File

@@ -1,8 +1,10 @@
use crate::error::Result; use crate::error::Result;
use log::info;
use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use yaak_models::models::{Environment, HttpRequestHeader, HttpUrlParameter, WebsocketRequest}; use yaak_models::models::{Environment, HttpRequestHeader, HttpUrlParameter, WebsocketRequest};
use yaak_models::render::make_vars_hashmap; 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<T: TemplateCallback>( pub async fn render_websocket_request<T: TemplateCallback>(
r: &WebsocketRequest, r: &WebsocketRequest,
@@ -32,10 +34,37 @@ pub async fn render_websocket_request<T: TemplateCallback>(
}) })
} }
let mut authentication = BTreeMap::new(); let authentication = {
for (k, v) in r.authentication.clone() { let mut disabled = false;
authentication.insert(k, render_json_value_raw(v, vars, cb, opt).await?); 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?; let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;

View File

@@ -1,24 +1,19 @@
import type { import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from '@yaakapp-internal/models';
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models'; import { patchModel } from '@yaakapp-internal/models';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { openFolderSettings } from '../commands/openFolderSettings'; import { openFolderSettings } from '../commands/openFolderSettings';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig'; import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig';
import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication'; import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication';
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from '../lib/resolvedModelName';
import { Checkbox } from './core/Checkbox'; import { Dropdown, type DropdownItem } from './core/Dropdown';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { Input, type InputProps } from './core/Input';
import { Link } from './core/Link'; import { Link } from './core/Link';
import { SegmentedControl } from './core/SegmentedControl';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { DynamicForm } from './DynamicForm'; import { DynamicForm } from './DynamicForm';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
@@ -36,7 +31,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, boolean>) => await patchModel(model, { authentication }), async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
[model], [model],
); );
@@ -98,30 +93,65 @@ export function HttpAuthenticationEditor({ model }: Props) {
} }
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
<HStack space={2} className="mb-2" alignItems="center"> <div>
<Checkbox <HStack space={2} alignItems="start">
className="w-full" <SegmentedControl
checked={!model.authentication.disabled} label="Enabled"
onChange={(disabled) => handleChange({ ...model.authentication, disabled: !disabled })} hideLabel
title="Enabled" name="enabled"
/> value={
{authConfig.data?.actions && authConfig.data.actions.length > 0 && ( model.authentication.disabled === false || model.authentication.disabled == null
<Dropdown ? '__TRUE__'
items={authConfig.data.actions.map( : model.authentication.disabled === true
(a): DropdownItem => ({ ? '__FALSE__'
label: a.label, : '__DYNAMIC__'
leftSlot: a.icon ? <Icon icon={a.icon} /> : null, }
onSelect: () => a.call(model), options={[
}), { label: 'Enabled', value: '__TRUE__' },
)} { label: 'Disabled', value: '__FALSE__' },
> { label: 'Enabled when...', value: '__DYNAMIC__' },
<IconButton title="Authentication Actions" icon="settings" size="xs" /> ]}
</Dropdown> onChange={async (enabled) => {
let disabled: boolean | string;
if (enabled === '__TRUE__') {
disabled = false;
} else if (enabled === '__FALSE__') {
disabled = true;
} else {
disabled = '';
}
console.log('SETTING DISABLED', disabled);
await handleChange({ ...model.authentication, disabled });
}}
/>
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
<Dropdown
items={authConfig.data.actions.map(
(a): DropdownItem => ({
label: a.label,
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
onSelect: () => a.call(model),
}),
)}
>
<IconButton title="Authentication Actions" icon="settings" size="xs" />
</Dropdown>
)}
</HStack>
{typeof model.authentication.disabled === 'string' && (
<div className="mt-3">
<AuthenticationDisabledInput
className="w-full"
stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled}
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
/>
</div>
)} )}
</HStack> </div>
<DynamicForm <DynamicForm
disabled={model.authentication.disabled} disabled={model.authentication.disabled === true}
autocompleteVariables autocompleteVariables
autocompleteFunctions autocompleteFunctions
stateKey={`auth.${model.id}.${model.authenticationType}`} stateKey={`auth.${model.id}.${model.authenticationType}`}
@@ -132,3 +162,45 @@ export function HttpAuthenticationEditor({ model }: Props) {
</div> </div>
); );
} }
function AuthenticationDisabledInput({
value,
onChange,
stateKey,
className,
}: {
value: string;
onChange: InputProps['onChange'];
stateKey: string;
className?: string;
}) {
const rendered = useRenderTemplate({
template: value,
enabled: true,
purpose: 'preview',
ignoreError: true,
refreshKey: value,
});
return (
<Input
size="sm"
className={className}
label="Dynamic Disabled"
hideLabel
defaultValue={value}
placeholder="Enabled when this renders a non-empty value"
rightSlot={
<div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending ? 'loading' : rendered.data ? 'enabled' : 'disabled'}
</div>
</div>
}
autocompleteFunctions
autocompleteVariables
onChange={onChange}
stateKey={stateKey}
/>
);
}

View File

@@ -67,8 +67,10 @@ export function MarkdownEditor({
<div className="absolute top-0 right-0 pt-1.5 pr-1.5"> <div className="absolute top-0 right-0 pt-1.5 pr-1.5">
<SegmentedControl <SegmentedControl
name={name} name={name}
label="View mode"
onChange={setViewMode} onChange={setViewMode}
value={viewMode} value={viewMode}
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
options={[ options={[
{ icon: 'eye', label: 'Preview mode', value: 'preview' }, { icon: 'eye', label: 'Preview mode', value: 'preview' },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' }, { icon: 'pencil', label: 'Edit mode', value: 'edit' },

View File

@@ -132,12 +132,13 @@ function InitializedTemplateFunctionDialog({
const debouncedTagText = useDebouncedValue(tagText.data ?? '', 400); const debouncedTagText = useDebouncedValue(tagText.data ?? '', 400);
const [renderKey, setRenderKey] = useState<string | null>(null); const [renderKey, setRenderKey] = useState<string | null>(null);
const rendered = useRenderTemplate( const rendered = useRenderTemplate({
debouncedTagText, template: debouncedTagText,
previewType !== 'none', enabled: previewType !== 'none',
previewType === 'click' ? 'send' : 'preview', purpose: previewType === 'click' ? 'send' : 'preview',
previewType === 'live' ? renderKey + debouncedTagText : renderKey, refreshKey: previewType === 'live' ? renderKey + debouncedTagText : renderKey,
); ignoreError: false,
});
const tooLarge = rendered.data ? rendered.data.length > 10000 : 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 // biome-ignore lint/correctness/useExhaustiveDependencies: Only update this on rendered data change to keep secrets hidden on input change

View File

@@ -1,75 +1,122 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useRef } from 'react'; import { type ReactNode, useRef } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps'; import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { generateId } from '../../lib/generateId';
import { Button } from './Button';
import type { IconProps } from './Icon'; import type { IconProps } from './Icon';
import { IconButton } from './IconButton'; import { IconButton, type IconButtonProps } from './IconButton';
import { Label } from './Label';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
interface Props<T extends string> { interface Props<T extends string> {
options: { value: T; label: string; icon: IconProps['icon'] }[]; options: { value: T; label: string; icon?: IconProps['icon'] }[];
onChange: (value: T) => void; onChange: (value: T) => void;
value: T; value: T;
name: string; name: string;
size?: IconButtonProps['size'];
label: string;
className?: string; className?: string;
hideLabel?: boolean;
labelClassName?: string;
help?: ReactNode;
} }
export function SegmentedControl<T extends string>({ export function SegmentedControl<T extends string>({
value, value,
onChange, onChange,
options, options,
size = 'xs',
label,
hideLabel,
labelClassName,
help,
className, className,
}: Props<T>) { }: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]); const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
return ( return (
<HStack <div className="w-full grid">
ref={containerRef} <Label
role="group" htmlFor={id.current}
dir="ltr" help={help}
space={0.5} visuallyHidden={hideLabel}
className={classNames( className={classNames(labelClassName)}
className, >
'bg-surface-highlight rounded-md mb-auto opacity-0', {label}
'transition-opacity transform-gpu', </Label>
'group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100', <HStack
)} id={id.current}
onKeyDown={(e) => { ref={containerRef}
const selectedIndex = options.findIndex((o) => o.value === selectedValue); role="group"
if (e.key === 'ArrowRight') { dir="ltr"
const newIndex = Math.abs((selectedIndex + 1) % options.length); space={1}
options[newIndex] && setSelectedValue(options[newIndex].value); className={classNames(
const child = containerRef.current?.children[newIndex] as HTMLButtonElement; className,
child.focus(); 'bg-surface-highlight rounded-lg mb-auto mr-auto',
} else if (e.key === 'ArrowLeft') { 'transition-opacity transform-gpu p-1',
const newIndex = Math.abs((selectedIndex - 1) % options.length); )}
options[newIndex] && setSelectedValue(options[newIndex].value); onKeyDown={(e) => {
const child = containerRef.current?.children[newIndex] as HTMLButtonElement; const selectedIndex = options.findIndex((o) => o.value === selectedValue);
child.focus(); if (e.key === 'ArrowRight') {
} e.preventDefault();
}} const newIndex = Math.abs((selectedIndex + 1) % options.length);
> options[newIndex] && setSelectedValue(options[newIndex].value);
{options.map((o) => { const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
const isSelected = selectedValue === o.value; child.focus();
const isActive = value === o.value; } else if (e.key === 'ArrowLeft') {
return ( e.preventDefault();
<IconButton const newIndex = Math.abs((selectedIndex - 1) % options.length);
size="xs" options[newIndex] && setSelectedValue(options[newIndex].value);
variant="solid" const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
color={isActive ? 'secondary' : undefined} child.focus();
role="radio" }
tabIndex={isSelected ? 0 : -1} }}
className={classNames( >
isActive && '!text-text', {options.map((o) => {
'!px-1.5 !w-auto', const isSelected = selectedValue === o.value;
'focus:ring-border-focus', const isActive = value === o.value;
)} if (o.icon == null) {
key={o.label} return (
title={o.label} <Button
icon={o.icon} key={o.label}
onClick={() => onChange(o.value)} size={size}
/> variant="solid"
); color={isActive ? 'secondary' : undefined}
})} role="radio"
</HStack> tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && '!text-text',
'focus:ring-1 focus:ring-border-focus',
)}
onClick={() => onChange(o.value)}
>
{o.label}
</Button>
);
} else {
return (
<IconButton
key={o.label}
size={size}
variant="solid"
color={isActive ? 'secondary' : undefined}
role="radio"
tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && '!text-text',
'!px-1.5 !w-auto',
'focus:ring-border-focus',
)}
title={o.label}
icon={o.icon}
onClick={() => onChange(o.value)}
/>
);
}
})}
</HStack>
</div>
); );
} }

View File

@@ -90,6 +90,7 @@ export function Select<T extends string>({
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
className={classNames( className={classNames(
'pr-7 w-full outline-none bg-transparent disabled:opacity-disabled', 'pr-7 w-full outline-none bg-transparent disabled:opacity-disabled',
'leading-[1]', // Center the text better vertically
)} )}
disabled={disabled} disabled={disabled}
> >

View File

@@ -6,20 +6,33 @@ import { invokeCmd } from '../lib/tauri';
import { useActiveEnvironment } from './useActiveEnvironment'; import { useActiveEnvironment } from './useActiveEnvironment';
import { activeWorkspaceIdAtom } from './useActiveWorkspace'; import { activeWorkspaceIdAtom } from './useActiveWorkspace';
export function useRenderTemplate( export function useRenderTemplate({
template: string, template,
enabled: boolean, enabled,
purpose: RenderPurpose, purpose,
refreshKey: string | null, refreshKey,
) { ignoreError,
preservePreviousValue,
}: {
template: string;
enabled: boolean;
purpose: RenderPurpose;
refreshKey?: string | null;
ignoreError?: boolean;
preservePreviousValue?: boolean;
}) {
const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? 'n/a'; const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? 'n/a';
const environmentId = useActiveEnvironment()?.id ?? null; const environmentId = useActiveEnvironment()?.id ?? null;
return useQuery<string>({ return useQuery<string>({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
enabled, enabled,
queryKey: ['render_template', workspaceId, environmentId, refreshKey, purpose], placeholderData: preservePreviousValue ? (prev) => prev : undefined,
queryKey: ['render_template', workspaceId, environmentId, refreshKey, purpose, ignoreError],
queryFn: () => 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, workspaceId,
environmentId, environmentId,
purpose, purpose,
ignoreError,
}: { }: {
template: string; template: string;
workspaceId: string; workspaceId: string;
environmentId: string | null; environmentId: string | null;
purpose: RenderPurpose; purpose: RenderPurpose;
ignoreError?: boolean;
}): Promise<string> { }): Promise<string> {
return invokeCmd('cmd_render_template', { template, workspaceId, environmentId, purpose }); return invokeCmd('cmd_render_template', {
template,
workspaceId,
environmentId,
purpose,
ignoreError,
});
} }
export async function decryptTemplate({ export async function decryptTemplate({