@cosmicdrift/kumiko-renderer 0.257.0 → 0.258.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,26 +19,29 @@ import type {
19
19
  ActionFormScreenDefinition,
20
20
  EntityDefinition,
21
21
  EntityEditScreenDefinition,
22
+ FieldDefinition,
23
+ SecretMintConfirmStep,
24
+ SecretMintScreenDefinition,
22
25
  } from "@cosmicdrift/kumiko-framework/ui-types";
23
26
 
24
27
  const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
25
28
 
26
- /** Baut eine minimale EntityDefinition aus den Inline-Fields des
27
- * ActionForm-Screens. RenderEdit + computeEditViewModel iterieren
28
- * über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
29
- * Felder bleiben undefined. */
29
+ /** Baut eine minimale EntityDefinition aus den Inline-Fields eines
30
+ * ActionForm- oder SecretMint-Screens. RenderEdit + computeEditViewModel
31
+ * iterieren über entity.fields zur Render-Zeit; alle weiteren
32
+ * EntityDefinition-Felder bleiben undefined. */
30
33
  export function synthesizeActionFormEntity(
31
- fields: ActionFormScreenDefinition["fields"],
34
+ fields: Readonly<Record<string, FieldDefinition>>,
32
35
  ): EntityDefinition {
33
36
  return { fields } as EntityDefinition;
34
37
  }
35
38
 
