@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.
Files changed (132) hide show
  1. package/dist/{catalog-BY_t9prv.d.cts → catalog-DJcfm5N2.d.cts} +20 -7
  2. package/dist/{catalog-BY_t9prv.d.ts → catalog-DJcfm5N2.d.ts} +20 -7
  3. package/dist/{chunk-JN6HZGLV.js → chunk-F7NRM6KI.js} +54 -28
  4. package/dist/{chunk-7IFKVGZV.js → chunk-LY52T4N4.js} +21 -2
  5. package/dist/{chunk-OTP3OSSW.js → chunk-XMMFN2WJ.js} +74 -3
  6. package/dist/client/index.cjs +132 -29
  7. package/dist/client/index.d.cts +53 -3
  8. package/dist/client/index.d.ts +53 -3
  9. package/dist/client/index.js +14 -2
  10. package/dist/index.cjs +319 -162
  11. package/dist/index.d.cts +30 -8
  12. package/dist/index.d.ts +30 -8
  13. package/dist/index.js +176 -133
  14. package/dist/quasar/index.cjs +76 -29
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +6 -2
  18. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  19. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  20. package/dist/{use-super-admin-i18n-c_Eqsu9u.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
  21. package/dist/{use-super-admin-i18n-DlJRGIVq.d.cts → use-super-admin-i18n-CgTuNi8h.d.cts} +34 -7
  22. package/package.json +18 -8
  23. package/src/client/i18n/messages/bundles.ts +0 -2
  24. package/src/client/i18n/messages/common.ts +22 -0
  25. package/src/client/i18n/messages/discovery.ts +2 -2
  26. package/src/client/i18n/messages/marketing.ts +2 -4
  27. package/src/client/i18n/messages/plan-detail.ts +0 -4
  28. package/src/client/i18n/messages/plans.ts +20 -8
  29. package/src/client/i18n/messages/tenants.ts +0 -2
  30. package/src/client/index.ts +1 -0
  31. package/src/client/manifest-loader.ts +1 -1
  32. package/src/client/resolve-plans.ts +144 -0
  33. package/src/components/BundleVersionPublishDialog.vue +6 -6
  34. package/src/components/KvBlock.vue +4 -4
  35. package/src/components/MarketingPromotionsTab.vue +34 -48
  36. package/src/components/MfaPromptDialog.vue +8 -2
  37. package/src/components/TenantActionConfirmDialog.vue +4 -4
  38. package/src/components/VersionDiffPreview.vue +7 -7
  39. package/src/components/admin-page/AdminFilters.vue +12 -0
  40. package/src/components/admin-page/AdminHero.vue +22 -5
  41. package/src/components/admin-page/AdminKpi.vue +84 -0
  42. package/src/components/admin-page/AdminPaginator.vue +195 -0
  43. package/src/components/admin-page/AdminRefreshBtn.vue +39 -0
  44. package/src/components/admin-page/AdminSection.vue +31 -16
  45. package/src/components/admin-page/AdminStatistics.vue +40 -0
  46. package/src/components/admin-page/AdminTable.vue +175 -0
  47. package/src/components/bundle-editor/BundleCreatePanel.vue +23 -23
  48. package/src/components/bundle-editor/BundleFeaturesEditor.vue +7 -7
  49. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +9 -9
  50. package/src/components/bundle-editor/BundleQuotasEditor.vue +11 -11
  51. package/src/components/bundle-editor/BundleStatusBanner.vue +6 -6
  52. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +18 -18
  53. package/src/components/bundle-editor/BundleVersionStrip.vue +12 -12
  54. package/src/components/dialogs/PilotCreateDialog.vue +12 -12
  55. package/src/components/dialogs/PilotEditDialog.vue +7 -7
  56. package/src/components/dialogs/PromoCodeCreateDialog.vue +8 -8
  57. package/src/components/dialogs/PromoCodeDialogFields.vue +33 -19
  58. package/src/components/dialogs/PromoCodeEditDialog.vue +10 -10
  59. package/src/components/plan/PlanGrid.vue +1 -1
  60. package/src/components/plan-create-dialog/PlanCreateDialog.vue +34 -46
  61. package/src/components/plan-detail/PlanDetail.vue +92 -302
  62. package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
  63. package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
  64. package/src/components/plan-list/PlanList.vue +72 -290
  65. package/src/components/plan-matrix/PlanMatrix.vue +66 -183
  66. package/src/components/plan-review/PlanReview.vue +191 -275
  67. package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -5
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +200 -295
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +23 -55
  70. package/src/index.ts +1 -0
  71. package/src/pages-standard/AdminLayout.vue +34 -10
  72. package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
  73. package/src/pages-standard/AuditPage.vue +53 -72
  74. package/src/pages-standard/BundlesPage.vue +128 -144
  75. package/src/pages-standard/DashboardPage.vue +43 -107
  76. package/src/pages-standard/DiscoveryPage.vue +158 -200
  77. package/src/pages-standard/EmailHistoryPage.vue +98 -88
  78. package/src/pages-standard/MarketingCatalogPage.vue +237 -267
  79. package/src/pages-standard/PilotsPage.vue +44 -60
  80. package/src/pages-standard/PlansPage.vue +230 -105
  81. package/src/pages-standard/PlatformEmailPage.vue +37 -40
  82. package/src/pages-standard/PromoCodeDetailPage.vue +16 -30
  83. package/src/pages-standard/PromoCodesPage.vue +86 -112
  84. package/src/pages-standard/SubscriptionsPage.vue +10 -17
  85. package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
  86. package/src/pages-standard/SuperAdminSetupWizard.vue +20 -17
  87. package/src/pages-standard/TenantDetailPage.vue +45 -41
  88. package/src/pages-standard/TenantsPage.vue +184 -264
  89. package/src/pages-standard/UsersPage.vue +69 -104
  90. package/src/pages-standard/bundles-page/BundleAccordionList.vue +20 -17
  91. package/src/pages-standard/bundles-page/BundlesKpis.vue +13 -22
  92. package/src/pages-standard/bundles-page/BundlesToolbar.vue +6 -10
  93. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +10 -10
  94. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +5 -5
  95. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +16 -16
  96. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +31 -27
  97. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +7 -7
  98. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +1 -1
  99. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +2 -13
  100. package/src/pages-standard/plan-versions/PlanDiffCard.vue +6 -6
  101. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +6 -6
  102. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +16 -16
  103. package/src/pages-standard/plans-page/PlanBundleOverview.vue +14 -29
  104. package/src/pages-standard/plans-page/PlanPublishDialog.vue +1 -1
  105. package/src/pages-standard/plans-page/PlansPageToast.vue +1 -1
  106. package/src/pages-standard/sa-theme.css +481 -71
  107. package/src/pages-standard/tenant-detail/TenantUsers.vue +2 -8
  108. package/src/pages-standard/tenants/StatusPill.vue +2 -2
  109. package/src/pages-tenant/MySubscriptionBundlesPage.vue +25 -25
  110. package/src/pages-tenant/OnboardingConfigurator.vue +1 -5
  111. package/src/pages-tenant/PackageSnapshotPanel.vue +1 -1
  112. package/src/pages-tenant/TenantPlanSection.vue +12 -20
  113. package/src/quasar/create-super-admin-app.ts +4 -0
  114. package/src/vue/entitlement-provider.ts +0 -1
  115. package/src/vue/feature-router-guard.ts +0 -1
  116. package/src/vue/super-admin-context.ts +106 -1
  117. package/src/vue/use-api-list.ts +2 -1
  118. package/src/vue/use-pagination.ts +63 -0
  119. package/src/vue/use-plans.ts +1 -2
  120. package/src/vue/use-sign-out.ts +90 -0
  121. package/src/vue/use-tenant-billing.ts +1 -1
  122. package/src/vue/use-tenant-manifest.ts +0 -1
  123. package/src/components/KpiCard.vue +0 -53
  124. package/src/components/plan-cockpit/PlanCockpit.vue +0 -1027
  125. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
  126. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -99
  127. package/src/components/plan-cockpit/PlanCockpitHeader.vue +0 -101
  128. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +0 -112
  129. package/src/components/plan-cockpit/PlanCockpitKpis.vue +0 -68
  130. package/src/components/plan-cockpit/PlanCockpitVersions.vue +0 -249
  131. package/src/components/plan-cockpit/types.ts +0 -72
  132. 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: #0f172a;
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: #64748b;
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: #64748b;
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: #475569;
435
+ color: var(--sa-muted-dark);
436
436
  }
