@modern-admin/react 0.1.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.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,1143 @@
1
+ // Admin Settings hub. Reachable from the user/profile dropdown menu and
2
+ // rendered by the router for `/settings/<section>`. Currently three
3
+ // sections: `api-keys`, `webhooks`, `ai-assistant`; the layout is built so
4
+ // adding more sections is just a new entry in `SECTIONS` + a switch case.
5
+
6
+ import * as React from 'react'
7
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
8
+ import {
9
+ Badge,
10
+ Button,
11
+ Checkbox,
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ InfoTooltip,
19
+ Input,
20
+ JsonEditor,
21
+ Label,
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ Switch,
28
+ Table,
29
+ TableBody,
30
+ TableCell,
31
+ TableHead,
32
+ TableHeader,
33
+ TableRow,
34
+ cn,
35
+ } from '@modern-admin/ui'
36
+ import {
37
+ AlertTriangle,
38
+ Bot,
39
+ Check,
40
+ Copy,
41
+ Edit,
42
+ Eye,
43
+ EyeOff,
44
+ KeyRound,
45
+ Plus,
46
+ Settings as SettingsIcon,
47
+ Trash2,
48
+ X,
49
+ } from 'lucide-react'
50
+ import { useAdminClient } from '../provider.js'
51
+ import { useFeatures, useResources } from '../hooks.js'
52
+ import { useI18n } from '../i18n.js'
53
+ import { Link, useNavigate } from '../router.js'
54
+ import { useNotify } from '../notify.js'
55
+ import { useDialogs } from '../dialogs.js'
56
+ import type { ApiKeyRecord, WebhookInput, WebhookRecord } from '../client.js'
57
+ import type { ResourceJSON } from '../types.js'
58
+ import { getSettingsSectionExtensions } from '../extension-registry.js'
59
+ import { AiAssistantSettingsSection } from './ai-assistant-settings-section.js'
60
+ import { SettingsCard, SettingsListState, SettingsTableScroll } from './settings-shared.js'
61
+
62
+ const KEY_LIST = ['modern-admin', 'api-keys'] as const
63
+ const KEY_WEBHOOKS = ['modern-admin', 'webhooks'] as const
64
+
65
+ type BuiltInSectionKey = 'api-keys' | 'ai-assistant' | 'webhooks'
66
+
67
+ interface SectionDef {
68
+ key: string
69
+ labelKey: string
70
+ icon: React.ComponentType<{ className?: string }>
71
+ /** When `true`, the section is supplied by an extension (no built-in renderer). */
72
+ isExtension?: boolean
73
+ }
74
+
75
+ const BUILT_IN_SECTIONS: SectionDef[] = [
76
+ { key: 'api-keys', labelKey: 'settings:apiKeys.title', icon: KeyRound },
77
+ { key: 'webhooks', labelKey: 'settings:webhooks.title', icon: SettingsIcon },
78
+ { key: 'ai-assistant', labelKey: 'aiAssistant:title', icon: Bot },
79
+ ]
80
+
81
+ export function SettingsPage({ section }: { section?: string }): React.ReactElement {
82
+ const { t } = useI18n()
83
+ const navigate = useNavigate()
84
+ const features = useFeatures()
85
+ // Only surface built-in sections whose backend subsystem is wired.
86
+ // Extension sections are always shown when registered.
87
+ const SECTIONS = React.useMemo<SectionDef[]>(
88
+ () => [
89
+ ...BUILT_IN_SECTIONS.filter((s) =>
90
+ s.key === 'api-keys' ? features.apiKeys
91
+ : s.key === 'webhooks' ? features.webhooks
92
+ : s.key === 'ai-assistant' ? features.aiAssistant
93
+ : true,
94
+ ),
95
+ ...getSettingsSectionExtensions().map((ext) => ({
96
+ key: ext.key,
97
+ labelKey: ext.labelKey,
98
+ icon: ext.icon,
99
+ isExtension: true as const,
100
+ })),
101
+ ],
102
+
103
+ [features.apiKeys, features.webhooks, features.aiAssistant],
104
+ )
105
+ // Resolve the requested section. If the URL is bogus or the section is
106
+ // disabled, fall back to the first enabled section.
107
+ const builtInKeys: BuiltInSectionKey[] = ['api-keys', 'webhooks', 'ai-assistant']
108
+ const isBuiltIn = (k: string): k is BuiltInSectionKey =>
109
+ (builtInKeys as string[]).includes(k)
110
+ const requested: string | null = section ?? null
111
+ const active: string | null =
112
+ requested && SECTIONS.some((s) => s.key === requested)
113
+ ? requested
114
+ : (SECTIONS[0]?.key ?? null)
115
+ if (active === null) {
116
+ // Defensive: the user menu hides Settings entirely when no section is
117
+ // available, so users normally won't land here. Direct navigation to
118
+ // /settings without any configured section ends up showing this.
119
+ return (
120
+ <div className="rounded-md border border-dashed bg-card p-8 text-center text-sm text-muted-foreground">
121
+ {t('settings:noSectionsConfigured')}
122
+ </div>
123
+ )
124
+ }
125
+ return (
126
+ // `minmax(0,1fr)` (not bare `1fr`) lets the content column shrink below
127
+ // its intrinsic min-width — otherwise wide tables push the whole grid
128
+ // past the viewport at ~`lg` widths (~1000–1100px).
129
+ <div className="flex flex-col gap-4 lg:grid lg:grid-cols-[14rem_minmax(0,1fr)]">
130
+ {/* Mobile: dropdown selector (handles many sections gracefully) */}
131
+ <div className="lg:hidden">
132
+ <Select value={active ?? ''} onValueChange={(v) => navigate({ name: 'settings', section: v })}>
133
+ <SelectTrigger className="w-full">
134
+ <SelectValue />
135
+ </SelectTrigger>
136
+ <SelectContent>
137
+ {SECTIONS.map(({ key, labelKey, icon: Icon }) => (
138
+ <SelectItem key={key} value={key}>
139
+ <span className="flex items-center gap-2">
140
+ <Icon className="size-4" />
141
+ <span>{t(labelKey)}</span>
142
+ </span>
143
+ </SelectItem>
144
+ ))}
145
+ </SelectContent>
146
+ </Select>
147
+ </div>
148
+ {/* Desktop: sidebar nav */}
149
+ <aside className="hidden lg:block">
150
+ <nav className="flex flex-col gap-1">
151
+ {SECTIONS.map(({ key, labelKey, icon: Icon }) => (
152
+ <Link
153
+ key={key}
154
+ to={{ name: 'settings', section: key }}
155
+ className={cn(
156
+ 'flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent',
157
+ active === key && 'bg-accent font-medium',
158
+ )}
159
+ >
160
+ <Icon className="size-4" />
161
+ <span>{t(labelKey)}</span>
162
+ </Link>
163
+ ))}
164
+ </nav>
165
+ </aside>
166
+ <section className="min-w-0">
167
+ {active && isBuiltIn(active) && active === 'api-keys' && <ApiKeysSection />}
168
+ {active && isBuiltIn(active) && active === 'webhooks' && <WebhooksSection />}
169
+ {active && isBuiltIn(active) && active === 'ai-assistant' && <AiAssistantSettingsSection />}
170
+ {active && !isBuiltIn(active) && (() => {
171
+ const extSection = getSettingsSectionExtensions().find((e) => e.key === active)
172
+ return extSection ? <extSection.component /> : null
173
+ })()}
174
+ </section>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ // ─── API Keys section ─────────────────────────────────────────────────────────
180
+
181
+ function ApiKeysSection(): React.ReactElement {
182
+ const { t } = useI18n()
183
+ const client = useAdminClient()
184
+ const qc = useQueryClient()
185
+ const notify = useNotify()
186
+ const dialogs = useDialogs()
187
+ const resources = useResources()
188
+ const [editorOpen, setEditorOpen] = React.useState(false)
189
+ const [editing, setEditing] = React.useState<ApiKeyRecord | null>(null)
190
+ const [createdSecret, setCreatedSecret] = React.useState<{ key: string; record: ApiKeyRecord } | null>(null)
191
+
192
+ const list = useQuery({
193
+ queryKey: KEY_LIST,
194
+ queryFn: () => client.listApiKeys(),
195
+ })
196
+
197
+ const deleteMut = useMutation({
198
+ mutationFn: (id: string) => client.deleteApiKey(id),
199
+ onSuccess: () => {
200
+ qc.invalidateQueries({ queryKey: KEY_LIST })
201
+ notify.success({ key: 'settings:apiKeys.notice.revoked' })
202
+ },
203
+ onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
204
+ })
205
+
206
+ const toggleEnabledMut = useMutation({
207
+ mutationFn: (vars: { id: string; enabled: boolean }) =>
208
+ client.updateApiKey(vars.id, { enabled: vars.enabled }),
209
+ onSuccess: () => qc.invalidateQueries({ queryKey: KEY_LIST }),
210
+ onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
211
+ })
212
+
213
+ const onCreate = (): void => {
214
+ setEditing(null)
215
+ setEditorOpen(true)
216
+ }
217
+
218
+ const onEdit = (key: ApiKeyRecord): void => {
219
+ setEditing(key)
220
+ setEditorOpen(true)
221
+ }
222
+
223
+ const onRevoke = async (key: ApiKeyRecord): Promise<void> => {
224
+ const ok = await dialogs.confirm({
225
+ title: t('settings:apiKeys.confirmRevoke.title'),
226
+ description: t('settings:apiKeys.confirmRevoke.description', { name: key.name ?? key.id }),
227
+ confirmLabel: t('settings:apiKeys.actions.revoke'),
228
+ destructive: true,
229
+ })
230
+ if (ok) deleteMut.mutate(key.id)
231
+ }
232
+
233
+ const keys = list.data?.keys ?? []
234
+
235
+ return (
236
+ <div className="flex flex-col gap-4">
237
+ <SettingsCard
238
+ icon={KeyRound}
239
+ title={t('settings:apiKeys.title')}
240
+ description={t('settings:apiKeys.description')}
241
+ action={
242
+ <Button onClick={onCreate} size="sm">
243
+ <Plus className="size-4" />
244
+ <span>{t('settings:apiKeys.actions.create')}</span>
245
+ </Button>
246
+ }
247
+ >
248
+ <SettingsListState
249
+ isLoading={list.isLoading}
250
+ error={list.error}
251
+ isEmpty={keys.length === 0}
252
+ loadingLabel={t('common:loading')}
253
+ empty={{
254
+ icon: KeyRound,
255
+ title: t('settings:apiKeys.empty.title'),
256
+ description: t('settings:apiKeys.empty.description'),
257
+ }}
258
+ >
259
+ <SettingsTableScroll>
260
+ <Table>
261
+ <TableHeader>
262
+ <TableRow>
263
+ <TableHead>{t('settings:apiKeys.columns.name')}</TableHead>
264
+ <TableHead className="hidden sm:table-cell">{t('settings:apiKeys.columns.start')}</TableHead>
265
+ <TableHead className="hidden md:table-cell">{t('settings:apiKeys.columns.permissions')}</TableHead>
266
+ <TableHead className="hidden md:table-cell">{t('settings:apiKeys.columns.expiresAt')}</TableHead>
267
+ <TableHead>{t('settings:apiKeys.columns.enabled')}</TableHead>
268
+ <TableHead className="text-right">{t('settings:apiKeys.columns.actions')}</TableHead>
269
+ </TableRow>
270
+ </TableHeader>
271
+ <TableBody>
272
+ {keys.map((k) => (
273
+ <TableRow key={k.id}>
274
+ <TableCell className="font-medium">
275
+ <div className="flex flex-col">
276
+ <span>{k.name ?? k.id}</span>
277
+ {k.lastRequest && (
278
+ <span className="text-xs text-muted-foreground">
279
+ {t('settings:apiKeys.lastUsed', { date: formatDate(k.lastRequest) })}
280
+ </span>
281
+ )}
282
+ </div>
283
+ </TableCell>
284
+ <TableCell className="hidden font-mono text-xs sm:table-cell">
285
+ {k.start ? `${k.start}…` : '—'}
286
+ </TableCell>
287
+ <TableCell className="hidden md:table-cell">
288
+ <PermissionsSummary permissions={k.permissions} />
289
+ </TableCell>
290
+ <TableCell className="hidden md:table-cell text-xs">
291
+ {k.expiresAt ? formatDate(k.expiresAt) : t('settings:apiKeys.expiresNever')}
292
+ </TableCell>
293
+ <TableCell>
294
+ <Switch
295
+ checked={k.enabled}
296
+ onCheckedChange={(enabled) => toggleEnabledMut.mutate({ id: k.id, enabled })}
297
+ aria-label={t('settings:apiKeys.columns.enabled')}
298
+ />
299
+ </TableCell>
300
+ <TableCell className="text-right">
301
+ <div className="inline-flex gap-1">
302
+ <Button variant="ghost" size="sm" onClick={() => onEdit(k)} aria-label={t('settings:apiKeys.actions.edit')}>
303
+ <Edit className="size-4" />
304
+ </Button>
305
+ <Button
306
+ variant="ghost"
307
+ size="sm"
308
+ onClick={() => onRevoke(k)}
309
+ aria-label={t('settings:apiKeys.actions.revoke')}
310
+ disabled={deleteMut.isPending}
311
+ >
312
+ <Trash2 className="size-4 text-destructive" />
313
+ </Button>
314
+ </div>
315
+ </TableCell>
316
+ </TableRow>
317
+ ))}
318
+ </TableBody>
319
+ </Table>
320
+ </SettingsTableScroll>
321
+ </SettingsListState>
322
+ </SettingsCard>
323
+
324
+ <ApiKeyEditorDialog
325
+ key={editing?.id ?? 'new'}
326
+ open={editorOpen}
327
+ onOpenChange={setEditorOpen}
328
+ editing={editing}
329
+ resources={resources}
330
+ onCreated={(result) => {
331
+ setEditorOpen(false)
332
+ setCreatedSecret(result)
333
+ qc.invalidateQueries({ queryKey: KEY_LIST })
334
+ }}
335
+ onUpdated={() => {
336
+ setEditorOpen(false)
337
+ qc.invalidateQueries({ queryKey: KEY_LIST })
338
+ }}
339
+ />
340
+
341
+ <CreatedSecretDialog
342
+ secret={createdSecret}
343
+ onClose={() => setCreatedSecret(null)}
344
+ />
345
+ </div>
346
+ )
347
+ }
348
+
349
+ // ─── Permissions matrix editor ────────────────────────────────────────────────
350
+
351
+ interface PermissionsState {
352
+ /** resourceId -> Set<actionName>. `'*'` means all actions of that resource. */
353
+ byResource: Record<string, Set<string>>
354
+ }
355
+
356
+ const buildState = (perms: Record<string, string[]>): PermissionsState => ({
357
+ byResource: Object.fromEntries(
358
+ Object.entries(perms).map(([k, v]) => [k, new Set(v)]),
359
+ ),
360
+ })
361
+
362
+ const stateToWire = (state: PermissionsState): Record<string, string[]> => {
363
+ const out: Record<string, string[]> = {}
364
+ for (const [k, set] of Object.entries(state.byResource)) {
365
+ if (set.size === 0) continue
366
+ out[k] = Array.from(set)
367
+ }
368
+ return out
369
+ }
370
+
371
+ function PermissionsMatrix({
372
+ resources,
373
+ state,
374
+ onChange,
375
+ }: {
376
+ resources: ResourceJSON[]
377
+ state: PermissionsState
378
+ onChange: (next: PermissionsState) => void
379
+ }): React.ReactElement {
380
+ const { t } = useI18n()
381
+
382
+ const toggle = (resourceId: string, action: string): void => {
383
+ const set = new Set(state.byResource[resourceId] ?? [])
384
+ if (set.has(action)) set.delete(action)
385
+ else set.add(action)
386
+ onChange({ byResource: { ...state.byResource, [resourceId]: set } })
387
+ }
388
+
389
+ const toggleAll = (resourceId: string, actions: string[]): void => {
390
+ const current = state.byResource[resourceId] ?? new Set<string>()
391
+ const allSelected = actions.every((a) => current.has(a))
392
+ const next = new Set<string>(allSelected ? [] : actions)
393
+ onChange({ byResource: { ...state.byResource, [resourceId]: next } })
394
+ }
395
+
396
+ const resourcesWithActions = React.useMemo(
397
+ () => resources.filter((r) => (r.actions ?? []).length > 0),
398
+ [resources],
399
+ )
400
+
401
+ const allGloballySelected =
402
+ resourcesWithActions.length > 0 &&
403
+ resourcesWithActions.every((r) => {
404
+ const actions = (r.actions ?? []).map((a) => a.name)
405
+ const current = state.byResource[r.id] ?? new Set<string>()
406
+ return actions.every((a) => current.has(a))
407
+ })
408
+
409
+ const toggleAllResources = (): void => {
410
+ const select = !allGloballySelected
411
+ const next: Record<string, Set<string>> = { ...state.byResource }
412
+ for (const r of resources) {
413
+ const actions = (r.actions ?? []).map((a) => a.name)
414
+ next[r.id] = select ? new Set(actions) : new Set<string>()
415
+ }
416
+ onChange({ byResource: next })
417
+ }
418
+
419
+ if (resources.length === 0) {
420
+ return (
421
+ <p className="text-sm text-muted-foreground">{t('settings:apiKeys.permissions.noResources')}</p>
422
+ )
423
+ }
424
+
425
+ return (
426
+ <div className="rounded-md border border-border">
427
+ {resourcesWithActions.length > 0 && (
428
+ <div className="flex flex-wrap items-center justify-end gap-2 border-b border-border bg-muted/30 px-3 py-2">
429
+ <Button
430
+ type="button"
431
+ variant="outline"
432
+ size="sm"
433
+ className="h-8"
434
+ onClick={toggleAllResources}
435
+ >
436
+ {allGloballySelected ? (
437
+ <>
438
+ <X className="mr-1.5 size-3.5" />
439
+ {t('settings:apiKeys.permissions.clearAllResources')}
440
+ </>
441
+ ) : (
442
+ <>
443
+ <Check className="mr-1.5 size-3.5" />
444
+ {t('settings:apiKeys.permissions.selectAllResources')}
445
+ </>
446
+ )}
447
+ </Button>
448
+ </div>
449
+ )}
450
+ <div className="max-h-[24rem] overflow-y-auto">
451
+ <Table>
452
+ <TableHeader className="sticky top-0 bg-card">
453
+ <TableRow>
454
+ <TableHead className="w-[14rem]">{t('settings:apiKeys.permissions.resource')}</TableHead>
455
+ <TableHead>{t('settings:apiKeys.permissions.actions')}</TableHead>
456
+ </TableRow>
457
+ </TableHeader>
458
+ <TableBody>
459
+ {resources.map((r) => {
460
+ const actions = (r.actions ?? []).map((a) => a.name)
461
+ const current = state.byResource[r.id] ?? new Set<string>()
462
+ const allSelected = actions.length > 0 && actions.every((a) => current.has(a))
463
+ return (
464
+ <TableRow key={r.id}>
465
+ <TableCell className="align-top">
466
+ <div className="flex flex-col gap-1">
467
+ <span className="font-medium">{r.name}</span>
468
+ <span className="text-xs text-muted-foreground">{r.id}</span>
469
+ <button
470
+ type="button"
471
+ className="mt-1 inline-flex items-center gap-1 self-start text-xs text-primary hover:underline"
472
+ onClick={() => toggleAll(r.id, actions)}
473
+ >
474
+ {allSelected ? <X className="size-3" /> : <Check className="size-3" />}
475
+ {allSelected
476
+ ? t('settings:apiKeys.permissions.clearAll')
477
+ : t('settings:apiKeys.permissions.selectAll')}
478
+ </button>
479
+ </div>
480
+ </TableCell>
481
+ <TableCell>
482
+ <div className="flex flex-wrap gap-2">
483
+ {actions.map((a) => {
484
+ const checked = current.has(a)
485
+ return (
486
+ <label
487
+ key={a}
488
+ className={cn(
489
+ 'inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs',
490
+ checked && 'border-primary bg-primary/10 text-primary',
491
+ )}
492
+ >
493
+ <Checkbox
494
+ checked={checked}
495
+ onCheckedChange={() => toggle(r.id, a)}
496
+ className="size-3.5"
497
+ />
498
+ <span>{a}</span>
499
+ </label>
500
+ )
501
+ })}
502
+ </div>
503
+ </TableCell>
504
+ </TableRow>
505
+ )
506
+ })}
507
+ </TableBody>
508
+ </Table>
509
+ </div>
510
+ </div>
511
+ )
512
+ }
513
+
514
+ function PermissionsSummary({ permissions }: { permissions: Record<string, string[]> }): React.ReactElement {
515
+ const { t } = useI18n()
516
+ const entries = Object.entries(permissions)
517
+ if (entries.length === 0) {
518
+ return <Badge variant="outline">{t('settings:apiKeys.permissions.none')}</Badge>
519
+ }
520
+ const totalActions = entries.reduce((sum, [, a]) => sum + a.length, 0)
521
+ return (
522
+ <Badge variant="secondary">
523
+ {t('settings:apiKeys.permissions.summary', { resources: entries.length, actions: totalActions })}
524
+ </Badge>
525
+ )
526
+ }
527
+
528
+ // ─── Editor dialog ────────────────────────────────────────────────────────────
529
+
530
+ function ApiKeyEditorDialog({
531
+ open,
532
+ onOpenChange,
533
+ editing,
534
+ resources,
535
+ onCreated,
536
+ onUpdated,
537
+ }: {
538
+ open: boolean
539
+ onOpenChange: (open: boolean) => void
540
+ editing: ApiKeyRecord | null
541
+ resources: ResourceJSON[]
542
+ onCreated: (result: { key: string; record: ApiKeyRecord }) => void
543
+ onUpdated: (record: ApiKeyRecord) => void
544
+ }): React.ReactElement {
545
+ const { t } = useI18n()
546
+ const client = useAdminClient()
547
+ const notify = useNotify()
548
+ const isEdit = !!editing
549
+ const [name, setName] = React.useState(editing?.name ?? '')
550
+ const [expiresInDays, setExpiresInDays] = React.useState<string>(() => {
551
+ if (!editing?.expiresAt) return ''
552
+ const ms = new Date(editing.expiresAt).getTime() - Date.now()
553
+ return ms > 0 ? String(Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)))) : ''
554
+ })
555
+ const [permissions, setPermissions] = React.useState<PermissionsState>(() =>
556
+ buildState(editing?.permissions ?? {}),
557
+ )
558
+
559
+ const save = useMutation({
560
+ mutationFn: async (): Promise<{ key?: string; record: ApiKeyRecord }> => {
561
+ const wire = stateToWire(permissions)
562
+ if (isEdit && editing) {
563
+ const expiry = expiresInDays.trim() === '' ? null : Number(expiresInDays)
564
+ const res = await client.updateApiKey(editing.id, {
565
+ name: name.trim(),
566
+ permissions: wire,
567
+ expiresInDays: expiry === null ? null : Number.isFinite(expiry) && expiry > 0 ? expiry : undefined,
568
+ })
569
+ return { record: res.record }
570
+ }
571
+ const expiry = expiresInDays.trim() === '' ? null : Number(expiresInDays)
572
+ return client.createApiKey({
573
+ name: name.trim(),
574
+ permissions: wire,
575
+ expiresInDays: expiry === null ? null : Number.isFinite(expiry) && expiry > 0 ? expiry : undefined,
576
+ }) as Promise<{ key: string; record: ApiKeyRecord }>
577
+ },
578
+ onSuccess: (result) => {
579
+ if (isEdit) {
580
+ notify.success({ key: 'settings:apiKeys.notice.updated' })
581
+ onUpdated(result.record)
582
+ } else {
583
+ if (result.key) onCreated({ key: result.key, record: result.record })
584
+ }
585
+ },
586
+ onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
587
+ })
588
+
589
+ // Sync state when dialog re-opens against a different record.
590
+ React.useEffect(() => {
591
+ if (!open) return
592
+ setName(editing?.name ?? '')
593
+ setPermissions(buildState(editing?.permissions ?? {}))
594
+ if (!editing?.expiresAt) setExpiresInDays('')
595
+ else {
596
+ const ms = new Date(editing.expiresAt).getTime() - Date.now()
597
+ setExpiresInDays(ms > 0 ? String(Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)))) : '')
598
+ }
599
+ }, [open, editing])
600
+
601
+ const totalSelected = Object.values(permissions.byResource).reduce(
602
+ (sum, set) => sum + (set?.size ?? 0),
603
+ 0,
604
+ )
605
+
606
+ return (
607
+ <Dialog open={open} onOpenChange={onOpenChange}>
608
+ <DialogContent className="max-w-3xl">
609
+ <DialogHeader>
610
+ <DialogTitle>
611
+ {isEdit ? t('settings:apiKeys.editor.titleEdit') : t('settings:apiKeys.editor.titleCreate')}
612
+ </DialogTitle>
613
+ <DialogDescription>{t('settings:apiKeys.editor.description')}</DialogDescription>
614
+ </DialogHeader>
615
+ <form
616
+ className="flex flex-col gap-4"
617
+ onSubmit={(e) => {
618
+ e.preventDefault()
619
+ if (!name.trim()) return
620
+ save.mutate()
621
+ }}
622
+ >
623
+ <div className="grid gap-4 sm:grid-cols-2">
624
+ <div className="flex flex-col gap-1.5">
625
+ <Label htmlFor="api-key-name">{t('settings:apiKeys.editor.name')}</Label>
626
+ <Input
627
+ id="api-key-name"
628
+ value={name}
629
+ onChange={(e) => setName(e.target.value)}
630
+ placeholder={t('settings:apiKeys.editor.namePlaceholder')}
631
+ required
632
+ maxLength={64}
633
+ />
634
+ </div>
635
+ <div className="flex flex-col gap-1.5">
636
+ <Label htmlFor="api-key-expires">{t('settings:apiKeys.editor.expiresInDays')}</Label>
637
+ <Input
638
+ id="api-key-expires"
639
+ type="number"
640
+ min={1}
641
+ max={3650}
642
+ value={expiresInDays}
643
+ onChange={(e) => setExpiresInDays(e.target.value)}
644
+ placeholder={t('settings:apiKeys.editor.expiresPlaceholder')}
645
+ />
646
+ <span className="text-xs text-muted-foreground">
647
+ {t('settings:apiKeys.editor.expiresHint')}
648
+ </span>
649
+ </div>
650
+ </div>
651
+ <div className="flex flex-col gap-2">
652
+ <div className="flex items-center justify-between">
653
+ <Label>{t('settings:apiKeys.editor.permissions')}</Label>
654
+ <span className="text-xs text-muted-foreground">
655
+ {t('settings:apiKeys.editor.selectedActions', { count: totalSelected })}
656
+ </span>
657
+ </div>
658
+ <PermissionsMatrix resources={resources} state={permissions} onChange={setPermissions} />
659
+ </div>
660
+ <DialogFooter>
661
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
662
+ {t('common:cancel')}
663
+ </Button>
664
+ <Button type="submit" disabled={save.isPending || !name.trim() || totalSelected === 0}>
665
+ {save.isPending ? t('common:saving') : isEdit ? t('common:save') : t('settings:apiKeys.actions.create')}
666
+ </Button>
667
+ </DialogFooter>
668
+ </form>
669
+ </DialogContent>
670
+ </Dialog>
671
+ )
672
+ }
673
+
674
+ // ─── Webhooks section ─────────────────────────────────────────────────────────
675
+
676
+ const WEBHOOK_EVENTS = ['record.created', 'record.updated', 'record.deleted', '*']
677
+
678
+ function WebhooksSection(): React.ReactElement {
679
+ const { t } = useI18n()
680
+ const client = useAdminClient()
681
+ const qc = useQueryClient()
682
+ const notify = useNotify()
683
+ const dialogs = useDialogs()
684
+ const resources = useResources()
685
+ const [editorOpen, setEditorOpen] = React.useState(false)
686
+ const [editing, setEditing] = React.useState<WebhookRecord | null>(null)
687
+ const [selectedId, setSelectedId] = React.useState<string | null>(null)
688
+
689
+ const list = useQuery({
690
+ queryKey: KEY_WEBHOOKS,
691
+ queryFn: () => client.listWebhooks(),
692
+ })
693
+ const deliveries = useQuery({
694
+ queryKey: ['modern-admin', 'webhooks', selectedId, 'deliveries'],
695
+ queryFn: () => client.listWebhookDeliveries(selectedId!),
696
+ enabled: !!selectedId,
697
+ })
698
+ const deleteMut = useMutation({
699
+ mutationFn: (id: string) => client.deleteWebhook(id),
700
+ onSuccess: () => {
701
+ qc.invalidateQueries({ queryKey: KEY_WEBHOOKS })
702
+ notify.success({ key: 'settings:webhooks.notice.deleted' })
703
+ },
704
+ onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
705
+ })
706
+ const testMut = useMutation({
707
+ mutationFn: (id: string) => client.testWebhook(id),
708
+ onSuccess: () => {
709
+ if (selectedId) qc.invalidateQueries({ queryKey: ['modern-admin', 'webhooks', selectedId, 'deliveries'] })
710
+ notify.success({ key: 'settings:webhooks.notice.testQueued' })
711
+ },
712
+ onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
713
+ })
714
+
715
+ const webhooks = list.data?.webhooks ?? []
716
+
717
+ const onDelete = async (webhook: WebhookRecord): Promise<void> => {
718
+ const ok = await dialogs.confirm({
719
+ title: t('settings:webhooks.confirmDelete.title'),
720
+ description: t('settings:webhooks.confirmDelete.description', { name: webhook.name }),
721
+ confirmLabel: t('common:delete'),
722
+ destructive: true,
723
+ })
724
+ if (ok) deleteMut.mutate(webhook.id)
725
+ }
726
+
727
+ return (
728
+ <div className="flex flex-col gap-4">
729
+ <SettingsCard
730
+ icon={SettingsIcon}
731
+ title={t('settings:webhooks.title')}
732
+ description={t('settings:webhooks.description')}
733
+ action={
734
+ <Button size="sm" onClick={() => { setEditing(null); setEditorOpen(true) }}>
735
+ <Plus className="size-4" />
736
+ {t('settings:webhooks.actions.create')}
737
+ </Button>
738
+ }
739
+ >
740
+ <SettingsListState
741
+ isLoading={list.isLoading}
742
+ error={list.error}
743
+ isEmpty={webhooks.length === 0}
744
+ loadingLabel={t('common:loading')}
745
+ empty={{
746
+ icon: SettingsIcon,
747
+ title: t('settings:webhooks.empty.title'),
748
+ description: t('settings:webhooks.empty.description'),
749
+ }}
750
+ >
751
+ <SettingsTableScroll>
752
+ <Table>
753
+ <TableHeader>
754
+ <TableRow>
755
+ <TableHead>{t('settings:webhooks.columns.name')}</TableHead>
756
+ <TableHead>{t('settings:webhooks.columns.resource')}</TableHead>
757
+ <TableHead>{t('settings:webhooks.columns.events')}</TableHead>
758
+ <TableHead>{t('settings:webhooks.columns.enabled')}</TableHead>
759
+ <TableHead className="text-right">{t('settings:webhooks.columns.actions')}</TableHead>
760
+ </TableRow>
761
+ </TableHeader>
762
+ <TableBody>
763
+ {webhooks.map((webhook) => (
764
+ <TableRow key={webhook.id}>
765
+ <TableCell>
766
+ <button
767
+ type="button"
768
+ className="text-left font-medium hover:underline"
769
+ onClick={() => setSelectedId(webhook.id)}
770
+ >
771
+ {webhook.name}
772
+ </button>
773
+ <div className="max-w-xs truncate text-xs text-muted-foreground">{webhook.url}</div>
774
+ </TableCell>
775
+ <TableCell>{resourceName(resources, webhook.resourceId, t('settings:webhooks.editor.allResources'))}</TableCell>
776
+ <TableCell className="max-w-xs">
777
+ <div className="flex flex-wrap gap-1">
778
+ {webhook.events.map((event) => <Badge key={event} variant="outline">{event}</Badge>)}
779
+ </div>
780
+ </TableCell>
781
+ <TableCell>{webhook.enabled ? t('common:yes') : t('common:no')}</TableCell>
782
+ <TableCell className="text-right">
783
+ <div className="inline-flex gap-1">
784
+ <Button variant="ghost" size="sm" onClick={() => testMut.mutate(webhook.id)}>
785
+ {t('settings:webhooks.actions.test')}
786
+ </Button>
787
+ <Button variant="ghost" size="sm" onClick={() => { setEditing(webhook); setEditorOpen(true) }}>
788
+ <Edit className="size-4" />
789
+ </Button>
790
+ <Button variant="ghost" size="sm" onClick={() => void onDelete(webhook)}>
791
+ <Trash2 className="size-4 text-destructive" />
792
+ </Button>
793
+ </div>
794
+ </TableCell>
795
+ </TableRow>
796
+ ))}
797
+ </TableBody>
798
+ </Table>
799
+ </SettingsTableScroll>
800
+ </SettingsListState>
801
+ </SettingsCard>
802
+
803
+ {selectedId && (
804
+ <SettingsCard icon={SettingsIcon} title={t('settings:webhooks.deliveries.title')}>
805
+ {deliveries.isLoading ? (
806
+ <div className="py-4 text-sm text-muted-foreground">{t('common:loading')}</div>
807
+ ) : (
808
+ <SettingsTableScroll>
809
+ <Table>
810
+ <TableHeader>
811
+ <TableRow>
812
+ <TableHead>{t('settings:webhooks.deliveries.status')}</TableHead>
813
+ <TableHead>{t('settings:webhooks.deliveries.event')}</TableHead>
814
+ <TableHead>{t('settings:webhooks.deliveries.attempt')}</TableHead>
815
+ <TableHead>{t('settings:webhooks.deliveries.response')}</TableHead>
816
+ <TableHead>{t('settings:webhooks.deliveries.createdAt')}</TableHead>
817
+ </TableRow>
818
+ </TableHeader>
819
+ <TableBody>
820
+ {(deliveries.data?.deliveries ?? []).map((delivery) => (
821
+ <TableRow key={delivery.id}>
822
+ <TableCell>{delivery.status}</TableCell>
823
+ <TableCell>{delivery.event}</TableCell>
824
+ <TableCell>{delivery.attempt}</TableCell>
825
+ <TableCell className="max-w-sm truncate">
826
+ {delivery.responseStatus ?? delivery.error ?? delivery.responseBody ?? '—'}
827
+ </TableCell>
828
+ <TableCell>{formatDate(delivery.createdAt)}</TableCell>
829
+ </TableRow>
830
+ ))}
831
+ </TableBody>
832
+ </Table>
833
+ </SettingsTableScroll>
834
+ )}
835
+ </SettingsCard>
836
+ )}
837
+
838
+ <WebhookEditorDialog
839
+ key={editing?.id ?? 'new'}
840
+ open={editorOpen}
841
+ onOpenChange={setEditorOpen}
842
+ webhook={editing}
843
+ resources={resources}
844
+ />
845
+ </div>
846
+ )
847
+ }
848
+
849
+ function WebhookEditorDialog({
850
+ open,
851
+ onOpenChange,
852
+ webhook,
853
+ resources,
854
+ }: {
855
+ open: boolean
856
+ onOpenChange(open: boolean): void
857
+ webhook: WebhookRecord | null
858
+ resources: ResourceJSON[]
859
+ }): React.ReactElement {
860
+ const { t } = useI18n()
861
+ const client = useAdminClient()
862
+ const qc = useQueryClient()
863
+ const notify = useNotify()
864
+ const [name, setName] = React.useState(webhook?.name ?? '')
865
+ const [url, setUrl] = React.useState(webhook?.url ?? '')
866
+ const [resourceId, setResourceId] = React.useState(webhook?.resourceId ?? '')
867
+ const [enabled, setEnabled] = React.useState(webhook?.enabled ?? true)
868
+ const [secret, setSecret] = React.useState(webhook?.secret ?? '')
869
+ const [events, setEvents] = React.useState<string[]>(webhook?.events ?? ['record.created', 'record.updated'])
870
+ const [headers, setHeaders] = React.useState<Record<string, unknown>>(webhook?.headers ?? {})
871
+ const [filters, setFilters] = React.useState<Record<string, unknown>>(webhook?.filters ?? {})
872
+ const [payloadFields, setPayloadFields] = React.useState<string[]>(webhook?.payloadFields ?? [])
873
+ const selectedResource = resources.find((r) => r.id === resourceId)
874
+
875
+ const save = useMutation({
876
+ mutationFn: (payload: WebhookInput) =>
877
+ webhook ? client.updateWebhook(webhook.id, payload) : client.createWebhook(payload),
878
+ onSuccess: () => {
879
+ qc.invalidateQueries({ queryKey: KEY_WEBHOOKS })
880
+ notify.success({ key: 'settings:webhooks.notice.saved' })
881
+ onOpenChange(false)
882
+ },
883
+ onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
884
+ })
885
+
886
+ const toggleEvent = (event: string): void => {
887
+ setEvents((prev) => prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event])
888
+ }
889
+
890
+ const onSubmit = (e: React.FormEvent): void => {
891
+ e.preventDefault()
892
+ save.mutate({
893
+ name: name.trim(),
894
+ url: url.trim(),
895
+ events,
896
+ resourceId: resourceId || null,
897
+ enabled,
898
+ ...(secret.trim() ? { secret: secret.trim() } : {}),
899
+ headers: stringRecord(headers),
900
+ filters: stringRecord(filters),
901
+ payloadFields,
902
+ })
903
+ }
904
+
905
+ return (
906
+ <Dialog open={open} onOpenChange={onOpenChange}>
907
+ <DialogContent className="max-w-3xl">
908
+ <DialogHeader>
909
+ <DialogTitle>{webhook ? t('settings:webhooks.editor.titleEdit') : t('settings:webhooks.editor.titleCreate')}</DialogTitle>
910
+ <DialogDescription>{t('settings:webhooks.editor.description')}</DialogDescription>
911
+ </DialogHeader>
912
+ <form className="space-y-4" onSubmit={onSubmit}>
913
+ <div className="grid gap-3 sm:grid-cols-2">
914
+ <div className="space-y-1.5">
915
+ <Label>{t('settings:webhooks.editor.name')}</Label>
916
+ <Input value={name} onChange={(e) => setName(e.target.value)} required />
917
+ </div>
918
+ <div className="space-y-1.5">
919
+ <Label>{t('settings:webhooks.editor.url')}</Label>
920
+ <Input value={url} onChange={(e) => setUrl(e.target.value)} required type="url" />
921
+ </div>
922
+ <div className="space-y-1.5">
923
+ <Label>{t('settings:webhooks.editor.resource')}</Label>
924
+ <Select
925
+ value={resourceId || '__all__'}
926
+ onValueChange={(v) => { setResourceId(v === '__all__' ? '' : v); setPayloadFields([]) }}
927
+ >
928
+ <SelectTrigger>
929
+ <SelectValue />
930
+ </SelectTrigger>
931
+ <SelectContent>
932
+ <SelectItem value="__all__">{t('settings:webhooks.editor.allResources')}</SelectItem>
933
+ {resources.map((resource) => (
934
+ <SelectItem key={resource.id} value={resource.id}>
935
+ {resource.name}
936
+ {resource.name !== resource.id && (
937
+ <span className="ml-1.5 text-xs text-muted-foreground">({resource.id})</span>
938
+ )}
939
+ </SelectItem>
940
+ ))}
941
+ </SelectContent>
942
+ </Select>
943
+ </div>
944
+ <div className="space-y-1.5">
945
+ <div className="flex items-center gap-1.5">
946
+ <Label>{t('settings:webhooks.editor.secret')}</Label>
947
+ <InfoTooltip
948
+ content={t('settings:webhooks.editor.secretHint')}
949
+ ariaLabel={t('settings:webhooks.editor.secret')}
950
+ side="right"
951
+ />
952
+ </div>
953
+ <Input value={secret} onChange={(e) => setSecret(e.target.value)} type="password" />
954
+ </div>
955
+ </div>
956
+ <div className="flex items-center gap-2">
957
+ <Switch checked={enabled} onCheckedChange={setEnabled} />
958
+ <Label>{t('settings:webhooks.editor.enabled')}</Label>
959
+ </div>
960
+ <div className="space-y-2">
961
+ <Label>{t('settings:webhooks.editor.events')}</Label>
962
+ <div className="flex flex-wrap gap-2">
963
+ {WEBHOOK_EVENTS.map((event) => (
964
+ <label key={event} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
965
+ <Checkbox checked={events.includes(event)} onCheckedChange={() => toggleEvent(event)} />
966
+ {event}
967
+ </label>
968
+ ))}
969
+ </div>
970
+ </div>
971
+ {selectedResource && (
972
+ <div className="space-y-2">
973
+ <div className="flex items-center gap-1.5">
974
+ <Label>{t('settings:webhooks.editor.payloadFields')}</Label>
975
+ <InfoTooltip
976
+ content={t('settings:webhooks.editor.payloadFieldsHint')}
977
+ ariaLabel={t('settings:webhooks.editor.payloadFields')}
978
+ side="right"
979
+ />
980
+ </div>
981
+ <div className="grid gap-2 sm:grid-cols-2">
982
+ {selectedResource.properties.map((property) => (
983
+ <label key={property.path} className="flex items-center gap-2 text-sm">
984
+ <Checkbox
985
+ checked={payloadFields.includes(property.path)}
986
+ onCheckedChange={() =>
987
+ setPayloadFields((prev) =>
988
+ prev.includes(property.path)
989
+ ? prev.filter((p) => p !== property.path)
990
+ : [...prev, property.path],
991
+ )
992
+ }
993
+ />
994
+ {property.label}
995
+ </label>
996
+ ))}
997
+ </div>
998
+ </div>
999
+ )}
1000
+ <div className="grid gap-3 sm:grid-cols-2">
1001
+ <div className="space-y-1.5">
1002
+ <div className="flex items-center gap-1.5">
1003
+ <Label>{t('settings:webhooks.editor.headers')}</Label>
1004
+ <InfoTooltip
1005
+ content={t('settings:webhooks.editor.headersHint')}
1006
+ ariaLabel={t('settings:webhooks.editor.headers')}
1007
+ side="top"
1008
+ />
1009
+ </div>
1010
+ <JsonEditor value={headers} onChange={(next) => setHeaders(toJsonRecord(next))} />
1011
+ </div>
1012
+ <div className="space-y-1.5">
1013
+ <div className="flex items-center gap-1.5">
1014
+ <Label>{t('settings:webhooks.editor.filters')}</Label>
1015
+ <InfoTooltip
1016
+ content={t('settings:webhooks.editor.filtersHint')}
1017
+ ariaLabel={t('settings:webhooks.editor.filters')}
1018
+ side="top"
1019
+ />
1020
+ </div>
1021
+ <JsonEditor value={filters} onChange={(next) => setFilters(toJsonRecord(next))} />
1022
+ </div>
1023
+ </div>
1024
+ <DialogFooter>
1025
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>{t('common:cancel')}</Button>
1026
+ <Button type="submit" disabled={save.isPending || !name.trim() || !url.trim() || events.length === 0}>
1027
+ {save.isPending ? t('common:saving') : t('common:save')}
1028
+ </Button>
1029
+ </DialogFooter>
1030
+ </form>
1031
+ </DialogContent>
1032
+ </Dialog>
1033
+ )
1034
+ }
1035
+
1036
+ const resourceName = (
1037
+ resources: ResourceJSON[],
1038
+ id: string | null | undefined,
1039
+ fallback: string,
1040
+ ): string => id ? (resources.find((r) => r.id === id)?.name ?? id) : fallback
1041
+
1042
+ const toJsonRecord = (value: unknown): Record<string, unknown> =>
1043
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
1044
+
1045
+ const stringRecord = (value: Record<string, unknown>): Record<string, string> => {
1046
+ const out: Record<string, string> = {}
1047
+ for (const [key, item] of Object.entries(value)) {
1048
+ if (item != null && item !== '') out[key] = String(item)
1049
+ }
1050
+ return out
1051
+ }
1052
+
1053
+ // ─── Created-secret dialog ────────────────────────────────────────────────────
1054
+
1055
+ function CreatedSecretDialog({
1056
+ secret,
1057
+ onClose,
1058
+ }: {
1059
+ secret: { key: string; record: ApiKeyRecord } | null
1060
+ onClose: () => void
1061
+ }): React.ReactElement {
1062
+ const { t } = useI18n()
1063
+ const notify = useNotify()
1064
+ const [reveal, setReveal] = React.useState(false)
1065
+ const [copied, setCopied] = React.useState(false)
1066
+
1067
+ React.useEffect(() => {
1068
+ if (!secret) {
1069
+ setReveal(false)
1070
+ setCopied(false)
1071
+ }
1072
+ }, [secret])
1073
+
1074
+ const onCopy = async (): Promise<void> => {
1075
+ if (!secret) return
1076
+ try {
1077
+ await navigator.clipboard.writeText(secret.key)
1078
+ setCopied(true)
1079
+ notify.success({ key: 'settings:apiKeys.notice.copied' })
1080
+ setTimeout(() => setCopied(false), 1500)
1081
+ } catch {
1082
+ notify.error({ key: 'settings:apiKeys.notice.copyFailed' })
1083
+ }
1084
+ }
1085
+
1086
+ return (
1087
+ <Dialog open={!!secret} onOpenChange={(open) => !open && onClose()}>
1088
+ <DialogContent className="max-w-lg">
1089
+ <DialogHeader>
1090
+ <DialogTitle className="flex items-center gap-2">
1091
+ <SettingsIcon className="size-5" />
1092
+ {t('settings:apiKeys.created.title')}
1093
+ </DialogTitle>
1094
+ <DialogDescription>{t('settings:apiKeys.created.description')}</DialogDescription>
1095
+ </DialogHeader>
1096
+ <div className="rounded-md border border-amber-300/60 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-300/30 dark:bg-amber-950/40 dark:text-amber-200">
1097
+ <div className="flex items-start gap-2">
1098
+ <AlertTriangle className="mt-0.5 size-4" />
1099
+ <p>{t('settings:apiKeys.created.warning')}</p>
1100
+ </div>
1101
+ </div>
1102
+ <div className="flex items-stretch gap-2">
1103
+ <div className="relative flex-1">
1104
+ <Input
1105
+ readOnly
1106
+ type={reveal ? 'text' : 'password'}
1107
+ value={secret?.key ?? ''}
1108
+ className="pr-10 font-mono text-xs"
1109
+ onFocus={(e) => e.currentTarget.select()}
1110
+ />
1111
+ <Button
1112
+ type="button"
1113
+ variant="ghost"
1114
+ size="sm"
1115
+ className="absolute right-1 top-1/2 -translate-y-1/2"
1116
+ onClick={() => setReveal((v) => !v)}
1117
+ aria-label={reveal ? t('settings:apiKeys.created.hide') : t('settings:apiKeys.created.reveal')}
1118
+ >
1119
+ {reveal ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
1120
+ </Button>
1121
+ </div>
1122
+ <Button type="button" onClick={onCopy} variant="secondary">
1123
+ {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
1124
+ <span className="hidden sm:inline">
1125
+ {copied ? t('settings:apiKeys.created.copied') : t('settings:apiKeys.created.copy')}
1126
+ </span>
1127
+ </Button>
1128
+ </div>
1129
+ <DialogFooter>
1130
+ <Button onClick={onClose}>{t('common:done')}</Button>
1131
+ </DialogFooter>
1132
+ </DialogContent>
1133
+ </Dialog>
1134
+ )
1135
+ }
1136
+
1137
+ const formatDate = (input: string | Date): string => {
1138
+ try {
1139
+ return new Date(input).toLocaleString()
1140
+ } catch {
1141
+ return String(input)
1142
+ }
1143
+ }