From 4373f358ac4cab5a0239e40a954468ca128b6157 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 25 Jun 2025 23:19:41 +0000 Subject: [PATCH 01/15] feat: allow masking workspace parameter inputs --- coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/database/db2sdk/db2sdk.go | 1 + codersdk/parameters.go | 1 + .../extending-templates/mask-input-example.md | 195 ++++++++++++++++++ docs/reference/api/schemas.md | 4 + docs/reference/api/templates.md | 1 + go.mod | 2 + go.sum | 4 +- site/src/api/typesGenerated.ts | 1 + .../DynamicParameter.stories.tsx | 39 ++++ .../DynamicParameter/DynamicParameter.tsx | 114 +++++++--- 12 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 docs/admin/templates/extending-templates/mask-input-example.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f08b7dfd7fc4d..96943fd9bcdd6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14057,6 +14057,9 @@ const docTemplate = `{ "label": { "type": "string" }, + "mask_input": { + "type": "boolean" + }, "placeholder": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8317f9ed90503..22281914281ea 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12723,6 +12723,9 @@ "label": { "type": "string" }, + "mask_input": { + "type": "boolean" + }, "placeholder": { "type": "string" } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index c74e63bb86f59..5e9be4d61a57c 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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), diff --git a/codersdk/parameters.go b/codersdk/parameters.go index bdf48f2c6e8fa..1e15d0496c1fa 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -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 { diff --git a/docs/admin/templates/extending-templates/mask-input-example.md b/docs/admin/templates/extending-templates/mask-input-example.md new file mode 100644 index 0000000000000..2c6d5d8ea4260 --- /dev/null +++ b/docs/admin/templates/extending-templates/mask-input-example.md @@ -0,0 +1,195 @@ +# Mask Input Feature + +The `mask_input` styling option allows you to hide sensitive parameter values by converting all characters to asterisks (*). This feature is designed for template parameters that contain sensitive information like passwords, API keys, or other secrets. + +> **Note**: This feature is purely cosmetic and does not provide any security. The actual parameter values are still transmitted and stored normally. This is only meant to hide the characters visually in the UI. + +## Usage + +The `mask_input` option can be applied to parameters with `form_type` of `input` or `textarea`. Add it to the `styling` block of your parameter definition: + +```hcl +variable "api_key" { + description = "API key for external service" + type = string + sensitive = true + + validation { + condition = length(var.api_key) > 0 + error_message = "API key cannot be empty." + } +} + +resource "coder_parameter" "api_key" { + name = "api_key" + display_name = "API Key" + description = "Enter your API key for the external service" + type = "string" + form_type = "input" + mutable = true + + styling = { + mask_input = true + placeholder = "Enter your API key" + } +} +``` + +## Examples + +### Masked Input Field + +```hcl +resource "coder_parameter" "database_password" { + name = "database_password" + display_name = "Database Password" + description = "Password for database connection" + type = "string" + form_type = "input" + mutable = true + + styling = { + mask_input = true + } +} +``` + +### Masked Textarea Field + +```hcl +resource "coder_parameter" "private_key" { + name = "private_key" + display_name = "Private Key" + description = "Private key for SSH access" + type = "string" + form_type = "textarea" + mutable = true + + styling = { + mask_input = true + placeholder = "Paste your private key here" + } +} +``` + +### Complete Example with Multiple Sensitive Parameters + +```hcl +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +variable "username" { + description = "Username for the service" + type = string +} + +variable "password" { + description = "Password for the service" + type = string + sensitive = true +} + +variable "ssl_certificate" { + description = "SSL certificate content" + type = string + sensitive = true +} + +resource "coder_parameter" "username" { + name = "username" + display_name = "Username" + description = "Enter your username" + type = "string" + form_type = "input" + mutable = true + default_value = var.username +} + +resource "coder_parameter" "password" { + name = "password" + display_name = "Password" + description = "Enter your password" + type = "string" + form_type = "input" + mutable = true + default_value = var.password + + styling = { + mask_input = true + placeholder = "Enter your password" + } +} + +resource "coder_parameter" "ssl_certificate" { + name = "ssl_certificate" + display_name = "SSL Certificate" + description = "Paste your SSL certificate" + type = "string" + form_type = "textarea" + mutable = true + default_value = var.ssl_certificate + + styling = { + mask_input = true + placeholder = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" + } +} +``` + +## User Interface Behavior + +When `mask_input` is enabled: + +1. **Masked Display**: All characters in the input field are displayed as asterisks (*) +2. **Show/Hide Toggle**: A eye icon button appears in the top-right corner of the field + - Click the eye icon to reveal the actual text + - Click again to hide it back to asterisks +3. **Normal Functionality**: The field works normally for typing, copying, and pasting +4. **Form Submission**: The actual unmasked value is submitted with the form + +## Limitations and Considerations + +- **No Security**: This feature provides no actual security - it's purely visual +- **Number Fields**: Masking is automatically disabled for `number` type parameters +- **Accessibility**: Screen readers will still read the actual values, not the masked version +- **Development**: Use in conjunction with Terraform's `sensitive = true` for variables that contain secrets + +## Best Practices + +1. **Combine with Sensitive Variables**: Always mark sensitive parameters with `sensitive = true` in your Terraform variables +2. **Use Descriptive Placeholders**: Provide helpful placeholder text to guide users +3. **Validate Input**: Add appropriate validation rules for sensitive parameters +4. **Documentation**: Clearly document what sensitive information is being collected + +```hcl +variable "api_token" { + description = "API token for external service (keep this secret!)" + type = string + sensitive = true # This prevents the value from appearing in Terraform logs + + validation { + condition = can(regex("^[A-Za-z0-9]{32,}$", var.api_token)) + error_message = "API token must be at least 32 alphanumeric characters." + } +} + +resource "coder_parameter" "api_token" { + name = "api_token" + display_name = "API Token" + description = "Enter your API token (this will be hidden for security)" + type = "string" + form_type = "input" + mutable = true + default_value = var.api_token + + styling = { + mask_input = true + placeholder = "Enter your 32+ character API token" + } +} +``` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e19c9d15da413..47acfe105439c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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", @@ -5020,6 +5021,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", @@ -5089,6 +5091,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" } ``` @@ -5099,6 +5102,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 diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index dc4605532ff41..cf99922877ab3 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -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", diff --git a/go.mod b/go.mod index 5325b190b1380..c7d5b7f1d5d18 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,8 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // TODO: replace once we cut release. replace github.com/coder/terraform-provider-coder/v2 => github.com/coder/terraform-provider-coder/v2 v2.7.1-0.20250623193313-e890833351e2 +replace github.com/coder/preview => github.com/coder/preview v1.0.2-0.20250625231609-10623f47565b + require ( cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb cloud.google.com/go/compute/metadata v0.7.0 diff --git a/go.sum b/go.sum index 14b86b4d38b4a..54abd58445bbd 100644 --- a/go.sum +++ b/go.sum @@ -916,8 +916,8 @@ github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048 h1:3jzYUlGH7ZELIH4XggX github.com/coder/pq v1.10.5-0.20240813183442-0c420cb5a048/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-0.20250625231609-10623f47565b h1:nka6oEgL/+GR78IcdwgG6CNDEpgLD1JFGts8h7yLD5w= +github.com/coder/preview v1.0.2-0.20250625231609-10623f47565b/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= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b2117cf15c987..8971c93dddad6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1825,6 +1825,7 @@ export interface PreviewParameterStyling { readonly placeholder?: string; readonly disabled?: boolean; readonly label?: string; + readonly mask_input?: boolean; } // From codersdk/parameters.go diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx index db3fa2f404c53..707c065142268 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx @@ -230,3 +230,42 @@ export const AllBadges: Story = { isPreset: true, }, }; + +export const MaskedInput: Story = { + args: { + parameter: { + ...MockPreviewParameter, + form_type: "input", + styling: { + ...MockPreviewParameter.styling, + mask_input: true, + }, + }, + }, +}; + +export const MaskedTextArea: Story = { + args: { + parameter: { + ...MockPreviewParameter, + form_type: "textarea", + styling: { + ...MockPreviewParameter.styling, + mask_input: true, + }, + }, + }, +}; + +export const MaskedInputWithPlaceholder: Story = { + args: { + parameter: { + ...MockPreviewParameter, + form_type: "input", + styling: { + placeholder: "Enter your secret value", + mask_input: true, + }, + }, + }, +}; diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index fa72142d52837..3a31c1ca1a5a0 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -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"; @@ -36,6 +37,8 @@ import { useDebouncedValue } from "hooks/debounce"; import { useEffectEvent } from "hooks/hookPolyfills"; import { CircleAlert, + Eye, + EyeOff, Hourglass, Info, LinkIcon, @@ -265,6 +268,12 @@ const DebouncedParameterField: FC = ({ const [localValue, setLocalValue] = useState( value !== undefined ? value : validValue(parameter.value), ); + const [showMaskedInput, setShowMaskedInput] = useState(false); + + // Helper function to mask all characters with * + const maskValue = (val: string): string => { + return "*".repeat(val.length); + }; const debouncedLocalValue = useDebouncedValue(localValue, 500); const onChangeEvent = useEffectEvent(onChange); // prevDebouncedValueRef is to prevent calling the onChangeEvent on the initial render @@ -308,23 +317,44 @@ const DebouncedParameterField: FC = ({ switch (parameter.form_type) { case "textarea": { + const shouldMask = parameter.styling?.mask_input && !showMaskedInput; + const displayValue = shouldMask ? maskValue(localValue) : localValue; + return ( -