Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions e2e/tests/variables.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ test.describe('Variables', () => {
await expect(row.locator('text=***')).toBeVisible();
});

test('sensitive variable value is masked on entry, revealable', async ({ page }) => {
await page.goto(`/workspaces/${workspaceId}?tab=variables`);

await page.click('button:has-text("Add Variable")');
const valField = page.locator('#var-val');
await valField.fill('shoulder-surf-me');

// Not sensitive yet -> value shown normally (no masking class).
await expect(valField).not.toHaveClass(/text-masked/);

// Mark sensitive -> the entry field masks (cross-browser disc font).
await page.locator('label:has-text("Sensitive") input[type="checkbox"]').check();
await expect(valField).toHaveClass(/text-masked/);

// Show toggle reveals it; hiding masks again.
await page.click('button[aria-label="Show value"]');
await expect(valField).not.toHaveClass(/text-masked/);
await page.click('button[aria-label="Hide value"]');
await expect(valField).toHaveClass(/text-masked/);
});

test('delete variable removes it from list', async ({ page }) => {
const varKey = `DELETE_e2e_${Date.now()}`;

Expand Down
Binary file added web/public/fonts/text-security-disc.woff2
Binary file not shown.
6 changes: 4 additions & 2 deletions web/src/app/admin/variable-sets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PageHeader } from '@/components/page-header'
import { LoadingSpinner } from '@/components/loading-spinner'
import { ErrorBanner } from '@/components/error-banner'
import { EmptyState } from '@/components/empty-state'
import { SensitiveValueInput } from '@/components/sensitive-value-input'
import { getAuthState, isAdmin } from '@/lib/auth'
import { apiFetch } from '@/lib/api'
import { usePollingInterval } from '@/lib/use-polling-interval'
Expand Down Expand Up @@ -514,7 +515,7 @@ export default function VariableSetDetailPage() {
</div>
<div>
<label htmlFor="var-val" className="block text-sm font-medium text-slate-300 mb-1">Value</label>
<textarea id="var-val" value={varValue} onChange={(e) => setVarValue(e.target.value)} placeholder="us-east-1"
<SensitiveValueInput id="var-val" value={varValue} onChange={setVarValue} sensitive={varSensitive} placeholder="us-east-1"
rows={2} className="w-full px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent resize-y" />
</div>
<div>
Expand Down Expand Up @@ -569,7 +570,8 @@ export default function VariableSetDetailPage() {
className="w-full px-2 py-1 text-sm border border-slate-600 rounded bg-slate-700 text-slate-100 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500" />
</td>
<td className="px-4 py-3">
<textarea value={editVarValue} onChange={(e) => setEditVarValue(e.target.value)}
<SensitiveValueInput value={editVarValue} onChange={setEditVarValue}
sensitive={editVarSensitive}
placeholder={editVarSensitive ? 'Enter new value' : ''}
rows={2}
className="w-full px-2 py-1 text-sm border border-slate-600 rounded bg-slate-700 text-slate-100 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500 resize-y" />
Expand Down
18 changes: 18 additions & 0 deletions web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,21 @@ body {
opacity: 1;
}
}

/* Cross-browser secret masking. `-webkit-text-security` only works in
Chromium/WebKit (not Firefox), so we mask by swapping in a font whose
every glyph is a disc — works in all browsers and keeps the real value
editable (multi-line, paste-safe) in the underlying input/textarea.
Apply `.text-masked` to mask; remove it to reveal. Font: text-security
(noppa/text-security), vendored at /public/fonts. */
@font-face {
font-family: "text-security-disc";
src: url("/fonts/text-security-disc.woff2") format("woff2");
font-display: block;
}

.text-masked {
font-family: "text-security-disc" !important;
/* Keep dots evenly spaced regardless of the field's normal font. */
letter-spacing: 0.05em;
}
6 changes: 4 additions & 2 deletions web/src/app/workspaces/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { LabelsEditor } from '@/components/labels-editor'
import { HealthConditions } from '@/components/health-conditions'
import { PlanSummaryBadges } from '@/components/plan-summary-badges'
import { WorkspacePicker } from '@/components/workspace-picker'
import { SensitiveValueInput } from '@/components/sensitive-value-input'
import { getAuthState, isAdmin } from '@/lib/auth'
import { apiFetch } from '@/lib/api'
import { useSortable } from '@/lib/use-sortable'
Expand Down Expand Up @@ -2311,7 +2312,7 @@ function WorkspaceDetailContent() {
</div>
<div>
<label htmlFor="var-val" className="block text-sm font-medium text-slate-300 mb-1">Value</label>
<textarea id="var-val" value={varValue} onChange={(e) => setVarValue(e.target.value)} placeholder="us-east-1" rows={2} className="w-full px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent resize-y" />
<SensitiveValueInput id="var-val" value={varValue} onChange={setVarValue} sensitive={varSensitive} placeholder="us-east-1" rows={2} className="w-full px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent resize-y" />
</div>
<div>
<label htmlFor="var-cat" className="block text-sm font-medium text-slate-300 mb-1">Category</label>
Expand Down Expand Up @@ -2361,7 +2362,8 @@ function WorkspaceDetailContent() {
className="w-full px-2 py-1 text-sm border border-slate-600 rounded bg-slate-700 text-slate-100 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500" />
</td>
<td className="px-4 py-3">
<textarea value={editVarValue} onChange={(e) => setEditVarValue(e.target.value)}
<SensitiveValueInput value={editVarValue} onChange={setEditVarValue}
sensitive={editVarSensitive}
placeholder={editVarSensitive ? 'Enter new value' : ''}
rows={2}
className="w-full px-2 py-1 text-sm border border-slate-600 rounded bg-slate-700 text-slate-100 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500 resize-y" />
Expand Down
75 changes: 75 additions & 0 deletions web/src/components/sensitive-value-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client'

import { useState } from 'react'

interface SensitiveValueInputProps {
id?: string
value: string
onChange: (value: string) => void
sensitive: boolean
placeholder?: string
rows?: number
className: string
}

/**
* Variable-value entry field. When the variable is marked sensitive the typed
* text is masked by default (with a Show/Hide toggle), so a secret isn't
* visible on screen — or shoulder-surfable — while it's being entered. It stays
* a multi-line textarea so certs / keys paste intact; masking swaps in the
* `text-security-disc` font (the `.text-masked` class in globals.css) so it
* works in every browser, Firefox included — `-webkit-text-security` is
* Chromium/WebKit-only. Non-sensitive values render exactly as before.
*/
export function SensitiveValueInput({
id,
value,
onChange,
sensitive,
placeholder,
rows = 2,
className,
}: SensitiveValueInputProps) {
const [reveal, setReveal] = useState(false)

if (!sensitive) {
return (
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className={className}
/>
)
}

return (
<div className="relative">
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
// Mask the characters when hidden via the cross-browser disc font;
// revealing (or an empty field) drops the class so the value shows
// normally and the placeholder stays readable (the mask is a font,
// so it would otherwise dot-out the placeholder too).
className={reveal || value.length === 0 ? className : `${className} text-masked`}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setReveal((r) => !r)}
tabIndex={-1}
aria-label={reveal ? 'Hide value' : 'Show value'}
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-slate-200"
>
{reveal ? 'Hide' : 'Show'}
</button>
</div>
)
}
Loading