@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.
- package/dist/{catalog-JXdKdIqG.d.cts → catalog-DJcfm5N2.d.cts} +21 -7
- package/dist/{catalog-JXdKdIqG.d.ts → catalog-DJcfm5N2.d.ts} +21 -7
- package/dist/{chunk-DPTIOQXX.js → chunk-F7NRM6KI.js} +56 -28
- package/dist/{chunk-RVG6LQHL.js → chunk-LY52T4N4.js} +21 -2
- package/dist/{chunk-ORX3ZG47.js → chunk-XMMFN2WJ.js} +74 -3
- package/dist/client/index.cjs +134 -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 +321 -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 +78 -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-BlqcD0N7.d.ts → use-super-admin-i18n-CHNdsSlm.d.ts} +34 -7
- package/dist/{use-super-admin-i18n-1nCOkvgi.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 +24 -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/AdminBody.vue +40 -0
- package/src/components/admin-page/AdminFilters.vue +12 -0
- package/src/components/admin-page/AdminHero.vue +49 -0
- package/src/components/admin-page/AdminKpi.vue +84 -0
- package/src/components/admin-page/AdminPage.vue +16 -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 +53 -0
- 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/PlanAuditLog.vue +7 -8
- package/src/components/plan-detail/PlanDetail.vue +95 -292
- package/src/components/plan-detail/PlanDetailKpis.vue +22 -27
- package/src/components/plan-detail/PlanTitleEdit.vue +133 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
- package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
- package/src/components/plan-list/PlanList.vue +206 -457
- 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 +38 -12
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -9
- package/src/pages-standard/AuditPage.vue +65 -109
- package/src/pages-standard/BundlesPage.vue +146 -179
- package/src/pages-standard/DashboardPage.vue +59 -154
- package/src/pages-standard/DiscoveryPage.vue +172 -227
- package/src/pages-standard/EmailHistoryPage.vue +118 -159
- package/src/pages-standard/MarketingCatalogPage.vue +399 -440
- package/src/pages-standard/PilotsPage.vue +69 -117
- package/src/pages-standard/PlansPage.vue +240 -112
- package/src/pages-standard/PlatformEmailPage.vue +56 -91
- package/src/pages-standard/PromoCodeDetailPage.vue +49 -74
- package/src/pages-standard/PromoCodesPage.vue +95 -128
- package/src/pages-standard/SubscriptionsPage.vue +19 -52
- package/src/pages-standard/SuperAdminLoginPage.vue +12 -12
- package/src/pages-standard/SuperAdminSetupWizard.vue +22 -18
- package/src/pages-standard/TenantDetailPage.vue +100 -191
- package/src/pages-standard/TenantsPage.vue +190 -295
- package/src/pages-standard/UsersPage.vue +89 -157
- 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 +63 -0
- 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 +111 -73
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +43 -62
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
- 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 +6 -6
- package/src/pages-standard/sa-theme.css +552 -86
- package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
- package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
- package/src/pages-standard/tenant-detail/TenantUsers.vue +15 -0
- package/src/pages-standard/tenant-detail/types.ts +38 -0
- 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-mfa-prompt.ts +72 -0
- 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 -1012
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +0 -72
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +0 -100
- 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 -251
- package/src/components/plan-cockpit/types.ts +0 -72
- package/src/components/plan-detail/PlanDetailHeader.vue +0 -194
- package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
- 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:
|
|
52
|
-
color: var(--sa-muted,
|
|
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:
|
|
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,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
|
+
}
|