@saasicat/ui-vue 0.19.0 → 0.21.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 (66) hide show
  1. package/dist/{catalog-JXdKdIqG.d.cts → catalog-BY_t9prv.d.cts} +1 -0
  2. package/dist/{catalog-JXdKdIqG.d.ts → catalog-BY_t9prv.d.ts} +1 -0
  3. package/dist/{chunk-RVG6LQHL.js → chunk-7IFKVGZV.js} +1 -1
  4. package/dist/{chunk-DPTIOQXX.js → chunk-JN6HZGLV.js} +2 -0
  5. package/dist/{chunk-ORX3ZG47.js → chunk-OTP3OSSW.js} +1 -1
  6. package/dist/client/index.cjs +2 -0
  7. package/dist/client/index.d.cts +2 -2
  8. package/dist/client/index.d.ts +2 -2
  9. package/dist/client/index.js +2 -2
  10. package/dist/index.cjs +2 -0
  11. package/dist/index.d.cts +4 -4
  12. package/dist/index.d.ts +4 -4
  13. package/dist/index.js +3 -3
  14. package/dist/quasar/index.cjs +2 -0
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +2 -2
  18. package/dist/{use-super-admin-i18n-1nCOkvgi.d.cts → use-super-admin-i18n-DlJRGIVq.d.cts} +1 -1
  19. package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-c_Eqsu9u.d.ts} +1 -1
  20. package/package.json +2 -2
  21. package/src/client/i18n/messages/common.ts +2 -0
  22. package/src/components/admin-page/AdminBody.vue +40 -0
  23. package/src/components/admin-page/AdminHero.vue +32 -0
  24. package/src/components/admin-page/AdminPage.vue +16 -0
  25. package/src/components/admin-page/AdminSection.vue +38 -0
  26. package/src/components/plan-cockpit/PlanCockpit.vue +15 -0
  27. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +8 -8
  28. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +10 -11
  29. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +8 -8
  30. package/src/components/plan-cockpit/PlanCockpitVersions.vue +9 -11
  31. package/src/components/plan-detail/PlanAuditLog.vue +7 -8
  32. package/src/components/plan-detail/PlanDetail.vue +14 -1
  33. package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
  34. package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
  35. package/src/components/plan-list/PlanList.vue +224 -257
  36. package/src/pages-standard/AdminLayout.vue +4 -2
  37. package/src/pages-standard/AuditPage.vue +18 -43
  38. package/src/pages-standard/BundlesPage.vue +17 -34
  39. package/src/pages-standard/DashboardPage.vue +20 -51
  40. package/src/pages-standard/DiscoveryPage.vue +38 -51
  41. package/src/pages-standard/EmailHistoryPage.vue +20 -71
  42. package/src/pages-standard/MarketingCatalogPage.vue +304 -315
  43. package/src/pages-standard/PilotsPage.vue +24 -56
  44. package/src/pages-standard/PlansPage.vue +25 -22
  45. package/src/pages-standard/PlatformEmailPage.vue +19 -51
  46. package/src/pages-standard/PromoCodeDetailPage.vue +39 -50
  47. package/src/pages-standard/PromoCodesPage.vue +8 -15
  48. package/src/pages-standard/SubscriptionsPage.vue +9 -35
  49. package/src/pages-standard/SuperAdminSetupWizard.vue +2 -1
  50. package/src/pages-standard/TenantDetailPage.vue +79 -174
  51. package/src/pages-standard/TenantsPage.vue +8 -33
  52. package/src/pages-standard/UsersPage.vue +21 -54
  53. package/src/pages-standard/bundles-page/BundlesToolbar.vue +67 -0
  54. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
  55. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +54 -62
  56. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
  57. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
  58. package/src/pages-standard/plans-page/PlansPageToast.vue +5 -5
  59. package/src/pages-standard/sa-theme.css +85 -29
  60. package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
  61. package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
  62. package/src/pages-standard/tenant-detail/TenantUsers.vue +21 -0
  63. package/src/pages-standard/tenant-detail/types.ts +38 -0
  64. package/src/vue/use-mfa-prompt.ts +72 -0
  65. package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
  66. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