36
- /** Wandelt ein ActionFormScreenDefinition in die EntityEditScreen-
37
- * Shape die RenderEdit erwartet. type wird auf "entityEdit" gesetzt
38
- * damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
39
- * gepinnt — RenderEdit liest das Feld nicht. */
39
+ /** Wandelt ein ActionFormScreenDefinition oder SecretMintScreenDefinition in
40
+ * die EntityEditScreen-Shape die RenderEdit erwartet. type wird auf
41
+ * "entityEdit" gesetzt damit der Type-Constraint hält; entity wird auf den
42
+ * Pseudo-Namen gepinnt — RenderEdit liest das Feld nicht. */
40
43
  export function synthesizeActionFormScreen(
41
- screen: ActionFormScreenDefinition,
44
+ screen: ActionFormScreenDefinition | SecretMintScreenDefinition,
42
45
  ): EntityEditScreenDefinition {
43
46
  return {
44
47
  id: screen.id,
@@ -49,3 +52,18 @@ export function synthesizeActionFormScreen(
49
52
  ...(screen.access !== undefined && { access: screen.access }),
50
53
  };
51
54
  }
55
+
56
+ /** Separate id (`${screen.id}:confirm`) keeps this confirm-form screen from
57
+ * colliding with the mint-form screen's draft key. */
58
+ export function synthesizeSecretMintConfirmScreen(
59
+ screen: SecretMintScreenDefinition,
60
+ confirm: SecretMintConfirmStep,
61
+ ): EntityEditScreenDefinition {
62
+ return {
63
+ id: `${screen.id}:confirm`,
64
+ type: "entityEdit",
65
+ entity: ACTION_FORM_PSEUDO_ENTITY,
66
+ layout: confirm.layout,
67
+ ...(screen.access !== undefined && { access: screen.access }),
68
+ };
69
+ }
@@ -82,6 +82,7 @@ import {
82
82
  stringifyNavParams,
83
83
  } from "./row-actions";
84
84
  import { screenAccessAllows } from "./screen-access";
85
+ import { SecretMintBody } from "./secret-mint-body";
85
86
  import { SecretsEditBody } from "./secrets-edit-body";
86
87
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
87
88
 
@@ -211,6 +212,8 @@ export function KumikoScreen({
211
212
  return <DashboardScreenBody screen={screen} translate={translate} />;
212
213
  case "actionForm":
213
214
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
215
+ case "secretMint":
216
+ return <SecretMintBody schema={schema} screen={screen} translate={translate} />;
214
217
  case "configEdit":
215
218
  return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
216
219
  case "secretsEdit":
@@ -345,11 +348,15 @@ export function buildInitialValues(
345
348
  ): Readonly<Record<string, unknown>> {
346
349
  const out: Record<string, unknown> = {};
347
350
  for (const [name, def] of Object.entries(fields)) {
348
- const shape = def as { type?: string; default?: unknown };
351
+ const shape = def as { type?: string; default?: unknown; multiple?: boolean };
349
352
  if (shape.default !== undefined) {
350
353
  out[name] = shape.default;
351
354
  continue;
352
355
  }
356
+ if (shape.type === "embedded" && shape.multiple === true) {
357
+ out[name] = [];
358
+ continue;
359
+ }
353
360
  if (shape.type === "money" && defaultCurrency !== undefined) {
354
361
  out[name] = { amount: 0, currency: defaultCurrency };
355
362
  continue;
@@ -399,7 +406,7 @@ function parseJsonOrRaw(raw: string): unknown {
399
406
  }
400
407
  }
401
408
 
402
- function warnMoneyParam(message: string): void {
409
+ function warnPrefillParam(message: string): void {
403
410
  // biome-ignore lint/suspicious/noConsole: dev-warning for an authoring error
404
411
  console.warn(`[kumiko] ${message}`);
405
412
  }
@@ -416,7 +423,7 @@ function coerceMoneyValue(
416
423
  if (value === null) return undefined;
417
424
  if (isMoneyValue(value)) return { amount: value.amount, currency: value.currency.toUpperCase() };
418
425
  if (typeof value === "object") {
419
- warnMoneyParam(
426
+ warnPrefillParam(
420
427
  `money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`,
421
428
  );
422
429
  return undefined;
@@ -424,12 +431,81 @@ function coerceMoneyValue(
424
431
  const amount = Number(typeof value === "number" ? value : raw);
425
432
  if (!Number.isFinite(amount)) return undefined;
426
433
  if (defaultCurrency !== undefined) return { amount, currency: defaultCurrency };
427
- warnMoneyParam(
434
+ warnPrefillParam(
428
435
  `money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`,
429
436
  );
430
437
  return amount;
431
438
  }
432
439
 
440
+ type EmbeddedCellShape = { readonly type?: string; readonly options?: readonly string[] };
441
+
442
+ const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
443
+
444
+ function coerceEmbeddedCell(value: unknown, cell: EmbeddedCellShape): unknown {
445
+ switch (cell.type) {
446
+ case "number":
447
+ case "decimal":
448
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
449
+ case "money":
450
+ // Row cells are signed minor units; the currency lives on the head, so a
451
+ // top-level `{amount, currency}` in major units would land 100x off.
452
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
453
+ case "boolean":
454
+ return typeof value === "boolean" ? value : undefined;
455
+ case "select":
456
+ return typeof value === "string" && (cell.options ?? []).includes(value) ? value : undefined;
457
+ default:
458
+ return typeof value === "string" ? value : undefined;
459
+ }
460
+ }
461
+
462
+ // A partially coerced list is the hazard the prefill exists to avoid: the
463
+ // handler replaces the list whole, so half a list overwrites the rest.
464
+ function coerceEmbeddedListRows(
465
+ raw: string,
466
+ fieldName: string,
467
+ schema: Readonly<Record<string, EmbeddedCellShape>>,
468
+ maxItems?: number,
469
+ ): readonly Readonly<Record<string, unknown>>[] | undefined {
470
+ const parsed = parseJsonOrRaw(raw);
471
+ if (!Array.isArray(parsed)) {
472
+ warnPrefillParam(
473
+ `embedded-list field "${fieldName}" was prefilled with something that is not a JSON array — the prefill is ignored.`,
474
+ );
475
+ return undefined;
476
+ }
477
+ if (parsed.length > (maxItems ?? EMBEDDED_LIST_PREFILL_ROW_LIMIT)) {
478
+ warnPrefillParam(
479
+ `embedded-list field "${fieldName}" was prefilled with ${parsed.length} rows, more than it accepts — the prefill is ignored.`,
480
+ );
481
+ return undefined;
482
+ }
483
+ const rows: Readonly<Record<string, unknown>>[] = [];
484
+ for (const candidate of parsed) {
485
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
486
+ warnPrefillParam(
487
+ `embedded-list field "${fieldName}" was prefilled with a row that is not an object — the prefill is ignored.`,
488
+ );
489
+ return undefined;
490
+ }
491
+ const cellEntries: [string, unknown][] = [];
492
+ for (const [cellName, cell] of Object.entries(schema)) {
493
+ const cellValue = (candidate as Record<string, unknown>)[cellName];
494
+ if (cellValue === undefined || cellValue === null) continue;
495
+ const coerced = coerceEmbeddedCell(cellValue, cell);
496
+ if (coerced === undefined) {
497
+ warnPrefillParam(
498
+ `embedded-list field "${fieldName}" was prefilled with a cell "${cellName}" the sub-schema rejects — the prefill is ignored.`,
499
+ );
500
+ return undefined;
501
+ }
502
+ cellEntries.push([cellName, coerced]);
503
+ }
504
+ rows.push(Object.fromEntries(cellEntries));
505
+ }
506
+ return rows;
507
+ }
508
+
433
509
  export function mergeSearchParamsIntoInitial(
434
510
  fields: Readonly<Record<string, unknown>>,
435
511
  searchParams: Readonly<Record<string, string>>,
@@ -449,9 +525,15 @@ export function mergeSearchParamsIntoInitial(
449
525
  const shape = fieldDef as {
450
526
  type?: string;
451
527
  sensitive?: boolean;
528
+ format?: string;
452
529
  options?: readonly (string | { readonly value: string })[];
530
+ multiple?: boolean;
531
+ schema?: Readonly<Record<string, EmbeddedCellShape>>;
532
+ maxItems?: number;
453
533
  };
454
534
  if (shape.sensitive === true) continue;
535
+ // A password field must never be prefilled from the URL, same as sensitive.
536
+ if (shape.format === "password") continue;
455
537
  if (overrides !== undefined && name in overrides) {
456
538
  merged[name] = overrides[name];
457
539
  continue;
@@ -499,6 +581,9 @@ export function mergeSearchParamsIntoInitial(
499
581
  }
500
582
  merged[name] =
501
583
  optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
584
+ } else if (shape.type === "embedded" && shape.multiple === true) {
585
+ const rows = coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems);
586
+ merged[name] = rows ?? defaults[name];
502
587
  } else {
503
588
  merged[name] = raw;
504
589
  }
@@ -0,0 +1,235 @@
1
+ import type { SecretMintScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
3
+ import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
4
+ import { RenderEdit } from "../components/render-edit";
5
+ import { useTranslation } from "../i18n";
6
+ import { usePrimitives } from "../primitives";
7
+ import {
8
+ synthesizeActionFormEntity,
9
+ synthesizeActionFormScreen,
10
+ synthesizeSecretMintConfirmScreen,
11
+ } from "./action-form-shim";
12
+ import type { FeatureSchema } from "./feature-schema";
13
+ import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-screen";
14
+ import { layoutFieldNames } from "./layout-fields";
15
+ import { useNav } from "./nav";
16
+ import { lastSegment } from "./qn";
17
+
18
+ export type SecretMintBodyProps = {
19
+ readonly schema: FeatureSchema;
20
+ readonly screen: SecretMintScreenDefinition;
21
+ readonly translate?: Translate;
22
+ };
23
+
24
+ function extractRevealValue(data: unknown, field: string): unknown {
25
+ if (typeof data !== "object" || data === null) return undefined;
26
+ if (!Object.hasOwn(data, field)) return undefined;
27
+ return (data as Record<string, unknown>)[field];
28
+ }
29
+
30
+ function extractCarriedValues(
31
+ data: unknown,
32
+ carry: readonly string[],
33
+ ): Readonly<Record<string, unknown>> {
34
+ const carried: Record<string, unknown> = {};
35
+ for (const field of carry) {
36
+ const value = extractRevealValue(data, field);
37
+ if (value === undefined) continue;
38
+ carried[field] = value;
39
+ }
40
+ return carried;
41
+ }
42
+
43
+ function isBlank(value: unknown): boolean {
44
+ if (value === undefined || value === null || value === "") return true;
45
+ return Array.isArray(value) && value.length === 0;
46
+ }
47
+
48
+ // Mint form → one-time reveal → optional confirm → done. The revealed values
49
+ // live ONLY in this component's `revealed` state — never in the URL, a query
50
+ // cache, or nav — the write-handler's success payload is the only place the
51
+ // secret ever exists (fw#2548). onSubmit copies exclusively the fields
52
+ // declared in `screen.reveal.fields` out of that payload (a whitelist, never
53
+ // the payload as a whole) so an unrelated field (e.g. an internal "id") can
54
+ // never leak into the reveal. `screen.confirm` (fw#2838, e.g. TOTP enroll:
55
+ // scan the code, then enter one) works the same way for its own carried
56
+ // mint-payload fields — those live only in `carriedRef`, never in React state
57
+ // that gets rendered, never in the confirm form's own values.
58
+ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProps): ReactNode {
59
+ const nav = useNav();
60
+ const { Card, Heading, Banner, Button, Text, Grid, GridCell, SecretReveal } = usePrimitives();
61
+ const t = useTranslation();
62
+ const effectiveTranslate = translate ?? t;
63
+ const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
64
+ const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
65
+ const initial = useMemo(
66
+ () =>
67
+ mergeSearchParamsIntoInitial(
68
+ screen.fields,
69
+ nav.searchParams,
70
+ layoutFieldNames(synthScreen),
71
+ ) as FormValues,
72
+ [screen.fields, nav.searchParams, synthScreen],
73
+ );
74
+ const [revealed, setRevealed] = useState<Readonly<Record<string, unknown>> | null>(null);
75
+ const [done, setDone] = useState(false);
76
+ // Never rendered, never merged into `revealed` or the confirm form's
77
+ // initial values — read only from `buildPayload` at confirm-submit time.
78
+ const carriedRef = useRef<Readonly<Record<string, unknown>>>({});
79
+
80
+ const confirm = screen.confirm;
81
+ const confirmEntity = useMemo(
82
+ () => (confirm !== undefined ? synthesizeActionFormEntity(confirm.fields) : undefined),
83
+ [confirm],
84
+ );
85
+ const confirmScreen = useMemo(
86
+ () => (confirm !== undefined ? synthesizeSecretMintConfirmScreen(screen, confirm) : undefined),
87
+ [screen, confirm],
88
+ );
89
+ const confirmInitial = useMemo(
90
+ () => (confirm !== undefined ? (buildInitialValues(confirm.fields) as FormValues) : undefined),
91
+ [confirm],
92
+ );
93
+
94
+ const handleSubmitted = useCallback(
95
+ (result: SubmitResult<unknown>) => {
96
+ if (!result.isSuccess) return;
97
+ const values: Record<string, unknown> = {};
98
+ for (const revealField of screen.reveal.fields) {
99
+ const value = extractRevealValue(result.data, revealField.field);
100
+ if (value === undefined) continue;
101
+ values[revealField.field] = value;
102
+ }
103
+ setRevealed(values);
104
+ carriedRef.current =
105
+ confirm?.carry !== undefined ? extractCarriedValues(result.data, confirm.carry) : {};
106
+ },
107
+ [screen.reveal.fields, confirm?.carry],
108
+ );
109
+
110
+ const handleCancel = useMemo<(() => void) | undefined>(() => {
111
+ const target = screen.cancelTarget ?? screen.redirect;
112
+ if (target === undefined || target === false) return undefined;
113
+ return () => nav.navigate({ screenId: lastSegment(target) });
114
+ }, [nav, screen.redirect, screen.cancelTarget]);
115
+
116
+ // Ends the reveal phase for both paths (the bare acknowledge button, and a
117
+ // successful confirm submit): clears the secret and the carried values, then
118
+ // either navigates (screen.redirect) or shows a done-state — never falls
119
+ // back to re-rendering the mint form, which would let a stray click mint
120
+ // (and invalidate) the secret again.
121
+ const finishMint = useCallback(() => {
122
+ setRevealed(null);
123
+ carriedRef.current = {};
124
+ if (screen.redirect !== undefined) {
125
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
126
+ } else {
127
+ setDone(true);
128
+ }
129
+ }, [nav, screen.redirect]);
130
+
131
+ const handleConfirmSubmitted = useCallback(
132
+ (result: SubmitResult<unknown>) => {
133
+ if (result.isSuccess) finishMint();
134
+ },
135
+ [finishMint],
136
+ );
137
+
138
+ if (done) {
139
+ return (
140
+ <Card>
141
+ <Banner variant="info" testId="kumiko-screen-secret-mint-done">
142
+ {effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done")}
143
+ </Banner>
144
+ </Card>
145
+ );
146
+ }
147
+
148
+ if (revealed !== null) {
149
+ const values = screen.reveal.fields.flatMap((revealField) => {
150
+ const raw = revealed[revealField.field];
151
+ if (isBlank(raw)) return [];
152
+ const display = revealField.display ?? "code";
153
+ const value =
154
+ display === "list" && Array.isArray(raw)
155
+ ? raw.map((v) => String(v)).join("\n")
156
+ : String(raw);
157
+ return [
158
+ {
159
+ label: effectiveTranslate(revealField.label),
160
+ value,
161
+ copyable: revealField.copyable ?? true,
162
+ multiline: display === "list",
163
+ ...(display === "qr" && { qr: true }),
164
+ },
165
+ ];
166
+ });
167
+ return (
168
+ <Card testId="kumiko-screen-secret-mint-card">
169
+ <Heading variant="page">
170
+ {effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title")}
171
+ </Heading>
172
+ <Banner variant="warning" testId="kumiko-screen-secret-mint-warning">
173
+ {effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning")}
174
+ </Banner>
175
+ {SecretReveal !== undefined ? (
176
+ <SecretReveal
177
+ values={values}
178
+ copyLabel={effectiveTranslate("kumiko.secretMint.copy")}
179
+ copiedLabel={effectiveTranslate("kumiko.secretMint.copied")}
180
+ testId="kumiko-screen-secret-mint-reveal"
181
+ />
182
+ ) : (
183
+ <Grid columns={1} testId="kumiko-screen-secret-mint-reveal">
184
+ {values.map((v) => (
185
+ <GridCell key={v.label}>
186
+ <Text>{v.label}</Text>
187
+ <Text variant="code">{v.value}</Text>
188
+ </GridCell>
189
+ ))}
190
+ </Grid>
191
+ )}
192
+ {confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (
193
+ <RenderEdit
194
+ screen={confirmScreen}
195
+ entity={confirmEntity}
196
+ featureName={schema.featureName}
197
+ initial={confirmInitial ?? ({} as FormValues)}
198
+ writeCommand={confirm.handler}
199
+ payloadMode="values"
200
+ buildPayload={(snapshot) => ({ ...snapshot.values, ...carriedRef.current })}
201
+ onSubmit={handleConfirmSubmitted}
202
+ {...(handleCancel !== undefined && { onCancel: handleCancel })}
203
+ {...(translate !== undefined && { translate })}
204
+ {...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel })}
205
+ />
206
+ ) : (
207
+ <Button
208
+ type="button"
209
+ variant="primary"
210
+ onClick={finishMint}
211
+ testId="kumiko-screen-secret-mint-confirm"
212
+ >
213
+ {effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm")}
214
+ </Button>
215
+ )}
216
+ </Card>
217
+ );
218
+ }
219
+
220
+ return (
221
+ <RenderEdit
222
+ screen={synthScreen}
223
+ entity={synthEntity}
224
+ featureName={schema.featureName}
225
+ initial={initial}
226
+ extensionInitialValues={initial}
227
+ writeCommand={screen.handler}
228
+ payloadMode="values"
229
+ onSubmit={handleSubmitted}
230
+ {...(handleCancel !== undefined && { onCancel: handleCancel })}
231
+ {...(translate !== undefined && { translate })}
232
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
233
+ />
234
+ );
235
+ }
@@ -320,6 +320,22 @@ describe("RenderEdit — submit path", () => {
320
320
 
321
321
  await waitFor(() => expect(rtlScreen.queryByTestId("render-edit-form-error")).toBeNull());
322
322
  });
323
+
324
+ // fw#2838: an input-less secretMint mint step declares an empty entity
325
+ // (fields: {}) and an empty layout (sections: []) — such a form can never
326
+ // go dirty, so the unchanged-gate must not permanently disable its submit.
327
+ test("a fieldless form's submit button is not disabled and stays visible", () => {
328
+ const zeroFieldScreen: EntityEditScreenDefinition = {
329
+ id: "trigger",
330
+ type: "entityEdit",
331
+ entity: "trigger",
332
+ layout: { sections: [] },
333
+ };
334
+ renderEdit(zeroFieldScreen, {}, { fields: {} });
335
+
336
+ const save = rtlScreen.getByTestId("render-edit-submit") as HTMLButtonElement;
337
+ expect(save.disabled).toBe(false);
338
+ });
323
339
  });
324
340
  describe("RenderEdit — custom actions", () => {
325
341
  test("renders an action button and runs its handler on click", async () => {
@@ -0,0 +1,101 @@
1
+ // fw#2548: TextFieldDef.format "password" is a pure render hint — the
2
+ // editable widget masks the input, and the read-only display never shows
3
+ // the plaintext value.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
7
+ import { render } from "@testing-library/react";
8
+ import type { ComponentType, ReactNode } from "react";
9
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
10
+ import {
11
+ type CorePrimitives,
12
+ type InputProps,
13
+ PrimitivesProvider,
14
+ type TextProps,
15
+ } from "../../primitives";
16
+ import { RenderField } from "../render-field";
17
+
18
+ let capturedInput: InputProps | undefined;
19
+ const captureInput: ComponentType<InputProps> = (props) => {
20
+ capturedInput = props;
21
+ return null;
22
+ };
23
+ let capturedText: TextProps | undefined;
24
+ const captureText: ComponentType<TextProps> = (props) => {
25
+ capturedText = props;
26
+ return null;
27
+ };
28
+ const noop = (): ReactNode => null;
29
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
30
+
31
+ const testPrimitives: CorePrimitives = {
32
+ Button: noop,
33
+ Banner: noop,
34
+ Field: passChildren,
35
+ Input: captureInput,
36
+ DataTable: noop,
37
+ Form: noop,
38
+ Section: noop,
39
+ Card: noop,
40
+ Grid: noop,
41
+ GridCell: noop,
42
+ Text: captureText,
43
+ Heading: noop,
44
+ Dialog: noop,
45
+ Modal: noop,
46
+ Lightbox: noop,
47
+ ConfigSourceBadge: noop,
48
+ ConfigCascadeView: noop,
49
+ Link: noop,
50
+ };
51
+
52
+ function textField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
53
+ return {
54
+ field: "apiToken",
55
+ label: "API token",
56
+ type: "text",
57
+ value: "s3cr3t",
58
+ visible: true,
59
+ readOnly: false,
60
+ required: false,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ function renderField(field: EditFieldViewModel, valueDisplay?: "form" | "text"): void {
66
+ capturedInput = undefined;
67
+ capturedText = undefined;
68
+ render(
69
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
70
+ <PrimitivesProvider value={testPrimitives}>
71
+ <RenderField
72
+ field={field}
73
+ onChange={() => {}}
74
+ {...(valueDisplay !== undefined && { valueDisplay })}
75
+ />
76
+ </PrimitivesProvider>
77
+ </LocaleProvider>,
78
+ );
79
+ }
80
+
81
+ describe("RenderField — format: 'password'", () => {
82
+ test("editable password field renders Input kind=password", () => {
83
+ renderField(textField({ format: "password" }));
84
+ expect(capturedInput?.kind).toBe("password");
85
+ });
86
+
87
+ test("a plain text field (no format) still renders Input kind=text", () => {
88
+ renderField(textField());
89
+ expect(capturedInput?.kind).toBe("text");
90
+ });
91
+
92
+ test("read-only password field shows a fixed mask, never the plaintext", () => {
93
+ renderField(textField({ format: "password", readOnly: true }), "text");
94
+ expect(capturedText?.children).toBe("••••••••");
95
+ });
96
+
97
+ test("read-only password field with an empty value shows the empty placeholder, not the mask", () => {
98
+ renderField(textField({ format: "password", readOnly: true, value: "" }), "text");
99
+ expect(capturedText?.children).toBe("—");
100
+ });
101
+ });
@@ -628,6 +628,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
628
628
 
629
629
  // true when editable fields exist or an extension opted into composed submit (fw#2359).
630
630
  const isFormEditable = hasEditableSection(filteredSections);
631
+ // A fieldless form (input-less secretMint mint, fw#2838 — the secret is
632
+ // server-generated, the mint step is only its submit button) has no
633
+ // section to gate on: it IS the action, so it always shows the submit
634
+ // button, and it can never go dirty either.
635
+ const isFieldless = Object.keys(entity.fields).length === 0;
631
636
 
632
637
  // A lone relatedList tab (hideSectionTitles is only ever set by the tabs
633
638
  // layout, which also narrows filteredSections to that one active section)
@@ -704,10 +709,23 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
704
709
  );
705
710
  return;
706
711
  }
712
+ const passwordFieldNames = new Set(
713
+ vm.sections.flatMap((section) =>
714
+ section.kind === "fields"
715
+ ? section.fields.filter((f) => f.format === "password").map((f) => f.field)
716
+ : [],
717
+ ),
718
+ );
719
+ // A password field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
720
+ const draftValues = Object.fromEntries(
721
+ Object.entries(controller.getSnapshot().values).filter(
722
+ ([field]) => !passwordFieldNames.has(field),
723
+ ),
724
+ );
707
725
  void dispatcher
708
726
  .write(FORM_DRAFT_SAVE, {
709
727
  draftKey: key,
710
- values: controller.getSnapshot().values,
728
+ values: draftValues,
711
729
  stepIndex,
712
730
  })
713
731
  .catch((err: unknown) => {
@@ -1005,7 +1023,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1005
1023
  const hasFormActions =
1006
1024
  (isWizard && currentStep > 0) ||
1007
1025
  (isWizard && !isLastWizardStep) ||
1008
- ((isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep));
1026
+ ((isFormEditable || hasExtensionRegistrations || isFieldless) &&
1027
+ (!isWizard || isLastWizardStep));
1009
1028
  const formActions = (
1010
1029
  <>
1011
1030
  {isWizard && currentStep > 0 && (
@@ -1029,18 +1048,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1029
1048
  {translate("kumiko.actions.next")}
1030
1049
  </Button>
1031
1050
  )}
1032
- {(isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep) && (
1033
- <Button
1034
- type="submit"
1035
- disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
1036
- loading={isSubmitting}
1037
- variant={submitVariant ?? "primary"}
1038
- icon="check"
1039
- testId="render-edit-submit"
1040
- >
1041
- {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
1042
- </Button>
1043
- )}
1051
+ {(isFormEditable || hasExtensionRegistrations || isFieldless) &&
1052
+ (!isWizard || isLastWizardStep) && (
1053
+ <Button
1054
+ type="submit"
1055
+ disabled={
1056
+ (snapshot.isUnchanged && !extensionDirty && !isFieldless) || isSubmitting || disabled
1057
+ }
1058
+ loading={isSubmitting}
1059
+ variant={submitVariant ?? "primary"}
1060
+ icon="check"
1061
+ testId="render-edit-submit"
1062
+ >
1063
+ {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
1064
+ </Button>
1065
+ )}
1044
1066
  </>
1045
1067
  );
1046
1068
 
@@ -751,6 +751,16 @@ function renderInput({
751
751
  );
752
752
  }
753
753
  default: {
754
+ if (field.type === "text" && field.format === "password") {
755
+ return (
756
+ <Input
757
+ kind="password"
758
+ {...common}
759
+ value={stringValue(field.value)}
760
+ onChange={(v) => onChange(v)}
761
+ />
762
+ );
763
+ }
754
764
  // text + unknown scalar type → text input. If TextFieldDef.multiline
755
765
  // is set (the view-model carries it), the renderer switches to
756
766
  // textarea. longText always renders a textarea — that's the point of
@@ -867,6 +877,7 @@ function locatedValue(v: unknown): { at: string; tz: string; utc?: string } | ""
867
877
  function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): string {
868
878
  const { type, value } = field;
869
879
  if (value === undefined || value === null || value === "") return "—";
880
+ if (type === "text" && field.format === "password") return "••••••••";
870
881
  switch (type) {
871
882
  case "boolean":
872
883
  return applyFormatSpec({ format: "boolean" }, value);