@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2

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.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -49,7 +49,15 @@ export interface ResourceComboboxProps<T extends ComboboxOption> {
49
49
  placeholder?: string
50
50
  /** Empty state text shown in the dropdown when items is empty. */
51
51
  emptyText?: string
52
+ /** Optional footer below the list — e.g. a count of rows not shown. */
53
+ listFooter?: ReactNode
52
54
  disabled?: boolean
55
+
56
+ /**
57
+ * Applied to the search input, so a sibling `<FieldLabel htmlFor>` names the
58
+ * field for screen readers.
59
+ */
60
+ id?: string
53
61
  }
54
62
 
55
63
  /**
@@ -72,7 +80,9 @@ export function ResourceCombobox<T extends ComboboxOption>({
72
80
  renderOption,
73
81
  placeholder,
74
82
  emptyText,
83
+ listFooter,
75
84
  disabled,
85
+ id,
76
86
  }: ResourceComboboxProps<T>) {
77
87
  const selected = items.find((r) => r.id === value) ?? null
78
88
 
@@ -92,7 +102,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
92
102
  // dropdown trigger + selection mechanics, not just the input.
93
103
  disabled={disabled}
94
104
  >
95
- <ComboboxInput placeholder={placeholder} disabled={disabled} showClear />
105
+ <ComboboxInput id={id} placeholder={placeholder} disabled={disabled} showClear />
96
106
  <ComboboxContent>
97
107
  <ComboboxEmpty>{emptyText}</ComboboxEmpty>
98
108
  <ComboboxList>
@@ -102,6 +112,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
102
112
  </ComboboxItem>
103
113
  )}
104
114
  </ComboboxList>
115
+ {listFooter}
105
116
  </ComboboxContent>
106
117
  </Combobox>
107
118
  )
@@ -4,8 +4,15 @@ import { cn } from '../lib/utils'
4
4
  interface ResourceNameCellProps {
5
5
  /** Resource identifier, mirrored into the bridge data attribute. */
6
6
  id: string
7
- /** Data attribute the `useRowClickBridge` listener targets (e.g. `data-payment-method-id`). */
8
- dataAttr: `data-${string}`
7
+ /**
8
+ * Data attribute the `useRowClickBridge` listener targets (e.g.
9
+ * `data-payment-method-id`).
10
+ *
11
+ * Omit it for a row that must not be opened — a record this panel may read
12
+ * but not write. The cell then renders as plain text rather than a button
13
+ * that looks clickable and does nothing.
14
+ */
15
+ dataAttr?: `data-${string}`
9
16
  /** Primary text — the resource's display name. */
10
17
  name: ReactNode
11
18
  /** Optional secondary line. Falsy values render nothing. */
@@ -31,14 +38,24 @@ export function ResourceNameCell({
31
38
  secondary,
32
39
  nameClassName,
33
40
  }: ResourceNameCellProps) {
41
+ const content = (
42
+ <>
43
+ <span className={cn('font-medium', nameClassName)}>{name}</span>
44
+ {secondary && <span className="text-xs text-muted-foreground">{secondary}</span>}
45
+ </>
46
+ )
47
+
48
+ if (!dataAttr) {
49
+ return <div className="flex h-full w-full flex-col items-start text-left">{content}</div>
50
+ }
51
+
34
52
  return (
35
53
  <button
36
54
  type="button"
37
55
  {...{ [dataAttr]: id }}
38
56
  className="flex h-full w-full cursor-pointer flex-col items-start rounded text-left focus-visible:outline-none"
39
57
  >
40
- <span className={cn('font-medium', nameClassName)}>{name}</span>
41
- {secondary && <span className="text-xs text-muted-foreground">{secondary}</span>}
58
+ {content}
42
59
  </button>
43
60
  )
44
61
  }
