feat: allow masking workspace parameter inputs (#18595)

This commit is contained in:
ケイラ
2025-07-01 16:27:43 -06:00
committed by GitHub
parent d22ac1cf65
commit 0b82f41a24
11 changed files with 139 additions and 48 deletions
+3
View File
@@ -14176,6 +14176,9 @@ const docTemplate = `{
"label": {
"type": "string"
},
"mask_input": {
"type": "boolean"
},
"placeholder": {
"type": "string"
}
+3
View File
@@ -12834,6 +12834,9 @@
"label": {
"type": "string"
},
"mask_input": {
"type": "boolean"
},
"placeholder": {
"type": "string"
}
+1
View File
@@ -816,6 +816,7 @@ func PreviewParameter(param previewtypes.Parameter) codersdk.PreviewParameter {
Placeholder: param.Styling.Placeholder,
Disabled: param.Styling.Disabled,
Label: param.Styling.Label,
MaskInput: param.Styling.MaskInput,
},
Mutable: param.Mutable,
DefaultValue: PreviewHCLString(param.DefaultValue),
+1
View File
@@ -91,6 +91,7 @@ type PreviewParameterStyling struct {
Placeholder *string `json:"placeholder,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Label *string `json:"label,omitempty"`
MaskInput *bool `json:"mask_input,omitempty"`
}
type PreviewParameterOption struct {
+4
View File
@@ -2890,6 +2890,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"styling": {
"disabled": true,
"label": "string",
"mask_input": true,
"placeholder": "string"
},
"type": "string",
@@ -5059,6 +5060,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
"styling": {
"disabled": true,
"label": "string",
"mask_input": true,
"placeholder": "string"
},
"type": "string",
@@ -5128,6 +5130,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
{
"disabled": true,
"label": "string",
"mask_input": true,
"placeholder": "string"
}
```
@@ -5138,6 +5141,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|---------------|---------|----------|--------------|-------------|
| `disabled` | boolean | false | | |
| `label` | string | false | | |
| `mask_input` | boolean | false | | |
| `placeholder` | string | false | | |
## codersdk.PreviewParameterValidation
+1
View File
@@ -2697,6 +2697,7 @@ curl -X POST http://coder-server:8080/api/v2/templateversions/{templateversion}/
"styling": {
"disabled": true,
"label": "string",
"mask_input": true,
"placeholder": "string"
},
"type": "string",
+1 -1
View File
@@ -483,7 +483,7 @@ require (
require (
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aisdk-go v0.0.9
github.com/coder/preview v1.0.1
github.com/coder/preview v1.0.2
github.com/fsnotify/fsnotify v1.9.0
github.com/mark3labs/mcp-go v0.32.0
)
+2 -2
View File
@@ -916,8 +916,8 @@ github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102 h1:ahTJlTRmTogsubgRVGO
github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
github.com/coder/preview v1.0.1 h1:f6q+RjNelwnkyXfGbmVlb4dcUOQ0z4mPsb2kuQpFHuU=
github.com/coder/preview v1.0.1/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM=
github.com/coder/preview v1.0.2 h1:ZFfox0PgXcIouB9iWGcZyOtdL0h2a4ju1iPw/dMqsg4=
github.com/coder/preview v1.0.2/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM=
github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE=
github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA=
github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=
+1
View File
@@ -1836,6 +1836,7 @@ export interface PreviewParameterStyling {
readonly placeholder?: string;
readonly disabled?: boolean;
readonly label?: string;
readonly mask_input?: boolean;
}
// From codersdk/parameters.go
@@ -8,18 +8,16 @@ const meta: Meta<typeof DynamicParameter> = {
parameters: {
layout: "centered",
},
args: {
parameter: MockPreviewParameter,
onChange: () => {},
},
};
export default meta;
type Story = StoryObj<typeof DynamicParameter>;
export const TextInput: Story = {
args: {
parameter: {
...MockPreviewParameter,
},
},
};
export const TextInput: Story = {};
export const TextArea: Story = {
args: {
@@ -230,3 +228,30 @@ export const AllBadges: Story = {
isPreset: true,
},
};
export const MaskedInput: Story = {
args: {
parameter: {
...MockPreviewParameter,
form_type: "input",
styling: {
...MockPreviewParameter.styling,
placeholder: "Tell me a secret",
mask_input: true,
},
},
},
};
export const MaskedTextArea: Story = {
args: {
parameter: {
...MockPreviewParameter,
form_type: "textarea",
styling: {
...MockPreviewParameter.styling,
mask_input: true,
},
},
},
};
@@ -5,6 +5,7 @@ import type {
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Checkbox } from "components/Checkbox/Checkbox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Input } from "components/Input/Input";
@@ -23,6 +24,7 @@ import {
SelectValue,
} from "components/Select/Select";
import { Slider } from "components/Slider/Slider";
import { Stack } from "components/Stack/Stack";
import { Switch } from "components/Switch/Switch";
import { TagInput } from "components/TagInput/TagInput";
import { Textarea } from "components/Textarea/Textarea";
@@ -36,6 +38,8 @@ import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills";
import {
CircleAlert,
Eye,
EyeOff,
Hourglass,
Info,
LinkIcon,
@@ -43,6 +47,7 @@ import {
TriangleAlert,
} from "lucide-react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import { cn } from "utils/cn";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
@@ -265,6 +270,7 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
const [localValue, setLocalValue] = useState(
value !== undefined ? value : validValue(parameter.value),
);
const [showMaskedInput, setShowMaskedInput] = useState(false);
const debouncedLocalValue = useDebouncedValue(localValue, 500);
const onChangeEvent = useEffectEvent(onChange);
// prevDebouncedValueRef is to prevent calling the onChangeEvent on the initial render
@@ -309,27 +315,56 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
switch (parameter.form_type) {
case "textarea": {
return (
<Textarea
ref={textareaRef}
id={id}
className="overflow-y-auto max-h-[500px]"
value={localValue}
onChange={(e) => {
const target = e.currentTarget;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
<Stack direction="row" spacing={0} alignItems="center">
<Textarea
ref={textareaRef}
id={id}
className={cn(
"overflow-y-auto max-h-[500px]",
parameter.styling?.mask_input &&
!showMaskedInput &&
"[-webkit-text-security:disc]",
)}
value={localValue}
onChange={(e) => {
const target = e.currentTarget;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
setLocalValue(e.target.value);
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
setLocalValue(e.target.value);
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
{parameter.styling?.mask_input && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
}
case "input": {
const inputType = parameter.type === "number" ? "number" : "text";
const inputType =
parameter.type === "number"
? "number"
: parameter.styling?.mask_input && !showMaskedInput
? "password"
: "text";
const inputProps: Record<string, unknown> = {};
if (parameter.type === "number") {
@@ -346,18 +381,37 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
}
return (
<Input
id={id}
type={inputType}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
<Stack direction="row" spacing={0} alignItems="center">
<Input
id={id}
type={inputType}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
{parameter.styling?.mask_input && parameter.type !== "number" && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
}
}
@@ -451,9 +505,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
return (
<MultiSelectCombobox
inputProps={{
id: id,
}}
inputProps={{ id }}
options={options}
defaultOptions={selectedOptions}
onChange={(newValues) => {
@@ -698,7 +750,7 @@ const isValidParameterOption = (
if (Array.isArray(parsed)) {
values = parsed;
}
} catch (e) {
} catch {
return false;
}
@@ -899,12 +951,12 @@ export const Diagnostics: FC<DiagnosticsProps> = ({ diagnostics }) => {
{diagnostics.map((diagnostic, index) => (
<div
key={`diagnostic-${diagnostic.summary}-${index}`}
className={`text-xs font-semibold flex flex-col rounded-md border px-3.5 py-3.5 border-solid
${
diagnostic.severity === "error"
? "text-content-primary border-border-destructive bg-content-destructive/15"
: "text-content-primary border-border-warning bg-content-warning/15"
}`}
className={cn(
"text-xs font-semibold flex flex-col rounded-md border px-3.5 py-3.5 border-solid",
diagnostic.severity === "error"
? "text-content-primary border-border-destructive bg-content-destructive/15"
: "text-content-primary border-border-warning bg-content-warning/15",
)}
>
<div className="flex flex-row items-start">
{diagnostic.severity === "error" && (