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
32 changes: 32 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Test

on:
push:
branches:
- main
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up pnpm
uses: pnpm/action-setup@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test
10 changes: 9 additions & 1 deletion apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
Expand All @@ -29,12 +31,18 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.6",
"jsdom": "^29.1.1",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"vite": "^8.0.4"
"vite": "^8.0.4",
"vitest": "^4.1.6"
}
}
10 changes: 7 additions & 3 deletions apps/console/src/components/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Form from "@rjsf/core"
import validator from "@rjsf/validator-ajv8"
import type { RJSFSchema, UiSchema, TemplatesType } from "@rjsf/utils"
import { keysOrderToUiSchema, sanitizeSchema } from "../lib/keys-order.ts"
import { addSensitiveStringWidgets } from "../lib/sensitive-fields.ts"
import { customTemplates, customWidgets } from "./rjsf-templates.tsx"
import { AdditionalPropertiesField } from "./AdditionalPropertiesField.tsx"
import { ResourceQuotasField } from "./ResourceQuotasField.tsx"
Expand Down Expand Up @@ -187,18 +188,21 @@ export function SchemaForm({
// Automatically add AdditionalPropertiesField for fields with additionalProperties schema
const withAdditionalProps = addAdditionalPropertiesWidgets(schema, withVMDisk)

// Mask credential-shaped string fields (access/secret keys, passwords, tokens).
const withSensitive = addSensitiveStringWidgets(schema, withAdditionalProps)

// Override resourceQuotas field with structured quota editor.
// Scoped to schemas where resourceQuotas has additionalProperties: {type: "string"}
// (the cozystack-tenants chart shape) to avoid activating on unrelated CRDs.
const rqSchema = (schema as any).properties?.resourceQuotas
if (rqSchema && rqSchema.additionalProperties?.type === "string") {
withAdditionalProps.resourceQuotas = {
...withAdditionalProps.resourceQuotas,
withSensitive.resourceQuotas = {
...withSensitive.resourceQuotas,
"ui:field": "ResourceQuotasField",
}
}

return withAdditionalProps
return withSensitive
}, [keysOrder, schema])

const customFields = useMemo(
Expand Down
174 changes: 174 additions & 0 deletions apps/console/src/components/SensitiveStringWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useState } from "react"
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type { WidgetProps } from "@rjsf/utils"
import { SensitiveStringWidget } from "./SensitiveStringWidget.tsx"

function makeProps(overrides: Partial<WidgetProps> = {}): WidgetProps {
const base = {
id: "sensitive",
name: "sensitive",
label: "sensitive",
value: undefined as unknown,
onChange: vi.fn(),
onBlur: vi.fn(),
onFocus: vi.fn(),
required: false,
disabled: false,
readonly: false,
autofocus: false,
placeholder: "",
options: {},
schema: { type: "string" },
uiSchema: {},
formContext: {},
rawErrors: [],
hideError: false,
multiple: false,
registry: {},
}
return { ...base, ...overrides } as unknown as WidgetProps
}

function renderWithLabel(props: WidgetProps) {
return render(
<>
<label htmlFor={props.id}>access key</label>
<SensitiveStringWidget {...props} />
</>,
)
}

const TOGGLE_NAME = /toggle credential visibility/i

describe("SensitiveStringWidget", () => {
it("renders a real <input> control associated with its label, regardless of state", () => {
renderWithLabel(makeProps({ value: "super-secret" }))

const input = screen.getByLabelText("access key") as HTMLInputElement
expect(input.tagName).toBe("INPUT")
expect(input.type).toBe("password")
expect(input.value).toBe("super-secret")
})

it("flips the input type from password to text when the toggle is pressed", async () => {
const user = userEvent.setup()
renderWithLabel(makeProps({ value: "super-secret" }))

const toggle = screen.getByRole("button", { name: TOGGLE_NAME })
expect(toggle).toHaveAttribute("aria-pressed", "false")

await user.click(toggle)

expect(toggle).toHaveAttribute("aria-pressed", "true")
expect((screen.getByLabelText("access key") as HTMLInputElement).type).toBe("text")
})

it("keeps the toggle's accessible name stable across state changes", async () => {
const user = userEvent.setup()
renderWithLabel(makeProps({ value: "super-secret" }))

const toggle = screen.getByRole("button", { name: TOGGLE_NAME })
await user.click(toggle)

// Same button is still findable by the same stable name after pressing.
expect(screen.getByRole("button", { name: TOGGLE_NAME })).toBe(toggle)
})

it("hides the value again after a second toggle press", async () => {
const user = userEvent.setup()
renderWithLabel(makeProps({ value: "super-secret" }))

const toggle = screen.getByRole("button", { name: TOGGLE_NAME })
await user.click(toggle)
await user.click(toggle)

expect(toggle).toHaveAttribute("aria-pressed", "false")
expect((screen.getByLabelText("access key") as HTMLInputElement).type).toBe("password")
})

it("carries the project's input styling in both states (no shape change on toggle)", async () => {
const user = userEvent.setup()
renderWithLabel(makeProps({ value: "super-secret" }))

const input = screen.getByLabelText("access key") as HTMLInputElement
expect(input).toHaveClass("rounded-md")
expect(input).toHaveClass("border")

await user.click(screen.getByRole("button", { name: TOGGLE_NAME }))

expect(input).toHaveClass("rounded-md")
expect(input).toHaveClass("border")
})

it("opts the input out of browser password-manager autofill", () => {
renderWithLabel(makeProps({ value: "super-secret" }))

const input = screen.getByLabelText("access key") as HTMLInputElement
expect(input).toHaveAttribute("autocomplete", "new-password")
})

it("forwards typing into onChange without requiring the toggle first", async () => {
const user = userEvent.setup()
const onChange = vi.fn()

function Host() {
const [value, setValue] = useState<string | undefined>("")
return (
<>
<label htmlFor="sensitive">access key</label>
<SensitiveStringWidget
{...makeProps({
value,
onChange: (next) => {
onChange(next)
setValue(next as string | undefined)
},
})}
/>
</>
)
}
render(<Host />)

await user.type(screen.getByLabelText("access key"), "abc")

expect(onChange).toHaveBeenLastCalledWith("abc")
})

it("emits undefined when the input is cleared", async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderWithLabel(makeProps({ value: "secret", onChange }))

await user.clear(screen.getByLabelText("access key") as HTMLInputElement)

expect(onChange).toHaveBeenLastCalledWith(undefined)
})

it("keeps the reveal toggle usable when the form is readonly", async () => {
const user = userEvent.setup()
renderWithLabel(makeProps({ value: "secret", readonly: true }))

const input = screen.getByLabelText("access key") as HTMLInputElement
expect(input).toHaveAttribute("readonly")

await user.click(screen.getByRole("button", { name: TOGGLE_NAME }))

expect(input.type).toBe("text")
expect(input.value).toBe("secret")
})

it("keeps the reveal toggle usable when the form is disabled", async () => {
const user = userEvent.setup()
renderWithLabel(makeProps({ value: "secret", disabled: true }))

const input = screen.getByLabelText("access key") as HTMLInputElement
expect(input).toBeDisabled()

await user.click(screen.getByRole("button", { name: TOGGLE_NAME }))

expect(input.type).toBe("text")
})
})
56 changes: 56 additions & 0 deletions apps/console/src/components/SensitiveStringWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useState } from "react"
import { Eye, EyeOff } from "lucide-react"
import type { WidgetProps } from "@rjsf/utils"

