@saasicat/ui-vue 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{catalog-BY_t9prv.d.cts → catalog-DJcfm5N2.d.cts} +20 -7
- package/dist/{catalog-BY_t9prv.d.ts → catalog-DJcfm5N2.d.ts} +20 -7
- package/dist/{chunk-JN6HZGLV.js → chunk-F7NRM6KI.js} +54 -28
- package/dist/{chunk-7IFKVGZV.js → chunk-LY52T4N4.js} +21 -2
- package/dist/{chunk-OTP3OSSW.js → chunk-XMMFN2WJ.js} +74 -3
- package/dist/client/index.cjs +132 -29
- package/dist/client/index.d.cts +53 -3
- package/dist/client/index.d.ts +53 -3
- package/dist/client/index.js +14 -2
- package/dist/index.cjs +319 -162
- package/dist/index.d.cts +30 -8
- package/dist/index.d.ts +30 -8
- package/dist/index.js +176 -133
- package/dist/quasar/index.cjs +76 -29
- package/dist/quasar/index.d.cts +2 -2
- package/dist/quasar/index.d.ts +2 -2
- package/dist/quasar/index.js +6 -2
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/{use-super-admin-i18n-c_Eqsu9u.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
- package/dist/{use-super-admin-i18n-DlJRGIVq.d.cts → use-super-admin-i18n-CgTuNi8h.d.cts} +34 -7
- package/package.json +18 -8
- package/src/client/i18n/messages/bundles.ts +0 -2
- package/src/client/i18n/messages/common.ts +22 -0
- package/src/client/i18n/messages/discovery.ts +2 -2
- package/src/client/i18n/messages/marketing.ts +2 -4
- package/src/client/i18n/messages/plan-detail.ts +0 -4
- package/src/client/i18n/messages/plans.ts +20 -8
- package/src/client/i18n/messages/tenants.ts +0 -2
- package/src/client/index.ts +1 -0
- package/src/client/manifest-loader.ts +1 -1
- package/src/client/resolve-plans.ts +144 -0
- package/src/components/BundleVersionPublishDialog.vue +6 -6
- package/src/components/KvBlock.vue +4 -4
- package/src/components/MarketingPromotionsTab.vue +34 -48
- package/src/components/MfaPromptDialog.vue +8 -2
- package/src/components/TenantActionConfirmDialog.vue +4 -4
- package/src/components/VersionDiffPreview.vue +7 -7
- package/src/components/admin-page/AdminFilters.vue +12 -0
- package/src/components/admin-page/AdminHero.vue +22 -5
- package/src/components/admin-page/AdminKpi.vue +84 -0
- package/src/components/admin-page/AdminPaginator.vue +195 -0
- package/src/components/admin-page/AdminRefreshBtn.vue +39 -0
- package/src/components/admin-page/AdminSection.vue +31 -16
- package/src/components/admin-page/AdminStatistics.vue +40 -0
- package/src/components/admin-page/AdminTable.vue +175 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +23 -23
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +7 -7
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +9 -9
- package/src/components/bundle-editor/BundleQuotasEditor.vue +11 -11
- package/src/components/bundle-editor/BundleStatusBanner.vue +6 -6
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +18 -18
- package/src/components/bundle-editor/BundleVersionStrip.vue +12 -12
- package/src/components/dialogs/PilotCreateDialog.vue +12 -12
- package/src/components/dialogs/PilotEditDialog.vue +7 -7
- package/src/components/dialogs/PromoCodeCreateDialog.vue +8 -8
- package/src/components/dialogs/PromoCodeDialogFields.vue +33 -19
- package/src/components/dialogs/PromoCodeEditDialog.vue +10 -10
- package/src/components/plan/PlanGrid.vue +1 -1
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +34 -46
- package/src/components/plan-detail/PlanDetail.vue +92 -302
- package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
- package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
- package/src/components/plan-list/PlanList.vue +72 -290
- package/src/components/plan-matrix/PlanMatrix.vue +66 -183
- package/src/components/plan-review/PlanReview.vue +191 -275
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -5
- package/src/components/plan-version-editor/PlanVersionEditor.vue +200 -295
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +23 -55
- package/src/index.ts +1 -0
- package/src/pages-standard/AdminLayout.vue +34 -10
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
- package/src/pages-standard/AuditPage.vue +53 -72
- package/src/pages-standard/BundlesPage.vue +128 -144
- package/src/pages-standard/DashboardPage.vue +43 -107
- package/src/pages-standard/DiscoveryPage.vue +158 -200
- package/src/pages-standard/EmailHistoryPage.vue +98 -88
- package/src/pages-standard/MarketingCatalogPage.vue +237 -267
- package/src/pages-standard/PilotsPage.vue +44 -60
- package/src/pages-standard/PlansPage.vue +230 -105
- package/src/pages-standard/PlatformEmailPage.vue +37 -40
- package/src/pages-standard/PromoCodeDetailPage.vue +16 -30
- package/src/pages-standard/PromoCodesPage.vue +86 -112
- package/src/pages-standard/SubscriptionsPage.vue +10 -17
- package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
- package/src/pages-standard/SuperAdminSetupWizard.vue +20 -17
- package/src/pages-standard/TenantDetailPage.vue +45 -41
- package/src/pages-standard/TenantsPage.vue +184 -264
- package/src/pages-standard/UsersPage.vue +69 -104
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +20 -17
- package/src/pages-standard/bundles-page/BundlesKpis.vue +13 -22
- package/src/pages-standard/bundles-page/BundlesToolbar.vue +6 -10
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +10 -10
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +5 -5
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +16 -16
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +31 -27
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +7 -7
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +1 -1
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +2 -13
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +6 -6
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +6 -6
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +16 -16
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +14 -29
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +1 -1
- package/src/pages-standard/plans-page/PlansPageToast.vue +1 -1
- package/src/pages-standard/sa-theme.css +481 -71
- package/src/pages-standard/tenant-detail/TenantUsers.vue +2 -8
- package/src/pages-standard/tenants/StatusPill.vue +2 -2
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +25 -25
- package/src/pages-tenant/OnboardingConfigurator.vue +1 -5
- package/src/pages-tenant/PackageSnapshotPanel.vue +1 -1
- package/src/pages-tenant/TenantPlanSection.vue +12 -20
- package/src/quasar/create-super-admin-app.ts +4 -0
- package/src/vue/entitlement-provider.ts +0 -1
- package/src/vue/feature-router-guard.ts +0 -1
- package/src/vue/super-admin-context.ts +106 -1
- package/src/vue/use-api-list.ts +2 -1
- package/src/vue/use-pagination.ts +63 -0
- package/src/vue/use-plans.ts +1 -2
- package/src/vue/use-sign-out.ts +90 -0
- package/src/vue/use-tenant-billing.ts +1 -1
- package/src/vue/use-tenant-manifest.ts +0 -1
- package/src/components/KpiCard.vue +0 -53
- package/src/components/plan-cockpit/PlanCockpit.vue +0 -1027
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -99
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +0 -101
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +0 -112
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +0 -68
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +0 -249
- package/src/components/plan-cockpit/types.ts +0 -72
- package/src/components/plan-detail/PlanDetailHeader.vue +0 -194
|
@@ -400,7 +400,7 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
400
400
|
BlinkMacSystemFont,
|
|
401
401
|
system-ui,
|
|
402
402
|
sans-serif;
|
|
403
|
-
color:
|
|
403
|
+
color: var(--sa-heading);
|
|
404
404
|
}
|
|
405
405
|
.msb-head {
|
|
406
406
|
display: flex;
|
|
@@ -416,7 +416,7 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
416
416
|
}
|
|
417
417
|
.msb-sub {
|
|
418
418
|
margin: 4px 0 0;
|
|
419
|
-
color:
|
|
419
|
+
color: var(--sa-muted);
|
|
420
420
|
font-size: 13px;
|
|
421
421
|
max-width: 580px;
|
|
422
422
|
line-height: 1.5;
|
|
@@ -424,7 +424,7 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
424
424
|
.msb-loading {
|
|
425
425
|
padding: 32px;
|
|
426
426
|
text-align: center;
|
|
427
|
-
color:
|
|
427
|
+
color: var(--sa-muted);
|
|
428
428
|
}
|
|
429
429
|
.msb-empty {
|
|
430
430
|
padding: 32px 24px;
|
|
@@ -432,12 +432,12 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
432
432
|
background: #fff;
|
|
433
433
|
border: 1px dashed #cbd5e1;
|
|
434
434
|
border-radius: 12px;
|
|
435
|
-
color:
|
|
435
|
+
color: var(--sa-muted-dark);
|
|
436
436
|
}
|
|
437
437
|
.msb-empty-link {
|
|
438
438
|
background: transparent;
|
|
439
439
|
border: 0;
|
|
440
|
-
color:
|
|
440
|
+
color: var(--sa-primary);
|
|
441
441
|
cursor: pointer;
|
|
442
442
|
font: inherit;
|
|
443
443
|
text-decoration: underline;
|
|
@@ -457,13 +457,13 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
457
457
|
}
|
|
458
458
|
.msb-card {
|
|
459
459
|
background: #fff;
|
|
460
|
-
border: 1px solid
|
|
460
|
+
border: 1px solid var(--sa-border);
|
|
461
461
|
border-radius: 12px;
|
|
462
462
|
padding: 14px 18px;
|
|
463
463
|
}
|
|
464
464
|
.msb-card--canceled-pending {
|
|
465
465
|
border-color: #fde68a;
|
|
466
|
-
background:
|
|
466
|
+
background: var(--sa-warning-bg);
|
|
467
467
|
}
|
|
468
468
|
.msb-card--canceled {
|
|
469
469
|
opacity: 0.65;
|
|
@@ -490,7 +490,7 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
490
490
|
.msb-card-meta {
|
|
491
491
|
margin-top: 4px;
|
|
492
492
|
font-size: 12.5px;
|
|
493
|
-
color:
|
|
493
|
+
color: var(--sa-muted);
|
|
494
494
|
}
|
|
495
495
|
.msb-chip {
|
|
496
496
|
font-size: 10.5px;
|
|
@@ -509,13 +509,13 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
509
509
|
color: #92400e;
|
|
510
510
|
}
|
|
511
511
|
.msb-chip--canceled {
|
|
512
|
-
background:
|
|
513
|
-
color:
|
|
512
|
+
background: var(--sa-border);
|
|
513
|
+
color: var(--sa-muted-dark);
|
|
514
514
|
}
|
|
515
515
|
.msb-cancel-info {
|
|
516
516
|
margin-top: 10px;
|
|
517
517
|
padding: 8px 12px;
|
|
518
|
-
background:
|
|
518
|
+
background: var(--sa-warning-bg);
|
|
519
519
|
border: 1px solid #fde68a;
|
|
520
520
|
border-radius: 6px;
|
|
521
521
|
color: #92400e;
|
|
@@ -533,22 +533,22 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
533
533
|
cursor: pointer;
|
|
534
534
|
font-family: inherit;
|
|
535
535
|
font-size: 13px;
|
|
536
|
-
color:
|
|
536
|
+
color: var(--sa-heading);
|
|
537
537
|
}
|
|
538
538
|
.msb-btn:hover:not(:disabled) {
|
|
539
|
-
background:
|
|
539
|
+
background: var(--sa-bg-surface-2);
|
|
540
540
|
}
|
|
541
541
|
.msb-btn:disabled {
|
|
542
542
|
cursor: not-allowed;
|
|
543
543
|
opacity: 0.55;
|
|
544
544
|
}
|
|
545
545
|
.msb-btn--primary {
|
|
546
|
-
background:
|
|
547
|
-
border-color:
|
|
546
|
+
background: var(--sa-primary);
|
|
547
|
+
border-color: var(--sa-primary);
|
|
548
548
|
color: #fff;
|
|
549
549
|
}
|
|
550
550
|
.msb-btn--primary:hover:not(:disabled) {
|
|
551
|
-
background:
|
|
551
|
+
background: var(--sa-primary-strong);
|
|
552
552
|
}
|
|
553
553
|
.msb-btn--ghost {
|
|
554
554
|
background: transparent;
|
|
@@ -580,7 +580,7 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
580
580
|
align-items: center;
|
|
581
581
|
justify-content: space-between;
|
|
582
582
|
padding: 16px 20px 10px;
|
|
583
|
-
border-bottom: 1px solid
|
|
583
|
+
border-bottom: 1px solid var(--sa-border);
|
|
584
584
|
}
|
|
585
585
|
.msb-modal-title {
|
|
586
586
|
font-size: 16px;
|
|
@@ -590,13 +590,13 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
590
590
|
background: transparent;
|
|
591
591
|
border: 0;
|
|
592
592
|
cursor: pointer;
|
|
593
|
-
color:
|
|
593
|
+
color: var(--sa-muted);
|
|
594
594
|
padding: 4px;
|
|
595
595
|
border-radius: 4px;
|
|
596
596
|
}
|
|
597
597
|
.msb-modal-x:hover {
|
|
598
|
-
background:
|
|
599
|
-
color:
|
|
598
|
+
background: var(--sa-border-soft);
|
|
599
|
+
color: var(--sa-heading);
|
|
600
600
|
}
|
|
601
601
|
.msb-modal-body {
|
|
602
602
|
padding: 16px 20px;
|
|
@@ -606,8 +606,8 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
606
606
|
}
|
|
607
607
|
.msb-modal-foot {
|
|
608
608
|
padding: 12px 20px;
|
|
609
|
-
border-top: 1px solid
|
|
610
|
-
background:
|
|
609
|
+
border-top: 1px solid var(--sa-border);
|
|
610
|
+
background: var(--sa-bg-surface-2);
|
|
611
611
|
border-radius: 0 0 12px 12px;
|
|
612
612
|
display: flex;
|
|
613
613
|
justify-content: flex-end;
|
|
@@ -621,11 +621,11 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
621
621
|
.msb-field-label {
|
|
622
622
|
font-size: 12px;
|
|
623
623
|
font-weight: 600;
|
|
624
|
-
color:
|
|
624
|
+
color: var(--sa-muted-dark);
|
|
625
625
|
}
|
|
626
626
|
.msb-field-hint {
|
|
627
627
|
font-size: 11px;
|
|
628
|
-
color:
|
|
628
|
+
color: var(--sa-muted-light);
|
|
629
629
|
}
|
|
630
630
|
.msb-input {
|
|
631
631
|
padding: 7px 10px;
|
|
@@ -633,7 +633,7 @@ function formatDate(date: Date | string | null | undefined): string {
|
|
|
633
633
|
border-radius: 6px;
|
|
634
634
|
font-family: inherit;
|
|
635
635
|
font-size: 13px;
|
|
636
|
-
color:
|
|
636
|
+
color: var(--sa-heading);
|
|
637
637
|
background: #fff;
|
|
638
638
|
}
|
|
639
639
|
</style>
|
|
@@ -100,11 +100,7 @@ import PlanGrid from '../components/plan/PlanGrid.vue';
|
|
|
100
100
|
import PublicBundleGrid from '../components/plan/PublicBundleGrid.vue';
|
|
101
101
|
import PromoCodeInput from '../components/plan/PromoCodeInput.vue';
|
|
102
102
|
import PriceSummary from '../components/plan/PriceSummary.vue';
|
|
103
|
-
import {
|
|
104
|
-
useSubscriptionDraft,
|
|
105
|
-
type PromoState,
|
|
106
|
-
type SubscriptionDraft,
|
|
107
|
-
} from '../vue/use-subscription-draft.js';
|
|
103
|
+
import { useSubscriptionDraft, type SubscriptionDraft } from '../vue/use-subscription-draft.js';
|
|
108
104
|
import type { CatalogPlan } from '../vue/use-tenant-billing-catalog.js';
|
|
109
105
|
import type { BillingCycleStr } from '../vue/use-tenant-billing.js';
|
|
110
106
|
import type {
|
|
@@ -108,7 +108,7 @@ import { computed, ref } from 'vue';
|
|
|
108
108
|
import type { PackageSnapshotShape } from '../vue/use-tenant-billing.js';
|
|
109
109
|
import type { TenantPlanSectionI18n } from './default-i18n.js';
|
|
110
110
|
|
|
111
|
-
// PackageSnapshotPanel — P11.4
|
|
111
|
+
// PackageSnapshotPanel — P11.4:
|
|
112
112
|
// Read-only display of `Subscription.packageSnapshot`. Shows the
|
|
113
113
|
// tenant the frozen website offer (offer snapshot) — plan,
|
|
114
114
|
// bundles, prices, capture timestamp. No actions, no diff against
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
:usage="usage"
|
|
65
65
|
:i18n="effectiveI18n"
|
|
66
66
|
:catalog-quota-keys="catalogQuotaKeys"
|
|
67
|
-
:quota-label="
|
|
68
|
-
:is-fractional-quota="
|
|
67
|
+
:quota-label="quotaLabelResolved"
|
|
68
|
+
:is-fractional-quota="isFractionalQuotaResolved"
|
|
69
69
|
:usage-bar-formatter="usageBarFormatter"
|
|
70
70
|
/>
|
|
71
71
|
|
|
@@ -179,11 +179,11 @@
|
|
|
179
179
|
:catalog-quota-keys="catalogQuotaKeys"
|
|
180
180
|
:format-currency="formatCurrency"
|
|
181
181
|
:format-date="formatDate"
|
|
182
|
-
:format-quota-label="
|
|
182
|
+
:format-quota-label="formatQuotaLabelResolved"
|
|
183
183
|
:format-quota-value="formatQuotaValue"
|
|
184
|
-
:quota-label="
|
|
184
|
+
:quota-label="quotaLabelResolved"
|
|
185
185
|
:feature-label="featureLabelResolved"
|
|
186
|
-
:is-fractional-quota="
|
|
186
|
+
:is-fractional-quota="isFractionalQuotaResolved"
|
|
187
187
|
:preview-plan-change="previewPlanChange"
|
|
188
188
|
:change-plan="changePlan"
|
|
189
189
|
:i18n="wizardI18n"
|
|
@@ -478,30 +478,22 @@ const wizardI18n = computed(() => ({
|
|
|
478
478
|
}));
|
|
479
479
|
|
|
480
480
|
// Helper hooks with defaults
|
|
481
|
-
function
|
|
481
|
+
function quotaLabelResolved(key: string): string {
|
|
482
482
|
return props.quotaLabel?.(key) ?? key;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
function
|
|
485
|
+
function featureLabelFromProps(key: string): string {
|
|
486
486
|
return props.featureLabel?.(key) ?? key;
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
-
function
|
|
489
|
+
function isFractionalQuotaResolved(key: string): boolean {
|
|
490
490
|
return props.isFractionalQuota?.(key) ?? key.toLowerCase().includes('storage');
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
-
function
|
|
494
|
-
return props.formatDate(input);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function formatCurrency(n: number): string {
|
|
498
|
-
return props.formatCurrency(n);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function formatQuotaLabel(key: string, value: number): string {
|
|
493
|
+
function formatQuotaLabelResolved(key: string, value: number): string {
|
|
502
494
|
if (props.formatQuotaLabel) return props.formatQuotaLabel(key, value);
|
|
503
|
-
if (value === -1) return `${
|
|
504
|
-
return `${value} ${
|
|
495
|
+
if (value === -1) return `${quotaLabelResolved(key)}: ∞`;
|
|
496
|
+
return `${value} ${quotaLabelResolved(key)}`;
|
|
505
497
|
}
|
|
506
498
|
|
|
507
499
|
function usageBarFormatter(key: string): ((value: number) => string) | undefined {
|
|
@@ -596,7 +588,7 @@ async function onConfirmBundlePreview() {
|
|
|
596
588
|
// Feature label with registry translation (#18): the registry label takes
|
|
597
589
|
// precedence, then the consumer hook, and finally the raw key.
|
|
598
590
|
function featureLabelResolved(key: string): string {
|
|
599
|
-
return catalog.featureRegistry.value?.[key]?.label ??
|
|
591
|
+
return catalog.featureRegistry.value?.[key]?.label ?? featureLabelFromProps(key);
|
|
600
592
|
}
|
|
601
593
|
|
|
602
594
|
// Mutation handlers
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
SUPER_ADMIN_EXTENSIONS_KEY,
|
|
33
33
|
SUPER_ADMIN_HTTP_KEY,
|
|
34
34
|
SUPER_ADMIN_LOGIN_ADAPTER_KEY,
|
|
35
|
+
SUPER_ADMIN_MANIFEST_CLEAR_CACHE_KEY,
|
|
35
36
|
SUPER_ADMIN_MANIFEST_KEY,
|
|
36
37
|
buildNavigationGuard,
|
|
37
38
|
type ActionsMap,
|
|
@@ -173,6 +174,9 @@ export function createSuperAdminApp(options: CreateSuperAdminAppOptions): SuperA
|
|
|
173
174
|
if (options.manifestGuard?.getManifest) {
|
|
174
175
|
app.provide(SUPER_ADMIN_MANIFEST_KEY, options.manifestGuard.getManifest);
|
|
175
176
|
}
|
|
177
|
+
if (options.manifestGuard?.clearCache) {
|
|
178
|
+
app.provide(SUPER_ADMIN_MANIFEST_CLEAR_CACHE_KEY, options.manifestGuard.clearCache);
|
|
179
|
+
}
|
|
176
180
|
if (options.loginAdapter) {
|
|
177
181
|
app.provide(SUPER_ADMIN_LOGIN_ADAPTER_KEY, options.loginAdapter);
|
|
178
182
|
}
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// resolve to the same identity — analogous to the inject keys in
|
|
10
10
|
// create-super-admin-app.
|
|
11
11
|
//
|
|
12
|
-
// Spec: handoff/superadmin/QUICKSTART_SIMPLIFICATIONS.md §P8.
|
|
13
12
|
|
|
14
13
|
import { inject, type App, type InjectionKey } from 'vue';
|
|
15
14
|
import type { UseEntitlementResult } from './use-entitlement.js';
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
// { path: '/x', component: XPage, meta: { requiresFeature: ['DMS', 'STORAGE_PRO'] } }
|
|
20
20
|
// ```
|
|
21
21
|
//
|
|
22
|
-
// Spec: handoff/superadmin/QUICKSTART_SIMPLIFICATIONS.md §P8.
|
|
23
22
|
|
|
24
23
|
import type { NavigationGuardWithThis, RouteLocationNormalized } from 'vue-router';
|
|
25
24
|
import type { UseEntitlementResult } from './use-entitlement.js';
|
|
@@ -79,6 +79,17 @@ export interface SuperAdminLoginAdapter {
|
|
|
79
79
|
* storage, MFA hops etc.
|
|
80
80
|
*/
|
|
81
81
|
login(email: string, password: string): Promise<SuperAdminLoginResult>;
|
|
82
|
+
/**
|
|
83
|
+
* Undoes what `login` established — token, session storage, app store.
|
|
84
|
+
*
|
|
85
|
+
* Whoever knows how to start a session is the only one who knows how to
|
|
86
|
+
* end it, so it belongs next to `login` rather than in a second seam. The
|
|
87
|
+
* platform's own sign-out affordances call it; without it they can only
|
|
88
|
+
* navigate to `/login`, and an app whose `isAuthenticated()` reads a
|
|
89
|
+
* surviving token lets the operator straight back in — a sign-out button
|
|
90
|
+
* that does not sign out.
|
|
91
|
+
*/
|
|
92
|
+
logout?(): void | Promise<void>;
|
|
82
93
|
/**
|
|
83
94
|
* Target route after a successful login. Default: `/admin/dashboard`
|
|
84
95
|
* (platform convention for the standard pages — apps with a different
|
|
@@ -119,6 +130,20 @@ export interface SuperAdminManifestGuardOptions {
|
|
|
119
130
|
* manifest guard again and produces a redirect loop.
|
|
120
131
|
*/
|
|
121
132
|
errorRoute?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Discards the cached manifest, so the next `ensureLoaded()` fetches a
|
|
135
|
+
* fresh body instead of revalidating.
|
|
136
|
+
*
|
|
137
|
+
* Needed because the loader keeps an ETag in storage. Its own documented
|
|
138
|
+
* failure — "server returned 304 but the cache body is missing" — survives
|
|
139
|
+
* a full page reload: the next request sends the same `If-None-Match`,
|
|
140
|
+
* gets another 304, and lands back on the error page. Retry is a dead end
|
|
141
|
+
* for that case unless something clears the ETag first.
|
|
142
|
+
*
|
|
143
|
+
* `ManifestLoader.clearCache()` and the manifest store's reset both fit;
|
|
144
|
+
* pass whichever the app uses.
|
|
145
|
+
*/
|
|
146
|
+
clearCache?: () => void | Promise<void>;
|
|
122
147
|
}
|
|
123
148
|
|
|
124
149
|
/**
|
|
@@ -170,6 +195,14 @@ export const SUPER_ADMIN_ACTIONS_KEY: InjectionKey<ActionsMap> = Symbol.for(
|
|
|
170
195
|
export const SUPER_ADMIN_MANIFEST_KEY: InjectionKey<() => AdminManifest | null> = Symbol.for(
|
|
171
196
|
'@saasicat/ui-vue/SUPER_ADMIN_MANIFEST',
|
|
172
197
|
);
|
|
198
|
+
/**
|
|
199
|
+
* Vue inject key for `manifestGuard.clearCache`.
|
|
200
|
+
*
|
|
201
|
+
* The fail-closed error page is route-mounted and gets no props, so the only
|
|
202
|
+
* way it can reach the app's cache is through the shell.
|
|
203
|
+
*/
|
|
204
|
+
export const SUPER_ADMIN_MANIFEST_CLEAR_CACHE_KEY: InjectionKey<() => void | Promise<void>> =
|
|
205
|
+
Symbol.for('@saasicat/ui-vue/SUPER_ADMIN_MANIFEST_CLEAR_CACHE');
|
|
173
206
|
/** Vue inject key for `useSuperAdminLoginAdapter()`. */
|
|
174
207
|
export const SUPER_ADMIN_LOGIN_ADAPTER_KEY: InjectionKey<SuperAdminLoginAdapter> = Symbol.for(
|
|
175
208
|
'@saasicat/ui-vue/SUPER_ADMIN_LOGIN_ADAPTER',
|
|
@@ -190,14 +223,52 @@ export type InstallPlugin = (app: App) => void;
|
|
|
190
223
|
* behavior (auth redirect, manifest fail-closed path). Consumers should call
|
|
191
224
|
* `createSuperAdminApp()`, not this helper directly.
|
|
192
225
|
*/
|
|
226
|
+
/**
|
|
227
|
+
* Recognises "your session is gone" among manifest-load failures.
|
|
228
|
+
*
|
|
229
|
+
* Structural rather than `instanceof`: the manifest loader lives in the
|
|
230
|
+
* framework-free client layer, and a consumer store may wrap or re-throw the
|
|
231
|
+
* error (or use its own loader entirely). What all of them carry is a numeric
|
|
232
|
+
* `status`.
|
|
233
|
+
*/
|
|
234
|
+
function isUnauthorizedError(err: unknown): boolean {
|
|
235
|
+
const status = (err as { status?: unknown } | null)?.status;
|
|
236
|
+
return status === 401 || status === 403;
|
|
237
|
+
}
|
|
238
|
+
|
|
193
239
|
export function buildNavigationGuard(
|
|
194
240
|
options: SuperAdminGuardOptions,
|
|
195
241
|
): NavigationGuardWithThis<undefined> | null {
|
|
196
242
|
const { authGuard, manifestGuard } = options;
|
|
197
243
|
if (!authGuard && !manifestGuard) return null;
|
|
198
244
|
|
|
245
|
+
// Set when a manifest 401/403 already sent the user to `onUnauthenticated()`.
|
|
246
|
+
// Cleared as soon as any navigation gets through, so a later expiry is
|
|
247
|
+
// handled again — see the loop note below.
|
|
248
|
+
let redirectedForUnauthorized = false;
|
|
249
|
+
// ...and whether they actually got there.
|
|
250
|
+
//
|
|
251
|
+
// This is the question the budget is really asking: has the operator had a
|
|
252
|
+
// chance to log in since we sent them away? Concurrent navigations have
|
|
253
|
+
// not, so they belong to the same episode and follow the first one to
|
|
254
|
+
// login rather than spending a second attempt.
|
|
255
|
+
//
|
|
256
|
+
// Deliberately NOT an identity comparison on the rejection or on the
|
|
257
|
+
// promise `ensureLoaded()` returned. Both were tried and both are
|
|
258
|
+
// implementation artefacts a valid consumer can change: a store may cache
|
|
259
|
+
// one `Error` and rethrow it, and `async () => { await store.ensure(); }`
|
|
260
|
+
// wraps one shared request in a fresh promise per call. The contract
|
|
261
|
+
// promises `Promise<void>`, nothing more. A navigation to a public route,
|
|
262
|
+
// by contrast, is something this guard observes directly.
|
|
263
|
+
let reachedLoginAfterRedirect = false;
|
|
264
|
+
|
|
199
265
|
return async (to: RouteLocationNormalized) => {
|
|
200
|
-
if (to.meta?.public === true)
|
|
266
|
+
if (to.meta?.public === true) {
|
|
267
|
+
// `onUnauthenticated()` sends them to the login route, which is
|
|
268
|
+
// public — so this is the guard seeing the redirect land.
|
|
269
|
+
if (redirectedForUnauthorized) reachedLoginAfterRedirect = true;
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
201
272
|
|
|
202
273
|
if (authGuard) {
|
|
203
274
|
if (!authGuard.isAuthenticated()) return authGuard.onUnauthenticated();
|
|
@@ -209,7 +280,41 @@ export function buildNavigationGuard(
|
|
|
209
280
|
if (manifestGuard) {
|
|
210
281
|
try {
|
|
211
282
|
await manifestGuard.ensureLoaded();
|
|
283
|
+
redirectedForUnauthorized = false;
|
|
284
|
+
reachedLoginAfterRedirect = false;
|
|
212
285
|
} catch (err) {
|
|
286
|
+
// A 401/403 is usually an expired or missing session, not a
|
|
287
|
+
// broken manifest. Sending it to the fail-closed error page
|
|
288
|
+
// tells the operator "the manifest could not be loaded" and
|
|
289
|
+
// leaves them on a dead end, when the truth is "log in again".
|
|
290
|
+
//
|
|
291
|
+
// This happens whenever `isAuthenticated()` only checks that a
|
|
292
|
+
// token exists (the common implementation) while the token has
|
|
293
|
+
// expired: auth passes, the manifest request does not.
|
|
294
|
+
//
|
|
295
|
+
// Exactly ONCE, though. `onUnauthenticated()` commonly clears
|
|
296
|
+
// the session (`authStore.logout(); return '/login'`), so if
|
|
297
|
+
// the manifest keeps rejecting for a reason the login cannot
|
|
298
|
+
// fix — the account lacks the admin role, the endpoint is
|
|
299
|
+
// misconfigured, the backend is down — retrying it produces
|
|
300
|
+
// an unbreakable loop: log in → /admin → 401 → logged out →
|
|
301
|
+
// /login. The second time we fail closed to the error page
|
|
302
|
+
// instead, which at least says something and keeps the session.
|
|
303
|
+
//
|
|
304
|
+
// "Once" means once per LOGIN OPPORTUNITY, not once per guard
|
|
305
|
+
// call.
|
|
306
|
+
// Two protected navigations can overlap, and `ensureLoaded()`
|
|
307
|
+
// hands both of them the same in-flight promise — so both land
|
|
308
|
+
// here before either navigation completes. Counting that as
|
|
309
|
+
// two attempts would send the second, newer navigation to the
|
|
310
|
+
// error page over an ordinary expired session.
|
|
311
|
+
if (authGuard && isUnauthorizedError(err)) {
|
|
312
|
+
if (!redirectedForUnauthorized || !reachedLoginAfterRedirect) {
|
|
313
|
+
redirectedForUnauthorized = true;
|
|
314
|
+
return authGuard.onUnauthenticated();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
213
318
|
if (manifestGuard.errorRoute && to.path !== manifestGuard.errorRoute) {
|
|
214
319
|
return manifestGuard.errorRoute;
|
|
215
320
|
}
|
package/src/vue/use-api-list.ts
CHANGED
|
@@ -115,7 +115,8 @@ export function useApiList<T, TFilter extends Record<string, unknown> = Record<s
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
async function setPageSize(size: number) {
|
|
118
|
-
pageSize
|
|
118
|
+
// The admin API's pageSize is 1..200; there is no "all" to pass on.
|
|
119
|
+
pageSize.value = Math.min(200, Math.max(1, Math.floor(size)));
|
|
119
120
|
page.value = 1;
|
|
120
121
|
await load();
|
|
121
122
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
// Client-side paging for a list that is already fully loaded.
|
|
4
|
+
//
|
|
5
|
+
// Most admin lists fetch everything and filter in the browser; without this
|
|
6
|
+
// they would each carry the same three lines of `slice()` plus the same
|
|
7
|
+
// off-the-end guard. Lists that page on the server (see `useApiList`) bind
|
|
8
|
+
// AdminPaginator to their own state instead and do not need this.
|
|
9
|
+
|
|
10
|
+
/** Quasar's convention: 0 rows per page means no paging at all. */
|
|
11
|
+
export const ALL_ROWS = 0;
|
|
12
|
+
|
|
13
|
+
/** Starting page size for every list; AdminPaginator restores a stored one. */
|
|
14
|
+
export const DEFAULT_ROWS_PER_PAGE = 10;
|
|
15
|
+
|
|
16
|
+
/** The sizes a locally paged list offers, in order. */
|
|
17
|
+
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, ALL_ROWS] as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The same without "all". A server-paged list cannot honour it: the admin API
|
|
21
|
+
* caps `pageSize` at 200 and falls back to 50 when it is omitted, so the
|
|
22
|
+
* option would claim to show everything while showing the first fifty.
|
|
23
|
+
*/
|
|
24
|
+
export const SERVER_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
|
25
|
+
|
|
26
|
+
export interface UsePaginationResult<T> {
|
|
27
|
+
page: Ref<number>;
|
|
28
|
+
rowsPerPage: Ref<number>;
|
|
29
|
+
/** Total across all pages — what AdminPaginator counts with. */
|
|
30
|
+
total: ComputedRef<number>;
|
|
31
|
+
/** The slice to render. */
|
|
32
|
+
pagedRows: ComputedRef<T[]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function usePagination<T>(
|
|
36
|
+
rows: Ref<readonly T[]> | ComputedRef<readonly T[]>,
|
|
37
|
+
initialRowsPerPage = DEFAULT_ROWS_PER_PAGE,
|
|
38
|
+
): UsePaginationResult<T> {
|
|
39
|
+
const page = ref(1);
|
|
40
|
+
const rowsPerPage = ref(initialRowsPerPage);
|
|
41
|
+
|
|
42
|
+
const total = computed(() => rows.value.length);
|
|
43
|
+
|
|
44
|
+
const pageCount = computed(() =>
|
|
45
|
+
rowsPerPage.value === ALL_ROWS
|
|
46
|
+
? 1
|
|
47
|
+
: Math.max(1, Math.ceil(total.value / rowsPerPage.value)),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Filtering can shrink a list under the current page — without this the
|
|
51
|
+
// table would render an empty slice and look like it lost its rows.
|
|
52
|
+
watch(pageCount, (count) => {
|
|
53
|
+
if (page.value > count) page.value = count;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const pagedRows = computed(() => {
|
|
57
|
+
if (rowsPerPage.value === ALL_ROWS) return [...rows.value];
|
|
58
|
+
const start = (page.value - 1) * rowsPerPage.value;
|
|
59
|
+
return rows.value.slice(start, start + rowsPerPage.value);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return { page, rowsPerPage, total, pagedRows };
|
|
63
|
+
}
|
package/src/vue/use-plans.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// usePlans + usePlanVersions — Vue 3 composables for the SuperAdmin
|
|
2
|
-
// plan management
|
|
2
|
+
// plan management.:
|
|
3
3
|
// Pack 1 → usePlans (root CRUD)
|
|
4
4
|
// Pack 2a → usePlanVersions (draft/publish lifecycle)
|
|
5
5
|
|
|
@@ -192,7 +192,6 @@ export function usePlans(options: UsePlansOptions): UsePlansResult {
|
|
|
192
192
|
|
|
193
193
|
// =============================================================================
|
|
194
194
|
// usePlanVersions — lifecycle operations for a specific plan ID
|
|
195
|
-
// (SPEC_V2 §11.1 M6 Pack 2a).
|
|
196
195
|
// =============================================================================
|
|
197
196
|
|
|
198
197
|
export interface UsePlanVersionsOptions {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Signing out, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Both platform affordances that offer it — the layout header button and the
|
|
4
|
+
// fail-closed error page — are mounted as bare route components in every
|
|
5
|
+
// consumer we know of, which means neither an `onLogout` prop nor a `@logout`
|
|
6
|
+
// listener reaches them. Their fallback therefore has to be the real thing.
|
|
7
|
+
//
|
|
8
|
+
// Navigating to `/login` on its own is not it. An app's `isAuthenticated()`
|
|
9
|
+
// typically reads a token out of storage, so a session left behind means the
|
|
10
|
+
// next navigation to `/admin` passes the guard again: the operator clicked
|
|
11
|
+
// "sign out", saw the login form, and is still signed in.
|
|
12
|
+
//
|
|
13
|
+
// The adapter that knows how to start a session is the only thing that knows
|
|
14
|
+
// how to end it, hence `loginAdapter.logout`.
|
|
15
|
+
|
|
16
|
+
import { inject } from 'vue';
|
|
17
|
+
import { useRouter } from 'vue-router';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
SUPER_ADMIN_LOGIN_ADAPTER_KEY,
|
|
21
|
+
SUPER_ADMIN_MANIFEST_CLEAR_CACHE_KEY,
|
|
22
|
+
} from './super-admin-context.js';
|
|
23
|
+
|
|
24
|
+
/** Where `createAdminRoutes()` registers the login page. */
|
|
25
|
+
export const DEFAULT_LOGIN_PATH = '/login';
|
|
26
|
+
|
|
27
|
+
export interface UseSignOutOptions {
|
|
28
|
+
/** Route to land on afterwards. Default `/login`. */
|
|
29
|
+
loginPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns the platform's default sign-out: end the session, then go to login.
|
|
34
|
+
*
|
|
35
|
+
* Must be called during component setup — it injects the login adapter and
|
|
36
|
+
* uses the router.
|
|
37
|
+
*/
|
|
38
|
+
export function useSignOut(options: UseSignOutOptions = {}): () => Promise<void> {
|
|
39
|
+
const router = useRouter();
|
|
40
|
+
const adapter = inject(SUPER_ADMIN_LOGIN_ADAPTER_KEY, undefined);
|
|
41
|
+
const clearManifestCache = inject(SUPER_ADMIN_MANIFEST_CLEAR_CACHE_KEY, undefined);
|
|
42
|
+
|
|
43
|
+
return async function signOut(): Promise<void> {
|
|
44
|
+
if (adapter?.logout) {
|
|
45
|
+
try {
|
|
46
|
+
await adapter.logout();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Navigate anyway. A rejected `logout()` — a server-side
|
|
49
|
+
// revocation that failed, say — must not strand the operator on
|
|
50
|
+
// the protected page they just asked to leave, and both callers
|
|
51
|
+
// discard this promise, so an escaping rejection would be
|
|
52
|
+
// invisible. Leaving is also the safer half: the adapter may
|
|
53
|
+
// well have cleared the local token before the request failed.
|
|
54
|
+
console.error(
|
|
55
|
+
'[SaaSiCat] Sign-out failed while ending the session — navigating to the login page anyway. ' +
|
|
56
|
+
'The session may still be valid on the server.',
|
|
57
|
+
err,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
// Loud rather than silent: the button looks like it worked either
|
|
62
|
+
// way, so the only place this can surface is the console.
|
|
63
|
+
console.warn(
|
|
64
|
+
'[SaaSiCat] Sign-out only navigated to the login page — the session was left ' +
|
|
65
|
+
'intact, so the next navigation to /admin will pass the guard again. Add ' +
|
|
66
|
+
'`logout()` to the `loginAdapter` you pass to createSuperAdminApp().',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Unconditionally, and after the adapter either way: the manifest store
|
|
71
|
+
// stays `loaded` across a sign-out, so without this the next
|
|
72
|
+
// `ensureLoaded()` returns instantly and hands the *next* operator the
|
|
73
|
+
// previous session's manifest — their navigation, their capabilities,
|
|
74
|
+
// their project. Only a full page reload would have cleared it, and
|
|
75
|
+
// signing out does not force one.
|
|
76
|
+
if (clearManifestCache) {
|
|
77
|
+
try {
|
|
78
|
+
await clearManifestCache();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error(
|
|
81
|
+
'[SaaSiCat] Sign-out could not discard the cached manifest. The next login in ' +
|
|
82
|
+
'this tab may see the previous session’s navigation until the page reloads.',
|
|
83
|
+
err,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await router.replace(options.loginPath ?? DEFAULT_LOGIN_PATH);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -56,7 +56,7 @@ export interface UsageSnapshotShape {
|
|
|
56
56
|
};
|
|
57
57
|
usage: Record<string, number>;
|
|
58
58
|
/**
|
|
59
|
-
* P11.4
|
|
59
|
+
* P11.4: Read-only package snapshot from the
|
|
60
60
|
* original CheckoutOffer. `null` for subscriptions without a
|
|
61
61
|
* CheckoutOffer origin. The JSON structure matches the Offer
|
|
62
62
|
* schema; the UI can read `bundleVersionIds`, `currency`,
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// backend renders per Tenant. This lets the app UI build the navigation
|
|
6
6
|
// declaratively from the Manifest instead of filtering it itself.
|
|
7
7
|
//
|
|
8
|
-
// Spec: handoff/superadmin/QUICKSTART_SIMPLIFICATIONS.md §P14.
|
|
9
8
|
|
|
10
9
|
import { ref, type Ref } from 'vue';
|
|
11
10
|
import { defaultHttpClient, type HttpClient } from '../client/types.js';
|