@@ -1,75 +1,90 @@
1
1
  <template>
2
- <div class="mc-window">
3
- <div class="mc-chrome">
4
- <span class="mc-chrome-dot" style="background: #ef4444" />
5
- <span class="mc-chrome-dot" style="background: #f59e0b" />
6
- <span class="mc-chrome-dot" style="background: #10b981" />
7
- <div class="mc-chrome-url">{{ previewUrl }}</div>
2
+ <div class="sa-marketing-window">
3
+ <div class="sa-marketing-chrome">
4
+ <span class="sa-marketing-chrome-dot" style="background: #ef4444" />
5
+ <span class="sa-marketing-chrome-dot" style="background: #f59e0b" />
6
+ <span class="sa-marketing-chrome-dot" style="background: #10b981" />
7
+ <div class="sa-marketing-chrome-url">{{ previewUrl }}</div>
8
8
  </div>
9
- <div class="mc-canvas">
10
- <div class="mc-eyebrow">{{ msg.preview.eyebrow }}</div>
11
- <h1 class="mc-hero">{{ msg.preview.hero }}</h1>
12
- <p class="mc-sub">{{ msg.preview.sub }}</p>
9
+ <div class="sa-marketing-canvas">
10
+ <div class="sa-marketing-eyebrow">{{ msg.preview.eyebrow }}</div>
11
+ <!-- Not a heading element: this is a picture of the public landing
12
+ page inside a browser mock-up, and assistive technology cannot
13
+ tell a mock-up from the document it is embedded in — a real
14
+ <h1> here would be a second page heading. -->
15
+ <div class="sa-marketing-hero">{{ msg.preview.hero }}</div>
16
+ <p class="sa-marketing-sub">{{ msg.preview.sub }}</p>
13
17
 
14
- <div v-if="visibleRows.length === 0" class="mc-banner mc-banner--info">
18
+ <div
19
+ v-if="visibleRows.length === 0"
20
+ class="sa-marketing-banner sa-marketing-banner--info"
21
+ >
15
22
  {{ msg.preview.emptyBefore }} <strong>{{ msg.tabs.admin }}</strong
16
23
  >{{ msg.preview.emptyAfter }}
17
24
  </div>
18
25
 
19
- <div v-else class="mc-grid">
26
+ <div v-else class="sa-marketing-grid">
20
27
  <div
21
28
  v-for="row in visibleRows"
22
29
  :key="row.plan.id"
23
- class="mc-card"
30
+ class="sa-marketing-card"
24
31
  :class="{ featured: row.m.highlight, 'has-promo': !!promoOf(row) }"
25
32
  >
26
33
  <div
27
34
  v-if="promoOf(row)"
28
- class="mc-promo-ribbon"
35
+ class="sa-marketing-promo-ribbon"
29
36
  :style="{ background: promoColorOf(row) }"
30
37
  >
31
38
  {{ promoBadgeOf(row) }}
32
39
  </div>
33
- <span v-else-if="row.m.badge" class="mc-card-badge">{{ row.m.badge }}</span>
34
- <div class="mc-card-key">{{ row.plan.planKey }}</div>
35
- <div class="mc-card-name">
40
+ <span v-else-if="row.m.badge" class="sa-marketing-card-badge">{{
41
+ row.m.badge
42
+ }}</span>
43
+ <div class="sa-marketing-card-key">{{ row.plan.planKey }}</div>
44
+ <div class="sa-marketing-card-name">
36
45
  {{ row.m.displayLabel || row.plan.label }}
37
46
  </div>
38
- <div class="mc-card-desc">
47
+ <div class="sa-marketing-card-desc">
39
48
  {{ row.m.description || row.plan.description || '—' }}
40
49
  </div>
41
50
 
42
- <div class="mc-card-price">
51
+ <div class="sa-marketing-card-price">
43
52
  <template v-if="row.m.priceTag">