437
437
  .msb-empty-link {
438
438
  background: transparent;
439
439
  border: 0;
440
- color: #2563eb;
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 #e2e8f0;
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: #fffbeb;
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: #64748b;
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: #e2e8f0;
513
- color: #475569;
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: #fffbeb;
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: #0f172a;
536
+ color: var(--sa-heading);
537
537
  }
538
538
  .msb-btn:hover:not(:disabled) {
539
- background: #f8fafc;
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: #2563eb;
547
- border-color: #2563eb;
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: #1d4ed8;
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 #e2e8f0;
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: #64748b;
593
+ color: var(--sa-muted);
594
594
  padding: 4px;
595
595
  border-radius: 4px;
596
596
  }
597
597
  .msb-modal-x:hover {
598
- background: #f1f5f9;
599
- color: #0f172a;
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 #e2e8f0;
610
- background: #f8fafc;
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: #475569;
624
+ color: var(--sa-muted-dark);
625
625
  }
626
626
  .msb-field-hint {
627
627
  font-size: 11px;
628
- color: #94a3b8;
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: #0f172a;
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 (METAMODELL §17a):
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="quotaLabel"
68
- :is-fractional-quota="isFractionalQuota"
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="formatQuotaLabel"
182
+ :format-quota-label="formatQuotaLabelResolved"
183
183
  :format-quota-value="formatQuotaValue"
184
- :quota-label="quotaLabel"
184
+ :quota-label="quotaLabelResolved"
185
185
  :feature-label="featureLabelResolved"
186
- :is-fractional-quota="isFractionalQuota"
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 quotaLabel(key: string): string {
481
+ function quotaLabelResolved(key: string): string {
482
482
  return props.quotaLabel?.(key) ?? key;
483
483
  }
484
484
 
485
- function featureLabel(key: string): string {
485
+ function featureLabelFromProps(key: string): string {
486
486
  return props.featureLabel?.(key) ?? key;
487
487
  }
488
488
 
489
- function isFractionalQuota(key: string): boolean {
489
+ function isFractionalQuotaResolved(key: string): boolean {
490
490
  return props.isFractionalQuota?.(key) ?? key.toLowerCase().includes('storage');
491
491
  }
492
492
 
493
- function formatDate(input: string | Date): string {
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 `${quotaLabel(key)}: ∞`;
504
- return `${value} ${quotaLabel(key)}`;
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 ?? featureLabel(key);
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) return 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
  }
@@ -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.value = Math.max(1, Math.floor(size));
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
+ }
@@ -1,5 +1,5 @@
1
1
  // usePlans + usePlanVersions — Vue 3 composables for the SuperAdmin
2
- // plan management. SPEC_V2 §11.1 M6:
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 (METAMODELL §17a): Read-only package snapshot from the
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';