@@ -0,0 +1,238 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Button } from '../ui/button'
4
+ import {
5
+ Dialog,
6
+ DialogBody,
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '../ui/dialog'
12
+ import { Field, FieldLabel } from '../ui/field'
13
+ import { Input } from '../ui/input'
14
+ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
15
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
16
+ import { Switch } from '../ui/switch'
17
+
18
+ /** How money goes back to the buyer. */
19
+ export type RefundMethod = 'original_payment' | 'store_credit'
20
+
21
+ /** One line of a return, as the receipt form needs it. */
22
+ export type ReturnLineForReceipt = {
23
+ id: string
24
+ label: string
25
+ quantity: number
26
+ }
27
+
28
+ type ReceiptRow = { quantity: number; resellable: boolean }
29
+
30
+ /**
31
+ * Records what actually arrived: a quantity per line, and whether each can go
32
+ * back on the shelf.
33
+ *
34
+ * Deliberately not defaulted server-side — an omitted `items` means "receive
35
+ * everything as requested", so a partial receipt has to say so line by line.
36
+ * Shared by the operator's order page and the seller's.
37
+ */
38
+ export function ReturnReceiveDialog({
39
+ lines,
40
+ onClose,
41
+ onSubmit,
42
+ pending = false,
43
+ }: {
44
+ lines: ReturnLineForReceipt[]
45
+ onClose: () => void
46
+ onSubmit: (
47
+ items: Array<{ return_line_item_id: string; quantity: number; resellable: boolean }>,
48
+ ) => void
49
+ pending?: boolean
50
+ }) {
51
+ const { t } = useTranslation()
52
+ const [rows, setRows] = useState<Record<string, ReceiptRow>>(() =>
53
+ Object.fromEntries(
54
+ lines.map((line) => [line.id, { quantity: line.quantity, resellable: true }]),
55
+ ),
56
+ )
57
+
58
+ return (
59
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
60
+ <DialogContent>
61
+ <DialogHeader>
62
+ <DialogTitle>{t('admin.pages.orders.detail.returns.receive_title')}</DialogTitle>
63
+ </DialogHeader>
64
+ <DialogBody className="flex flex-col gap-4">
65
+ {lines.map((line) => (
66
+ <div key={line.id} className="flex flex-col gap-3">
67
+ <span className="font-medium text-sm">{line.label}</span>
68
+ <div className="flex flex-col gap-3">
69
+ <Field>
70
+ <FieldLabel htmlFor={`qty-${line.id}`}>
71
+ {t('admin.pages.orders.detail.returns.received_quantity')}
72
+ </FieldLabel>
73
+ <Input
74
+ id={`qty-${line.id}`}
75
+ type="number"
76
+ min={0}
77
+ max={line.quantity}
78
+ value={rows[line.id]?.quantity ?? 0}
79
+ onChange={(event) =>
80
+ setRows((current) => ({
81
+ ...current,
82
+ [line.id]: {
83
+ // `min`/`max` bind the stepper only — a typed value
84
+ // reaches onChange unclamped.
85
+ quantity: Math.max(
86
+ 0,
87
+ Math.min(Number(event.target.value) || 0, line.quantity),
88
+ ),
89
+ resellable: current[line.id]?.resellable ?? true,
90
+ },
91
+ }))
92
+ }
93
+ />
94
+ </Field>
95
+ <Field orientation="horizontal">
96
+ <FieldLabel htmlFor={`resellable-${line.id}`}>
97
+ {t('admin.pages.orders.detail.returns.resellable')}
98
+ </FieldLabel>
99
+ <Switch
100
+ id={`resellable-${line.id}`}
101
+ checked={rows[line.id]?.resellable ?? true}
102
+ onCheckedChange={(checked) =>
103
+ setRows((current) => ({
104
+ ...current,
105
+ [line.id]: {
106
+ quantity: current[line.id]?.quantity ?? 0,
107
+ resellable: checked,
108
+ },
109
+ }))
110
+ }
111
+ />
112
+ </Field>
113
+ </div>
114
+ </div>
115
+ ))}
116
+ </DialogBody>
117
+ <DialogFooter>
118
+ <Button type="button" variant="outline" onClick={onClose} disabled={pending}>
119
+ {t('admin.actions.cancel')}
120
+ </Button>
121
+ <Button
122
+ type="button"
123
+ disabled={pending}
124
+ onClick={() =>
125
+ onSubmit(
126
+ lines.map((line) => ({
127
+ return_line_item_id: line.id,
128
+ quantity: rows[line.id]?.quantity ?? 0,
129
+ resellable: rows[line.id]?.resellable ?? true,
130
+ })),
131
+ )
132
+ }
133
+ >
134
+ {t('admin.pages.orders.detail.returns.actions.receive')}
135
+ </Button>
136
+ </DialogFooter>
137
+ </DialogContent>
138
+ </Dialog>
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Gives the money back: how much, and by what means.
144
+ *
145
+ * The currency symbol comes from the caller — the operator's panel reads it
146
+ * from the store it is looking at, and a seller has no currency of their own.
147
+ */
148
+ export function ReturnRefundDialog({
149
+ refundableTotal,
150
+ currencySymbol,
151
+ onClose,
152
+ onSubmit,
153
+ pending = false,
154
+ }: {
155
+ refundableTotal: string
156
+ currencySymbol: string
157
+ onClose: () => void
158
+ onSubmit: (params: { refundMethod: RefundMethod; amount?: string }) => void
159
+ pending?: boolean
160
+ }) {
161
+ const { t } = useTranslation()
162
+ const [refundMethod, setRefundMethod] = useState<RefundMethod>('original_payment')
163
+ const [amount, setAmount] = useState(refundableTotal)
164
+
165
+ const methodOptions = [
166
+ {
167
+ value: 'original_payment',
168
+ label: t('admin.pages.orders.detail.returns.refund_methods.original_payment'),
169
+ },
170
+ {
171
+ value: 'store_credit',
172
+ label: t('admin.pages.orders.detail.returns.refund_methods.store_credit'),
173
+ },
174
+ ]
175
+
176
+ return (
177
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
178
+ <DialogContent>
179
+ <DialogHeader>
180
+ <DialogTitle>{t('admin.pages.orders.detail.returns.refund_title')}</DialogTitle>
181
+ </DialogHeader>
182
+ <DialogBody className="flex flex-col gap-4">
183
+ <Field>
184
+ <FieldLabel htmlFor="refund-amount">
185
+ {t('admin.pages.orders.detail.returns.refund_amount')}
186
+ </FieldLabel>
187
+ <InputGroup>
188
+ <InputGroupAddon>
189
+ <InputGroupText>{currencySymbol}</InputGroupText>
190
+ </InputGroupAddon>
191
+ <InputGroupInput
192
+ id="refund-amount"
193
+ type="number"
194
+ step="0.01"
195
+ min="0"
196
+ value={amount}
197
+ onChange={(event) => setAmount(event.target.value)}
198
+ />
199
+ </InputGroup>
200
+ </Field>
201
+ <Field>
202
+ <FieldLabel htmlFor="refund-method">
203
+ {t('admin.pages.orders.detail.returns.refund_method')}
204
+ </FieldLabel>
205
+ <Select
206
+ items={methodOptions}
207
+ value={refundMethod}
208
+ onValueChange={(value) => setRefundMethod(value as RefundMethod)}
209
+ >
210
+ <SelectTrigger id="refund-method">
211
+ <SelectValue />
212
+ </SelectTrigger>
213
+ <SelectContent>
214
+ {methodOptions.map((option) => (
215
+ <SelectItem key={option.value} value={option.value}>
216
+ {option.label}
217
+ </SelectItem>
218
+ ))}
219
+ </SelectContent>
220
+ </Select>
221
+ </Field>
222
+ </DialogBody>
223
+ <DialogFooter>
224
+ <Button type="button" variant="outline" onClick={onClose} disabled={pending}>
225
+ {t('admin.actions.cancel')}
226
+ </Button>
227
+ <Button
228
+ type="button"
229
+ disabled={pending}
230
+ onClick={() => onSubmit({ refundMethod, amount })}
231
+ >
232
+ {t('admin.pages.orders.detail.returns.actions.refund')}
233
+ </Button>
234
+ </DialogFooter>
235
+ </DialogContent>
236
+ </Dialog>
237
+ )
238
+ }
@@ -1,4 +1,3 @@
1
- import { AlertTriangleIcon, RotateCcwIcon } from 'lucide-react'
2
1
  import { Component, type ReactNode } from 'react'
3
2
  import { useTranslation } from 'react-i18next'
4
3
  import { Button } from '../ui/button'
@@ -10,6 +9,7 @@ import {
10
9
  EmptyMedia,
11
10
  EmptyTitle,
12
11
  } from '../ui/empty'
12
+ import { AlertTriangleIcon, RotateCcwIcon } from './icons'
13
13
 
14
14
  interface ErrorStateProps {
15
15
  /** Headline. Defaults to "Something went wrong". */
@@ -1,4 +1,3 @@
1
- import { CopyIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, Trash2Icon } from 'lucide-react'
2
1
  import type { ReactNode } from 'react'
3
2
  import { useTranslation } from 'react-i18next'
4
3
  import { Button } from '../ui/button'
@@ -8,6 +7,7 @@ import {
8
7
  DropdownMenuItem,
9
8
  DropdownMenuTrigger,
10
9
  } from '../ui/dropdown-menu'
10
+ import { CopyIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, Trash2Icon } from './icons'
11
11
 
12
12
  export interface RowAction {
13
13
  /** Stable key for React reconciliation. Doubles as the i18n action slug
@@ -73,7 +73,7 @@ export function RowActions({ actions }: RowActionsProps) {
73
73
  key={action.key}
74
74
  onClick={action.onSelect}
75
75
  disabled={action.disabled}
76
- className={action.destructive ? 'text-destructive focus:text-destructive' : undefined}
76
+ variant={action.destructive ? 'destructive' : undefined}
77
77
  >
78
78
  {action.icon ?? BUILTIN_ICONS[action.key]}
79
79
  {action.label ?? t(`admin.row_actions.${action.key}`)}
@@ -0,0 +1,76 @@
1
+ import type * as React from 'react'
2
+ import { useRef } from 'react'
3
+ import { cn } from '../lib/utils'
4
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group'
5
+ import { SearchIcon, XIcon } from './icons'
6
+
7
+ /**
8
+ * Search box with a leading magnifier and our own clear button.
9
+ *
10
+ * The clear button is hand-rolled rather than `type="search"`'s native one:
11
+ * that control is unstyleable, ignores the design tokens, renders at a size no
12
+ * touch guideline would accept, and Safari on iOS omits it entirely — so on a
13
+ * phone the field could not be cleared at all. The input stays `type="text"`
14
+ * to keep the native affordance from rendering alongside this one, and carries
15
+ * `role="searchbox"` so it still announces as a search field — the role a
16
+ * `type="search"` input would have given it for free.
17
+ *
18
+ * Clearing returns focus to the field, since the user is almost always about
19
+ * to type a different query.
20
+ */
21
+ export function SearchInput({
22
+ value,
23
+ onValueChange,
24
+ className,
25
+ clearLabel = 'Clear search',
26
+ ...props
27
+ }: Omit<React.ComponentProps<'input'>, 'value' | 'onChange' | 'type' | 'className'> & {
28
+ value: string
29
+ onValueChange: (value: string) => void
30
+ /** Sizing and surface for the whole field. The input fills it. */
31
+ className?: string
32
+ /** Accessible name for the clear button. */
33
+ clearLabel?: string
34
+ }) {
35
+ const inputRef = useRef<HTMLInputElement>(null)
36
+
37
+ return (
38
+ <InputGroup className={className}>
39
+ <InputGroupAddon>
40
+ <SearchIcon className="size-4" />
41
+ </InputGroupAddon>
42
+ {/* `h-full` so callers size the field once, on the wrapper, rather than
43
+ repeating the same height rule on both. */}
44
+ <InputGroupInput
45
+ ref={inputRef}
46
+ type="text"
47
+ role="searchbox"
48
+ value={value}
49
+ onChange={(event) => onValueChange(event.target.value)}
50
+ className="h-full"
51
+ {...props}
52
+ />
53
+ {value && (
54
+ <InputGroupAddon align="inline-end">
55
+ <InputGroupButton
56
+ // `icon-sm` rather than the default `xs`: that variant hardcodes
57
+ // `h-6`, which tailwind-merge cannot reconcile against a `size-*`
58
+ // override, so the button would stay 24px however it was classed.
59
+ size="icon-sm"
60
+ aria-label={clearLabel}
61
+ onClick={() => {
62
+ onValueChange('')
63
+ inputRef.current?.focus()
64
+ }}
65
+ // Touch-sized by default and only compact from `md` up, so a phone
66
+ // always gets the larger target whether or not the field happens to
67
+ // sit inside the mobile drawer. The glyph stays small either way.
68
+ className={cn('size-9 rounded-full md:size-6 in-data-[mobile=true]:size-9')}
69
+ >
70
+ <XIcon className="size-4" />
71
+ </InputGroupButton>
72
+ </InputGroupAddon>
73
+ )}
74
+ </InputGroup>
75
+ )
76
+ }
@@ -1,4 +1,3 @@
1
- import { EyeIcon, EyeOffIcon } from 'lucide-react'
2
1
  import { useState } from 'react'
3
2
  import { useTranslation } from 'react-i18next'
4
3
  import { Button } from '../ui/button'
@@ -20,11 +19,9 @@ interface SecretInputProps {
20
19
  value: unknown
21
20
  onChange: (value: unknown) => void
22
21
  /**
23
- * When true, a value that arrives carrying the server's mask token
24
- * (`••••3K9z`) is treated as "already stored" — show the masked badge
25
- * with a Replace button instead of an editable input. Cancel restores
26
- * the original masked value so the backend's round-trip guard keeps
27
- * the existing secret.
22
+ * @deprecated Masked values always render as the stored badge — there is
23
+ * nothing to reveal, since the server never sends the secret. Retained so
24
+ * existing callers keep compiling; the prop has no effect.
28
25
  */
29
26
  redactWhenMasked?: boolean
30
27
  /** Replaces the default "Stored on the server. Click Replace to rotate." caption. */
@@ -34,12 +31,13 @@ interface SecretInputProps {
34
31
  }
35
32
 
36
33
  /**
37
- * Stripe-style credential field. When the value comes back from the API
38
- * masked (`••••3K9z`), display it as a read-only badge with a "Replace"
39
- * button. Replace switches to a password input pre-filled empty so the
40
- * admin opts in to rotation; Cancel reverts to the masked badge so the
41
- * next save preserves the existing secret via the backend's masked
42
- * round-trip guard.
34
+ * Stripe-style credential field. A stored secret arrives from the API as a
35
+ * mask (`••••3K9z`) and renders as a read-only badge with a "Replace"
36
+ * button — never as an input, and never with a reveal toggle, because the
37
+ * server does not send the secret and there is nothing to unhide. Replace
38
+ * switches to an empty password input so the admin opts in to rotation;
39
+ * Cancel reverts to the badge so the next save preserves the existing
40
+ * secret via the backend's masked round-trip guard.
43
41
  *
44
42
  * Domain-agnostic — used by `<PreferencesForm>` for `:password`-typed
45
43
  * preferences, but also suitable for any other secret that round-trips
@@ -50,7 +48,6 @@ export function SecretInput({
50
48
  label,
51
49
  value,
52
50
  onChange,
53
- redactWhenMasked = false,
54
51
  helpText,
55
52
  placeholder,
56
53
  }: SecretInputProps) {
@@ -59,15 +56,16 @@ export function SecretInput({
59
56
  // Captured at click time so Cancel can restore the original mask even
60
57
  // after `onChange('')` has cleared the parent value.
61
58
  const [pendingMask, setPendingMask] = useState<string | null>(null)
62
- const [revealed, setRevealed] = useState(false)
63
59
  const replacing = pendingMask !== null
64
60
 
65
- if (redactWhenMasked && storedMask && !replacing) {
61
+ // A stored secret always renders as the badge: the server sent a mask,
62
+ // not the value, so there is nothing a reveal toggle could show.
63
+ if (storedMask && !replacing) {
66
64
  return (
67
65
  <Field>
68
66
  <FieldLabel htmlFor={id}>{label}</FieldLabel>
69
67
  <div className="flex items-center gap-2">
70
- <div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5 shadow-xs">
68
+ <div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5">
71
69
  <span className="font-mono text-sm tabular-nums text-muted-foreground" id={id}>
72
70
  {storedMask}
73
71
  </span>
@@ -95,37 +93,25 @@ export function SecretInput({
95
93
  <Field>
96
94
  <FieldLabel htmlFor={id}>{label}</FieldLabel>
97
95
  <div className="flex items-center gap-2">
98
- <div className="relative flex-1">
99
- <Input
100
- id={id}
101
- type={revealed ? 'text' : 'password'}
102
- autoComplete="new-password"
103
- placeholder={
104
- replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
105
- }
106
- value={(value as string) ?? ''}
107
- onChange={(e) => onChange(e.target.value)}
108
- className="pr-9"
109
- />
110
- <button
111
- type="button"
112
- aria-label={revealed ? t('admin.a11y.hide_value') : t('admin.a11y.show_value')}
113
- aria-pressed={revealed}
114
- onClick={() => setRevealed((v) => !v)}
115
- className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
116
- >
117
- {revealed ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
118
- </button>
119
- </div>
96
+ <Input
97
+ id={id}
98
+ type="password"
99
+ autoComplete="new-password"
100
+ placeholder={
101
+ replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
102
+ }
103
+ value={(value as string) ?? ''}
104
+ onChange={(e) => onChange(e.target.value)}
105
+ className="flex-1"
106
+ />
120
107
  {replacing && (
121
108
  <Button
122
109
  type="button"
123
110
  size="sm"
124
- variant="ghost"
111
+ variant="outline"
125
112
  onClick={() => {
126
113
  onChange(pendingMask)
127
114
  setPendingMask(null)
128
- setRevealed(false)
129
115
  }}
130
116
  >
131
117
  {t('admin.actions.cancel')}