@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.
- package/README.md +32 -8
- package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
- package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
- package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
- package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
- package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
- package/dist/client/index.cjs +758 -83
- package/dist/client/index.d.cts +414 -9
- package/dist/client/index.d.ts +414 -9
- package/dist/client/index.js +84 -12
- package/dist/index.cjs +1325 -294
- package/dist/index.d.cts +205 -15
- package/dist/index.d.ts +205 -15
- package/dist/index.js +566 -240
- package/dist/quasar/index.cjs +574 -62
- package/dist/quasar/index.d.cts +61 -6
- package/dist/quasar/index.d.ts +61 -6
- package/dist/quasar/index.js +83 -19
- package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
- package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
- package/package.json +4 -3
- package/src/client/admin-error.ts +552 -0
- package/src/client/admin-resource-client.ts +9 -12
- package/src/client/batch-column-fetcher.ts +2 -2
- package/src/client/boot-loader.ts +12 -1
- package/src/client/http/axios-http-client.ts +383 -0
- package/src/client/http/fetch-http-client.ts +89 -0
- package/src/client/http/index.ts +6 -0
- package/src/client/http-json.ts +82 -16
- package/src/client/i18n/messages/discovery.ts +0 -6
- package/src/client/i18n/messages/errors.ts +44 -0
- package/src/client/i18n/messages/marketing.ts +0 -6
- package/src/client/i18n/messages/promos.ts +0 -2
- package/src/client/i18n/messages/shell.ts +12 -0
- package/src/client/i18n/messages.ts +3 -0
- package/src/client/index.ts +6 -0
- package/src/client/manifest-loader.ts +65 -15
- package/src/client/resources/audit.resource.ts +25 -0
- package/src/client/resources/define-resource.ts +166 -0
- package/src/client/resources/index.ts +42 -0
- package/src/client/resources/list-resource.ts +176 -0
- package/src/client/resources/plans.resource.ts +169 -0
- package/src/client/resources/resource-request.ts +117 -0
- package/src/client/resources/tenants.resource.ts +27 -0
- package/src/client/types.ts +10 -3
- package/src/components/BundleVersionPublishDialog.vue +1 -1
- package/src/components/MarketingPromotionsTab.vue +263 -241
- package/src/components/ThemeSwitcher.vue +85 -0
- package/src/components/admin-page/AdminAccordion.vue +125 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
- package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
- package/src/components/plan/PlanCycleToggle.vue +1 -1
- package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
- package/src/index.ts +5 -0
- package/src/pages-standard/AdminLayout.vue +57 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
- package/src/pages-standard/BundlesPage.vue +5 -35
- package/src/pages-standard/DiscoveryPage.vue +8 -1
- package/src/pages-standard/MarketingCatalogPage.vue +14 -3
- package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
- package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
- package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
- package/src/quasar/confirm.ts +83 -0
- package/src/quasar/create-super-admin-app.ts +84 -10
- package/src/quasar/dark-bridge.ts +47 -10
- package/src/quasar/index.ts +1 -0
- package/src/ui/theme/components/accordion.css +118 -0
- package/src/ui/theme/index.css +1 -0
- package/src/vue/create-admin-routes.ts +9 -1
- package/src/vue/platform-loaders.ts +12 -2
- package/src/vue/resource-registry.ts +211 -0
- package/src/vue/super-admin-context.ts +20 -5
- package/src/vue/ui-confirm.ts +76 -0
- package/src/vue/use-api-list.ts +50 -45
- package/src/vue/use-async-action.ts +170 -0
- package/src/vue/use-async-data.ts +83 -0
- package/src/vue/use-bundles.ts +34 -7
- package/src/vue/use-catalog-entries.ts +37 -12
- package/src/vue/use-discovery.ts +27 -6
- package/src/vue/use-marketing-projections.ts +29 -7
- package/src/vue/use-plans.ts +36 -8
- package/src/vue/use-promotions.ts +27 -7
- package/src/vue/use-resource-list.ts +272 -0
- package/src/vue/use-sa-theme.ts +34 -5
- 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],
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
92
|
-
|
|
93
|
-
|
|
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;
|