@saasicat/ui-vue 0.2.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/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- package/src/version.ts +10 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Shared E2E test skeleton for the SuperAdmin UI pages.
|
|
2
|
+
//
|
|
3
|
+
// App-specific specs (the consumer admins' `tests-e2e/`) pass their
|
|
4
|
+
// configuration through via `runAdminPagesSuite(config)` — the suite then
|
|
5
|
+
// clicks through login + all declared standard pages and checks for each:
|
|
6
|
+
// - no pageerror events
|
|
7
|
+
// - no console.error events (except whitelisted)
|
|
8
|
+
// - no HTTP 4xx/5xx on `/api/` calls (except whitelisted)
|
|
9
|
+
//
|
|
10
|
+
// Plus a dashboard test that asserts the KPI card labels, distribution headers
|
|
11
|
+
// and shortcut titles against a declarative list.
|
|
12
|
+
//
|
|
13
|
+
// `import { test, expect } from '@playwright/test'` must be imported by the
|
|
14
|
+
// consumer so that Playwright's test runner finds the `test` calls.
|
|
15
|
+
|
|
16
|
+
import type { Page, expect as PwExpect, test as PwTest } from '@playwright/test';
|
|
17
|
+
|
|
18
|
+
export interface AdminPagesSuiteConfig {
|
|
19
|
+
/** Display name, e.g. 'demoapp' / 'clubapp'. */
|
|
20
|
+
appName: string;
|
|
21
|
+
/** URL of the login page (full). */
|
|
22
|
+
loginUrl: string;
|
|
23
|
+
/** Test account. */
|
|
24
|
+
email: string;
|
|
25
|
+
password: string;
|
|
26
|
+
/** Paths the smoke test should click through. */
|
|
27
|
+
pages: ReadonlyArray<{
|
|
28
|
+
/** Display name in the test description. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Path relative to the origin (e.g. '/admin/tenants'). */
|
|
31
|
+
path: string;
|
|
32
|
+
/** Optional: if set, check that this selector exists. */
|
|
33
|
+
expectVisible?: string;
|
|
34
|
+
/** Optional: HTTP patterns that are OK as 4xx/5xx (e.g. missing backend endpoints). */
|
|
35
|
+
allowedFailures?: readonly RegExp[];
|
|
36
|
+
}>;
|
|
37
|
+
/** Expected KPI card labels on the dashboard page. */
|
|
38
|
+
expectedKpiLabels: readonly string[];
|
|
39
|
+
/** Expected distribution section titles. */
|
|
40
|
+
expectedDistributionTitles?: readonly string[];
|
|
41
|
+
/** Expected shortcut titles on the dashboard. */
|
|
42
|
+
expectedShortcutTitles: readonly string[];
|
|
43
|
+
/** Shortcuts that must NOT appear (e.g. "Dashboard"). */
|
|
44
|
+
forbiddenShortcutTitles?: readonly string[];
|
|
45
|
+
/** Console error patterns that should be ignored. */
|
|
46
|
+
consoleErrorAllowlist?: readonly RegExp[];
|
|
47
|
+
/** Path to the dashboard page (default `/admin/`). */
|
|
48
|
+
dashboardPath?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PageErrors {
|
|
52
|
+
pageErrors: string[];
|
|
53
|
+
consoleErrors: string[];
|
|
54
|
+
networkErrors: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// "Failed to load resource: the server responded with a status of …" is
|
|
58
|
+
// duplicated by the browser with the separately captured network errors. We
|
|
59
|
+
// don't treat it as a standalone console.error — otherwise every 404 counts
|
|
60
|
+
// twice and the signal from real app code gets lost in the noise.
|
|
61
|
+
const RESOURCE_FAILURE_MARKER = /Failed to load resource:/;
|
|
62
|
+
|
|
63
|
+
function attachListeners(
|
|
64
|
+
page: Page,
|
|
65
|
+
consoleAllowlist: readonly RegExp[] = [],
|
|
66
|
+
networkAllowlist: readonly RegExp[] = [],
|
|
67
|
+
): PageErrors {
|
|
68
|
+
const out: PageErrors = { pageErrors: [], consoleErrors: [], networkErrors: [] };
|
|
69
|
+
page.on('pageerror', (err) => out.pageErrors.push(err.message));
|
|
70
|
+
page.on('console', (msg) => {
|
|
71
|
+
if (msg.type() !== 'error') return;
|
|
72
|
+
const text = msg.text();
|
|
73
|
+
if (RESOURCE_FAILURE_MARKER.test(text)) return;
|
|
74
|
+
if (consoleAllowlist.some((re) => re.test(text))) return;
|
|
75
|
+
out.consoleErrors.push(text);
|
|
76
|
+
});
|
|
77
|
+
page.on('response', (resp) => {
|
|
78
|
+
if (resp.status() < 400) return;
|
|
79
|
+
const url = resp.url();
|
|
80
|
+
if (!url.includes('/api/')) return;
|
|
81
|
+
if (networkAllowlist.some((re) => re.test(url))) return;
|
|
82
|
+
out.networkErrors.push(`HTTP ${resp.status()} ${url}`);
|
|
83
|
+
});
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function loginIfNeeded(page: Page, config: AdminPagesSuiteConfig): Promise<void> {
|
|
88
|
+
if (!page.url().includes('/login')) {
|
|
89
|
+
await page.goto(config.loginUrl, { waitUntil: 'networkidle' });
|
|
90
|
+
}
|
|
91
|
+
if (!page.url().includes('/login')) return; // already logged in
|
|
92
|
+
await page.locator('input[type="email"]').first().fill(config.email);
|
|
93
|
+
const pw = page.locator('input[type="password"]').first();
|
|
94
|
+
await pw.fill(config.password);
|
|
95
|
+
// Enter triggers q-form @submit more reliably than a button click (Quasar
|
|
96
|
+
// unifies form submit on the submit event, not on the button).
|
|
97
|
+
await pw.press('Enter');
|
|
98
|
+
await page.waitForURL((u) => !u.toString().includes('/login'), {
|
|
99
|
+
timeout: 15_000,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function runAdminPagesSuite(
|
|
104
|
+
test: typeof PwTest,
|
|
105
|
+
expect: typeof PwExpect,
|
|
106
|
+
config: AdminPagesSuiteConfig,
|
|
107
|
+
): void {
|
|
108
|
+
const dashboardPath = config.dashboardPath ?? '/admin/';
|
|
109
|
+
|
|
110
|
+
test.describe(`${config.appName}: SuperAdmin-Pages Smoke`, () => {
|
|
111
|
+
for (const pageDef of config.pages) {
|
|
112
|
+
test(`${pageDef.name} rendert ohne Fehler`, async ({ page }) => {
|
|
113
|
+
const errors = attachListeners(
|
|
114
|
+
page,
|
|
115
|
+
config.consoleErrorAllowlist,
|
|
116
|
+
pageDef.allowedFailures,
|
|
117
|
+
);
|
|
118
|
+
await loginIfNeeded(page, config);
|
|
119
|
+
const origin = new URL(config.loginUrl).origin;
|
|
120
|
+
await page.goto(`${origin}${pageDef.path}`, {
|
|
121
|
+
waitUntil: 'networkidle',
|
|
122
|
+
timeout: 15_000,
|
|
123
|
+
});
|
|
124
|
+
await page.waitForTimeout(800);
|
|
125
|
+
if (pageDef.expectVisible) {
|
|
126
|
+
await expect(page.locator(pageDef.expectVisible).first()).toBeVisible({
|
|
127
|
+
timeout: 5_000,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
expect(errors.pageErrors, `pageerror-Events auf ${pageDef.path}`).toHaveLength(0);
|
|
131
|
+
expect(
|
|
132
|
+
errors.consoleErrors,
|
|
133
|
+
`console.error-Events auf ${pageDef.path}`,
|
|
134
|
+
).toHaveLength(0);
|
|
135
|
+
expect(
|
|
136
|
+
errors.networkErrors,
|
|
137
|
+
`HTTP-Errors auf /api/ während ${pageDef.path}`,
|
|
138
|
+
).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test('Dashboard zeigt erwartete KPIs, Distributions und Shortcuts', async ({ page }) => {
|
|
143
|
+
attachListeners(page, config.consoleErrorAllowlist);
|
|
144
|
+
await loginIfNeeded(page, config);
|
|
145
|
+
const origin = new URL(config.loginUrl).origin;
|
|
146
|
+
await page.goto(`${origin}${dashboardPath}`, {
|
|
147
|
+
waitUntil: 'networkidle',
|
|
148
|
+
timeout: 15_000,
|
|
149
|
+
});
|
|
150
|
+
// KPI strip: platform CSS class `.sa-kpi__label`
|
|
151
|
+
const kpiLabels = await page.locator('.sa-kpi__label').allTextContents();
|
|
152
|
+
const normalizedKpis = kpiLabels.map((s) => s.trim());
|
|
153
|
+
for (const expected of config.expectedKpiLabels) {
|
|
154
|
+
expect(normalizedKpis, `KPI '${expected}' fehlt auf Dashboard`).toContain(expected);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (config.expectedDistributionTitles?.length) {
|
|
158
|
+
const distTitles = await page
|
|
159
|
+
.locator('.sa-dashboard__row-head h2')
|
|
160
|
+
.allTextContents();
|
|
161
|
+
const normalizedDist = distTitles.map((s) => s.trim());
|
|
162
|
+
for (const expected of config.expectedDistributionTitles) {
|
|
163
|
+
expect(
|
|
164
|
+
normalizedDist,
|
|
165
|
+
`Distribution '${expected}' fehlt auf Dashboard`,
|
|
166
|
+
).toContain(expected);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const shortcutTitles = await page
|
|
171
|
+
.locator('.sa-dashboard__shortcut-title')
|
|
172
|
+
.allTextContents();
|
|
173
|
+
const normalizedShortcuts = shortcutTitles.map((s) => s.trim());
|
|
174
|
+
for (const expected of config.expectedShortcutTitles) {
|
|
175
|
+
expect(normalizedShortcuts, `Shortcut '${expected}' fehlt auf Dashboard`).toContain(
|
|
176
|
+
expected,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
for (const forbidden of config.forbiddenShortcutTitles ?? []) {
|
|
180
|
+
expect(
|
|
181
|
+
normalizedShortcuts,
|
|
182
|
+
`Shortcut '${forbidden}' darf nicht auf Dashboard sein`,
|
|
183
|
+
).not.toContain(forbidden);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared configuration types for all UI-Vue loaders/composables.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal abstraction over `fetch`. Consumers may pass their own
|
|
5
|
+
* implementation (e.g. an axios wrapper that already injects auth headers
|
|
6
|
+
* and tenant headers).
|
|
7
|
+
*/
|
|
8
|
+
export type HttpClient = (
|
|
9
|
+
url: string,
|
|
10
|
+
init?: {
|
|
11
|
+
method?: string;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
body?: string;
|
|
14
|
+
},
|
|
15
|
+
) => Promise<HttpResponse>;
|
|
16
|
+
|
|
17
|
+
export interface HttpResponse {
|
|
18
|
+
status: number;
|
|
19
|
+
headers: { get(name: string): string | null };
|
|
20
|
+
json(): Promise<unknown>;
|
|
21
|
+
text(): Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Simple persistence adapter; default is `localStorage`. */
|
|
25
|
+
export interface KvStore {
|
|
26
|
+
get(key: string): string | null;
|
|
27
|
+
set(key: string, value: string): void;
|
|
28
|
+
remove(key: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns a `KvStore` wrapper around `localStorage` (or `null` under SSR).
|
|
33
|
+
* Tests may pass an in-memory stub.
|
|
34
|
+
*/
|
|
35
|
+
export function defaultKvStore(): KvStore {
|
|
36
|
+
if (typeof globalThis.localStorage === 'undefined') {
|
|
37
|
+
// SSR / non-browser → no-op stub.
|
|
38
|
+
return {
|
|
39
|
+
get: () => null,
|
|
40
|
+
set: () => {},
|
|
41
|
+
remove: () => {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const ls = globalThis.localStorage;
|
|
45
|
+
return {
|
|
46
|
+
get: (k) => ls.getItem(k),
|
|
47
|
+
set: (k, v) => ls.setItem(k, v),
|
|
48
|
+
remove: (k) => ls.removeItem(k),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default `HttpClient` over `fetch`. Consumers pass their own variant
|
|
54
|
+
* through when they need auth headers / tenant headers / retry logic.
|
|
55
|
+
*/
|
|
56
|
+
export function defaultHttpClient(): HttpClient {
|
|
57
|
+
return (url, init) => fetch(url, init);
|
|
58
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// useActions — Vue 3 composable over ActionRegistry.
|
|
2
|
+
//
|
|
3
|
+
// Wraps a reactive `Ref<AdminManifest | null>` and a static
|
|
4
|
+
// `actions:` map (consumer shell build) and returns a reactive
|
|
5
|
+
// registry object that is automatically rebuilt as soon as the
|
|
6
|
+
// manifest changes (e.g. after `manifest reload`).
|
|
7
|
+
|
|
8
|
+
import { computed, type ComputedRef, type Ref } from 'vue';
|
|
9
|
+
import type { AdminManifest } from '@saasicat/types';
|
|
10
|
+
import { ActionRegistry, type ActionHandler } from './action-registry.js';
|
|
11
|
+
|
|
12
|
+
export interface UseActionsResult {
|
|
13
|
+
registry: ComputedRef<ActionRegistry | null>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useActions(
|
|
17
|
+
manifest: Ref<AdminManifest | null>,
|
|
18
|
+
actions: Record<string, ActionHandler>,
|
|
19
|
+
): UseActionsResult {
|
|
20
|
+
const registry = computed<ActionRegistry | null>(() => {
|
|
21
|
+
if (!manifest.value) return null;
|
|
22
|
+
return new ActionRegistry(manifest.value, actions);
|
|
23
|
+
});
|
|
24
|
+
return { registry };
|
|
25
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// useApiList — generic reactive list composable with filter + pagination.
|
|
2
|
+
//
|
|
3
|
+
// Consumer components ($-table, $-list, …) consume the typed wrappers
|
|
4
|
+
// (`useTenants`, `useAuditEntries`, …), which are based on `useApiList`.
|
|
5
|
+
// Direct use is also allowed for custom endpoints.
|
|
6
|
+
|
|
7
|
+
import { ref, watch, type Ref } from 'vue';
|
|
8
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
9
|
+
|
|
10
|
+
export interface ApiListResponse<T> {
|
|
11
|
+
items: T[];
|
|
12
|
+
page?: number;
|
|
13
|
+
pageSize?: number;
|
|
14
|
+
total?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseApiListOptions<TFilter> {
|
|
18
|
+
endpoint: string;
|
|
19
|
+
/**
|
|
20
|
+
* Reactive filter object. The composable serializes it into `?key=value`
|
|
21
|
+
* pairs (with URL encoding); empty/null values are omitted.
|
|
22
|
+
*/
|
|
23
|
+
filter?: Ref<TFilter>;
|
|
24
|
+
http?: HttpClient;
|
|
25
|
+
getAuthToken?: () => string | null;
|
|
26
|
+
/**
|
|
27
|
+
* When `true`, loads automatically on mount. Defaults to `true`.
|
|
28
|
+
* Set to `false` when the consumer wants to trigger the first load
|
|
29
|
+
* explicitly (e.g. after auth-state init).
|
|
30
|
+
*/
|
|
31
|
+
autoLoad?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UseApiListResult<T> {
|
|
35
|
+
items: Ref<T[]>;
|
|
36
|
+
page: Ref<number>;
|
|
37
|
+
pageSize: Ref<number>;
|
|
38
|
+
total: Ref<number>;
|
|
39
|
+
loading: Ref<boolean>;
|
|
40
|
+
error: Ref<Error | null>;
|
|
41
|
+
/** Reloads fresh (e.g. after a mutation). */
|
|
42
|
+
reload: () => Promise<void>;
|
|
43
|
+
/** Jumps to a specific page (1-based) and loads. */
|
|
44
|
+
goToPage: (page: number) => Promise<void>;
|
|
45
|
+
/** Changes the page size and jumps to page 1. */
|
|
46
|
+
setPageSize: (size: number) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useApiList<T, TFilter extends Record<string, unknown> = Record<string, unknown>>(
|
|
50
|
+
options: UseApiListOptions<TFilter>,
|
|
51
|
+
): UseApiListResult<T> {
|
|
52
|
+
const http = options.http ?? defaultHttpClient();
|
|
53
|
+
const items = ref<T[]>([]) as Ref<T[]>;
|
|
54
|
+
const page = ref(1);
|
|
55
|
+
const pageSize = ref(50);
|
|
56
|
+
const total = ref(0);
|
|
57
|
+
const loading = ref(false);
|
|
58
|
+
const error = ref<Error | null>(null);
|
|
59
|
+
|
|
60
|
+
function buildUrl(): string {
|
|
61
|
+
const params = new URLSearchParams();
|
|
62
|
+
params.set('page', String(page.value));
|
|
63
|
+
params.set('pageSize', String(pageSize.value));
|
|
64
|
+
const f = options.filter?.value ?? ({} as TFilter);
|
|
65
|
+
for (const [k, v] of Object.entries(f)) {
|
|
66
|
+
if (v === undefined || v === null || v === '') continue;
|
|
67
|
+
params.set(k, String(v));
|
|
68
|
+
}
|
|
69
|
+
const sep = options.endpoint.includes('?') ? '&' : '?';
|
|
70
|
+
return `${options.endpoint}${sep}${params.toString()}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function load() {
|
|
74
|
+
loading.value = true;
|
|
75
|
+
error.value = null;
|
|
76
|
+
try {
|
|
77
|
+
const headers: Record<string, string> = {};
|
|
78
|
+
const token = options.getAuthToken?.();
|
|
79
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
80
|
+
const res = await http(buildUrl(), { method: 'GET', headers });
|
|
81
|
+
if (res.status !== 200) {
|
|
82
|
+
throw new Error(`Endpoint ${options.endpoint} → HTTP ${res.status}`);
|
|
83
|
+
}
|
|
84
|
+
// Apps deliver different shapes for list endpoints:
|
|
85
|
+
// - raw array `[{...}, …]` (no wrapper).
|
|
86
|
+
// - paginated: `{ items, total, page, pageSize }`.
|
|
87
|
+
// The platform composable accepts both — otherwise correctly
|
|
88
|
+
// delivered array responses would be shown as an empty list.
|
|
89
|
+
const raw = (await res.json()) as unknown;
|
|
90
|
+
if (Array.isArray(raw)) {
|
|
91
|
+
items.value = raw as T[];
|
|
92
|
+
total.value = raw.length;
|
|
93
|
+
} else if (raw !== null && typeof raw === 'object') {
|
|
94
|
+
const body = raw as ApiListResponse<T>;
|
|
95
|
+
items.value = body.items ?? [];
|
|
96
|
+
if (typeof body.page === 'number') page.value = body.page;
|
|
97
|
+
if (typeof body.pageSize === 'number') pageSize.value = body.pageSize;
|
|
98
|
+
if (typeof body.total === 'number') total.value = body.total;
|
|
99
|
+
} else {
|
|
100
|
+
items.value = [];
|
|
101
|
+
total.value = 0;
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
105
|
+
items.value = [];
|
|
106
|
+
total.value = 0;
|
|
107
|
+
} finally {
|
|
108
|
+
loading.value = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function goToPage(p: number) {
|
|
113
|
+
page.value = Math.max(1, Math.floor(p));
|
|
114
|
+
await load();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function setPageSize(size: number) {
|
|
118
|
+
pageSize.value = Math.max(1, Math.floor(size));
|
|
119
|
+
page.value = 1;
|
|
120
|
+
await load();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (options.filter) {
|
|
124
|
+
watch(
|
|
125
|
+
options.filter,
|
|
126
|
+
() => {
|
|
127
|
+
page.value = 1;
|
|
128
|
+
void load();
|
|
129
|
+
},
|
|
130
|
+
{ deep: true },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (options.autoLoad !== false) {
|
|
135
|
+
// One microtask later, so the composable does not block `setup()`
|
|
136
|
+
// during the initial sync phase.
|
|
137
|
+
Promise.resolve().then(() => void load());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
items,
|
|
142
|
+
page,
|
|
143
|
+
pageSize,
|
|
144
|
+
total,
|
|
145
|
+
loading,
|
|
146
|
+
error,
|
|
147
|
+
reload: load,
|
|
148
|
+
goToPage,
|
|
149
|
+
setPageSize,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// useAuditEntries — Vue composable over the admin audit endpoint.
|
|
2
|
+
//
|
|
3
|
+
// **The endpoint is mandatory** and is supplied by the consuming app
|
|
4
|
+
// (e.g. `/api/admin/audit` or `/api/v1/admin/audit`).
|
|
5
|
+
//
|
|
6
|
+
// The consumer backend exposes the endpoint via an `AuditQueryPort.list` adapter.
|
|
7
|
+
// The composable types the response as `AuditEntry[]`.
|
|
8
|
+
|
|
9
|
+
import { ref, type Ref } from 'vue';
|
|
10
|
+
import type { AuditEntry, AuditQuery } from '@saasicat/types';
|
|
11
|
+
import { useApiList, type UseApiListOptions, type UseApiListResult } from './use-api-list.js';
|
|
12
|
+
|
|
13
|
+
export interface UseAuditEntriesOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Fully-qualified audit endpoint including the app's globalPrefix
|
|
16
|
+
* (`/api/admin/audit`, `/api/v1/admin/audit`, …). Mandatory.
|
|
17
|
+
*/
|
|
18
|
+
endpoint: string;
|
|
19
|
+
filter?: Ref<AuditQuery>;
|
|
20
|
+
http?: UseApiListOptions<Record<string, unknown>>['http'];
|
|
21
|
+
getAuthToken?: () => string | null;
|
|
22
|
+
autoLoad?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseAuditEntriesResult extends UseApiListResult<AuditEntry> {
|
|
26
|
+
filter: Ref<AuditQuery>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useAuditEntries(options: UseAuditEntriesOptions): UseAuditEntriesResult {
|
|
30
|
+
if (!options?.endpoint) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'useAuditEntries: `endpoint` ist Pflicht (z. B. "/api/admin/audit" ' +
|
|
33
|
+
'oder "/api/v1/admin/audit"). Plattform hat keinen Default, weil ' +
|
|
34
|
+
'Apps unterschiedliche globalPrefix-Konventionen haben.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const filter = options.filter ?? ref<AuditQuery>({});
|
|
38
|
+
// AuditQuery has optional-only fields; index-signature cast for the
|
|
39
|
+
// useApiList constraint.
|
|
40
|
+
const list = useApiList<AuditEntry>({
|
|
41
|
+
endpoint: options.endpoint,
|
|
42
|
+
filter: filter as unknown as Ref<Record<string, unknown>>,
|
|
43
|
+
http: options.http,
|
|
44
|
+
getAuthToken: options.getAuthToken,
|
|
45
|
+
autoLoad: options.autoLoad,
|
|
46
|
+
});
|
|
47
|
+
return { ...list, filter };
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// useBatchColumns — Vue 3 composable on top of BatchColumnFetcher.
|
|
2
|
+
//
|
|
3
|
+
// Reactively reloads the custom TenantColumn data whenever either the
|
|
4
|
+
// `tenantIds` list (pagination → different tenant IDs) or the Manifest
|
|
5
|
+
// changes (e.g. after `manifest reload`). Loading and error state are
|
|
6
|
+
// aggregated per column; granular per-column loading state can be added
|
|
7
|
+
// by consumers via a direct `fetcher.fetchOne` call.
|
|
8
|
+
|
|
9
|
+
import { ref, watch, type Ref } from 'vue';
|
|
10
|
+
import type { AdminManifest } from '@saasicat/types';
|
|
11
|
+
import {
|
|
12
|
+
BatchColumnFetcher,
|
|
13
|
+
type BatchColumnData,
|
|
14
|
+
type BatchColumnFetcherOptions,
|
|
15
|
+
} from './batch-column-fetcher.js';
|
|
16
|
+
|
|
17
|
+
export interface UseBatchColumnsResult {
|
|
18
|
+
/** Data per column key (`columnKey → tenantId → value`). */
|
|
19
|
+
data: Ref<BatchColumnData>;
|
|
20
|
+
loading: Ref<boolean>;
|
|
21
|
+
error: Ref<Error | null>;
|
|
22
|
+
/** Manual re-fetch (e.g. after a mutation). */
|
|
23
|
+
reload: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useBatchColumns(
|
|
27
|
+
manifest: Ref<AdminManifest | null>,
|
|
28
|
+
tenantIds: Ref<string[]>,
|
|
29
|
+
options: BatchColumnFetcherOptions = {},
|
|
30
|
+
): UseBatchColumnsResult {
|
|
31
|
+
const fetcher = new BatchColumnFetcher(options);
|
|
32
|
+
const data = ref<BatchColumnData>({});
|
|
33
|
+
const loading = ref(false);
|
|
34
|
+
const error = ref<Error | null>(null);
|
|
35
|
+
|
|
36
|
+
async function load() {
|
|
37
|
+
if (!manifest.value || tenantIds.value.length === 0) {
|
|
38
|
+
data.value = {};
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
loading.value = true;
|
|
42
|
+
error.value = null;
|
|
43
|
+
try {
|
|
44
|
+
data.value = await fetcher.fetchAll(manifest.value, tenantIds.value);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
47
|
+
data.value = {};
|
|
48
|
+
} finally {
|
|
49
|
+
loading.value = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Reactive: reloads as soon as the Manifest or tenantIds change.
|
|
54
|
+
watch([manifest, tenantIds], () => {
|
|
55
|
+
void load();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { data, loading, error, reload: load };
|
|
59
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// useBulkPublish — bulk-publish modal orchestration.
|
|
2
|
+
//
|
|
3
|
+
// SuperAdmin selects N drafts from the versions page list, enters a shared
|
|
4
|
+
// `changeNote` and triggers the publish. The platform composable runs the
|
|
5
|
+
// publishes **in parallel** and tracks the status per draft (pending/
|
|
6
|
+
// publishing/published/failed) — the modal renders a live table with a
|
|
7
|
+
// progress bar per item.
|
|
8
|
+
//
|
|
9
|
+
// What the platform does NOT do: TOTP code entry + setting the MFA header.
|
|
10
|
+
// The consumer shell (Quasar modal or similar) does that and passes the
|
|
11
|
+
// `mfaCode` parameter through.
|
|
12
|
+
|
|
13
|
+
import { computed, ref, type ComputedRef, type Ref } from 'vue';
|
|
14
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
15
|
+
|
|
16
|
+
export type BulkItemKind = 'plan';
|
|
17
|
+
|
|
18
|
+
export type BulkItemStatus = 'pending' | 'publishing' | 'published' | 'failed';
|
|
19
|
+
|
|
20
|
+
export interface BulkPublishItem {
|
|
21
|
+
/** Composite ID: `<kind>:<draftId>`. Unique within the bulk set. */
|
|
22
|
+
key: string;
|
|
23
|
+
kind: BulkItemKind;
|
|
24
|
+
draftId: string;
|
|
25
|
+
/** Display label in the modal (e.g. "STANDARD v3"). */
|
|
26
|
+
label: string;
|
|
27
|
+
status: BulkItemStatus;
|
|
28
|
+
error?: string;
|
|
29
|
+
/** Server response when status='published'. */
|
|
30
|
+
result?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseBulkPublishOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Endpoint mapping per kind. **Required** — the platform does not know
|
|
36
|
+
* the app's globalPrefix (e.g. `/api/admin/...` or
|
|
37
|
+
* `/api/v1/admin/...`), so consumers supply the full URL per kind.
|
|
38
|
+
*/
|
|
39
|
+
endpoints: Record<BulkItemKind, (draftId: string) => string>;
|
|
40
|
+
http?: HttpClient;
|
|
41
|
+
getAuthToken?: () => string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UseBulkPublishResult {
|
|
45
|
+
items: Ref<BulkPublishItem[]>;
|
|
46
|
+
/** Fraction of items already completed (0..1). */
|
|
47
|
+
progress: ComputedRef<number>;
|
|
48
|
+
/** Returns `true` once all items are either `published` or `failed`. */
|
|
49
|
+
done: ComputedRef<boolean>;
|
|
50
|
+
/** Number of successful publishes. */
|
|
51
|
+
successCount: ComputedRef<number>;
|
|
52
|
+
/** Number of failed publishes. */
|
|
53
|
+
failureCount: ComputedRef<number>;
|
|
54
|
+
/** Resets the bulk set — e.g. after modal open. */
|
|
55
|
+
setItems: (items: Array<Omit<BulkPublishItem, 'status'>>) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Triggers the bulk publish. `changeNote` is required for all
|
|
58
|
+
* drafts; `mfaCode` is optional and is sent in the `X-Mfa-Code`
|
|
59
|
+
* header when present.
|
|
60
|
+
*/
|
|
61
|
+
run: (input: { changeNote: string; mfaCode?: string }) => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useBulkPublish(options: UseBulkPublishOptions): UseBulkPublishResult {
|
|
65
|
+
if (!options?.endpoints) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'useBulkPublish: `endpoints` ist Pflicht (Mapping plan → URL-Builder). ' +
|
|
68
|
+
'Plattform hat keinen Default, weil Apps unterschiedliche ' +
|
|
69
|
+
'globalPrefix-Konventionen haben.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const http = options.http ?? defaultHttpClient();
|
|
73
|
+
const endpoints = options.endpoints;
|
|
74
|
+
const items = ref<BulkPublishItem[]>([]) as Ref<BulkPublishItem[]>;
|
|
75
|
+
|
|
76
|
+
const successCount = computed(() => items.value.filter((i) => i.status === 'published').length);
|
|
77
|
+
const failureCount = computed(() => items.value.filter((i) => i.status === 'failed').length);
|
|
78
|
+
const progress = computed(() => {
|
|
79
|
+
if (items.value.length === 0) return 0;
|
|
80
|
+
const done = successCount.value + failureCount.value;
|
|
81
|
+
return done / items.value.length;
|
|
82
|
+
});
|
|
83
|
+
const done = computed(
|
|
84
|
+
() =>
|
|
85
|
+
items.value.length > 0 &&
|
|
86
|
+
items.value.every((i) => i.status === 'published' || i.status === 'failed'),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
function setItems(next: Array<Omit<BulkPublishItem, 'status'>>): void {
|
|
90
|
+
items.value = next.map((i) => ({ ...i, status: 'pending' as BulkItemStatus }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function publishOne(
|
|
94
|
+
item: BulkPublishItem,
|
|
95
|
+
body: { changeNote: string },
|
|
96
|
+
mfaCode?: string,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
item.status = 'publishing';
|
|
99
|
+
try {
|
|
100
|
+
const url = endpoints[item.kind](item.draftId);
|
|
101
|
+
const headers: Record<string, string> = {
|
|
102
|
+
'Content-Type': 'application/json',
|
|
103
|
+
};
|
|
104
|
+
const token = options.getAuthToken?.();
|
|
105
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
106
|
+
if (mfaCode) headers['X-Mfa-Code'] = mfaCode;
|
|
107
|
+
const res = await http(url, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers,
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
});
|
|
112
|
+
if (res.status >= 200 && res.status < 300) {
|
|
113
|
+
item.result = await res.json();
|
|
114
|
+
item.status = 'published';
|
|
115
|
+
} else {
|
|
116
|
+
item.error = `HTTP ${res.status}: ${await res.text()}`;
|
|
117
|
+
item.status = 'failed';
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
item.error = err instanceof Error ? err.message : String(err);
|
|
121
|
+
item.status = 'failed';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function run(input: { changeNote: string; mfaCode?: string }): Promise<void> {
|
|
126
|
+
const note = input.changeNote.trim();
|
|
127
|
+
if (note.length === 0) {
|
|
128
|
+
// Mark all items as failed with a clear reason.
|
|
129
|
+
for (const item of items.value) {
|
|
130
|
+
item.status = 'failed';
|
|
131
|
+
item.error = 'changeNote ist Pflicht beim Publish.';
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Parallel publishes — the server enforces an optimistic lock per draft.
|
|
136
|
+
await Promise.all(
|
|
137
|
+
items.value.map((item) => publishOne(item, { changeNote: note }, input.mfaCode)),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
items,
|
|
143
|
+
progress,
|
|
144
|
+
done,
|
|
145
|
+
successCount,
|
|
146
|
+
failureCount,
|
|
147
|
+
setItems,
|
|
148
|
+
run,
|
|
149
|
+
};
|
|
150
|
+
}
|