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
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
14 changes: 13 additions & 1 deletion backend/druks/ui/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ class UploadField(Schema):
is_required: bool = False


class SecretField(Schema):
"""One secret the operator hands over: a token, a key. It has no ``value``,
so a page cannot send a stored secret back to the browser."""

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


Field = Annotated[
TextField
| TextAreaField
Expand All @@ -108,6 +119,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
39 changes: 37 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,40 @@ 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.

It has no `value`. A file input cannot be seeded, and a secret must not be. A
field with nowhere to put one cannot send a stored secret back to the browser.

The shell masks it and keeps it from the browser's password managers. A
successful submit leaves it empty.

Masking protects the screen. It does not protect the stored secret.

A refusal never repeats the server's words on a secret field. The shell shows a
fixed line, because a validation message can carry the submitted value back. It
shows a fixed line on the form too, for a refusal that names no field on screen.
Every other field keeps the server's own words.

## Page and Follows

```python
Expand Down
18 changes: 17 additions & 1 deletion docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,22 @@ 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.")
```

Druks masks it, keeps it from the browser's password managers, and leaves it
empty after a successful submit. A refusal shows a fixed line, never the
server's words, because a validation message can carry the token back.

The masking protects the screen. Your operation receives the token in plain
text and owns it from there. Keep one secret per record in an
`EncryptedJsonField` column or a `SecretsMapping`. A token the whole app shares
belongs in `AppSettings` as a `Secret`, where Druks encrypts it and never reads
it back.

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 +1409,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
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export type Field =
| (FieldBase & { field: 'radio'; options: Option[]; value: string })
| (FieldBase & { field: 'checkbox'; value: boolean })
| (FieldBase & { field: 'upload'; accept: string })
| (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
15 changes: 15 additions & 0 deletions frontend/src/druksui/Fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ function Input({
onChange={(event) => onChange(field.name, event.target.checked)}
/>
)
case 'secret':
return (
<input
{...shared}
className="dui-input"
// The set the settings modal uses for its bearer token. Keep them
// together.
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
136 changes: 136 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,134 @@ 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('redacts a string-detail refusal to a fixed form message when a secret is present', async () => {
// The repo's normal app-route failure is HTTPException(status, "detail"): a
// plain string, not the array Pydantic ships. It must be redacted too.
const said = 'token sk-live-abc123 was rejected by the provider'
callOperation.mockRejectedValueOnce(new ApiError(said, 400, said))
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('Some of what you entered is not valid.')).toBeTruthy(),
)
expect(screen.queryByText(/was rejected by the provider/)).toBeNull()
expect(screen.queryByText(/sk-live-abc123/)).toBeNull()
})

it('says what went wrong when a secret form never reached the server', async () => {
// No response, so no server words to echo the secret back. Redacting here
// would send the operator to revoke a credential that is fine.
callOperation.mockRejectedValueOnce(new TypeError('Failed to fetch'))
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('Failed to fetch')).toBeTruthy())
expect(screen.queryByText('Some of what you entered is not valid.')).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