Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions backend/druks/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
NumberField,
Option,
RadioField,
SecretField,
SelectField,
TextAreaField,
TextField,
Expand Down Expand Up @@ -89,6 +90,7 @@
"ProgressStep",
"Quote",
"RadioField",
"SecretField",
"Section",
"SelectField",
"Stack",
Expand Down
17 changes: 16 additions & 1 deletion backend/druks/ui/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ class UploadField(Schema):
is_required: bool = False


class SecretField(Schema):
"""A secret the operator hands over — a token, a key. It carries no
``value``, the way ``UploadField`` carries none: a file input cannot be
seeded, and a secret must not be, so an app can never echo a stored secret
back to the browser by declaring one. The shell masks the input and keeps it
from the password manager, and the successful-submit reset clears it."""

field: Literal["secret"] = "secret"
name: str
label: str
help_text: str = ""
is_required: bool = False


Field = Annotated[
TextField
| TextAreaField
Expand All @@ -108,6 +122,7 @@ class UploadField(Schema):
| MultiSelectField
| RadioField
| CheckboxField
| UploadField,
| UploadField
| SecretField,
Discriminator("field"),
]
1 change: 1 addition & 0 deletions backend/tests/test_author_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"ProgressStep",
"Quote",
"RadioField",
"SecretField",
"Section",
"SelectField",
"Stack",
Expand Down
37 changes: 36 additions & 1 deletion backend/tests/test_ui_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
from druks.apps.exceptions import AppRouteConflict
from druks.apps.loader import load_app
from druks.apps.schemas import Operation
from druks.ui import Action, Card, EmptyState, Form, Link, Page, Section, TextField
from druks.ui import (
Action,
Card,
EmptyState,
Form,
Link,
Page,
SecretField,
Section,
TextField,
)