// Matches the styling applied to `input[type="text"]` in schema-form.css.
// The CSS rule there is keyed on the `type` attribute, so a `type="password"`
// input would otherwise fall back to native browser chrome and visibly differ
// from neighbouring fields every time the user toggles reveal.
const INPUT_CLASS =
"w-full rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-sm text-slate-900 shadow-sm outline-none transition-shadow focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"

const TOGGLE_CLASS =
"flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100"

export function SensitiveStringWidget(props: WidgetProps) {
const { id, value, onChange, required, disabled, readonly, autofocus, placeholder } = props
const [revealed, setRevealed] = useState(false)

const stringValue = typeof value === "string" ? value : ""

return (
<div className="flex items-center gap-2">
<input
id={id}
type={revealed ? "text" : "password"}
// Best-effort hint to skip browser password-manager autofill on this
// ad-hoc credential field. Browser behaviour varies; `new-password` is
// the most reliable value but the prompt is not fully suppressible.
autoComplete="new-password"
autoFocus={autofocus}
placeholder={placeholder}
value={stringValue}
required={required}
disabled={disabled}
readOnly={readonly}
onChange={(event) => {
// Empty string is coerced to undefined so the surrounding form
// drops the key from the spec entirely on clear, matching the
// convention used by the other custom widgets in this folder.
const next = event.target.value
onChange(next === "" ? undefined : next)
}}
className={INPUT_CLASS}
/>
<button
type="button"
onClick={() => setRevealed((prev) => !prev)}
aria-label="Toggle credential visibility"
aria-pressed={revealed}
className={TOGGLE_CLASS}
>
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
)
}
2 changes: 2 additions & 0 deletions apps/console/src/components/rjsf-templates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { StorageClassWidget } from "./StorageClassWidget.tsx"
import { AdditionalPropertiesWidget } from "./AdditionalPropertiesWidget.tsx"
import { VMDiskWidget } from "./VMDiskWidget.tsx"
import { BackupClassWidget } from "./BackupClassWidget.tsx"
import { SensitiveStringWidget } from "./SensitiveStringWidget.tsx"

function IconButton<
T = any,
Expand Down Expand Up @@ -93,4 +94,5 @@ export const customWidgets = {
AdditionalPropertiesWidget: AdditionalPropertiesWidget,
VMDiskWidget: VMDiskWidget,
BackupClassWidget: BackupClassWidget,
SensitiveStringWidget: SensitiveStringWidget,
}
Loading
Loading