Add ability to conditionally disable auth (#309)

This commit is contained in:
Gregory Schier
2025-11-26 11:05:07 -08:00
committed by GitHub
parent dfa6f1c5b4
commit 8a80e7b833
11 changed files with 346 additions and 110 deletions

View File

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

View File

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

View File

@@ -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<T: TemplateCallback>(
template: &str,
@@ -45,10 +46,37 @@ pub async fn render_grpc_request<T: TemplateCallback>(
})
}
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<T: TemplateCallback>(
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?;

View File

@@ -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)

View File

@@ -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<T: TemplateCallback>(
r: &WebsocketRequest,
@@ -32,10 +34,37 @@ pub async fn render_websocket_request<T: TemplateCallback>(
})
}
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?;

View File

@@ -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<string, boolean>) => await patchModel(model, { authentication }),
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
[model],
);
@@ -98,30 +99,64 @@ export function HttpAuthenticationEditor({ model }: Props) {
}
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<HStack space={2} className="mb-2" alignItems="center">
<Checkbox
className="w-full"
checked={!model.authentication.disabled}
onChange={(disabled) => handleChange({ ...model.authentication, disabled: !disabled })}
title="Enabled"
/>
{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>
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
<div>
<HStack space={2} alignItems="start">
<SegmentedControl
label="Enabled"
hideLabel
name="enabled"
value={
model.authentication.disabled === false || model.authentication.disabled == null
? '__TRUE__'
: model.authentication.disabled === true
? '__FALSE__'
: '__DYNAMIC__'
}
options={[
{ label: 'Enabled', value: '__TRUE__' },
{ label: 'Disabled', value: '__FALSE__' },
{ label: 'Enabled when...', value: '__DYNAMIC__' },
]}
onChange={async (enabled) => {
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 && (
<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
disabled={model.authentication.disabled}
disabled={model.authentication.disabled === true}
autocompleteVariables
autocompleteFunctions
stateKey={`auth.${model.id}.${model.authenticationType}`}
@@ -132,3 +167,44 @@ export function HttpAuthenticationEditor({ model }: Props) {
</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',
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">
<SegmentedControl
name={name}
label="View mode"
onChange={setViewMode}
value={viewMode}
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
options={[
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },

View File

@@ -132,12 +132,13 @@ function InitializedTemplateFunctionDialog({
const debouncedTagText = useDebouncedValue(tagText.data ?? '', 400);
const [renderKey, setRenderKey] = useState<string | null>(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

View File

@@ -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<T extends string> {
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<T extends string>({
value,
onChange,
options,
size = 'xs',
label,
hideLabel,
labelClassName,
help,
className,
}: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
return (
<HStack
ref={containerRef}
role="group"
dir="ltr"
space={0.5}
className={classNames(
className,
'bg-surface-highlight rounded-md mb-auto opacity-0',
'transition-opacity transform-gpu',
'group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100',
)}
onKeyDown={(e) => {
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 (
<IconButton
size="xs"
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',
)}
key={o.label}
title={o.label}
icon={o.icon}
onClick={() => onChange(o.value)}
/>
);
})}
</HStack>
<div className="w-full grid">
<Label
htmlFor={id.current}
help={help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
{label}
</Label>
<HStack
id={id.current}
ref={containerRef}
role="group"
dir="ltr"
space={1}
className={classNames(
className,
'bg-surface-highlight rounded-lg mb-auto mr-auto',
'transition-opacity transform-gpu p-1',
)}
onKeyDown={(e) => {
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 (
<Button
key={o.label}
aria-checked={isActive}
size={size}
variant="solid"
color={isActive ? 'secondary' : undefined}
role="radio"
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}
aria-checked={isActive}
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)}
className={classNames(
'pr-7 w-full outline-none bg-transparent disabled:opacity-disabled',
'leading-[1]', // Center the text better vertically
)}
disabled={disabled}
>

View File

@@ -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<string>({
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<string> {
return invokeCmd('cmd_render_template', { template, workspaceId, environmentId, purpose });
return invokeCmd('cmd_render_template', {
template,
workspaceId,
environmentId,
purpose,
ignoreError,
});
}
export async function decryptTemplate({