44
- <span class="mc-card-price-big" style="font-size: 22px">
53
+ <span class="sa-marketing-card-price-big" style="font-size: 22px">
45
54
  {{ row.m.priceTag }}
46
55
  </span>
47
56
  </template>
48
57
  <template v-else-if="!row.liveVersion">
49
- <span class="mc-card-price-big" style="font-size: 22px">
58
+ <span class="sa-marketing-card-price-big" style="font-size: 22px">
50
59
  {{ msg.preview.priceOnRequest }}
51
60
  </span>
52
61
  </template>
53
62
  <template v-else-if="promoResultOf(row)">
54
- <span class="mc-card-price-big">
63
+ <span class="sa-marketing-card-price-big">
55
64
  {{ formatEuro(promoResultOf(row)?.discounted ?? 0) }}
56
65
  </span>
57
- <span class="mc-card-price-unit">{{ msg.preview.perMonth }}</span>
66
+ <span class="sa-marketing-card-price-unit">{{
67
+ msg.preview.perMonth
68
+ }}</span>
58
69
  </template>
59
70
  <template v-else>
60
- <span class="mc-card-price-big">
71
+ <span class="sa-marketing-card-price-big">
61
72
  {{ formatEuro(monthlyOf(row)) }}
62
73
  </span>
63
- <span class="mc-card-price-unit">{{ msg.preview.perMonth }}</span>
74
+ <span class="sa-marketing-card-price-unit">{{
75
+ msg.preview.perMonth
76
+ }}</span>
64
77
  </template>
65
78
  </div>
66
- <div v-if="promoResultOf(row)" class="mc-card-price-strike">
79
+ <div v-if="promoResultOf(row)" class="sa-marketing-card-price-strike">
67
80
  <s>{{ formatEuro(promoResultOf(row)?.original ?? 0) }}</s>
68
- <span class="mc-price-regular">{{ msg.preview.regularPrice }}</span>
81
+ <span class="sa-marketing-price-regular">{{
82
+ msg.preview.regularPrice
83
+ }}</span>
69
84
  </div>
70
85
  <div
71
86
  v-else-if="row.liveVersion && yearlyOf(row) > 0 && !row.m.priceTag"
72
- class="mc-card-price-y"
87
+ class="sa-marketing-card-price-y"
73
88
  >
