@saasicat/ui-vue 0.24.2 → 0.26.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 (90) hide show
  1. package/README.md +32 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +574 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -0,0 +1,552 @@
1
+ // One error type for everything that fails on the way to or from the admin
2
+ // API, and one function that turns whatever was caught into it.
3
+ //
4
+ // The problem this solves: the package's HTTP contract (`HttpClient` in
5
+ // `types.ts`) is a bare function type, so an error can arrive in any shape the
6
+ // consumer's client produces — an axios rejection with `err.response.data`, a
7
+ // `fetch` TypeError, a platform `HttpJsonError`, or a plain string. Code that
8
+ // reads exactly one of those shapes works for exactly one kind of consumer and
9
+ // silently shows nothing to the others.
10
+ //
11
+ // Two ideas are deliberately kept apart:
12
+ //
13
+ // - `message` is the diagnostic. It names the request and what came back,
14
+ // and it belongs in logs and stack traces. It is never user-facing.
15
+ // - `detail` is the text the failing side supplied — a response body's
16
+ // `message`, or the message of a non-HTTP error we wrapped. It is the only
17
+ // candidate for showing a user, and it is `undefined` when there is none.
18
+ //
19
+ // `adminErrorMessage()` is what turns an error into user-facing text; it is
20
+ // the only place that decides between `detail` and a translated fallback.
21
+
22
+ import { formatMessage } from './i18n/format.js';
23
+ import type { SaMessages } from './i18n/messages.js';
24
+
25
+ /**
26
+ * Brand for cross-copy recognition.
27
+ *
28
+ * The package ships `dist/index.*` and `dist/client/index.*` as separate
29
+ * rollups, so an app that reaches `@saasicat/ui-vue` through both entries can
30
+ * hold two `AdminError` classes — `instanceof` is then false for an error the
31
+ * other copy created, and re-wrapping it would drop `status`, `code` and
32
+ * `body`. A `Symbol.for` key resolves through the process-wide registry, so
33
+ * every copy agrees on it.
34
+ */
35
+ const ADMIN_ERROR = Symbol.for('@saasicat/ui-vue/AdminError');
36
+
37
+ /**
38
+ * Brand for the error classes this package throws itself.
39
+ *
40
+ * `toAdminError` has to answer one question about a caught error: is its
41
+ * `message` something an operator should read? For a consumer's
42
+ * `Error('Quota exhausted')` the answer is yes. For
43
+ * `PlansApiError(403, {}, 'Plans API responded with HTTP 403')` it is no — that
44
+ * is a diagnostic, and showing it instead of the translated wording is what
45
+ * this whole separation exists to prevent.
46
+ *
47
+ * Shape cannot answer it. Both are `Error`s with a numeric `status`, and two
48
+ * successive attempts to guess — first from `status === 0`, then from a class
49
+ * name ending in `ApiError` — each turned out to catch consumer errors as well.
50
+ * A name suffix is a convention anyone may share; identity is not. `Symbol.for`
51
+ * because the package ships more than one bundle copy.
52
+ */
53
+ const PLATFORM_ERROR = Symbol.for('@saasicat/ui-vue/PlatformError');
54
+
55
+ /**
56
+ * Marks an error as one this package raised. Called by the package's own error
57
+ * classes in their constructors; nothing else should call it.
58
+ */
59
+ export function markPlatformError(error: Error): void {
60
+ Object.defineProperty(error, PLATFORM_ERROR, { value: true });
61
+ }
62
+
63
+ /** Whether an error came from this package, across bundle copies. */
64
+ export function isPlatformError(value: unknown): boolean {
65
+ return typeof value === 'object' && value !== null && PLATFORM_ERROR in value;
66
+ }
67
+
68
+ /**
69
+ * Brand for the throw sites where the request completed and only the body was
70
+ * missing.
71
+ *
72
+ * A second brand rather than an inference from the first, because the two
73
+ * answer different questions about different things. `PLATFORM_ERROR` is a
74
+ * fact about the *class* — is this `message` a diagnostic, or a consumer's
75
+ * words? Whether the server answered with an empty body is a fact about the
76
+ * *throw site*, and one class hosts both: `PlansApiError` is raised for
77
+ * `Plans API responded with HTTP 403` and for `Create returned no body`.
78
+ *
79
+ * Deriving the second from the first (`status === 0 && isPlatformError(err)`)
80
+ * looked equivalent and was not. `BootLoader.load`, `ManifestLoader` and
81
+ * `useDiscovery` pass a client-supplied status straight into a branded error
82
+ * behind a `status !== 200` guard, so an `HttpClient` that reports a transport
83
+ * failure as `status: 0` instead of rejecting — which XHR and axios do, and
84
+ * which `HttpClient` explicitly permits — produced a branded status-0 error
85
+ * for a read-only GET. `adminErrorMessage` then answered "check whether the
86
+ * change was applied" for a request that never left the machine and could not
87
+ * have changed anything.
88
+ *
89
+ * `Symbol.for` for the same reason as above: the package ships more than one
90
+ * bundle copy.
91
+ */
92
+ const EMPTY_RESPONSE = Symbol.for('@saasicat/ui-vue/EmptyResponse');
93
+
94
+ /**
95
+ * Marks an error as the empty-body sentinel: the call reached the server, the
96
+ * server answered, and the body the caller needed was not in it. Returns the
97
+ * error so a throw site stays one expression.
98
+ *
99
+ * Called at those throw sites only — never in a constructor, because the class
100
+ * is also raised for calls that failed outright.
101
+ */
102
+ export function markEmptyResponse<E extends Error>(error: E): E {
103
+ Object.defineProperty(error, EMPTY_RESPONSE, { value: true });
104
+ return error;
105
+ }
106
+
107
+ /** Whether an error is that sentinel, across bundle copies. */
108
+ export function isEmptyResponse(value: unknown): boolean {
109
+ return typeof value === 'object' && value !== null && EMPTY_RESPONSE in value;
110
+ }
111
+
112
+ /**
113
+ * Brand for the opposite fact: the request never reached the server.
114
+ *
115
+ * The third brand, and for the third time because the shape does not carry the
116
+ * answer. `err instanceof TypeError` was the guess here, and `TypeError` is
117
+ * also what a null dereference raises — `rows.map` on a `null` produced "check
118
+ * your connection" for a bug in the page. A name, a status and a class are all
119
+ * things two unrelated failures can share; only the seam that made the request
120
+ * knows whether it left the machine.
121
+ *
122
+ * `defaultHttpClient` marks what `fetch` rejects with, which is the one client
123
+ * this package ships. A consumer's `HttpClient` is welcome to mark its own
124
+ * rejections — that is why this is exported — and axios is read rather than
125
+ * branded, because a library cannot be asked to mark itself. What is read of it
126
+ * is its own statement about the request, not a shape that merely correlates
127
+ * with one; see `isAxiosNoResponseError`.
128
+ *
129
+ * `Symbol.for` for the same reason as above: the package ships more than one
130
+ * bundle copy.
131
+ */
132
+ const TRANSPORT_FAILURE = Symbol.for('@saasicat/ui-vue/TransportFailure');
133
+
134
+ /**
135
+ * Marks an error as one the request did not survive: no connection, DNS
136
+ * failure, CORS rejection, abort, or a client that reported the same by
137
+ * resolving without an HTTP status. Returns the error so a throw site stays one
138
+ * expression.
139
+ */
140
+ export function markTransportFailure<E>(error: E): E {
141
+ if (typeof error === 'object' && error !== null) {
142
+ try {
143
+ Object.defineProperty(error, TRANSPORT_FAILURE, { value: true });
144
+ } catch {
145
+ // A frozen or sealed error cannot take the brand. Losing it costs
146
+ // the precise sentence; replacing the error would cost the
147
+ // diagnostic altogether, so the original is what propagates.
148
+ }
149
+ }
150
+ return error;
151
+ }
152
+
153
+ /** Whether an error is a declared transport failure, across bundle copies. */
154
+ export function isTransportFailure(value: unknown): boolean {
155
+ return typeof value === 'object' && value !== null && TRANSPORT_FAILURE in value;
156
+ }
157
+
158
+ export interface AdminErrorInit {
159
+ /** HTTP status. `0` means the request never produced one. */
160
+ status?: number;
161
+ /** Machine-readable code from the response body (`{ code }`). */
162
+ code?: string;
163
+ /** Parsed response body, when there was one. */
164
+ body?: unknown;
165
+ /** Requested URL, when known. */
166
+ url?: string;
167
+ /** Request method, when known. */
168
+ method?: string;
169
+ /**
170
+ * Text the failing side supplied. Never invent one — its absence is the
171
+ * signal that only a translated fallback can be shown.
172
+ */
173
+ detail?: string;
174
+ /**
175
+ * The request completed and the answer was unusable — a 2xx that carried
176
+ * no body where one was required.
177
+ *
178
+ * Its own state because the model could not otherwise tell it from a
179
+ * transport failure: both have no HTTP status to report, and both were
180
+ * therefore `status: 0`. They need opposite words. A request that never
181
+ * left says "check your connection"; one the server accepted and answered
182
+ * with nothing says "check whether the change was applied", because it may
183
+ * well have been.
184
+ */
185
+ emptyResponse?: boolean;
186
+ /**
187
+ * The request never reached the server.
188
+ *
189
+ * Its own state for the same reason as `emptyResponse`: neither has an HTTP
190
+ * status to reason from, so a status of `0` cannot tell them apart — and
191
+ * neither can it tell either of them from an error that simply carries no
192
+ * status, such as a bug in page code. Only the seam that made the request
193
+ * knows, and it says so with `markTransportFailure`.
194
+ */
195
+ transportFailure?: boolean;
196
+ /** Diagnostic message. Derived from the fields above when omitted. */
197
+ message?: string;
198
+ /** The error this one was built from. */
199
+ cause?: unknown;
200
+ }
201
+
202
+ /**
203
+ * Builds the diagnostic message. Deliberately shaped like the one
204
+ * `admin-resource-client.ts` already produces (`GET /url → HTTP 403`), so
205
+ * logs read the same whichever layer threw.
206
+ */
207
+ function describe(init: AdminErrorInit): string {
208
+ const status = init.status ?? 0;
209
+ const where = [init.method, init.url].filter(Boolean).join(' ');
210
+ const what = status > 0 ? `HTTP ${status}` : 'request failed';
211
+ const head = where ? `${where} → ${what}` : what;
212
+ const code = init.code ? ` (${init.code})` : '';
213
+ return init.detail ? `${head}${code}: ${init.detail}` : `${head}${code}`;
214
+ }
215
+
216
+ /** A failed admin API call, in the one shape the package works with. */
217
+ export class AdminError extends Error {
218
+ /** HTTP status. `0` means the request never produced one. */
219
+ readonly status: number;
220
+ /** Machine-readable code from the response body, when it carried one. */
221
+ readonly code?: string;
222
+ /** Parsed response body, when there was one. */
223
+ readonly body?: unknown;
224
+ /** Requested URL, when known. */
225
+ readonly url?: string;
226
+ /** Request method, when known. */
227
+ readonly method?: string;
228
+ /**
229
+ * Text the failing side supplied — the only candidate for user-facing
230
+ * output. `undefined` when nothing was supplied.
231
+ */
232
+ readonly detail?: string;
233
+ /** The request completed but the answer was unusable. See `AdminErrorInit`. */
234
+ readonly emptyResponse: boolean;
235
+ /** The request never reached the server. See `AdminErrorInit`. */
236
+ readonly transportFailure: boolean;
237
+
238
+ constructor(init: AdminErrorInit = {}) {
239
+ super(init.message ?? describe(init), { cause: init.cause });
240
+ this.name = 'AdminError';
241
+ this.status = init.status ?? 0;
242
+ this.code = init.code;
243
+ this.body = init.body;
244
+ this.url = init.url;
245
+ this.method = init.method;
246
+ this.detail = init.detail;
247
+ this.emptyResponse = init.emptyResponse ?? false;
248
+ this.transportFailure = init.transportFailure ?? false;
249
+ Object.defineProperty(this, ADMIN_ERROR, { value: true });
250
+ // `message` here is `describe()`'s diagnostic, or one a throw site
251
+ // supplied — never text for a screen, which is exactly what the
252
+ // platform brand states. `toAdminError` returns an `AdminError`
253
+ // unchanged and so never reads it, but the promise holds for anyone
254
+ // who does, and it keeps the guard's rule ("every error class this
255
+ // package declares carries the brand") free of exceptions.
256
+ markPlatformError(this);
257
+ }
258
+ }
259
+
260
+ /** Whether `err` is an `AdminError`, including one from another bundle copy. */
261
+ export function isAdminError(err: unknown): err is AdminError {
262
+ return typeof err === 'object' && err !== null && ADMIN_ERROR in err;
263
+ }
264
+
265
+ /** Narrows to an indexable object without asserting anything about its keys. */
266
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
267
+ return typeof value === 'object' && value !== null
268
+ ? (value as Record<string, unknown>)
269
+ : undefined;
270
+ }
271
+
272
+ function asString(value: unknown): string | undefined {
273
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
274
+ }
275
+
276
+ /**
277
+ * Whether an error is axios saying, in its own words, that the request was made
278
+ * and no response ever arrived.
279
+ *
280
+ * This is the one transport failure read rather than branded, because a library
281
+ * cannot be asked to mark itself. What it reads is axios's own three-way split,
282
+ * documented under "Handling errors" in its README and visible in the
283
+ * `AxiosError` constructor: `response` is set only when the server answered,
284
+ * `request` only once one was actually made, and an error with neither happened
285
+ * while the request was still being set up.
286
+ *
287
+ * Measured against axios 1.18.1 driven at a real `node:http` server, and the
288
+ * groups came out disjoint:
289
+ *
290
+ * | rejection | `isAxiosError` | `request` | `response` |
291
+ * | ----------------------------------------------- | -------------- | --------- | ---------- |
292
+ * | connection refused / reset, DNS, timeout, abort | `true` | yes | no |
293
+ * | the same, rethrown by a rejection interceptor | `true` | yes | no |
294
+ * | a 401 or 500 the server really sent | `true` | yes | yes |
295
+ * | interceptor rejects with `new Error(…)` | absent | no | no |
296
+ * | …the same, but copying `error.config` across | absent | no | no |
297
+ * | …the same, copying `config` and `request` | absent | yes | no |
298
+ * | unsupported protocol, signal aborted before call | `true` | no | no |
299
+ * | request interceptor throws, malformed URL | absent | no | no |
300
+ *
301
+ * Three readings follow from that table, and only the first is free of choice:
302
+ *
303
+ * - `response` present means the server answered, whatever else is set.
304
+ * - `request` present without it is axios's "made, nothing came back". The
305
+ * browser and `fetch` adapters attach it too — an `XMLHttpRequest` and a
306
+ * `Request` respectively — so the reading is not Node-only.
307
+ * - Requiring `isAxiosError` on top of that is the decision. It removes the
308
+ * ordinary interceptor that copies `config` and `request` onto a hand-built
309
+ * `new Error`, which is shape-wise axios's no-response form after the server
310
+ * had answered.
311
+ *
312
+ * The previous reading was `config` without `response`, which is not axios's
313
+ * statement about anything: `config` is echoed on every axios error including
314
+ * the ones carrying a 401, and it is the field an interceptor is most likely to
315
+ * carry over when it replaces the rejection. It matched three rows above that
316
+ * mean the opposite of a transport failure.
317
+ *
318
+ * ## What this cannot decide, and which way it errs
319
+ *
320
+ * This is read *after* the consumer's interceptors have run — after the one
321
+ * place that can set or delete any field on the rejection. Both readings are
322
+ * therefore forgeable, in both directions, and each was reproduced:
323
+ *
324
+ * - An interceptor that **drops** `request` — `AxiosError.from(err, code,
325
+ * config)` without its fourth argument, `err.toJSON()`, or a `delete
326
+ * err.request` before logging — leaves a genuine `ECONNREFUSED` unbranded.
327
+ * The operator then reads `connect ECONNREFUSED 127.0.0.1:44947` instead of
328
+ * the localized sentence.
329
+ * - An interceptor that **builds** one — `AxiosError.from(msg, code,
330
+ * error.config, error.request)` after an answered 401 — is branded. Nothing
331
+ * distinguishes it: `AxiosError.from` is public API, so `isAxiosError` is
332
+ * available to user code as well.
333
+ *
334
+ * Neither is closable by a better predicate; the fields simply do not carry the
335
+ * fact by then. So the reading errs deliberately toward *not* branding, because
336
+ * the two mistakes do not cost the same: an unbranded transport failure shows a
337
+ * true sentence in the wrong register, while a branded application failure tells
338
+ * the operator to check a connection that is fine. Saying less beats saying
339
+ * something false.
340
+ *
341
+ * An interceptor that rewrites rejections should say what it means with
342
+ * `markTransportFailure`, which is exported for exactly this. It is checked
343
+ * first and settles the case.
344
+ */
345
+ export function isAxiosNoResponseError(value: unknown): boolean {
346
+ const record = asRecord(value);
347
+ if (!record) return false;
348
+ return record.isAxiosError === true && record.request != null && record.response == null;
349
+ }
350
+
351
+ /**
352
+ * Reads the human-readable message out of a response body.
353
+ *
354
+ * Covers the two shapes the platform and its host produce: a coded platform
355
+ * error (`{ code, message }`) carries a string, while a NestJS
356
+ * `ValidationPipe` rejection carries `message: string[]` — one entry per
357
+ * failed constraint. Joining them is what keeps a validation error readable
358
+ * instead of rendering as `[object Object]`.
359
+ *
360
+ * Exported because every place that builds an `AdminError` from a response
361
+ * needs the same answer. It had two implementations for one release and they
362
+ * disagreed: the one in `http-json.ts` accepted only a string, so a validation
363
+ * rejection arriving through `getJson`/`postJson` lost its constraints
364
+ * entirely and the operator was shown the generic fallback.
365
+ */
366
+ export function readErrorDetail(body: unknown): string | undefined {
367
+ const record = asRecord(body);
368
+ if (!record) return asString(body);
369
+ const message = record.message;
370
+ if (Array.isArray(message)) {
371
+ const parts = message.filter((part): part is string => typeof part === 'string');
372
+ return parts.length > 0 ? parts.join(', ') : undefined;
373
+ }
374
+ return asString(message) ?? asString(record.error) ?? asString(record.reason);
375
+ }
376
+
377
+ /** Reads the machine-readable code out of a response body, if it carries one. */
378
+ export function readErrorCode(body: unknown): string | undefined {
379
+ return asString(asRecord(body)?.code);
380
+ }
381
+
382
+ /**
383
+ * Turns anything that was caught into an `AdminError`.
384
+ *
385
+ * Recognises, in order: an `AdminError` (from any bundle copy), an axios-style
386
+ * rejection, an error carrying a numeric `status` — which is every one of the
387
+ * package's own `*ApiError` classes — and finally any `Error`, string or
388
+ * unknown value.
389
+ */
390
+ export function toAdminError(err: unknown): AdminError {
391
+ if (isAdminError(err)) return err;
392
+
393
+ const record = asRecord(err);
394
+ const response = asRecord(record?.response);
395
+
396
+ // Axios rejection: the body sits under `response.data`, the request under
397
+ // `config`. Both are absent on a network failure, which axios reports as a
398
+ // rejection with no `response` at all.
399
+ if (response && typeof response.status === 'number') {
400
+ const config = asRecord(record?.config);
401
+ const body = response.data;
402
+ return new AdminError({
403
+ status: response.status,
404
+ code: readErrorCode(body),
405
+ body,
406
+ url: asString(config?.url),
407
+ method: asString(config?.method)?.toUpperCase(),
408
+ // Only what the response actually said. `err.message` here is
409
+ // axios's own generated line ("Request failed with status code
410
+ // 401") — English, untranslated, and about the transport rather
411
+ // than about what went wrong. Letting it through as `detail` would
412
+ // make `adminErrorMessage` return it in preference to the
413
+ // localized wording, which is the whole point of that wording.
414
+ detail: readErrorDetail(body),
415
+ cause: err,
416
+ });
417
+ }
418
+
419
+ // The package's own API errors (`BundlesApiError`, `PlansApiError`, …) all
420
+ // carry `status` and most carry the parsed `body`.
421
+ if (record && typeof record.status === 'number') {
422
+ // Only this package's own classes carry diagnostics in `message`. A
423
+ // consumer's status-bearing error — `Object.assign(new Error('Quota
424
+ // exhausted'), { status: 429 })` — says something an operator needs,
425
+ // and the page-level helper this replaces showed it.
426
+ const ours = isPlatformError(err);
427
+ return new AdminError({
428
+ status: record.status,
429
+ code: readErrorCode(record.body) ?? asString(record.code),
430
+ body: record.body,
431
+ // Same reasoning as above: `BundlesApiError`'s message is
432
+ // "Bundles API responded with HTTP 403", a diagnostic. It stays on
433
+ // `message`, where logs read it.
434
+ detail: readErrorDetail(record.body) ?? (ours ? undefined : asString(record.message)),
435
+ // Asked of the throw site, not of the class: only the site that
436
+ // read the response knows whether one arrived. `status === 0 &&
437
+ // ours` was the same question put to the wrong witness — the
438
+ // loaders raise branded errors from a status the client chose, so
439
+ // a client that resolves a transport failure as `0` turned a
440
+ // failed GET into "the change may have been applied".
441
+ emptyResponse: isEmptyResponse(err),
442
+ transportFailure: isTransportFailure(err),
443
+ message: asString(record.message),
444
+ cause: err,
445
+ });
446
+ }
447
+
448
+ if (err instanceof Error) {
449
+ // A transport failure carries no message from a failing side — only
450
+ // the client's own diagnostic, generated in English by whichever client
451
+ // is installed. Leaving `detail` unset is what lets
452
+ // `adminErrorMessage` reach `msgs.network`, the one sentence that
453
+ // actually tells the operator what to do.
454
+ //
455
+ // Which errors those are is declared, not inferred. `defaultHttpClient`
456
+ // brands what `fetch` rejects with; a consumer's client may brand its
457
+ // own. `err instanceof TypeError` used to stand in for it and was
458
+ // wrong in both directions: it caught every null dereference in page
459
+ // code ("Cannot read properties of null") and told the operator to
460
+ // check their connection, and it missed any client that fails with
461
+ // something else. Axios is the one shape still read rather than
462
+ // branded, because the package cannot ask a library to mark itself —
463
+ // but what is read of it is axios's own statement that the request was
464
+ // made and nothing came back, not the `config` it echoes on every
465
+ // rejection it produces. See `isAxiosNoResponseError` for the
466
+ // measurement; the `config` reading it replaces also caught an
467
+ // interceptor answering a 401 with its own error, which suppressed the
468
+ // very message the interceptor wrote.
469
+ //
470
+ // Anything else keeps its message: an `Error('Plan is locked')` from
471
+ // app code IS what the failing side said, and it is what the page-level
472
+ // copies this replaces showed.
473
+ const transport = isTransportFailure(err) || isAxiosNoResponseError(err);
474
+ // A `TypeError` nobody declared a transport failure is a JavaScript
475
+ // fault — `rows.map` on a `null`, or a client that failed in a way it
476
+ // did not mark. Either way its text is the engine's ("Cannot read
477
+ // properties of null (reading 'map')"), which belongs in a stack trace
478
+ // and not on an admin screen, so it stays on `message` and the fallback
479
+ // in `adminErrorMessage` answers instead.
480
+ const saidSomethingReadable = !transport && !(err instanceof TypeError);
481
+ return new AdminError({
482
+ detail: saidSomethingReadable ? asString(err.message) : undefined,
483
+ transportFailure: transport,
484
+ message: err.message,
485
+ cause: err,
486
+ });
487
+ }
488
+
489
+ // A rejection that is not an `Error` still says something when it carries a
490
+ // readable `message`. A client rejecting with `{ code, message }` is an
491
+ // ordinary shape, and an `Error` built in another realm fails `instanceof`
492
+ // while being exactly that. The status-bearing branch above already reads
493
+ // both fields; leaving them unread here treated the same information
494
+ // differently depending on whether a status happened to travel with it.
495
+ //
496
+ // What this cannot separate is a cross-realm `TypeError`, whose message is
497
+ // the engine's rather than the failing side's — `instanceof` is the only
498
+ // thing that tells those apart, and it is precisely what has already failed
499
+ // by the time execution reaches here. So the reading is a decision, not a
500
+ // measurement: a consumer message shown once too often costs a confusing
501
+ // sentence, one swallowed costs the only explanation there was.
502
+ const text = asString(err) ?? asString(record?.message);
503
+ return new AdminError({
504
+ code: asString(record?.code),
505
+ detail: text,
506
+ message: text,
507
+ cause: err,
508
+ });
509
+ }
510
+
511
+ /**
512
+ * Maps `status` to the key that describes what happened. `undefined` means the
513
+ * status has no dedicated wording and falls through to the generic template.
514
+ */
515
+ function statusKey(status: number): keyof SaMessages['errors'] | undefined {
516
+ if (status === 400 || status === 422) return 'validation';
517
+ if (status === 401) return 'unauthorized';
518
+ if (status === 403) return 'forbidden';
519
+ if (status === 404) return 'notFound';
520
+ if (status === 409) return 'conflict';
521
+ if (status === 429) return 'rateLimited';
522
+ if (status >= 500) return 'server';
523
+ return undefined;
524
+ }
525
+
526
+ /**
527
+ * Turns anything that was caught into text for a user.
528
+ *
529
+ * What the failing side said outranks anything this package could guess, so a
530
+ * `detail` wins whenever there is one. After that come the two facts a seam
531
+ * declared about a request with no HTTP status — they need opposite sentences
532
+ * and no number can tell them apart — and only then the status.
533
+ *
534
+ * The last line is the honest one. An error with no status, no text and no
535
+ * declaration is an error nothing knows anything about; "check your connection"
536
+ * used to be the answer, and it sent an operator after their router for a null
537
+ * dereference in a page. Whoever knows better says so with
538
+ * `markTransportFailure` or `markEmptyResponse`.
539
+ *
540
+ * Pages do not call this — `useAsyncAction` and the error banner do.
541
+ */
542
+ export function adminErrorMessage(err: unknown, msgs: SaMessages['errors']): string {
543
+ const error = toAdminError(err);
544
+ if (error.detail) return error.detail;
545
+ if (error.emptyResponse) return msgs.emptyResponse;
546
+ if (error.transportFailure) return msgs.network;
547
+ if (error.status > 0) {
548
+ const key = statusKey(error.status);
549
+ return key ? msgs[key] : formatMessage(msgs.httpStatus, { status: error.status });
550
+ }
551
+ return msgs.unexpected;
552
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  AuditEntry,
8
8
  PromoCodeRecord,
9
9
  } from '@saasicat/types';
