@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,44 @@
1
+ // What to tell a user when a request failed and the failing side said nothing
2
+ // itself. Each key answers one question — "what went wrong" — so none of them
3
+ // names an action; `common.errorLoadFailed` and its siblings do that.
4
+ //
5
+ // `adminErrorMessage()` reaches exactly one of these per error, and reaches a
6
+ // key here only when the response carried no message of its own.
7
+ //
8
+ // `network`, `emptyResponse` and `unexpected` all describe a request with no
9
+ // HTTP status and must not be swapped. The first two are reached only when a
10
+ // seam declared which one it was (`markTransportFailure`, `markEmptyResponse`);
11
+ // `unexpected` is what is left when nothing knows — including the JavaScript
12
+ // faults that used to be reported as connection problems.
13
+
14
+ import { defineMessages } from '../define.js';
15
+
16
+ export const errorsMessages = defineMessages(
17
+ {
18
+ network: 'Die Anfrage konnte nicht abgeschlossen werden. Bitte Verbindung prüfen.',
19
+ emptyResponse:
20
+ 'Der Server hat nichts zurückgegeben. Bitte prüfen, ob die Änderung angekommen ist.',
21
+ unauthorized: 'Die Sitzung ist abgelaufen. Bitte erneut anmelden.',
22
+ forbidden: 'Für diese Aktion fehlt die Berechtigung.',
23
+ notFound: 'Der angeforderte Eintrag existiert nicht mehr.',
24
+ conflict: 'Der Eintrag wurde zwischenzeitlich geändert. Bitte neu laden.',
25
+ validation: 'Die Eingaben wurden nicht akzeptiert.',
26
+ rateLimited: 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.',
27
+ server: 'Der Server konnte die Anfrage nicht verarbeiten.',
28
+ httpStatus: 'Die Anfrage ist fehlgeschlagen (HTTP {status}).',
29
+ unexpected: 'Es ist ein unerwarteter Fehler aufgetreten. Bitte erneut versuchen.',
30
+ },
31
+ {
32
+ network: 'The request could not be completed. Please check your connection.',
33
+ emptyResponse: 'The server returned nothing. Please check whether the change was applied.',
34
+ unauthorized: 'Your session has expired. Please sign in again.',
35
+ forbidden: 'You do not have permission for this action.',
36
+ notFound: 'The requested entry no longer exists.',
37
+ conflict: 'The entry changed in the meantime. Please reload.',
38
+ validation: 'The input was not accepted.',
39
+ rateLimited: 'Too many requests. Please wait a moment and try again.',
40
+ server: 'The server could not process the request.',
41
+ httpStatus: 'The request failed (HTTP {status}).',
42
+ unexpected: 'Something went wrong. Please try again.',
43
+ },
44
+ );
@@ -130,9 +130,6 @@ export const marketingMessages = defineMessages(
130
130
  delete: 'Aktion löschen',
131
131
  deleteConfirm: 'Aktion „{label}“ wirklich löschen?',
132
132
  },
133
- errors: {
134
- projectionsApi: 'MarketingProjections-API antwortete mit HTTP {status}',
135
- },
136
133
  },
137
134
  {
138
135
  topFeatures: 'Top features',
@@ -261,8 +258,5 @@ export const marketingMessages = defineMessages(
261
258
  delete: 'Delete promotion',
262
259
  deleteConfirm: 'Really delete promotion "{label}"?',
263
260
  },
264
- errors: {
265
- projectionsApi: 'MarketingProjections API responded with HTTP {status}',
266
- },
267
261
  },
268
262
  );