74
89
  {{
75
90
  formatMessage(msg.preview.orYearly, {
@@ -78,18 +93,21 @@
78
93
  }}
79
94
  </div>
80
95
 
81
- <button type="button" class="mc-card-cta">{{ ctaText(row) }}</button>
82
- <div v-if="showTrialNote(row) && !promoOf(row)" class="mc-card-trialnote">
96
+ <button type="button" class="sa-marketing-card-cta">{{ ctaText(row) }}</button>
97
+ <div
98
+ v-if="showTrialNote(row) && !promoOf(row)"
99
+ class="sa-marketing-card-trialnote"
100
+ >
83
101
  {{ formatMessage(msg.preview.trialNote, { days: row.m.trialDays }) }}
84
102
  </div>
85
- <div v-if="promoFineprintOf(row)" class="mc-card-fineprint">
103
+ <div v-if="promoFineprintOf(row)" class="sa-marketing-card-fineprint">
86
104
  {{ promoFineprintOf(row) }}
87
105
  </div>
88
106
 
89
- <div class="mc-card-includes">{{ msg.topFeatures }}</div>
90
- <ul v-if="row.m.topFeatures.length > 0" class="mc-card-features">
107
+ <div class="sa-marketing-card-includes">{{ msg.topFeatures }}</div>
108
+ <ul v-if="row.m.topFeatures.length > 0" class="sa-marketing-card-features">
91
109
  <li v-for="(f, i) in row.m.topFeatures" :key="i">
92
- <span class="mc-tick">
110
+ <span class="sa-marketing-tick">
93
111
  <svg
94
112
  width="14"
95
113
  height="14"
@@ -107,7 +125,7 @@
107
125
  </span>
108
126
  </li>
109
127
  </ul>
110
- <div v-else class="mc-card-features-empty">
128
+ <div v-else class="sa-marketing-card-features-empty">
111
129
  {{ msg.preview.noTopFeatures }}
112
130
  </div>
113
131
  </div>
@@ -1,9 +1,9 @@
1
1
  <template>
2
- <div class="mc-toolbar">
3
- <div class="mc-tabbar">
2
+ <div class="sa-marketing-toolbar">
3
+ <div class="sa-marketing-tabbar">
4
4
  <button
5
5
  type="button"
6
- class="mc-tab"
6
+ class="sa-marketing-tab"
7
7
  :class="{ active: tab === 'preview' }"
8
8
  @click="$emit('update:tab', 'preview')"
9
9
  >
@@ -11,7 +11,7 @@
11
11
  </button>
12
12
  <button
13
13
  type="button"
14
- class="mc-tab"
14
+ class="sa-marketing-tab"
15
15
  :class="{ active: tab === 'admin' }"
16
16
  @click="$emit('update:tab', 'admin')"
17
17
  >
@@ -19,17 +19,17 @@
19
19
  </button>
20
20
  <button
21
21
  type="button"
22
- class="mc-tab"
22
+ class="sa-marketing-tab"
23
23
  :class="{ active: tab === 'promos' }"
24
24
  @click="$emit('update:tab', 'promos')"
25
25
  >
26
26
  {{ msg.tabs.promos }}
27
- <span v-if="activePromoCount > 0" class="mc-tab-count">
27
+ <span v-if="activePromoCount > 0" class="sa-marketing-tab-count">
28
28
  {{ activePromoCount }}
29
29
  </span>
30
30
  </button>
31
31
  </div>
32
- <div class="mc-meta">
32
+ <div class="sa-marketing-meta">
33
33
  <span>catalogVersion</span>
34
34
  <code>{{ catalogVersion }}</code>
35
35
  <span>·</span>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <transition name="pl-toast">
2
+ <transition name="sa-plans-toast">
3
3
  <div v-if="message" class="sa-plans__toast">
4
4
  <span class="sa-plans__toast-tick" aria-hidden="true">
5
5
  <svg
@@ -55,14 +55,14 @@ defineProps<{
55
55
  place-items: center;
56
56
  flex: 0 0 auto;
57
57
  }
58
- .pl-toast-enter-active,
59
- .pl-toast-leave-active {
58
+ .sa-plans-toast-enter-active,
59
+ .sa-plans-toast-leave-active {
60
60
  transition:
61
61
  opacity 0.22s ease,
62
62
  transform 0.22s ease;
63
63
  }
64
- .pl-toast-enter-from,
65
- .pl-toast-leave-to {
64
+ .sa-plans-toast-enter-from,
65
+ .sa-plans-toast-leave-to {
66
66
  opacity: 0;
67
67
  transform: translate(-50%, 12px);
68
68
  }
@@ -37,18 +37,43 @@
37
37
  }
38
38
 
39
39
  /* ─────────────────────────────────────────────────────────────
40
- * Shared page chrome — header strip, stat pills, toolbar, card.
41
- * Pattern from the plan simulation; pages use it instead of creating their own
42
- * BEM variants, so the look stays consistent across the entire
43
- * SuperAdmin surface.
40
+ * Shared page chrome — page frame, header strip, section, stat pills, card.
41
+ *
42
+ * These classes are the styling half of the page shell. The structural half is
43
+ * the `AdminPage` / `AdminHero` / `AdminSection` components in
44
+ * `components/admin-page/`, and pages are expected to reach for those rather
45
+ * than write the markup by hand: a component with a required `title` prop
46
+ * cannot be half-adopted the way a class name can.
47
+ *
48
+ * The classes stay public and unprefixed by scoping on purpose, so a consumer
49
+ * app can restyle `.sa-page-head__title` from its own CSS without patching the
50
+ * platform.
44
51
  * ───────────────────────────────────────────────────────────── */
45
52
 
53
+ /* Page frame — canvas, min-height and the page padding, in one place
54
+ * instead of once per page (see AdminPage). */
55
+ .sa-page {
56
+ min-height: calc(100vh - 56px);
57
+ background: var(--sa-bg-app, #f1f5f9);
58
+ padding: 20px 28px 28px;
59
+ }
60
+
61
+ /* The hero reads as a banner rather than as the first paragraph of the page:
62
+ * a tinted, bordered block that separates "which page am I on" from the
63
+ * content below it. Tint and border come from the primary token, so an app
64
+ * that overrides `--sa-primary` re-tints the hero with it. */
46
65
  .sa-page-head {
66
+ /* Top-aligned, so the controls sit level with the title rather than
67
+ * drifting down with the subtitle's height. */
47
68
  display: flex;
48
- align-items: flex-end;
69
+ align-items: flex-start;
49
70
  gap: 20px;
50
- margin-bottom: 16px;
71
+ margin-bottom: 20px;
51
72
  flex-wrap: wrap;
73
+ padding: 13px 22px 15px;
74
+ border: 1px solid var(--sa-primary-border);
75
+ border-radius: 16px;
76
+ background: linear-gradient(135deg, var(--sa-primary-soft) 0%, var(--sa-primary-softer) 100%);
52
77
  }
53
78
  .sa-page-head__title {
54
79
  margin: 0;
@@ -56,11 +81,13 @@
56
81
  font-size: 22px;
57
82
  font-weight: 700;
58
83
  letter-spacing: -0.02em;
84
+ line-height: 1.25;
59
85
  color: var(--sa-heading);
60
86
  }
61
87
  .sa-page-head__sub {
62
- margin: 3px 0 0;
88
+ margin: 1px 0 0;
63
89
  font-size: 12.5px;
90
+ line-height: 1.35;
64
91
  color: var(--sa-muted);
65
92
  }
66
93
  .sa-page-head__actions {
@@ -175,35 +202,64 @@
175
202
  0 2px 6px rgba(168, 85, 247, 0.1);
176
203
  }
177
204
 
178
- /* Toolbar (search + filter buttons + sort). */
179
- .sa-toolbar {
205
+ /* Page body — everything below the hero (see AdminBody), including the
206
+ * loading and empty states the pages used to spell out one by one. */
207
+ .sa-page-body__state {
208
+ padding: 32px 0;
209
+ color: var(--sa-muted);
180
210
  display: flex;
181
211
  align-items: center;
182
- gap: 8px;
183
- margin-bottom: 14px;
184
- flex-wrap: wrap;
212
+ gap: 12px;
185
213
  }
186
- .sa-toolbar__search {
187
- flex: 1;
188
- min-width: 220px;
214
+ .sa-page-body__state--empty {
215
+ font-size: 13px;
216
+ }
217
+
218
+ /* Rhythm only for sections sitting directly in the page flow. Deliberately a
219
+ * child selector: sections placed inside a grid or flex container get their
220
+ * spacing from that container, and a blanket margin would fight it. */
221
+ .sa-page > .sa-section,
222
+ .sa-page-body > .sa-section {
223
+ margin-bottom: 16px;
224
+ }
225
+ .sa-page > .sa-section:last-child,
226
+ .sa-page-body > .sa-section:last-child {
227
+ margin-bottom: 0;
228
+ }
229
+ /* Section — a labelled block within a page (see AdminSection). Purely
230
+ * structural: it carries the heading scale and the rhythm between blocks, not
231
+ * a surface. A section that should read as a card adds `.sa-card` alongside. */
232
+ .sa-section__head {
189
233
  display: flex;
190
234
  align-items: center;
191
- gap: 9px;
192
- background: var(--sa-bg-surface);
193
- border: 1px solid var(--sa-border);
194
- border-radius: 10px;
195
- padding: 6px 12px;
235
+ gap: 10px;
236
+ margin-bottom: 12px;
196
237
  }
197
- .sa-toolbar__search input {
198
- flex: 1;
199
- border: 0;
200
- outline: 0;
201
- background: transparent;
202
- font: 13px var(--sa-font-body);
203
- color: var(--sa-body);
238
+ .sa-section__title {
239
+ margin: 0;
240
+ font-family: var(--sa-font-head);
241
+ font-size: 14px;
242
+ font-weight: 700;
243
+ letter-spacing: -0.005em;
244
+ color: var(--sa-heading);
204
245
  }
205
- .sa-toolbar__search input::placeholder {
206
- color: #94a3b8;
246
+ .sa-section__sub {
247
+ margin: 2px 0 0;
248
+ font-size: 12.5px;
249
+ color: var(--sa-muted);
250
+ }
251
+ .sa-section__actions {
252
+ margin-left: auto;
253
+ display: flex;
254
+ gap: 8px;
255
+ align-items: center;
256
+ }
257
+
258
+ /* Key-value grid — the layout `KvBlock` is meant to sit in. */
259
+ .sa-kv-grid {
260
+ display: grid;
261
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
262
+ gap: 12px;
207
263
  }
208
264
 
209
265
  /* Generic card (cards in dashboard/detail sections). */
@@ -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,21 @@
1
+ <template>
2
+ <q-table
3
+ flat
4
+ :rows="users"
5
+ :columns="columns"
6
+ row-key="id"
7
+ :pagination="{ rowsPerPage: 0 }"
8
+ hide-pagination
9
+ />
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ import type { QTableColumn } from 'quasar';
14
+
15
+ // The tenant's users. The column set comes from the page, which falls back to
16
+ // the platform default when the consumer supplies none.
17
+ defineProps<{
18
+ users: Array<Record<string, unknown> & { id: string }>;
19
+ columns: QTableColumn[];
20
+ }>();
21
+ </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
+ }
@@ -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
+ }
@@ -1,68 +0,0 @@
1
- <template>
2
- <header class="sa-bundles__head">
3
- <div>
4
- <h1 class="sa-bundles__title">{{ msg.header.title }}</h1>
5
- <p class="sa-bundles__sub">{{ msg.header.subtitle }}</p>
6
- </div>
7
- <div class="sa-bundles__head-actions">
8
- <q-select
9
- v-if="locales.length > 1"
10
- :model-value="displayLocale"
11
- :options="localeOptions"
12
- outlined
13
- dense
14
- emit-value
15
- map-options
16
- class="sa-bundles__locale"
17
- :label="msg.header.displayLocale"
18
- @update:model-value="(value) => emit('update:displayLocale', String(value))"
19
- >
20
- <template #prepend><q-icon name="translate" size="18px" /></template>
21
- </q-select>
22
- <q-btn
23
- unelevated
24
- color="primary"
25
- icon="add"
26
- :label="msg.header.newBundle"
27
- @click="emit('create')"
28
- />
29
- <q-btn flat icon="refresh" :loading="loading" @click="emit('refresh')">
30
- <q-tooltip>{{ msg.header.reload }}</q-tooltip>
31
- </q-btn>
32
- </div>
33
- </header>
34
- </template>
35
-
36
- <script setup lang="ts">
37
- import { computed } from 'vue';
38
-
39
- import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
40
-
41
- // The language selector controls which locale feature/quota labels are resolved
42
- // on the create form and in the detail view (fallback locale → DE → key). The default is the
43
- // default locale (DE); further options come from the active project locales.
44
-
45
- const props = defineProps<{
46
- loading: boolean;
47
- displayLocale: string;
48
- locales: string[];
49
- }>();
50
-
51
- const emit = defineEmits<{
52
- create: [];
53
- refresh: [];
54
- 'update:displayLocale': [locale: string];
55
- }>();
56
-
57
- const msg = useSaMessages('bundles');
58
-
59
- const localeOptions = computed(() =>
60
- props.locales.map((locale) => ({ label: locale.toUpperCase(), value: locale })),
61
- );
62
- </script>
63
-
64
- <style scoped>
65
- .sa-bundles__locale {
66
- min-width: 150px;
67
- }
68
- </style>