@saasicat/ui-vue 0.19.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/{catalog-JXdKdIqG.d.cts → catalog-DJcfm5N2.d.cts} +21 -7
  2. package/dist/{catalog-JXdKdIqG.d.ts → catalog-DJcfm5N2.d.ts} +21 -7
  3. package/dist/{chunk-DPTIOQXX.js → chunk-F7NRM6KI.js} +56 -28
  4. package/dist/{chunk-RVG6LQHL.js → chunk-LY52T4N4.js} +21 -2
  5. package/dist/{chunk-ORX3ZG47.js → chunk-XMMFN2WJ.js} +74 -3
  6. package/dist/client/index.cjs +134 -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 +321 -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 +78 -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-BlqcD0N7.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
  21. package/dist/{use-super-admin-i18n-1nCOkvgi.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 +24 -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/AdminBody.vue +40 -0
  40. package/src/components/admin-page/AdminFilters.vue +12 -0
  41. package/src/components/admin-page/AdminHero.vue +49 -0
  42. package/src/components/admin-page/AdminKpi.vue +84 -0
  43. package/src/components/admin-page/AdminPage.vue +16 -0
  44. package/src/components/admin-page/AdminPaginator.vue +195 -0
  45. package/src/components/admin-page/AdminRefreshBtn.vue +39 -0
  46. package/src/components/admin-page/AdminSection.vue +53 -0
  47. package/src/components/admin-page/AdminStatistics.vue +40 -0
  48. package/src/components/admin-page/AdminTable.vue +175 -0
  49. package/src/components/bundle-editor/BundleCreatePanel.vue +23 -23
  50. package/src/components/bundle-editor/BundleFeaturesEditor.vue +7 -7
  51. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +9 -9
  52. package/src/components/bundle-editor/BundleQuotasEditor.vue +11 -11
  53. package/src/components/bundle-editor/BundleStatusBanner.vue +6 -6
  54. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +18 -18
  55. package/src/components/bundle-editor/BundleVersionStrip.vue +12 -12
  56. package/src/components/dialogs/PilotCreateDialog.vue +12 -12
  57. package/src/components/dialogs/PilotEditDialog.vue +7 -7
  58. package/src/components/dialogs/PromoCodeCreateDialog.vue +8 -8
  59. package/src/components/dialogs/PromoCodeDialogFields.vue +33 -19
  60. package/src/components/dialogs/PromoCodeEditDialog.vue +10 -10
  61. package/src/components/plan/PlanGrid.vue +1 -1
  62. package/src/components/plan-create-dialog/PlanCreateDialog.vue +34 -46
  63. package/src/components/plan-detail/PlanAuditLog.vue +7 -8
  64. package/src/components/plan-detail/PlanDetail.vue +95 -292
  65. package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
  66. package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
  67. package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
  68. package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
  69. package/src/components/plan-list/PlanList.vue +206 -457
  70. package/src/components/plan-matrix/PlanMatrix.vue +66 -183
  71. package/src/components/plan-review/PlanReview.vue +191 -275
  72. package/src/components/plan-version-editor/PlanCatalogPreview.vue +7 -5
  73. package/src/components/plan-version-editor/PlanVersionEditor.vue +200 -295
  74. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +23 -55
  75. package/src/index.ts +1 -0
  76. package/src/pages-standard/AdminLayout.vue +38 -12
  77. package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
  78. package/src/pages-standard/AuditPage.vue +65 -109
  79. package/src/pages-standard/BundlesPage.vue +146 -179
  80. package/src/pages-standard/DashboardPage.vue +59 -154
  81. package/src/pages-standard/DiscoveryPage.vue +172 -227
  82. package/src/pages-standard/EmailHistoryPage.vue +118 -159
  83. package/src/pages-standard/MarketingCatalogPage.vue +399 -440
  84. package/src/pages-standard/PilotsPage.vue +69 -117
  85. package/src/pages-standard/PlansPage.vue +240 -112
  86. package/src/pages-standard/PlatformEmailPage.vue +56 -91
  87. package/src/pages-standard/PromoCodeDetailPage.vue +49 -74
  88. package/src/pages-standard/PromoCodesPage.vue +95 -128
  89. package/src/pages-standard/SubscriptionsPage.vue +19 -52
  90. package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
  91. package/src/pages-standard/SuperAdminSetupWizard.vue +22 -18
  92. package/src/pages-standard/TenantDetailPage.vue +100 -191
  93. package/src/pages-standard/TenantsPage.vue +190 -295
  94. package/src/pages-standard/UsersPage.vue +89 -157
  95. package/src/pages-standard/bundles-page/BundleAccordionList.vue +20 -17
  96. package/src/pages-standard/bundles-page/BundlesKpis.vue +13 -22
  97. package/src/pages-standard/bundles-page/BundlesToolbar.vue +63 -0
  98. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +10 -10
  99. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +5 -5
  100. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +16 -16
  101. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +31 -27
  102. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +7 -7
  103. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
  104. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +43 -62
  105. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
  106. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
  107. package/src/pages-standard/plan-versions/PlanDiffCard.vue +6 -6
  108. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +6 -6
  109. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +16 -16
  110. package/src/pages-standard/plans-page/PlanBundleOverview.vue +14 -29
  111. package/src/pages-standard/plans-page/PlanPublishDialog.vue +1 -1
  112. package/src/pages-standard/plans-page/PlansPageToast.vue +6 -6
  113. package/src/pages-standard/sa-theme.css +552 -86
  114. package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
  115. package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
  116. package/src/pages-standard/tenant-detail/TenantUsers.vue +15 -0
  117. package/src/pages-standard/tenant-detail/types.ts +38 -0
  118. package/src/pages-standard/tenants/StatusPill.vue +2 -2
  119. package/src/pages-tenant/MySubscriptionBundlesPage.vue +25 -25
  120. package/src/pages-tenant/OnboardingConfigurator.vue +1 -5
  121. package/src/pages-tenant/PackageSnapshotPanel.vue +1 -1
  122. package/src/pages-tenant/TenantPlanSection.vue +12 -20
  123. package/src/quasar/create-super-admin-app.ts +4 -0
  124. package/src/vue/entitlement-provider.ts +0 -1
  125. package/src/vue/feature-router-guard.ts +0 -1
  126. package/src/vue/super-admin-context.ts +106 -1
  127. package/src/vue/use-api-list.ts +2 -1
  128. package/src/vue/use-mfa-prompt.ts +72 -0
  129. package/src/vue/use-pagination.ts +63 -0
  130. package/src/vue/use-plans.ts +1 -2
  131. package/src/vue/use-sign-out.ts +90 -0
  132. package/src/vue/use-tenant-billing.ts +1 -1
  133. package/src/vue/use-tenant-manifest.ts +0 -1
  134. package/src/components/KpiCard.vue +0 -53
  135. package/src/components/plan-cockpit/PlanCockpit.vue +0 -1012
  136. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
  137. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -100
  138. package/src/components/plan-cockpit/PlanCockpitHeader.vue +0 -101
  139. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +0 -112
  140. package/src/components/plan-cockpit/PlanCockpitKpis.vue +0 -68
  141. package/src/components/plan-cockpit/PlanCockpitVersions.vue +0 -251
  142. package/src/components/plan-cockpit/types.ts +0 -72
  143. package/src/components/plan-detail/PlanDetailHeader.vue +0 -194
  144. package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
  145. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <div class="sa-kv-grid">
3
+ <KvBlock :label="labels.plan" :value="data.subscription?.plan ?? EMPTY" />
4
+ <KvBlock :label="labels.status" :value="data.subscription?.status ?? EMPTY" />
5
+ <KvBlock :label="labels.pilot" :value="data.subscription?.isPilot ? yes : no" />
6
+ <KvBlock :label="labels.trialEnd" :value="formatDate(data.subscription?.trialEndsAt)" />
7
+ <KvBlock :label="labels.pilotEnd" :value="formatDate(data.subscription?.pilotEndsAt)" />
8
+ <KvBlock v-if="data.vatId" :label="labels.vatId" :value="data.vatId" />
9
+ <slot />
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import KvBlock from '../../components/KvBlock.vue';
15
+ import type { TenantDetailData, TenantMasterDataLabels } from './types.js';
16
+
17
+ // The subscription facts of a tenant. Pure presentation: the page resolves the
18
+ // labels and the date format, so this block has no i18n and no data access.
19
+ defineProps<{
20
+ data: TenantDetailData;
21
+ labels: TenantMasterDataLabels;
22
+ formatDate: (value: string | null | undefined) => string;
23
+ yes: string;
24
+ no: string;
25
+ }>();
26
+
27
+ const EMPTY = '—';
28
+ </script>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <div class="sa-kv-grid">
3
+ <KvBlock
4
+ v-for="(field, i) in fields"
5
+ :key="field.label + i"
6
+ :label="field.label"
7
+ :value="resolve(field)"
8
+ />
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ import KvBlock from '../../components/KvBlock.vue';
14
+ import type { TenantDetailData, VerbrauchField } from './types.js';
15
+
16
+ // Usage counters. A field either names a key in `data.counts` or brings its own
17
+ // getter, so a consumer can surface a number the platform knows nothing about.
18
+ const props = defineProps<{
19
+ data: TenantDetailData;
20
+ fields: readonly VerbrauchField[];
21
+ }>();
22
+
23
+ const EMPTY = '—';
24
+
25
+ function resolve(field: VerbrauchField): string {
26
+ if (field.getter) return String(field.getter(props.data));
27
+ if (!field.key) return EMPTY;
28
+ const value = props.data.counts?.[field.key];
29
+ return value === undefined || value === null ? '0' : String(value);
30
+ }
31
+ </script>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <AdminTable :rows="users" :columns="columns" storage-key="tenant-users" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import AdminTable from '../../components/admin-page/AdminTable.vue';
7
+ import type { QTableColumn } from 'quasar';
8
+
9
+ // The tenant's users. The column set comes from the page, which falls back to
10
+ // the platform default when the consumer supplies none.
11
+ defineProps<{
12
+ users: Array<Record<string, unknown> & { id: string }>;
13
+ columns: QTableColumn[];
14
+ }>();
15
+ </script>
@@ -0,0 +1,38 @@
1
+ export interface TenantDetailData {
2
+ id: string;
3
+ slug: string;
4
+ name: string;
5
+ isActive: boolean;
6
+ vatId?: string | null;
7
+ subscription?: {
8
+ plan?: string | null;
9
+ status?: string | null;
10
+ isPilot?: boolean | null;
11
+ trialEndsAt?: string | null;
12
+ pilotEndsAt?: string | null;
13
+ } | null;
14
+ users?: Array<Record<string, unknown> & { id: string }>;
15
+ /** Freely selectable usage numbers — the page renders them via `verbrauchFields`. */
16
+ counts?: Record<string, number | string>;
17
+ }
18
+
19
+ export interface VerbrauchField {
20
+ label: string;
21
+ /** Lookup key in `data.counts`. */
22
+ key?: string;
23
+ /** Alternative: custom getter. Takes precedence over `key`. */
24
+ getter?: (data: TenantDetailData) => string | number;
25
+ }
26
+
27
+ /**
28
+ * The resolved labels for the master-data block. The page owns the i18n
29
+ * lookup and the per-label prop overrides; the block only renders them.
30
+ */
31
+ export interface TenantMasterDataLabels {
32
+ plan: string;
33
+ status: string;
34
+ pilot: string;
35
+ trialEnd: string;
36
+ pilotEnd: string;
37
+ vatId: string;
38
+ }
@@ -48,8 +48,8 @@ defineProps<{
48
48
  color: var(--sa-negative, #dc2626);
49
49
  }
50
50
  .sa-pill--muted {
51
- background: #f1f5f9;
52
- color: var(--sa-muted, #64748b);
51
+ background: var(--sa-border-soft);
52
+ color: var(--sa-muted, var(--sa-muted));
53
53
  }
54
54
  .sa-pill--primary {
55
55
  background: var(--sa-primary-soft, rgba(63, 107, 255, 0.08));
@@ -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,72 @@
1
+ import { ref, type Ref } from 'vue';
2
+
3
+ /**
4
+ * Turns the MFA dialog into an awaitable step.
5
+ *
6
+ * A write flow that needs a second factor wants to read as one function:
7
+ * ask for the code, then call the API with it. What that needs is a promise
8
+ * the dialog resolves — and the plumbing for that (a visibility flag, an
9
+ * error slot, a description, and a resolver held between two event handlers)
10
+ * was spelled out identically on five standard pages.
11
+ *
12
+ * Cancelling resolves to `null` rather than rejecting: a user closing the
13
+ * dialog is an outcome, not an error, and `if (!code) return` reads better at
14
+ * the call site than a try/catch around the happy path.
15
+ */
16
+ export interface MfaPrompt {
17
+ /** Bind to the dialog's `model-value`. */
18
+ readonly show: Ref<boolean>;
19
+ /** Bind to the dialog's `description`. */
20
+ readonly description: Ref<string>;
21
+ /** Bind to the dialog's `error`; set it to keep the dialog open on a failed code. */
22
+ readonly error: Ref<string>;
23
+ /** Resolves with the entered code, or `null` when the user cancels. */
24
+ prompt: (description: string) => Promise<string | null>;
25
+ /** Bind to the dialog's `confirm` event. */
26
+ onConfirm: (code: string) => void;
27
+ /** Bind to the dialog's `update:model-value` event. */
28
+ onVisibility: (open: boolean) => void;
29
+ /** Closes the dialog and cancels a pending prompt. */
30
+ close: () => void;
31
+ }
32
+
33
+ export function useMfaPrompt(): MfaPrompt {
34
+ const show = ref(false);
35
+ const description = ref('');
36
+ const error = ref('');
37
+ let pendingResolve: ((code: string | null) => void) | null = null;
38
+
39
+ function settle(code: string | null): void {
40
+ const resolve = pendingResolve;
41
+ pendingResolve = null;
42
+ resolve?.(code);
43
+ }
44
+
45
+ return {
46
+ show,
47
+ description,
48
+ error,
49
+ prompt(next: string): Promise<string | null> {
50
+ // A prompt opening while another is pending would strand the first
51
+ // caller's promise forever; cancel it rather than leak it.
52
+ settle(null);
53
+ description.value = next;
54
+ error.value = '';
55
+ show.value = true;
56
+ return new Promise((resolve) => {
57
+ pendingResolve = resolve;
58
+ });
59
+ },
60
+ onConfirm(code: string): void {
61
+ settle(code);
62
+ },
63
+ onVisibility(open: boolean): void {
64
+ show.value = open;
65
+ if (!open) settle(null);
66
+ },
67
+ close(): void {
68
+ show.value = false;
69
+ settle(null);
70
+ },
71
+ };
72
+ }