@@ -4,7 +4,6 @@ export const promosMessages = defineMessages(
4
4
  {
5
5
  title: 'Promo-Codes',
6
6
  createAction: 'Promo-Code anlegen',
7
- apiErrorHttpStatus: 'Promotions-API antwortete mit HTTP {status}',
8
7
  list: {
9
8
  searchLabel: 'Suche (Code)',
10
9
  tileScheduled: 'Geplant',
@@ -103,7 +102,6 @@ export const promosMessages = defineMessages(
103
102
  {
104
103
  title: 'Promo codes',
105
104
  createAction: 'Create promo code',
106
- apiErrorHttpStatus: 'Promotions API responded with HTTP {status}',
107
105
  list: {
108
106
  searchLabel: 'Search (code)',
109
107
  tileScheduled: 'Scheduled',
@@ -11,6 +11,12 @@ export const shellMessages = defineMessages(
11
11
  roleBadge: 'SUPER ADMIN',
12
12
  logout: 'Abmelden',
13
13
  language: 'Sprache',
14
+ theme: {
15
+ label: 'Darstellung',
16
+ light: 'Hell',
17
+ dark: 'Dunkel',
18
+ system: 'System',
19
+ },
14
20
  },
15
21
  drawer: {
16
22
  docs: 'Doku öffnen',
@@ -83,6 +89,12 @@ export const shellMessages = defineMessages(
83
89
  roleBadge: 'SUPER ADMIN',
84
90
  logout: 'Sign out',
85
91
  language: 'Language',
92
+ theme: {
93
+ label: 'Appearance',
94
+ light: 'Light',
95
+ dark: 'Dark',
96
+ system: 'System',
97
+ },
86
98
  },
87
99
  drawer: {
88
100
  docs: 'Open documentation',
@@ -10,6 +10,7 @@ import { commonMessages } from './messages/common.js';
10
10
  import { dashboardMessages } from './messages/dashboard.js';
11
11
  import { discoveryMessages } from './messages/discovery.js';
12
12
  import { emailMessages } from './messages/email.js';
13
+ import { errorsMessages } from './messages/errors.js';
13
14
  import { marketingMessages } from './messages/marketing.js';
14
15
  import { navMessages } from './messages/nav.js';
15
16
  import { pilotsMessages } from './messages/pilots.js';
@@ -25,6 +26,7 @@ import { usersMessages } from './messages/users.js';
25
26
  /** Complete platform catalog for one locale, keyed by namespace. */
26
27
  export type SaMessages = {
27
28
  readonly common: (typeof commonMessages)['de'];
29
+ readonly errors: (typeof errorsMessages)['de'];
28
30
  readonly nav: (typeof navMessages)['de'];
29
31
  readonly shell: (typeof shellMessages)['de'];
30
32
  readonly dashboard: (typeof dashboardMessages)['de'];
@@ -49,6 +51,7 @@ export type SaMessagesOverrides = PartialMessages<SaMessages>;
49
51
  function catalogFor(locale: SaBuiltinLocale): SaMessages {
50
52
  return {
51
53
  common: commonMessages[locale],
54
+ errors: errorsMessages[locale],
52
55
  nav: navMessages[locale],
53
56
  shell: shellMessages[locale],
54
57
  dashboard: dashboardMessages[locale],
@@ -5,6 +5,9 @@
5
5
  //
6
6
  // Contents:
7
7
  // - types: HttpClient, KvStore, defaultHttpClient, defaultKvStore
8
+ // - http: createFetchHttpClient, createAxiosHttpClient
9
+ // - resources: defineResource, bindResource, the endpoint descriptors
10
+ // - admin-error: AdminError, toAdminError, adminErrorMessage
8
11
  // - http-json: JSON fetch helper over HttpClient
9
12
  // - boot-loader: BootLoader + BootLoadError
10
13
  // - manifest-loader: ManifestLoader with ETag cache + ManifestLoadError
@@ -16,6 +19,9 @@
16
19
 
17
20
  export * from './version.js';
18
21
  export * from './types.js';
22
+ export * from './http/index.js';
23
+ export * from './resources/index.js';
24
+ export * from './admin-error.js';
19
25
  export * from './http-json.js';
20
26
  export * from './boot-loader.js';
21
27
  export * from './manifest-loader.js';
@@ -11,11 +11,27 @@
11
11
  // - Subsequent call: GET with `If-None-Match: <etag>` → on 304 the
12
12
  // cached body is returned, no re-parse needed. On 200 the
13
13
  // cache is overwritten.
14
+ // - The two keys can come apart — a quota eviction, another tab clearing
15
+ // one of them, a store that took the first write and refused the second.
16
+ // A 304 then answers a question this loader asked on behalf of a body it
17
+ // no longer holds, and the repair is to forget the ETag and ask again
18
+ // unconditionally. See `load()`.
14
19
  //
15
20
  // Spec: admin-api.openapi.yaml `GET /admin/manifest`.
16
21
 
17
22
  import type { AdminManifest } from '@saasicat/types';
18
- import { defaultHttpClient, defaultKvStore, type HttpClient, type KvStore } from './types.js';
23
+ import { markPlatformError } from './admin-error.js';
24
+ import { requireServerAnswer } from './http-json.js';
25
+ import {
26
+ defaultHttpClient,
27
+ defaultKvStore,
28
+ type HttpClient,
29
+ type HttpResponse,
30
+ type KvStore,
31
+ } from './types.js';
32
+
33
+ /** The conditional GET matched: no body was sent, so the cache has to answer. */
34
+ const NOT_MODIFIED = 304;
19
35
 
20
36
  export interface ManifestLoaderOptions {
21
37
  /**
@@ -39,6 +55,11 @@ export interface ManifestLoaderOptions {
39
55
  getAuthToken?: () => string | null;
40
56
  }
41
57
 
58
+ /**
59
+ * The manifest could not be loaded. Its `message` is a diagnostic for the log,
60
+ * never text for a screen — `markPlatformError` is what tells `toAdminError`
61
+ * so, and it is a promise this class has to keep at every throw site.
62
+ */
42
63
  export class ManifestLoadError extends Error {
43
64
  constructor(
44
65
  public readonly status: number,
@@ -46,6 +67,9 @@ export class ManifestLoadError extends Error {
46
67
  ) {
47
68
  super(message);
48
69
  this.name = 'ManifestLoadError';
70
+ // Identity, so `toAdminError` can tell this diagnostic from a
71
+ // consumer error whose message an operator needs to read.
72
+ markPlatformError(this);
49
73
  }
50
74
  }
51
75
 
@@ -82,27 +106,53 @@ export class ManifestLoader {
82
106
  /**
83
107
  * Loads the current manifest. On a cache hit (304) the cached
84
108
  * body is returned — otherwise the fresh server body.
109
+ *
110
+ * A 304 with no usable cached body is not a failure to report but one to
111
+ * undo: the request only carried `If-None-Match` because this loader put
112
+ * the ETag there, and the cache it validated against is this loader's own.
113
+ * So it drops the pair and asks once more without the header, which is the
114
+ * same recovery a person would have had to trigger by hand — except that
115
+ * the person reading an admin screen has neither this loader nor a
116
+ * console. Exactly one retry: a 304 to a request that asked nothing
117
+ * conditional is a server fault, and no repetition fixes it.
85
118
  */
86
119
  async load(): Promise<AdminManifest> {
87
120
  const cachedEtag = this.storage.get(this.etagKey);
121
+ const res = await this.request(cachedEtag);
122
+ if (res.status !== NOT_MODIFIED) return this.acceptFresh(res);
123
+
124
+ const cached = this.readCachedBody();
125
+ if (cached) return cached.body;
126
+
127
+ if (cachedEtag) {
128
+ this.clearCache();
129
+ const fresh = await this.request(null);
130
+ if (fresh.status !== NOT_MODIFIED) return this.acceptFresh(fresh);
131
+ }
132
+
133
+ throw new ManifestLoadError(
134
+ NOT_MODIFIED,
135
+ 'Manifest endpoint answered HTTP 304 to a request without If-None-Match',
136
+ );
137
+ }
138
+
139
+ /** Sends the manifest GET, conditional when an ETag is supplied. */
140
+ private request(etag: string | null): Promise<HttpResponse> {
88
141
  const headers: Record<string, string> = {};
89
142
  const token = this.getAuthToken?.();
90
143
  if (token) headers.Authorization = `Bearer ${token}`;
91
- if (cachedEtag) headers['If-None-Match'] = cachedEtag;
92
-
93
- const res = await this.http(this.endpoint, { method: 'GET', headers });
94
-
95
- if (res.status === 304) {
96
- const cachedBody = this.readCachedBody();
97
- if (!cachedBody) {
98
- throw new ManifestLoadError(
99
- 304,
100
- 'Server returned 304 but the cache body is missing — call clearCache() and reload',
101
- );
102
- }
103
- return cachedBody.body;
104
- }
144
+ if (etag) headers['If-None-Match'] = etag;
145
+ return this.http(this.endpoint, { method: 'GET', headers });
146
+ }
105
147
 
148
+ /** Takes a non-304 response as the current manifest and caches it. */
149
+ private async acceptFresh(res: HttpResponse): Promise<AdminManifest> {
150
+ requireServerAnswer(
151
+ res.status,
152
+ 'GET',
153
+ this.endpoint,
154
+ (diagnostic) => new ManifestLoadError(res.status, diagnostic),
155
+ );
106
156
  if (res.status !== 200) {
107
157
  throw new ManifestLoadError(
108
158
  res.status,
@@ -0,0 +1,25 @@
1
+ // The audit trail.
2
+ //
3
+ // This descriptor speaks the contract `useAuditEntries` speaks: `AuditQuery`
4
+ // (`actorTag`, `from`, `to`, `entity`, …) over a paginated endpoint. The
5
+ // package holds a second, incompatible one — `createAdminResourceClient`
6
+ // sends `actor`/`since`/`limit` at `AdminAuditListFilter`, unpaginated, and
7
+ // that is what `AuditPage.vue` receives through its `loadAudit` prop.
8
+ //
9
+ // Two audit contracts in one package is a defect, but it is not this change's
10
+ // to settle: choosing either spelling here would send a different request than
11
+ // one of the two callers sends today. The descriptor mirrors the composable it
12
+ // is measured against, and the reconciliation belongs to the page rebuild that
13
+ // can move both sides at once.
14
+
15
+ import type { AuditEntry, AuditQuery } from '@saasicat/types';
16
+
17
+ import { defineResource } from './define-resource.js';
18
+ import { defineListOp, type ListFilterOf } from './list-resource.js';
19
+
20
+ /** What the audit list can be narrowed by. The page number is not a filter. */
21
+ export type AuditListFilter = ListFilterOf<AuditQuery>;
22
+
23
+ export const auditResource = defineResource('audit', {
24
+ list: defineListOp<AuditEntry, AuditListFilter>((ctx) => `${ctx.apiBase}/audit`),
25
+ });
@@ -0,0 +1,166 @@
1
+ // A resource is a named set of operations over `(http, ctx, ...args)`.
2
+ //
3
+ // The point is to define an endpoint once. Today the same URL is assembled in
4
+ // several places — `catalog/plans/:id/versions` is built by `usePlanVersions`
5
+ // and again by `useLivePlanVersions`, and the JSON request/response dance
6
+ // around it (`204` means empty, a 2xx body may be unparsable, `>= 400` throws
7
+ // with the parsed body attached) is written out three times verbatim in this
8
+ // package. An operation that lives in one place can be read, tested and
9
+ // overridden in one place.
10
+ //
11
+ // Framework-free on purpose: no Vue, no refs. An op is a plain async function,
12
+ // so it can be checked with `node --test` against `dist/` like the rest of
13
+ // `src/client/`, and reused by a binding that is not Vue at all.
14
+
15
+ import type { HttpClient } from '../types.js';
16
+
17
+ /**
18
+ * What an operation needs to know about the app it runs in, beyond its own
19
+ * arguments.
20
+ *
21
+ * Today each composable takes these as its own options and each consumer
22
+ * passes them again per page. They are app-wide constants — which is why they
23
+ * belong in one context rather than in every signature.
24
+ */
25
+ export interface ResourceContext {
26
+ /** Admin API root, e.g. `/api/v1/admin`. No trailing slash. */
27
+ readonly apiBase: string;
28
+ /** The project whose catalogue is being administered. */
29
+ readonly projectKey: string;
30
+ /** Active UI locale, for the operations whose payload is per-language. */
31
+ readonly locale: string;
32
+ }
33
+
34
+ /** One operation: everything it needs, in the order it needs it. */
35
+ export type ResourceOp<A extends unknown[], R> = (
36
+ http: HttpClient,
37
+ ctx: ResourceContext,
38
+ ...args: A
39
+ ) => Promise<R>;
40
+
41
+ /** The operations of one resource, keyed by name. */
42
+ export type ResourceOps = Record<string, ResourceOp<never[], unknown>>;
43
+
44
+ export interface ResourceDef<TOps extends ResourceOps> {
45
+ /** Name this resource is registered and overridden under. */
46
+ readonly key: string;
47
+ readonly ops: TOps;
48
+ /**
49
+ * Whether these operations read `ctx.projectKey`.
50
+ *
51
+ * Declared per descriptor rather than known centrally, because it is not a
52
+ * property of the registry: one of the four platform resources is
53
+ * project-scoped and three are not, and the tenant list would be refused
54
+ * for a project it never asks about. `bindResource` refuses to bind a
55
+ * descriptor that declares this against a context that names no project.
56
+ *
57
+ * `resources-declare-their-project-scope.test.js` drives every operation
58
+ * and fails when a declaration and what the operations actually read
59
+ * disagree — in either direction.
60
+ */
61
+ readonly projectScoped: boolean;
62
+ }
63
+
64
+ /** What a descriptor declares beyond its name and its operations. */
65
+ export interface DefineResourceOptions {
66
+ /** See `ResourceDef.projectScoped`. Default `false`. */
67
+ projectScoped?: boolean;
68
+ }
69
+
70
+ /**
71
+ * The same operations with `(http, ctx)` already supplied — the form a page or
72
+ * a composable calls.
73
+ */
74
+ export type Bound<TOps extends ResourceOps> = {
75
+ [K in keyof TOps]: TOps[K] extends ResourceOp<infer A, infer R>
76
+ ? (...args: A) => Promise<R>
77
+ : never;
78
+ };
79
+
80
+ /**
81
+ * Declares a resource.
82
+ *
83
+ * Deliberately thin — it exists to name the set and to fix the type, not to
84
+ * add behaviour. Anything it did at definition time would be a thing an
85
+ * override could not replace.
86
+ */
87
+ export function defineResource<const TOps extends ResourceOps>(
88
+ key: string,
89
+ ops: TOps,
90
+ options: DefineResourceOptions = {},
91
+ ): ResourceDef<TOps> {
92
+ return { key, ops, projectScoped: options.projectScoped ?? false };
93
+ }
94
+
95
+ /**
96
+ * Refuses a project-scoped resource that has no project to be scoped to.
97
+ *
98
+ * An empty `projectKey` is not caught by anything downstream: the request goes
99
+ * out as `?projectKey=`, and an admin API that filters on it answers a list for
100
+ * no project at all — a valid-looking empty catalogue, with nothing on screen
101
+ * saying the shell was misconfigured. The same context then puts `projectKey:
102
+ * ''` in a create body, where it finally fails validation, one operator action
103
+ * and one screen later than the mistake.
104
+ *
105
+ * `usePlans` has refused this at construction from the start; a page reaching
106
+ * the same endpoints through the registry did not.
107
+ */
108
+ function requireProjectKey(
109
+ def: { key: string; projectScoped: boolean },
110
+ ctx: ResourceContext,
111
+ ): void {
112
+ if (!def.projectScoped) return;
113
+ if (typeof ctx.projectKey === 'string' && ctx.projectKey.trim() !== '') return;
114
+ throw new Error(
115
+ `Resource "${def.key}" is project-scoped, but the context it is bound to names no ` +
116
+ 'project. Its requests would go out as `?projectKey=`, which the admin API answers ' +
117
+ 'for no project at all: the catalogue reads as empty and the first create fails ' +
118
+ 'validation instead. Name the project this admin administers — `endpoints.projectKey` ' +
119
+ 'for createSuperAdminApp(), `context.projectKey` for createResourceRegistry().',
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Supplies `(http, ctx)` once and hands back the callable operations.
125
+ *
126
+ * The context is read at call time rather than captured per operation, so a
127
+ * context that changes — a locale the operator switches, a project the shell
128
+ * re-scopes — is picked up by calls made afterwards without rebinding.
129
+ */
130
+ export function bindResource<TOps extends ResourceOps>(
131
+ def: ResourceDef<TOps>,
132
+ http: HttpClient,
133
+ ctx: ResourceContext | (() => ResourceContext),
134
+ ): Bound<TOps> {
135
+ const readContext = typeof ctx === 'function' ? ctx : () => ctx;
136
+ // Checked at binding AND before each call, and the two are not redundant.
137
+ //
138
+ // Binding is where an app's configuration is handed over, so it is the
139
+ // moment a missing option can still be reported as one — named at boot
140
+ // rather than at the click that reads an empty list. But the context is
141
+ // read per call by design (a locale the operator switches, a project the
142
+ // shell re-scopes), so a key that is present at boot can be gone later,
143
+ // and only checking then would send `?projectKey=` after all.
144
+ //
145
+ // The getter is consulted only when the resource is project-scoped: the
146
+ // public contract says the context is read when an operation runs, and a
147
+ // registry of unscoped resources whose context is built lazily must not be
148
+ // made to produce one at construction.
149
+ if (def.projectScoped) requireProjectKey(def, readContext());
150
+ const bound: Record<string, unknown> = {};
151
+ for (const [name, op] of Object.entries(def.ops)) {
152
+ // `async`, so a missing key REJECTS rather than throwing synchronously.
153
+ // An operation returns a promise, and a caller written as
154
+ // `plans.list().catch(handle)` would otherwise take an exception where
155
+ // it expected a rejection — the promise it means to catch never exists.
156
+ bound[name] = async (...args: never[]) => {
157
+ const context = readContext();
158
+ requireProjectKey(def, context);
159
+ return op(http, context, ...args);
160
+ };
161
+ }
162
+ // The loop cannot express, per key, that it produced exactly the operation
163
+ // `Bound<TOps>` names — the relationship holds across the whole record, not
164
+ // one entry at a time.
165
+ return bound as Bound<TOps>;
166
+ }
@@ -0,0 +1,42 @@
1
+ // The resource layer: every admin endpoint defined once, framework-free.
2
+ //
3
+ // Partial by design. The roster this is growing towards covers the whole admin
4
+ // surface, and the descriptors land family by family as the composables that
5
+ // own those endpoints are rebuilt on them — a descriptor nothing calls has
6
+ // nothing keeping it honest.
7
+ //
8
+ // What is here so far: the plan catalogue, the tenant list and the audit
9
+ // trail. What is not: bundles, catalog entries, discovery, marketing, promos,
10
+ // users, pilots, email, subscriptions, dashboard.
11
+ //
12
+ // `tenants` and `audit` are called by `useResourceList`, not by `useTenants`
13
+ // and `useAuditEntries` — those keep their own implementation and their own
14
+ // signatures. The two sides are held to the same request by
15
+ // `tests/resources-match-the-composables.test.js`, which drives both and
16
+ // compares what reaches the client, in either direction.
17
+
18
+ export * from './define-resource.js';
19
+ export * from './resource-request.js';
20
+ export * from './list-resource.js';
21
+ export * from './plans.resource.js';
22
+ export * from './tenants.resource.js';
23
+ export * from './audit.resource.js';
24
+
25
+ import { auditResource } from './audit.resource.js';
26
+ import { planVersionsResource, plansResource } from './plans.resource.js';
27
+ import { tenantsResource } from './tenants.resource.js';
28
+
29
+ /**
30
+ * Every resource the shell offers by default.
31
+ *
32
+ * The registry binds these; an app overrides by key. Partial while the roster
33
+ * grows — a page whose resource is not here yet keeps its props.
34
+ */
35
+ export const platformResources = {
36
+ plans: plansResource,
37
+ planVersions: planVersionsResource,
38
+ tenants: tenantsResource,
39
+ audit: auditResource,
40
+ };
41
+
42
+ export type PlatformResources = typeof platformResources;