OPERATIONS = {
"write_note": Operation(id="write_note", method="POST", path="/api/field_notes/notes"),
Expand Down Expand Up @@ -61,6 +71,31 @@ def test_a_form_carries_its_fields_and_the_action_that_sends_them():
assert block["action"]["operation"] == "write_note"


def test_a_secret_field_declares_no_value():
(block,) = wire(
Form(
action=Action(label="Connect", operation="write_note", tone="primary"),
fields=[
SecretField(
name="token",
label="Access token",
help_text="From your account settings.",
)
],
)
)

assert block["fields"] == [
{
"field": "secret",
"name": "token",
"label": "Access token",
"helpText": "From your account settings.",
"isRequired": False,
}
]


def test_a_form_sends_each_value_once():
with pytest.raises(ValueError, match="two fields named"):
Form(
Expand Down
40 changes: 38 additions & 2 deletions docs/druks-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ TimeValue
Option TextField TextAreaField fields
NumberField SelectField MultiSelectField
RadioField CheckboxField UploadField
SecretField
Block Value Field the three unions
```

Expand Down Expand Up @@ -514,7 +515,7 @@ Value = Annotated[TextValue | NumberValue | StatusValue | TimeValue, Discriminat

Field = Annotated[
TextField | TextAreaField | NumberField | SelectField | MultiSelectField
| RadioField | CheckboxField | UploadField,
| RadioField | CheckboxField | UploadField | SecretField,
Discriminator("field"),
]
```
Expand Down Expand Up @@ -1217,7 +1218,7 @@ time in the title attribute.

Every field carries a `field` discriminator, `name`, `label`, `help_text`, and
`is_required`. `name` is the key the shell sends. Every field but `UploadField`
also has a `value`, which is what it starts on.
and `SecretField` also has a `value`, which is what it starts on.

### TextField

Expand Down Expand Up @@ -1412,6 +1413,41 @@ A file over the platform's upload cap is refused, and the shell puts the refusal
on that field. A file whose form is never submitted stays stored with nothing
pointing at it.

### SecretField

```python
class SecretField:
field: Literal["secret"] = "secret"
name: str
label: str
help_text: str = ""
is_required: bool = False
```

```python
ui.SecretField(name="token", label="Access token", help_text="From your account settings.")
```

```json
{"field": "secret", "name": "token", "label": "Access token", "helpText": "From your account settings.", "isRequired": false}
```

One secret the operator hands over — a token, a key — and, like `UploadField`,
no starting `value`: a file input cannot be seeded, and a secret must not be, so
an app can never echo a stored secret back to the browser by declaring one.

The shell renders it as a masked `type="password"` input that the browser's
password managers leave alone, the same input the settings modal uses for a
bearer token. On a successful submit the form's reset returns the field to empty,
so nothing has to clear it.

Masking protects the screen, not the stored secret. When a route rejects the
submitted value, its 422 message can carry the secret back — Pydantic embeds the
submitted input in many of its messages. So the shell shows a fixed line on a
secret field, and a fixed form-level line for any refusal that names no field on
screen, whenever the form holds a secret. Every other field still shows the
server's own words.

## Page and Follows

```python
Expand Down
21 changes: 20 additions & 1 deletion docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,25 @@ Once the operation answers, the action does what it declared: `refresh` reads
the page or the region again, `link` navigates, and `confirm` asks the operator
first. A `tone` of `danger` says so on screen.

A form that needs a token takes a `ui.SecretField`:

```python
ui.SecretField(name="token", label="Access token", help_text="From your account settings.")
```

It renders masked, is kept from the browser's password managers, carries no
starting value, and clears itself on a successful submit. When a route rejects
the value, the shell shows a fixed line rather than the server's — a validation
message can echo the submitted token back — and does the same for any refusal
that names no field on screen while the form holds a secret.

That masking protects the screen, not the stored secret. Your operation receives
the plaintext token and stays responsible for keeping it safe. For one secret per
record — a credential per connected account — store it in an
`EncryptedJsonField` column or a `SecretsMapping`, never a plain string. A token
the whole app shares belongs in `AppSettings` with a `Secret` field instead,
where the platform masks, encrypts, and never reads it back — not in a form.

Two routes of one app cannot share an `operation_id`. An action that names an
operation the app does not declare, a GET route, or a route with a query
parameter fails the page read: a GET is a read, and an action fills path
Expand Down Expand Up @@ -1393,7 +1412,7 @@ Import from concern namespaces, not from `druks.durable` or internal modules:
| `druks.sandbox` | `Sandbox` |
| `druks.db` | `Base`, `StoredSubject`, `db_session` |
| `druks.schemas` | `Schema` |
| `druks.ui` | `Action`, `Block`, `Callout`, `Card`, `Chart`, `ChartSeries`, `CheckboxField`, `Columns`, `Divider`, `EmptyState`, `Fact`, `Facts`, `Field`, `FileSummary`, `Files`, `Follows`, `Form`, `GateControls`, `Image`, `ImageGallery`, `Link`, `List`, `Markdown`, `Metric`, `Metrics`, `MultiSelectField`, `NumberField`, `NumberValue`, `Option`, `Page`, `Progress`, `ProgressStep`, `RadioField`, `Section`, `SelectField`, `Stack`, `StatusValue`, `Table`, `TableColumn`, `TableRow`, `Text`, `TextAreaField`, `TextField`, `TextValue`, `TimeValue`, `Timeline`, `TimelineItem`, `UploadField`, `Value`, `page` |
| `druks.ui` | `Action`, `Block`, `Callout`, `Card`, `Chart`, `ChartSeries`, `CheckboxField`, `Columns`, `Divider`, `EmptyState`, `Fact`, `Facts`, `Field`, `FileSummary`, `Files`, `Follows`, `Form`, `GateControls`, `Image`, `ImageGallery`, `Link`, `List`, `Markdown`, `Metric`, `Metrics`, `MultiSelectField`, `NumberField`, `NumberValue`, `Option`, `Page`, `Progress`, `ProgressStep`, `RadioField`, `Section`, `SecretField`, `SelectField`, `Stack`, `StatusValue`, `Table`, `TableColumn`, `TableRow`, `Text`, `TextAreaField`, `TextField`, `TextValue`, `TimeValue`, `Timeline`, `TimelineItem`, `UploadField`, `Value`, `page` |
| `druks.signals` | `subscribe` |
| `druks.events` | `Event` |
| `druks.files` | `File`, `FileField` |
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ export type Field =
| (FieldBase & { field: 'radio'; options: Option[]; value: string })
| (FieldBase & { field: 'checkbox'; value: boolean })
| (FieldBase & { field: 'upload'; accept: string })
// A secret carries no value: nothing the server sends could seed it, so an app
// cannot echo a stored secret back to the browser.
| (FieldBase & { field: 'secret' })

// A control that calls one of the app's own operations. The shell resolves the
// operation to a method and a URL through the roster.
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/druksui/Fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,22 @@ function Input({
onChange={(event) => onChange(field.name, event.target.checked)}
/>
)
case 'secret':
return (
<input
{...shared}
className="dui-input"
// The same masked, password-manager-ignored input the settings modal
// uses for the MCP bearer token, so a secret is never on screen and
// never offered to a manager as ordinary text.
type="password"
autoComplete="new-password"
data-1p-ignore=""
data-lpignore="true"
value={String(value ?? '')}
onChange={(event) => onChange(field.name, event.target.value)}
/>
)
case 'upload':
return (
<input
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/druksui/Form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ const PHOTO: Field = {
isRequired: false,
}

const SECRET: Field = {
field: 'secret',
name: 'token',
label: 'Access token',
helpText: 'From your account settings.',
isRequired: false,
}

describe('fields', () => {
it('renders every V1 field', () => {
renderBlocks([
Expand Down Expand Up @@ -583,6 +591,104 @@ describe('what an action does next', () => {
})
})

describe('a secret field', () => {
it('renders a masked input kept from the password manager, with its metadata', () => {
renderBlocks([form([{ ...SECRET, isRequired: true }])])

const input = screen.getByLabelText(/Access token/) as HTMLInputElement
expect(input.type).toBe('password')
expect(input.getAttribute('autocomplete')).toBe('new-password')
expect(input.getAttribute('data-1p-ignore')).toBe('')
expect(input.getAttribute('data-lpignore')).toBe('true')
// Label, help, and required survive the same as any other field.
expect(input.required).toBe(true)
expect(screen.getByText('From your account settings.')).toBeTruthy()
})

it('submits the secret under its name and is empty after a successful submit', async () => {
renderBlocks([form([SECRET], action({ refresh: 'none' }))])
fireEvent.change(screen.getByLabelText(/Access token/), { target: { value: 'sk-live-abc123' } })

fireEvent.click(screen.getByText('Save'))

await waitFor(() => expect(callOperation).toHaveBeenCalled())
expect(callOperation).toHaveBeenCalledWith('POST', '/api/field_notes/notes', {
token: 'sk-live-abc123',
})
// The successful-submit reset returns the no-value field to empty.
await waitFor(() =>
expect((screen.getByLabelText(/Access token/) as HTMLInputElement).value).toBe(''),
)
})

it('redacts a refusal that names the secret field, showing neither message nor secret', async () => {
callOperation.mockRejectedValueOnce(
new ApiError('validation', 422, [
{ loc: ['body', 'token'], msg: 'Value error, sk-live-abc123 is not a valid token' },
]),
)
renderBlocks([form([SECRET], action({ refresh: 'none' }))])
fireEvent.change(screen.getByLabelText(/Access token/), { target: { value: 'sk-live-abc123' } })

fireEvent.click(screen.getByText('Save'))

await waitFor(() => expect(screen.getByText('This value is not valid.')).toBeTruthy())
expect(screen.queryByText(/is not a valid token/)).toBeNull()
expect(screen.queryByText(/sk-live-abc123/)).toBeNull()
})

it('redacts a refusal naming no field to a fixed form message when a secret is present', async () => {
callOperation.mockRejectedValueOnce(
new ApiError('validation', 422, [
{ loc: ['body'], msg: 'Value error, token sk-live-abc123 was rejected' },
]),
)
renderBlocks([form([SECRET], action({ refresh: 'none' }))])

fireEvent.click(screen.getByText('Save'))

await waitFor(() =>
expect(screen.getByText('Some of what you entered is not valid.')).toBeTruthy(),
)
expect(screen.queryByText(/sk-live-abc123/)).toBeNull()
})

it('keeps the server words for a non-secret field even when the form holds a secret', async () => {
callOperation.mockRejectedValueOnce(
new ApiError('validation', 422, [
{ loc: ['body', 'budget'], msg: 'Input should be greater than 0' },
]),
)
const budget: Field = {
field: 'number',
name: 'budget',
label: 'Budget',
value: null,
minimum: 0,
maximum: null,
step: null,
helpText: '',
isRequired: false,
}
renderBlocks([form([budget, SECRET], action({ refresh: 'none' }))])

fireEvent.click(screen.getByText('Save'))

await waitFor(() => expect(screen.getByText('Input should be greater than 0')).toBeTruthy())
})

it('keeps the server words for an unmatched refusal when the form holds no secret', async () => {
callOperation.mockRejectedValueOnce(
new ApiError('validation', 422, [{ loc: ['body', 'nowhere'], msg: 'model level: incoherent' }]),
)
renderBlocks([form([BODY], action({ refresh: 'none' }))])

fireEvent.click(screen.getByText('Save'))

await waitFor(() => expect(screen.getByText(/model level: incoherent/)).toBeTruthy())
})
})

describe('an upload field', () => {
function pick(container: HTMLElement, file: File) {
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
Expand Down
Loading