10
+ import { filterQueryString } from './resources/list-resource.js';
10
11
  import type { HttpClient } from './types.js';
11
12
 
12
13
  export interface AdminResourceClientOptions {
@@ -57,11 +58,11 @@ export function createAdminResourceClient(options: AdminResourceClientOptions) {
57
58
  getJson<AdminTenantDetail>(`${tenantsEndpoint}/${encodeURIComponent(slug)}`),
58
59
  loadUsers: (filter: AdminUserListFilter) =>
59
60
  getJson<AdminUserListRow[]>(
60
- `${base}/users${queryString({ q: filter.q, tenant: filter.tenant })}`,
61
+ `${base}/users${filterQueryString({ q: filter.q, tenant: filter.tenant })}`,
61
62
  ),
62
63
  loadAudit: (filter: AdminAuditListFilter) =>
63
64
  getJson<AuditEntry[]>(
64
- `${base}/audit${queryString({
65
+ `${base}/audit${filterQueryString({
65
66
  actor: filter.actor,
66
67
  action: filter.action,
67
68
  entity: filter.entity,
@@ -72,7 +73,7 @@ export function createAdminResourceClient(options: AdminResourceClientOptions) {
72
73
  loadSubscriptions: () => getJson<AdminSubscriptionListRow[]>(`${base}/subscriptions`),
73
74
  loadPromos: (filter: AdminPromoListFilter) =>
74
75
  getJson<AdminPromoListRow[]>(
75
- `${base}/promo-codes${queryString({
76
+ `${base}/promo-codes${filterQueryString({
76
77
  search: filter.search,
77
78
  status: filter.status,
78
79
  })}`,
@@ -101,12 +102,8 @@ function assertSuccess(status: number, method: string, url: string): void {
101
102
  }
102
103
  }
103
104
 
104
- function queryString(params: Record<string, string | number | null | undefined>): string {
105
- const search = new URLSearchParams();
106
- for (const [key, value] of Object.entries(params)) {
107
- if (value === undefined || value === null || value === '') continue;
108
- search.set(key, String(value));
109
- }
110
- const value = search.toString();
111
- return value ? `?${value}` : '';
112
- }
105
+ // The query string these endpoints take is `filterQueryString` the same
106
+ // omit-the-empties rule the paginated lists apply, minus the pagination these
107
+ // controllers do not offer. It used to be written out again here, which is one
108
+ // decision in two places: the copies agreed by coincidence, and either could
109
+ // have started sending `status=null` on its own.
@@ -46,7 +46,7 @@ export class BatchColumnDriftError extends Error {
46
46
  public readonly column: TenantColumnDef,
47
47
  reason: string,
48
48
  ) {
49
- super(`Spalte "${column.key}": ${reason}`);
49
+ super(`Column "${column.key}": ${reason}`);
50
50
  this.name = 'BatchColumnDriftError';
51
51
  }
52
52
  }
@@ -91,7 +91,7 @@ export class BatchColumnFetcher {
91
91
  const res = await this.http(url, { method: 'GET', headers });
92
92
  if (res.status !== 200) {
93
93
  throw new Error(
94
- `Spalte "${column.key}" — Endpoint ${column.endpoint} antwortete HTTP ${res.status}`,
94
+ `Column "${column.key}" — endpoint ${column.endpoint} responded with HTTP ${res.status}`,
95
95
  );
96
96
  }
97
97
  const body = (await res.json()) as BatchColumnRow;
@@ -12,6 +12,8 @@
12
12
  // Spec: admin-api.openapi.yaml.
13
13
 
14
14
  import type { PublicBootResponse } from '@saasicat/types';
15
+ import { markPlatformError } from './admin-error.js';
16
+ import { requireServerAnswer } from './http-json.js';
15
17
  import { defaultHttpClient, type HttpClient } from './types.js';
16
18
 
17
19
  export interface BootLoaderOptions {
@@ -31,6 +33,9 @@ export class BootLoadError extends Error {
31
33
  ) {
32
34
  super(message);
33
35
  this.name = 'BootLoadError';
36
+ // Identity, so `toAdminError` can tell this diagnostic from a
37
+ // consumer error whose message an operator needs to read.
38
+ markPlatformError(this);
34
39
  }
35
40
  }
36
41
 
@@ -52,8 +57,14 @@ export class BootLoader {
52
57
 
53
58
  async load(): Promise<PublicBootResponse> {
54
59
  const res = await this.http(this.endpoint);
60
+ requireServerAnswer(
61
+ res.status,
62
+ 'GET',
63
+ this.endpoint,
64
+ (diagnostic) => new BootLoadError(res.status, diagnostic),
65
+ );
55
66
  if (res.status !== 200) {
56
- throw new BootLoadError(res.status, `Boot-Endpunkt antwortete HTTP ${res.status}`);
67
+ throw new BootLoadError(res.status, `Boot endpoint responded with HTTP ${res.status}`);
57
68
  }
58
69
  return (await res.json()) as PublicBootResponse;
59
70
  }