@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.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- 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
|
+
}
|