@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
package/src/client.ts ADDED
@@ -0,0 +1,1093 @@
1
+ // Tiny `fetch` wrapper aimed at the @modern-admin/nest REST surface. We avoid
2
+ // pulling axios — the host browser already has fetch, and TanStack Query gives
3
+ // us retry/dedup/caching anyway.
4
+
5
+ import type { DashboardBlob } from '@modern-admin/core'
6
+ import type {
7
+ AdminConfig,
8
+ CustomActionResponse,
9
+ CurrentUser,
10
+ ListQuery,
11
+ ListResponse,
12
+ RecordResponse,
13
+ } from './types.js'
14
+
15
+ export interface AuthUiProps {
16
+ /** Enabled OAuth provider ids, e.g. ['google', 'github']. */
17
+ providers: string[]
18
+ emailAndPassword: boolean
19
+ }
20
+
21
+ interface StoredDemoSession {
22
+ email: string
23
+ password: string
24
+ }
25
+
26
+ const DEFAULT_DEMO_SESSION_STORAGE_KEY = 'modern-admin:demo-session:v1'
27
+
28
+ export interface AdminClientOptions {
29
+ /** Absolute base URL of the API, e.g. http://localhost:3001 — defaults to same-origin. */
30
+ baseUrl?: string
31
+ /** Send credentials with every request (cookies for Better Auth sessions). */
32
+ credentials?: RequestCredentials
33
+ /** Optional global headers (auth tokens, CSRF). */
34
+ headers?: Record<string, string>
35
+ persistDemoSession?: boolean
36
+ demoSessionStorageKey?: string
37
+ /**
38
+ * Path under which the host mounts Better Auth's Node handler
39
+ * (`toNodeHandler(auth)`) AND configures `betterAuth({ basePath })`.
40
+ * Drives the sign-in / sign-out endpoints — defaults to
41
+ * `/admin/api/auth`, matching the canonical CLI scaffold. Override only
42
+ * if the host mounts Better Auth elsewhere; pass *without* a trailing
43
+ * slash, e.g. `'/api/auth'` (Better Auth's own default).
44
+ */
45
+ authBasePath?: string
46
+ }
47
+
48
+ const DEFAULT_AUTH_BASE_PATH = '/admin/api/auth'
49
+
50
+ const buildQuery = (query?: ListQuery): string => {
51
+ if (!query) return ''
52
+ const params = new URLSearchParams()
53
+ if (query.page != null) params.set('page', String(query.page))
54
+ if (query.perPage != null) params.set('perPage', String(query.perPage))
55
+ if (query.sortBy) params.set('sortBy', query.sortBy)
56
+ if (query.direction) params.set('direction', query.direction)
57
+ if (query.filters) {
58
+ // Bracket notation — Express's qs parser turns `filters[k]=v` into
59
+ // `query.filters = { k: 'v' }` which is what the list action expects.
60
+ for (const [k, v] of Object.entries(query.filters)) {
61
+ if (v !== '' && v != null) params.set(`filters[${k}]`, v)
62
+ }
63
+ }
64
+ const qs = params.toString()
65
+ return qs ? `?${qs}` : ''
66
+ }
67
+
68
+ export class AdminClient {
69
+ private readonly baseUrl: string
70
+ private readonly credentials: RequestCredentials
71
+ private readonly headers: Record<string, string>
72
+ private readonly demoSessionStorageKey: string | null
73
+ private readonly authBasePath: string
74
+ private readonly signInPath: string
75
+ private readonly signOutPath: string
76
+
77
+ constructor(opts: AdminClientOptions = {}) {
78
+ this.baseUrl = opts.baseUrl?.replace(/\/$/, '') ?? ''
79
+ this.credentials = opts.credentials ?? 'include'
80
+ this.headers = opts.headers ?? {}
81
+ this.demoSessionStorageKey = opts.persistDemoSession
82
+ ? (opts.demoSessionStorageKey ?? DEFAULT_DEMO_SESSION_STORAGE_KEY)
83
+ : null
84
+ this.authBasePath = (opts.authBasePath ?? DEFAULT_AUTH_BASE_PATH).replace(/\/$/, '')
85
+ this.signInPath = `${this.authBasePath}/sign-in/email`
86
+ this.signOutPath = `${this.authBasePath}/sign-out`
87
+ }
88
+
89
+ private async requestOnce<T>(path: string, init: RequestInit = {}): Promise<T> {
90
+ const res = await fetch(`${this.baseUrl}${path}`, {
91
+ credentials: this.credentials,
92
+ ...init,
93
+ headers: {
94
+ 'Content-Type': 'application/json',
95
+ ...this.headers,
96
+ ...(init.headers as Record<string, string> | undefined),
97
+ },
98
+ })
99
+ if (!res.ok) {
100
+ const text = await res.text().catch(() => '')
101
+ throw new AdminApiError(res.status, text || res.statusText)
102
+ }
103
+ if (res.status === 204) return undefined as T
104
+ return (await res.json()) as T
105
+ }
106
+
107
+ private async request<T>(path: string, init: RequestInit = {}, allowDemoRestore = true): Promise<T> {
108
+ try {
109
+ return await this.requestOnce<T>(path, init)
110
+ } catch (err) {
111
+ const canRestore =
112
+ allowDemoRestore &&
113
+ err instanceof AdminApiError &&
114
+ err.status === 401 &&
115
+ path !== this.signInPath &&
116
+ path !== this.signOutPath
117
+ if (!canRestore) throw err
118
+ const restored = await this.restoreDemoSession()
119
+ if (!restored) throw err
120
+ return this.requestOnce<T>(path, init)
121
+ }
122
+ }
123
+
124
+ private readDemoSession(): StoredDemoSession | null {
125
+ if (!this.demoSessionStorageKey || typeof window === 'undefined') return null
126
+ try {
127
+ const raw = window.localStorage.getItem(this.demoSessionStorageKey)
128
+ if (!raw) return null
129
+ const parsed = JSON.parse(raw) as Partial<StoredDemoSession>
130
+ if (typeof parsed.email !== 'string' || typeof parsed.password !== 'string') return null
131
+ return { email: parsed.email, password: parsed.password }
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ private writeDemoSession(session: StoredDemoSession): void {
138
+ if (!this.demoSessionStorageKey || typeof window === 'undefined') return
139
+ try {
140
+ window.localStorage.setItem(this.demoSessionStorageKey, JSON.stringify(session))
141
+ } catch {
142
+ return
143
+ }
144
+ }
145
+
146
+ private clearDemoSession(): void {
147
+ if (!this.demoSessionStorageKey || typeof window === 'undefined') return
148
+ try {
149
+ window.localStorage.removeItem(this.demoSessionStorageKey)
150
+ } catch {
151
+ return
152
+ }
153
+ }
154
+
155
+ private async restoreDemoSession(): Promise<boolean> {
156
+ const session = this.readDemoSession()
157
+ if (!session) return false
158
+ try {
159
+ await this.request<unknown>(this.signInPath, {
160
+ method: 'POST',
161
+ body: JSON.stringify(session),
162
+ })
163
+ return true
164
+ } catch {
165
+ this.clearDemoSession()
166
+ return false
167
+ }
168
+ }
169
+
170
+ config(): Promise<AdminConfig> {
171
+ return this.request<AdminConfig>('/admin/api/config')
172
+ }
173
+
174
+ /** Resolve the current authenticated admin. Throws AdminApiError(401) when
175
+ * unauthenticated — callers should branch on that to render login. */
176
+ me(): Promise<{ user: CurrentUser }> {
177
+ return this.request<{ user: CurrentUser }>('/admin/api/auth/me')
178
+ }
179
+
180
+ /** Email/password login via the host-mounted Better Auth handler. The
181
+ * endpoint sets an http-only session cookie that subsequent requests
182
+ * rely on (`credentials: 'include'`). The audit-log entry is written
183
+ * server-side by Better Auth's `session.create.after` hook, which
184
+ * covers email/password, OAuth, passkey and api-key flows uniformly. */
185
+ async login(email: string, password: string): Promise<void> {
186
+ await this.request<unknown>(this.signInPath, {
187
+ method: 'POST',
188
+ body: JSON.stringify({ email, password }),
189
+ })
190
+ this.writeDemoSession({ email, password })
191
+ }
192
+
193
+ /** Fetch public auth UI metadata: which social providers are enabled,
194
+ * whether email/password login is active. The endpoint is unauthenticated. */
195
+ getAuthUiProps(): Promise<AuthUiProps> {
196
+ return this.requestOnce<AuthUiProps>('/admin/api/auth/ui-props')
197
+ }
198
+
199
+ /** Initiate an OAuth social-login flow. Calls Better Auth's sign-in/social
200
+ * endpoint which returns a redirect URL, then navigates the browser there.
201
+ * `callbackUrl` defaults to the current page so the app re-checks auth
202
+ * after the provider redirects back. */
203
+ async loginSocial(provider: string, callbackUrl?: string): Promise<void> {
204
+ const resolved =
205
+ callbackUrl ?? (typeof window !== 'undefined' ? window.location.href : '/')
206
+ const data = await this.requestOnce<{ url?: string }>(
207
+ `${this.authBasePath}/sign-in/social`,
208
+ {
209
+ method: 'POST',
210
+ body: JSON.stringify({ provider, callbackURL: resolved }),
211
+ },
212
+ )
213
+ if (data.url && typeof window !== 'undefined') {
214
+ window.location.href = data.url
215
+ }
216
+ }
217
+
218
+ /** Sign the current session out. Better Auth's sign-out endpoint requires
219
+ * an explicit JSON body (even if empty) when Content-Type is JSON. */
220
+ async logout(): Promise<void> {
221
+ try {
222
+ await this.request<unknown>(this.signOutPath, {
223
+ method: 'POST',
224
+ body: '{}',
225
+ })
226
+ } finally {
227
+ this.clearDemoSession()
228
+ }
229
+ }
230
+
231
+ list(resourceId: string, query?: ListQuery): Promise<ListResponse> {
232
+ return this.request<ListResponse>(
233
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/list${buildQuery(query)}`,
234
+ )
235
+ }
236
+
237
+ show(resourceId: string, recordId: string): Promise<RecordResponse> {
238
+ return this.request<RecordResponse>(
239
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/actions/show`,
240
+ )
241
+ }
242
+
243
+ create(resourceId: string, payload: Record<string, unknown>): Promise<RecordResponse> {
244
+ return this.request<RecordResponse>(
245
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/new`,
246
+ { method: 'POST', body: JSON.stringify(payload) },
247
+ )
248
+ }
249
+
250
+ update(
251
+ resourceId: string,
252
+ recordId: string,
253
+ payload: Record<string, unknown>,
254
+ ): Promise<RecordResponse> {
255
+ return this.request<RecordResponse>(
256
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/actions/edit`,
257
+ { method: 'PATCH', body: JSON.stringify(payload) },
258
+ )
259
+ }
260
+
261
+ delete(resourceId: string, recordId: string): Promise<void> {
262
+ return this.request<void>(
263
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/actions/delete`,
264
+ { method: 'DELETE' },
265
+ )
266
+ }
267
+
268
+ /** Bulk-delete a set of records via the resource's `bulkDelete` action. */
269
+ bulkDelete(resourceId: string, recordIds: ReadonlyArray<string>): Promise<unknown> {
270
+ return this.request<unknown>(
271
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/bulkDelete`,
272
+ { method: 'POST', body: JSON.stringify({ recordIds }) },
273
+ )
274
+ }
275
+
276
+ /** Fetch distinct values for a field — used by the filter value picker.
277
+ * Returns `{ values, hasMore }`. */
278
+ distinctValues(
279
+ resourceId: string,
280
+ field: string,
281
+ options?: { search?: string; limit?: number },
282
+ ): Promise<{ values: string[]; hasMore: boolean }> {
283
+ const params = new URLSearchParams({ field })
284
+ if (options?.search) params.set('search', options.search)
285
+ if (options?.limit != null) params.set('limit', String(options.limit))
286
+ return this.request<{ values: string[]; hasMore: boolean }>(
287
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/values?${params.toString()}`,
288
+ )
289
+ }
290
+
291
+ search(resourceId: string, query: string): Promise<ListResponse> {
292
+ const qs = query ? `?q=${encodeURIComponent(query)}` : ''
293
+ return this.request<ListResponse>(
294
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/search${qs}`,
295
+ )
296
+ }
297
+
298
+ /** Cross-resource search. Fans `query` out to every registered resource's
299
+ * `search` action; resources the principal cannot access are omitted.
300
+ *
301
+ * Accepts an optional `AbortSignal` so the command-palette dialog can
302
+ * cancel an in-flight request when the user keeps typing — without this
303
+ * the server is hit once per keystroke and stale responses race with
304
+ * newer ones. */
305
+ globalSearch(
306
+ query: string,
307
+ perResourceLimit?: number,
308
+ options: { signal?: AbortSignal } = {},
309
+ ): Promise<GlobalSearchResponse> {
310
+ const params = new URLSearchParams({ q: query })
311
+ if (perResourceLimit != null) params.set('perResourceLimit', String(perResourceLimit))
312
+ return this.request<GlobalSearchResponse>(
313
+ `/admin/api/global-search?${params.toString()}`,
314
+ options.signal ? { signal: options.signal } : {},
315
+ )
316
+ }
317
+
318
+ /** Invoke a custom record-scoped action (actionType: 'record'). */
319
+ invokeRecordAction(
320
+ resourceId: string,
321
+ recordId: string,
322
+ actionName: string,
323
+ payload: Record<string, unknown> = {},
324
+ ): Promise<CustomActionResponse> {
325
+ return this.request<CustomActionResponse>(
326
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/actions/${encodeURIComponent(actionName)}`,
327
+ { method: 'POST', body: JSON.stringify(payload) },
328
+ )
329
+ }
330
+
331
+ /** Invoke a custom bulk-scoped action (actionType: 'bulk'). */
332
+ invokeBulkAction(
333
+ resourceId: string,
334
+ actionName: string,
335
+ recordIds: ReadonlyArray<string>,
336
+ ): Promise<CustomActionResponse> {
337
+ return this.request<CustomActionResponse>(
338
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/${encodeURIComponent(actionName)}`,
339
+ { method: 'POST', body: JSON.stringify({ recordIds: Array.from(recordIds) }) },
340
+ )
341
+ }
342
+
343
+ /** Invoke a custom resource-scoped action (actionType: 'resource'). */
344
+ invokeResourceAction(
345
+ resourceId: string,
346
+ actionName: string,
347
+ payload: Record<string, unknown> = {},
348
+ ): Promise<CustomActionResponse> {
349
+ return this.request<CustomActionResponse>(
350
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/actions/${encodeURIComponent(actionName)}`,
351
+ { method: 'POST', body: JSON.stringify(payload) },
352
+ )
353
+ }
354
+
355
+ /**
356
+ * Run a time-series aggregation. KPI mode = `step: 'all'`. Multi-series
357
+ * via secondary `groupBy` (top-N truncation server-side).
358
+ */
359
+ timeseries(query: TimeSeriesQuery): Promise<TimeSeriesResponse> {
360
+ const body: Record<string, unknown> = {
361
+ resource: query.resource,
362
+ dateField: query.dateField,
363
+ step: query.step,
364
+ metric: query.metric,
365
+ from: toIsoDateTime(query.from, 'start'),
366
+ to: toIsoDateTime(query.to, 'end'),
367
+ }
368
+ if (query.field) body.field = query.field
369
+ if (query.groupBy) body.groupBy = query.groupBy
370
+ if (query.topN != null) body.topN = query.topN
371
+ if (query.filters && Object.keys(query.filters).length) {
372
+ body.filters = query.filters
373
+ }
374
+ if (query.comparePrevious) body.comparePrevious = true
375
+ if (query.groupByLabelResource) body.groupByLabelResource = query.groupByLabelResource
376
+ return this.request<TimeSeriesResponse>('/admin/api/timeseries', {
377
+ method: 'POST',
378
+ body: JSON.stringify(body),
379
+ })
380
+ }
381
+
382
+ listHistory(
383
+ resourceId: string,
384
+ recordId: string,
385
+ query: { limit?: number; offset?: number } = {},
386
+ ): Promise<HistoryListResponse> {
387
+ const params = new URLSearchParams()
388
+ if (query.limit != null) params.set('limit', String(query.limit))
389
+ if (query.offset != null) params.set('offset', String(query.offset))
390
+ const qs = params.toString() ? `?${params.toString()}` : ''
391
+ return this.request<HistoryListResponse>(
392
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/history${qs}`,
393
+ )
394
+ }
395
+
396
+ getHistoryRevision(
397
+ resourceId: string,
398
+ recordId: string,
399
+ revisionId: string,
400
+ ): Promise<HistoryRevisionResponse> {
401
+ return this.request<HistoryRevisionResponse>(
402
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/history/${encodeURIComponent(revisionId)}`,
403
+ )
404
+ }
405
+
406
+ revertHistoryRevision(
407
+ resourceId: string,
408
+ recordId: string,
409
+ revisionId: string,
410
+ body: { reason?: string } = {},
411
+ ): Promise<RecordResponse> {
412
+ return this.request<RecordResponse>(
413
+ `/admin/api/resources/${encodeURIComponent(resourceId)}/records/${encodeURIComponent(recordId)}/history/${encodeURIComponent(revisionId)}/revert`,
414
+ { method: 'POST', body: JSON.stringify(body) },
415
+ )
416
+ }
417
+
418
+ listAuditLog(query: AuditLogQuery = {}): Promise<AuditLogResponse> {
419
+ const params = new URLSearchParams()
420
+ if (query.resourceId) params.set('resourceId', query.resourceId)
421
+ if (query.recordId) params.set('recordId', query.recordId)
422
+ if (query.userId) params.set('userId', query.userId)
423
+ if (query.actions?.length) params.set('actions', query.actions.join(','))
424
+ if (query.from) params.set('from', toIsoDateTime(query.from, 'start'))
425
+ if (query.to) params.set('to', toIsoDateTime(query.to, 'end'))
426
+ if (query.limit != null) params.set('limit', String(query.limit))
427
+ if (query.offset != null) params.set('offset', String(query.offset))
428
+ if (query.before != null) params.set('before', String(query.before))
429
+ const qs = params.toString() ? `?${params.toString()}` : ''
430
+ return this.request<AuditLogResponse>(`/admin/api/audit-log${qs}`)
431
+ }
432
+
433
+ /**
434
+ * Upload a single file for a `type: 'file'` property, reporting progress
435
+ * via `options.onProgress`. Uses `XMLHttpRequest` because the fetch API
436
+ * does not expose upload-side progress events.
437
+ *
438
+ * Returns the metadata record for the uploaded file. The server endpoint
439
+ * (`POST /admin/api/resources/:id/actions/upload`) accepts batch uploads
440
+ * and replies with an array; we send one file per request and unwrap the
441
+ * single result so the caller gets per-file progress and per-file errors.
442
+ */
443
+ uploadFile(
444
+ resourceId: string,
445
+ field: string,
446
+ file: File,
447
+ options: UploadFileOptions = {},
448
+ ): Promise<UploadedFileInfo> {
449
+ const url = `${this.baseUrl}/admin/api/resources/${encodeURIComponent(resourceId)}/actions/upload?field=${encodeURIComponent(field)}`
450
+ const form = new FormData()
451
+ form.append('files', file)
452
+ return new Promise<UploadedFileInfo>((resolve, reject) => {
453
+ const xhr = new XMLHttpRequest()
454
+ xhr.open('POST', url, true)
455
+ xhr.withCredentials = this.credentials !== 'omit'
456
+ for (const [k, v] of Object.entries(this.headers)) xhr.setRequestHeader(k, v)
457
+ xhr.responseType = 'text'
458
+ xhr.upload.onprogress = (ev): void => {
459
+ if (!options.onProgress) return
460
+ const total = ev.lengthComputable ? ev.total : file.size
461
+ const loaded = ev.loaded
462
+ options.onProgress({
463
+ loaded,
464
+ total,
465
+ percent: total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0,
466
+ })
467
+ }
468
+ xhr.onload = (): void => {
469
+ if (xhr.status >= 200 && xhr.status < 300) {
470
+ try {
471
+ const arr = JSON.parse(xhr.responseText) as UploadedFileInfo[]
472
+ const first = arr[0]
473
+ if (!first) {
474
+ reject(new AdminApiError(500, 'Server returned no upload result'))
475
+ return
476
+ }
477
+ // Emit a final 100% progress tick so UI can settle the bar.
478
+ options.onProgress?.({ loaded: file.size, total: file.size, percent: 100 })
479
+ resolve(first)
480
+ } catch (err) {
481
+ reject(new AdminApiError(500, err instanceof Error ? err.message : 'Invalid upload response'))
482
+ }
483
+ } else {
484
+ reject(new AdminApiError(xhr.status, xhr.responseText || xhr.statusText))
485
+ }
486
+ }
487
+ xhr.onerror = (): void => reject(new AdminApiError(0, 'Network error during upload'))
488
+ xhr.onabort = (): void => reject(new AdminApiError(0, 'Upload aborted'))
489
+ if (options.signal) {
490
+ if (options.signal.aborted) {
491
+ xhr.abort()
492
+ return
493
+ }
494
+ options.signal.addEventListener('abort', () => xhr.abort(), { once: true })
495
+ }
496
+ xhr.send(form)
497
+ })
498
+ }
499
+
500
+ /**
501
+ * Upload many files with bounded concurrency, reporting per-file progress
502
+ * via `options.onItem*` callbacks. Each file is sent in its own request,
503
+ * so partial failures do not affect already-completed uploads.
504
+ *
505
+ * Returns the array of successful results in the order files were passed.
506
+ * Failed entries are omitted from the returned array; consumers wanting
507
+ * to react to failures should observe `onItemError`.
508
+ */
509
+ async uploadFiles(
510
+ resourceId: string,
511
+ field: string,
512
+ files: ReadonlyArray<File>,
513
+ options: UploadFilesOptions = {},
514
+ ): Promise<UploadedFileInfo[]> {
515
+ if (files.length === 0) return []
516
+ const concurrency = Math.max(1, options.concurrency ?? 3)
517
+ const results: Array<UploadedFileInfo | null> = new Array(files.length).fill(null)
518
+ let cursor = 0
519
+ const runOne = async (): Promise<void> => {
520
+ while (true) {
521
+ const i = cursor++
522
+ if (i >= files.length) return
523
+ const file = files[i]!
524
+ options.onItemStart?.(i, file)
525
+ try {
526
+ const info = await this.uploadFile(resourceId, field, file, {
527
+ signal: options.signal,
528
+ onProgress: (p) => options.onItemProgress?.(i, file, p),
529
+ })
530
+ results[i] = info
531
+ options.onItemComplete?.(i, file, info)
532
+ } catch (err) {
533
+ const e = err instanceof Error ? err : new Error(String(err))
534
+ options.onItemError?.(i, file, e)
535
+ }
536
+ }
537
+ }
538
+ const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => runOne())
539
+ await Promise.all(workers)
540
+ return results.filter((r): r is UploadedFileInfo => r != null)
541
+ }
542
+
543
+ /** Load the current user's dashboard layout from the server config store. */
544
+ loadDashboard(): Promise<{ dashboard: DashboardBlob }> {
545
+ return this.request<{ dashboard: DashboardBlob }>('/admin/api/dashboard')
546
+ }
547
+
548
+ /** Persist the current user's dashboard layout to the server config store. */
549
+ saveDashboard(dashboard: DashboardBlob): Promise<{ ok: boolean }> {
550
+ return this.request<{ ok: boolean }>('/admin/api/dashboard', {
551
+ method: 'PUT',
552
+ body: JSON.stringify(dashboard),
553
+ })
554
+ }
555
+
556
+ /** List API keys belonging to the current admin. */
557
+ listApiKeys(): Promise<{ keys: ApiKeyRecord[] }> {
558
+ return this.request<{ keys: ApiKeyRecord[] }>('/admin/api/api-keys')
559
+ }
560
+
561
+ /** Create a new API key. The plaintext secret is returned exactly once. */
562
+ createApiKey(payload: {
563
+ name: string
564
+ permissions: Record<string, string[]>
565
+ expiresInDays?: number | null
566
+ }): Promise<{ key: string; record: ApiKeyRecord }> {
567
+ return this.request<{ key: string; record: ApiKeyRecord }>('/admin/api/api-keys', {
568
+ method: 'POST',
569
+ body: JSON.stringify(payload),
570
+ })
571
+ }
572
+
573
+ /** Patch a key — name/enabled/permissions/expiry. */
574
+ updateApiKey(
575
+ id: string,
576
+ payload: {
577
+ name?: string
578
+ enabled?: boolean
579
+ permissions?: Record<string, string[]>
580
+ expiresInDays?: number | null
581
+ },
582
+ ): Promise<{ record: ApiKeyRecord }> {
583
+ return this.request<{ record: ApiKeyRecord }>(
584
+ `/admin/api/api-keys/${encodeURIComponent(id)}`,
585
+ { method: 'PATCH', body: JSON.stringify(payload) },
586
+ )
587
+ }
588
+
589
+ /** Permanently revoke (delete) an API key. */
590
+ deleteApiKey(id: string): Promise<{ success: true }> {
591
+ return this.request<{ success: true }>(
592
+ `/admin/api/api-keys/${encodeURIComponent(id)}`,
593
+ { method: 'DELETE' },
594
+ )
595
+ }
596
+
597
+ listWebhooks(): Promise<{ webhooks: WebhookRecord[] }> {
598
+ return this.request<{ webhooks: WebhookRecord[] }>('/admin/api/webhooks')
599
+ }
600
+
601
+ createWebhook(payload: WebhookInput): Promise<{ webhook: WebhookRecord }> {
602
+ return this.request<{ webhook: WebhookRecord }>('/admin/api/webhooks', {
603
+ method: 'POST',
604
+ body: JSON.stringify(payload),
605
+ })
606
+ }
607
+
608
+ updateWebhook(id: string, payload: Partial<WebhookInput>): Promise<{ webhook: WebhookRecord }> {
609
+ return this.request<{ webhook: WebhookRecord }>(
610
+ `/admin/api/webhooks/${encodeURIComponent(id)}`,
611
+ { method: 'PATCH', body: JSON.stringify(payload) },
612
+ )
613
+ }
614
+
615
+ deleteWebhook(id: string): Promise<{ success: true }> {
616
+ return this.request<{ success: true }>(
617
+ `/admin/api/webhooks/${encodeURIComponent(id)}`,
618
+ { method: 'DELETE' },
619
+ )
620
+ }
621
+
622
+ listWebhookDeliveries(id: string, limit = 50): Promise<{ deliveries: WebhookDeliveryRecord[] }> {
623
+ return this.request<{ deliveries: WebhookDeliveryRecord[] }>(
624
+ `/admin/api/webhooks/${encodeURIComponent(id)}/deliveries?limit=${encodeURIComponent(String(limit))}`,
625
+ )
626
+ }
627
+
628
+ testWebhook(id: string): Promise<{ success: true }> {
629
+ return this.request<{ success: true }>(
630
+ `/admin/api/webhooks/${encodeURIComponent(id)}/test`,
631
+ { method: 'POST', body: '{}' },
632
+ )
633
+ }
634
+
635
+ /**
636
+ * Cancel a still-pending upload — deletes the file from storage immediately.
637
+ * Use when the user removes a freshly uploaded file *before* saving the
638
+ * form. Files that have already been confirmed (record saved) cannot be
639
+ * cancelled via this endpoint and the call will reject with 404.
640
+ */
641
+ async cancelUpload(resourceId: string, field: string, key: string): Promise<void> {
642
+ const qs = `?field=${encodeURIComponent(field)}&key=${encodeURIComponent(key)}`
643
+ const res = await fetch(
644
+ `${this.baseUrl}/admin/api/resources/${encodeURIComponent(resourceId)}/actions/upload${qs}`,
645
+ { method: 'DELETE', credentials: this.credentials, headers: this.headers },
646
+ )
647
+ if (!res.ok && res.status !== 404) {
648
+ const text = await res.text().catch(() => '')
649
+ throw new AdminApiError(res.status, text || res.statusText)
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Send a single image to the resource's `aiFill` action and receive a
655
+ * `values` map the edit form can hydrate from. Uses multipart/form-data
656
+ * because vision models expect raw bytes, not base64-stringified payloads.
657
+ *
658
+ * Supports an optional `signal` for cancellation and the demo-session 401
659
+ * auto-restore logic (matching the behaviour of `request()`). Cannot use
660
+ * `requestOnce` directly because that always sets `Content-Type: application/json`,
661
+ * which would override the browser-generated multipart boundary.
662
+ */
663
+ async aiFillFromImage(
664
+ resourceId: string,
665
+ file: File,
666
+ options: { signal?: AbortSignal } = {},
667
+ ): Promise<AiFillResponse> {
668
+ const url = `${this.baseUrl}/admin/api/resources/${encodeURIComponent(resourceId)}/ai-fill`
669
+
670
+ const doFetch = async (): Promise<AiFillResponse> => {
671
+ const form = new FormData()
672
+ form.append('image', file)
673
+ const res = await fetch(url, {
674
+ method: 'POST',
675
+ credentials: this.credentials,
676
+ // Intentionally omit Content-Type — the browser sets it automatically
677
+ // with the correct multipart/form-data boundary.
678
+ headers: this.headers,
679
+ body: form,
680
+ signal: options.signal,
681
+ })
682
+ if (!res.ok) {
683
+ const text = await res.text().catch(() => '')
684
+ throw new AdminApiError(res.status, text || res.statusText)
685
+ }
686
+ return (await res.json()) as AiFillResponse
687
+ }
688
+
689
+ try {
690
+ return await doFetch()
691
+ } catch (err) {
692
+ const canRestore =
693
+ err instanceof AdminApiError &&
694
+ err.status === 401 &&
695
+ !options.signal?.aborted
696
+ if (!canRestore) throw err
697
+ const restored = await this.restoreDemoSession()
698
+ if (!restored) throw err
699
+ return doFetch()
700
+ }
701
+ }
702
+
703
+ getAiAssistantSettings(): Promise<AiAssistantSettings> {
704
+ return this.request<AiAssistantSettings>('/admin/api/ai-assistant/settings')
705
+ }
706
+
707
+ async updateAiAssistantSettings(payload: {
708
+ enabled: boolean
709
+ model: string
710
+ apiKey?: string
711
+ systemPrompt?: string
712
+ }): Promise<AiAssistantSettings> {
713
+ return this.request<AiAssistantSettings>('/admin/api/ai-assistant/settings', {
714
+ method: 'PUT',
715
+ body: JSON.stringify(payload),
716
+ })
717
+ }
718
+
719
+ async sendAiAssistantChat(
720
+ messages: AiAssistantChatMessage[],
721
+ requestId?: string,
722
+ locale?: string,
723
+ conversationId?: string,
724
+ clientContext?: AiClientContext,
725
+ ): Promise<AiAssistantChatEnqueueResponse> {
726
+ return this.request<AiAssistantChatEnqueueResponse>('/admin/api/ai-assistant/chat', {
727
+ method: 'POST',
728
+ body: JSON.stringify({
729
+ messages,
730
+ ...(requestId ? { requestId } : {}),
731
+ ...(locale ? { locale } : {}),
732
+ ...(conversationId ? { conversationId } : {}),
733
+ ...(clientContext ? { clientContext } : {}),
734
+ }),
735
+ })
736
+ }
737
+
738
+ async listAiAssistantChats(): Promise<AiAssistantChatHistoryItem[]> {
739
+ return this.request<AiAssistantChatHistoryItem[]>('/admin/api/ai-assistant/chats')
740
+ }
741
+
742
+ async getAiAssistantTask(taskId: string): Promise<AiAssistantTask> {
743
+ return this.request<AiAssistantTask>(`/admin/api/ai-assistant/tasks/${encodeURIComponent(taskId)}`)
744
+ }
745
+
746
+ }
747
+
748
+ // ─── Time-series ──────────────────────────────────────────────────────────
749
+
750
+ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/
751
+
752
+ const toIsoDateTime = (value: string, edge: 'start' | 'end'): string => {
753
+ if (DATE_ONLY_RE.test(value)) {
754
+ return `${value}T${edge === 'start' ? '00:00:00.000' : '23:59:59.999'}Z`
755
+ }
756
+ const date = new Date(value)
757
+ return Number.isNaN(date.getTime()) ? value : date.toISOString()
758
+ }
759
+
760
+ export type TimeSeriesMetric = 'count' | 'sum' | 'avg' | 'min' | 'max'
761
+ export type TimeSeriesStep = 'day' | 'week' | 'month' | 'year' | 'all'
762
+
763
+ export interface TimeSeriesQuery {
764
+ resource: string
765
+ /** Property path of the date/datetime column used for X-axis bucketing. */
766
+ dateField: string
767
+ step: TimeSeriesStep
768
+ metric: TimeSeriesMetric
769
+ /** Required for non-count metrics. */
770
+ field?: string
771
+ /** Optional secondary breakdown — produces one series per distinct value. */
772
+ groupBy?: string
773
+ /** Maximum series count for grouped charts. Default 10. */
774
+ topN?: number
775
+ /**
776
+ * When set, the server resolves each non-special series key (FK id) to a
777
+ * human-readable title via `findMany` on this resource.
778
+ */
779
+ groupByLabelResource?: string
780
+ /** ISO datetime, inclusive start. */
781
+ from: string
782
+ /** ISO datetime, inclusive end. */
783
+ to: string
784
+ /** List-page-style filters narrowing the dataset. */
785
+ filters?: Record<string, string>
786
+ /** When true, response includes the equal-length previous window (KPI delta). */
787
+ comparePrevious?: boolean
788
+ }
789
+
790
+ export interface TimeSeriesPoint {
791
+ /** ISO date `YYYY-MM-DD`. */
792
+ date: string
793
+ value: number
794
+ }
795
+
796
+ export interface TimeSeriesSeries {
797
+ /** Series identifier. `'__total__'` when no `groupBy` is set, `'__other__'` for the topN remainder. */
798
+ key: string
799
+ points: TimeSeriesPoint[]
800
+ }
801
+
802
+ export interface TimeSeriesResponse {
803
+ series: TimeSeriesSeries[]
804
+ /** Populated when the request used `comparePrevious: true`. */
805
+ previous?: TimeSeriesSeries[]
806
+ /** Captured raw SQL — only present for callers whose role is allowed. */
807
+ sql?: string
808
+ /** `false` when the resource's adapter does not implement aggregateTimeSeries. */
809
+ supported: boolean
810
+ /** Populated when `groupByLabelResource` was set — maps series key → title. */
811
+ resolvedLabels?: Record<string, string>
812
+ }
813
+
814
+ export type HistoryOp = 'create' | 'update' | 'delete'
815
+
816
+ export interface HistoryDiffEntry {
817
+ path: string
818
+ before?: unknown
819
+ after?: unknown
820
+ kind: 'added' | 'changed' | 'removed'
821
+ }
822
+
823
+ export interface HistoryRevision {
824
+ id: string
825
+ resourceId: string
826
+ recordId: string
827
+ op: HistoryOp
828
+ userId?: string
829
+ snapshot: Record<string, unknown>
830
+ snapshotBefore?: Record<string, unknown>
831
+ createdAt: string
832
+ }
833
+
834
+ export interface HistoryListResponse {
835
+ revisions: HistoryRevision[]
836
+ }
837
+
838
+ export interface HistoryRevisionResponse {
839
+ revision: HistoryRevision
840
+ }
841
+
842
+ export interface AuditLogEntry {
843
+ /** UUID v7 assigned by the writer; optional for legacy in-memory rows. */
844
+ id?: string
845
+ resourceId: string
846
+ action: string
847
+ recordId?: string
848
+ recordIds?: string[]
849
+ /** Human-readable title of the affected record, stored at write time. */
850
+ recordTitle?: string
851
+ userId?: string
852
+ payload?: Record<string, unknown>
853
+ result?: Record<string, unknown>
854
+ at: number
855
+ }
856
+
857
+ export interface AuditLogQuery {
858
+ resourceId?: string
859
+ recordId?: string
860
+ userId?: string
861
+ actions?: string[]
862
+ from?: string
863
+ to?: string
864
+ limit?: number
865
+ offset?: number
866
+ /** Cursor: fetch only entries with `at` strictly before this unix-ms value. */
867
+ before?: number
868
+ }
869
+
870
+ export interface AuditLogResponse {
871
+ events: AuditLogEntry[]
872
+ }
873
+
874
+ /** Wire shape of an API key record exposed by `/admin/api/api-keys/*`. */
875
+ export interface ApiKeyRecord {
876
+ id: string
877
+ name: string | null
878
+ start: string | null
879
+ prefix: string | null
880
+ enabled: boolean
881
+ permissions: Record<string, string[]>
882
+ expiresAt: string | null
883
+ lastRequest: string | null
884
+ createdAt: string
885
+ updatedAt: string
886
+ }
887
+
888
+ export interface WebhookInput {
889
+ name: string
890
+ url: string
891
+ events: string[]
892
+ resourceId?: string | null
893
+ enabled?: boolean
894
+ secret?: string
895
+ headers?: Record<string, string>
896
+ filters?: Record<string, string>
897
+ payloadFields?: string[]
898
+ }
899
+
900
+ export interface WebhookRecord extends Required<Omit<WebhookInput, 'secret'>> {
901
+ id: string
902
+ secret?: string
903
+ createdAt: string
904
+ updatedAt: string
905
+ }
906
+
907
+ export interface WebhookDeliveryRecord {
908
+ id: string
909
+ webhookId: string
910
+ event: string
911
+ payload: Record<string, unknown>
912
+ status: 'pending' | 'success' | 'failed'
913
+ responseStatus?: number
914
+ responseBody?: string
915
+ error?: string
916
+ attempt: number
917
+ createdAt: string
918
+ deliveredAt?: string
919
+ }
920
+
921
+ export interface AiAssistantSettings {
922
+ enabled: boolean
923
+ configured: boolean
924
+ provider: 'openrouter'
925
+ model: string
926
+ maskedApiKey: string | null
927
+ systemPrompt: string
928
+ canManage: boolean
929
+ canChat: boolean
930
+ readOnly: boolean
931
+ }
932
+
933
+ export interface AiAssistantChatMessage {
934
+ role: 'user' | 'assistant'
935
+ content: string
936
+ }
937
+
938
+ /**
939
+ * Snapshot of the admin frontend at the moment the user sent the message,
940
+ * passed alongside the chat payload so the assistant can ground itself.
941
+ */
942
+ export interface AiClientContext {
943
+ /** Current window pathname, e.g. "/" or "/resources/posts/abc". */
944
+ pathname?: string
945
+ }
946
+
947
+ /** Allowed navigation targets — mirrors the safe subset of `Route`. */
948
+ export type AiNavigateRoute =
949
+ | { name: 'home' }
950
+ | { name: 'audit-log' }
951
+ | { name: 'list'; resourceId: string }
952
+ | { name: 'show'; resourceId: string; recordId: string }
953
+ | { name: 'settings'; section?: string }
954
+
955
+ /** Side-effect emitted by the assistant for the frontend to execute. */
956
+ export type AiUiAction =
957
+ | { kind: 'navigate'; route: AiNavigateRoute }
958
+ | { kind: 'refresh'; target: 'dashboard' }
959
+
960
+ export interface AiAssistantCitation {
961
+ resourceId: string
962
+ recordId?: string
963
+ label: string
964
+ }
965
+
966
+ export interface AiAssistantChatResponse {
967
+ message: { role: 'assistant'; content: string }
968
+ citations: AiAssistantCitation[]
969
+ toolCalls: Array<{ toolName: string; state: string }>
970
+ taskId?: string
971
+ }
972
+
973
+ export interface AiAssistantChatEnqueueResponse {
974
+ taskId: string
975
+ status: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
976
+ }
977
+
978
+ export interface AiAssistantChatHistoryItem {
979
+ conversationId: string
980
+ taskId: string
981
+ title: string
982
+ status: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
983
+ updatedAt: string
984
+ }
985
+
986
+ export interface AiAssistantTask {
987
+ id: string
988
+ kind: string
989
+ resourceId?: string
990
+ recordId?: string
991
+ userId?: string
992
+ status: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
993
+ input: Record<string, unknown>
994
+ output?: {
995
+ text?: string
996
+ citations?: AiAssistantCitation[]
997
+ toolCalls?: Array<{ toolName: string }>
998
+ uiActions?: AiUiAction[]
999
+ [key: string]: unknown
1000
+ }
1001
+ error?: string
1002
+ progress: number | null
1003
+ createdAt: string
1004
+ updatedAt: string
1005
+ startedAt?: string
1006
+ finishedAt?: string
1007
+ }
1008
+
1009
+ export interface AiFillResponse {
1010
+ /** Map of property path → extracted value. Only fields the model could
1011
+ * confidently extract are present; the form merges them into existing
1012
+ * values without clobbering unrelated columns. */
1013
+ values: Record<string, unknown>
1014
+ }
1015
+
1016
+ /** Metadata returned by `POST /upload` for one file. */
1017
+ export interface UploadedFileInfo {
1018
+ key: string
1019
+ url: string
1020
+ name: string
1021
+ size: number
1022
+ mimeType: string
1023
+ }
1024
+
1025
+ /** Bytes-loaded snapshot emitted by `XMLHttpRequest.upload.onprogress`. */
1026
+ export interface UploadProgress {
1027
+ loaded: number
1028
+ total: number
1029
+ /** 0–100, rounded. */
1030
+ percent: number
1031
+ }
1032
+
1033
+ export interface UploadFileOptions {
1034
+ onProgress?: (p: UploadProgress) => void
1035
+ signal?: AbortSignal
1036
+ }
1037
+
1038
+ export interface UploadFilesOptions {
1039
+ /** Maximum concurrent in-flight requests. Default 3. */
1040
+ concurrency?: number
1041
+ signal?: AbortSignal
1042
+ onItemStart?: (index: number, file: File) => void
1043
+ onItemProgress?: (index: number, file: File, p: UploadProgress) => void
1044
+ onItemComplete?: (index: number, file: File, info: UploadedFileInfo) => void
1045
+ onItemError?: (index: number, file: File, error: Error) => void
1046
+ }
1047
+
1048
+ export interface GlobalSearchHit {
1049
+ resourceId: string
1050
+ resourceName: string
1051
+ recordId: string
1052
+ title: string
1053
+ /** Property path whose value matched the query (omitted when matched on title or id). */
1054
+ matchedField?: string
1055
+ /** ~80-char excerpt with the matched substring near its center. */
1056
+ snippet?: string
1057
+ /** Relevance ranking — higher is better. Mirrors the server-side rubric. */
1058
+ score?: number
1059
+ }
1060
+
1061
+ export interface GlobalSearchGroup {
1062
+ resourceId: string
1063
+ resourceName: string
1064
+ records: GlobalSearchHit[]
1065
+ }
1066
+
1067
+ export interface GlobalSearchResponse {
1068
+ query: string
1069
+ groups: GlobalSearchGroup[]
1070
+ total: number
1071
+ }
1072
+
1073
+ export class AdminApiError extends Error {
1074
+ constructor(public readonly status: number, message: string) {
1075
+ super(message)
1076
+ this.name = 'AdminApiError'
1077
+ }
1078
+ }
1079
+
1080
+ /** Extract a human-readable message from any error thrown by AdminClient.
1081
+ * The raw message is the HTTP response body text (often JSON). We try to
1082
+ * parse the `.message` field from the JSON payload before falling back. */
1083
+ export function parseApiError(err: unknown): { status?: number; message: string } {
1084
+ if (err instanceof AdminApiError) {
1085
+ try {
1086
+ const body = JSON.parse(err.message) as { message?: string }
1087
+ return { status: err.status, message: body.message ?? err.message }
1088
+ } catch {
1089
+ return { status: err.status, message: err.message }
1090
+ }
1091
+ }
1092
+ return { message: err instanceof Error ? err.message : String(err) }
1093
+ }