@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,7 +1,7 @@
1
1
  <template>
2
- <div class="sa-tenant-detail">
3
- <header class="sa-page-head">
4
- <div>
2
+ <AdminPage class="sa-tenant-detail">
3
+ <AdminHero :title="data?.name ?? labels.title">
4
+ <template #before-title>
5
5
  <q-btn
6
6
  flat
7
7
  dense
@@ -10,105 +10,78 @@
10
10
  :to="backRoute"
11
11
  class="sa-tenant-detail__back"
12
12
  />
13
- <h1 class="sa-page-head__title">{{ data?.name ?? labels.title }}</h1>
14
- <p v-if="data" class="sa-page-head__sub">
15
- {{ labels.slug }}: <code>{{ data.slug }}</code>
16
- </p>
17
- </div>
18
- <slot name="header-actions" :data="data" :reload="load" />
19
- </header>
20
-
21
- <div class="sa-tenant-detail__body">
22
- <div v-if="loading" class="sa-tenant-detail__state">
23
- <q-spinner size="32px" /> {{ common.loading }}
24
- </div>
25
-
26
- <template v-else-if="data">
13
+ </template>
14
+ <template v-if="data" #subtitle>
15
+ {{ labels.slug }}: <code>{{ data.slug }}</code>
16
+ </template>
17
+ <template #actions>
18
+ <slot name="header-actions" :data="data" :reload="load" />
19
+ </template>
20
+ </AdminHero>
21
+
22
+ <AdminBody :loading="loading" :empty="!data">
23
+ <template v-if="data">
27
24
  <!-- Master data -->
28
- <div class="sa-card q-mb-md">
29
- <header class="sa-tenant-detail__card-head">
30
- <div>
31
- <h2 class="sa-card__title">{{ labels.masterData }}</h2>
32
- <p v-if="stammdatenSub" class="sa-tenant-detail__card-sub">
33
- {{ stammdatenSub }}
34
- </p>
35
- </div>
36
- <div class="sa-tenant-detail__card-actions">
37
- <!-- Manifest-driven default actions (Suspend/Reactivate) -->
38
- <q-btn
39
- v-for="action in manifestActions"
40
- :key="action.def.id"
41
- outline
42
- :color="toneColor(action.def.actionKey)"
43
- :icon="iconForActionKey(action.def.actionKey)"
44
- :label="action.def.label"
45
- @click="action.onClick"
46
- />
47
- <slot name="card-actions" :data="data" :reload="load" />
48
- </div>
49
- </header>
25
+ <AdminSection
26
+ :title="labels.masterData"
27
+ :subtitle="stammdatenSub"
28
+ class="sa-card q-mb-md"
29
+ >
30
+ <template #actions>
31
+ <!-- Manifest-driven default actions (Suspend/Reactivate) -->
32
+ <q-btn
33
+ v-for="action in manifestActions"
34
+ :key="action.def.id"
35
+ outline
36
+ :color="toneColor(action.def.actionKey)"
37
+ :icon="iconForActionKey(action.def.actionKey)"
38
+ :label="action.def.label"
39
+ @click="action.onClick"
40
+ />
41
+ <slot name="card-actions" :data="data" :reload="load" />
42
+ </template>
50
43
  <slot name="stammdaten" :data="data">
51
- <div class="sa-tenant-detail__grid">
52
- <KvBlock :label="labels.plan" :value="data.subscription?.plan ?? '—'" />
53
- <KvBlock
54
- :label="labels.status"
55
- :value="data.subscription?.status ?? '—'"
56
- />
57
- <KvBlock
58
- :label="labels.pilot"
59
- :value="data.subscription?.isPilot ? common.yes : common.no"
60
- />
61
- <KvBlock
62
- :label="labels.trialEnd"
63
- :value="formatDate(data.subscription?.trialEndsAt)"
64
- />
65
- <KvBlock
66
- :label="labels.pilotEnd"
67
- :value="formatDate(data.subscription?.pilotEndsAt)"
68
- />
69
- <KvBlock v-if="data.vatId" :label="labels.vatId" :value="data.vatId" />
44
+ <TenantMasterData
45
+ :data="data"
46
+ :labels="labels"
47
+ :format-date="formatDate"
48
+ :yes="common.yes"
49
+ :no="common.no"
50
+ >
70
51
  <slot name="extra-stammdaten" :data="data" />
71
- </div>
52
+ </TenantMasterData>
72
53
  </slot>
73
- </div>
54
+ </AdminSection>
74
55
 
75
56
  <!-- Usage -->
76
- <div v-if="verbrauchFields.length > 0" class="sa-card q-mb-md">
77
- <h3 class="sa-card__title">{{ labels.usage }}</h3>
78
- <div class="sa-tenant-detail__grid">
79
- <KvBlock
80
- v-for="(field, i) in verbrauchFields"
81
- :key="field.label + i"
82
- :label="field.label"
83
- :value="resolveVerbrauch(field)"
84
- />
85
- </div>
86
- </div>
57
+ <AdminSection
58
+ v-if="verbrauchFields.length > 0"
59
+ :title="labels.usage"
60
+ class="sa-card q-mb-md"
61
+ >
62
+ <TenantUsage :data="data" :fields="verbrauchFields" />
63
+ </AdminSection>
87
64
 
88
65
  <!-- Users -->
89
- <div v-if="showUsers && data.users" class="sa-card q-mb-md">
90
- <h3 class="sa-card__title">{{ labels.users }}</h3>
91
- <q-table
92
- flat
93
- :rows="data.users"
94
- :columns="userColumns ?? defaultUserColumns"
95
- row-key="id"
96
- :pagination="{ rowsPerPage: 0 }"
97
- hide-pagination
98
- />
99
- </div>
66
+ <AdminSection
67
+ v-if="showUsers && data.users"
68
+ :title="labels.users"
69
+ class="sa-card q-mb-md"
70
+ >
71
+ <TenantUsers :users="data.users" :columns="userColumns ?? defaultUserColumns" />
72
+ </AdminSection>
100
73
 
101
74
  <slot name="extra-cards" :data="data" :reload="load" />
102
75
  </template>
103
- </div>
76
+ </AdminBody>
104
77
 
105
78
  <!-- Manifest-driven action flow dialogs -->
106
79
  <MfaPromptDialog
107
- v-model="mfaState.show"
108
- :description="mfaState.description"
109
- :error="mfaState.error"
110
- @update:model-value="onMfaDialogVisibility"
111
- @confirm="onMfaConfirm"
80
+ :model-value="mfa.show.value"
81
+ :description="mfa.description.value"
82
+ :error="mfa.error.value"
83
+ @update:model-value="mfa.onVisibility"
84
+ @confirm="mfa.onConfirm"
112
85
  />
113
86
  <TenantActionConfirmDialog
114
87
  v-model="confirmState.show"
@@ -118,47 +91,31 @@
118
91
  @submit="onConfirmSubmit"
119
92
  @cancel="onConfirmCancel"
120
93
  />
121
- </div>
94
+ </AdminPage>
122
95
  </template>
123
96
 
124
97
  <script setup lang="ts">
125
98
  import { computed, onMounted, ref, toRef } from 'vue';
99
+ import AdminBody from '../components/admin-page/AdminBody.vue';
100
+ import { useMfaPrompt } from '../vue/use-mfa-prompt.js';
101
+ import AdminHero from '../components/admin-page/AdminHero.vue';
102
+ import AdminSection from '../components/admin-page/AdminSection.vue';
103
+ import AdminPage from '../components/admin-page/AdminPage.vue';
126
104
  import type { RouteLocationRaw } from 'vue-router';
105
+ import type { TenantDetailData, VerbrauchField } from './tenant-detail/types.js';
127
106
  import type { QTableColumn } from 'quasar';
128
107
  import { useSuperAdminNotify } from '../quasar/notify.js';
129
108
  import type { AdminManifest, TenantActionDef, TenantDto } from '@saasicat/types';
130
109
  import { formatMessage } from '../client/i18n/format.js';
131
- import KvBlock from '../components/KvBlock.vue';
110
+ import TenantMasterData from './tenant-detail/TenantMasterData.vue';
111
+ import TenantUsage from './tenant-detail/TenantUsage.vue';
112
+ import TenantUsers from './tenant-detail/TenantUsers.vue';
132
113
  import MfaPromptDialog from '../components/MfaPromptDialog.vue';
133
114
  import TenantActionConfirmDialog from '../components/TenantActionConfirmDialog.vue';
134
115
  import { useSaMessages } from '../vue/use-super-admin-i18n.js';
135
116
  import { useTenantActionFlow } from '../vue/use-tenant-action-flow.js';
136
117
 
137
- export interface TenantDetailData {
138
- id: string;
139
- slug: string;
140
- name: string;
141
- isActive: boolean;
142
- vatId?: string | null;
143
- subscription?: {
144
- plan?: string | null;
145
- status?: string | null;
146
- isPilot?: boolean | null;
147
- trialEndsAt?: string | null;
148
- pilotEndsAt?: string | null;
149
- } | null;
150
- users?: Array<Record<string, unknown> & { id: string }>;
151
- /** Freely selectable usage numbers — the page renders them via `verbrauchFields`. */
152
- counts?: Record<string, number | string>;
153
- }
154
-
155
- export interface VerbrauchField {
156
- label: string;
157
- /** Lookup key in `data.counts`. */
158
- key?: string;
159
- /** Alternative: custom getter. Takes precedence over `key`. */
160
- getter?: (data: TenantDetailData) => string | number;
161
- }
118
+ export type { TenantDetailData, VerbrauchField } from './tenant-detail/types.js';
162
119
 
163
120
  const props = withDefaults(
164
121
  defineProps<{
@@ -236,51 +193,18 @@ function formatDate(value: string | null | undefined): string {
236
193
  return props.formatDate ? props.formatDate(value) : defaultFormatDate(value);
237
194
  }
238
195
 
239
- function resolveVerbrauch(field: VerbrauchField): string {
240
- if (!data.value) return '—';
241
- if (field.getter) return String(field.getter(data.value));
242
- if (field.key) {
243
- const v = data.value.counts?.[field.key];
244
- return v === undefined || v === null ? '0' : String(v);
245
- }
246
- return '—';
247
- }
248
-
249
196
  // ── Manifest-driven Action Flow (Suspend/Reactivate, Default) ──────────
250
197
  const manifestRef = toRef(() => props.manifest);
251
198
 
252
- const mfaState = ref<{ show: boolean; description: string; error: string }>({
253
- show: false,
254
- description: '',
255
- error: '',
256
- });
257
- let pendingMfaResolve: ((code: string | null) => void) | null = null;
199
+ const mfa = useMfaPrompt();
258
200
 
259
201
  function showMfaDialog(def: TenantActionDef, ctx: { row: TenantDto }): Promise<string | null> {
260
- return new Promise((resolve) => {
261
- mfaState.value = {
262
- show: true,
263
- description: formatMessage(msg.value.actions.mfaDescription, {
264
- action: def.label,
265
- tenant: ctx.row.name,
266
- }),
267
- error: '',
268
- };
269
- pendingMfaResolve = (code) => {
270
- pendingMfaResolve = null;
271
- resolve(code);
272
- };
273
- });
274
- }
275
-
276
- function onMfaConfirm(code: string): void {
277
- pendingMfaResolve?.(code);
278
- mfaState.value.show = false;
279
- }
280
-
281
- function onMfaDialogVisibility(open: boolean): void {
282
- mfaState.value.show = open;
283
- if (!open && pendingMfaResolve) pendingMfaResolve(null);
202
+ return mfa.prompt(
203
+ formatMessage(msg.value.actions.mfaDescription, {
204
+ action: def.label,
205
+ tenant: ctx.row.name,
206
+ }),
207
+ );
284
208
  }
285
209
 
286
210
  const confirmState = ref<{
@@ -392,16 +316,9 @@ const defaultUserColumns = computed<QTableColumn[]>(() => [
392
316
  </script>
393
317
 
394
318
  <style scoped>
395
- .sa-tenant-detail {
396
- min-height: calc(100vh - 56px);
397
- background: var(--sa-bg-app);
398
- }
399
319
  .sa-tenant-detail__back {
400
320
  margin-bottom: 6px;
401
321
  }
402
- .sa-tenant-detail__body {
403
- padding: 12px 28px 28px;
404
- }
405
322
  .sa-tenant-detail__card-head {
406
323
  display: flex;
407
324
  justify-content: space-between;
@@ -421,18 +338,6 @@ const defaultUserColumns = computed<QTableColumn[]>(() => [
421
338
  gap: 8px;
422
339
  align-items: center;
423
340
  }
424
- .sa-tenant-detail__grid {
425
- display: grid;
426
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
427
- gap: 12px;
428
- }
429
- .sa-tenant-detail__state {
430
- padding: 32px 0;
431
- color: var(--sa-muted);
432
- display: flex;
433
- align-items: center;
434
- gap: 12px;
435
- }
436
341
  .sa-tenant-detail__empty {
437
342
  color: var(--sa-muted);
438
343
  font-size: 13px;
@@ -1,11 +1,6 @@
1
1
  <template>
2
- <div class="sa-tenants">
3
- <header class="sa-tenants__head">
4
- <div>
5
- <h1 class="sa-tenants__title">{{ msg.list.title }}</h1>
6
- <p v-if="subtitle" class="sa-tenants__sub">{{ subtitle }}</p>
7
- </div>
8
- </header>
2
+ <AdminPage class="sa-tenants">
3
+ <AdminHero :title="msg.list.title" :subtitle="subtitle" />
9
4
 
10
5
  <div class="sa-tenants__filter">
11
6
  <div class="sa-tenants__search">
@@ -162,11 +157,13 @@
162
157
  @cancel="manifestFlow.onConfirmCancel"
163
158
  />
164
159
  </template>
165
- </div>
160
+ </AdminPage>
166
161
  </template>
167
162
 
168
163
  <script setup lang="ts">
169
164
  import { computed, ref, watch } from 'vue';
165
+ import AdminHero from '../components/admin-page/AdminHero.vue';
166
+ import AdminPage from '../components/admin-page/AdminPage.vue';
170
167
  import type { AdminManifest, TenantActionDef, TenantDto, TenantListFilter } from '@saasicat/types';
171
168
  import type { HttpClient } from '../client/types.js';
172
169
  import { formatMessage } from '../client/i18n/format.js';
@@ -518,30 +515,8 @@ function visibleActions(row: TenantRow): TenantRowAction[] {
518
515
  </script>
519
516
 
520
517
  <style scoped>
521
- .sa-tenants {
522
- min-height: calc(100vh - 56px);
523
- background: var(--sa-bg-app, #f1f5f9);
524
- }
525
- .sa-tenants__head {
526
- padding: 20px 28px 8px;
527
- }
528
- .sa-tenants__title {
529
- margin: 0;
530
- font-family: var(--sa-font-head, system-ui, sans-serif);
531
- font-weight: 700;
532
- font-size: 22px;
533
- color: var(--sa-heading, #0f172a);
534
- letter-spacing: -0.01em;
535
- }
536
- .sa-tenants__sub {
537
- margin: 4px 0 0;
538
- color: var(--sa-muted-dark, #475569);
539
- font-size: 13.5px;
540
- line-height: 1.5;
541
- }
542
-
543
518
  .sa-tenants__filter {
544
- padding: 12px 28px;
519
+ padding: 0 0 12px;
545
520
  display: flex;
546
521
  gap: 10px;
547
522
  flex-wrap: wrap;
@@ -587,7 +562,7 @@ function visibleActions(row: TenantRow): TenantRowAction[] {
587
562
  }
588
563
 
589
564
  .sa-tenants__card {
590
- margin: 0 28px 28px;
565
+ margin: 0 0 16px;
591
566
  background: #fff;
592
567
  border: 1px solid var(--sa-border, #e2e8f0);
593
568
  border-radius: 12px;
@@ -737,7 +712,7 @@ td {
737
712
  }
738
713
 
739
714
  .sa-tenants__foot {
740
- padding: 0 28px 28px;
715
+ padding: 0;
741
716
  text-align: right;
742
717
  font-size: 12px;
743
718
  color: var(--sa-muted, #64748b);
@@ -1,11 +1,7 @@
1
1
  <template>
2
- <div class="sa-users">
3
- <header class="sa-page-head">
4
- <div>
5
- <h1 class="sa-page-head__title">{{ resolvedTitle }}</h1>
6
- <p v-if="subtitle" class="sa-page-head__sub">{{ subtitle }}</p>
7
- </div>
8
- <div class="sa-page-head__actions">
2
+ <AdminPage class="sa-users">
3
+ <AdminHero :title="resolvedTitle" :subtitle="subtitle">
4
+ <template #actions>
9
5
  <q-btn
10
6
  unelevated
11
7
  color="primary"
@@ -13,8 +9,8 @@
13
9
  :label="common.search"
14
10
  @click="reload"
15
11
  />
16
- </div>
17
- </header>
12
+ </template>
13
+ </AdminHero>
18
14
 
19
15
  <div class="sa-stats">
20
16
  <button
@@ -99,20 +95,23 @@
99
95
 
100
96
  <MfaPromptDialog
101
97
  v-if="needsMfaDialog"
102
- :model-value="showMfa"
103
- :description="mfaDescription"
104
- :error="mfaError"
98
+ :model-value="mfa.show.value"
99
+ :description="mfa.description.value"
100
+ :error="mfa.error.value"
105
101
  :setup-hint="mfaSetupHint"
106
- @update:model-value="onMfaDialogVisibility"
107
- @confirm="onMfaConfirm"
102
+ @update:model-value="mfa.onVisibility"
103
+ @confirm="mfa.onConfirm"
108
104
  />
109
- </div>
105
+ </AdminPage>
110
106
  </template>
111
107
 
112
108
  <script setup lang="ts">
113
109
  import { computed, onMounted, reactive, ref } from 'vue';
110
+ import { useMfaPrompt } from '../vue/use-mfa-prompt.js';
114
111
  import { useQuasar } from 'quasar';
115
112
  import { useSuperAdminNotify } from '../quasar/notify.js';
113
+ import AdminHero from '../components/admin-page/AdminHero.vue';
114
+ import AdminPage from '../components/admin-page/AdminPage.vue';
116
115
  import MfaPromptDialog from '../components/MfaPromptDialog.vue';
117
116
  import { formatMessage } from '../client/i18n/format.js';
118
117
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
@@ -191,40 +190,13 @@ const rows = ref<UserRow[]>([]);
191
190
  const loading = ref(false);
192
191
  const filter = reactive({ q: '', tenant: '' });
193
192
 
194
- // MFA dialog state for per-flow MFA (reset/deactivate). Promise-resolver
195
- // pattern analogous to PilotsPage: the action handler calls `promptMfa` and
196
- // waits for `onMfaConfirm` (or cancellation via `update:modelValue=false`).
197
- const showMfa = ref(false);
198
- const mfaError = ref('');
199
- const mfaDescription = ref('');
200
- let pendingMfaResolve: ((code: string | null) => void) | null = null;
193
+ // Per-flow MFA for reset/deactivate: the action handler awaits `mfa.prompt`,
194
+ // which resolves with the code or with null when the user closes the dialog.
195
+ const mfa = useMfaPrompt();
201
196
  const needsMfaDialog = computed(
202
197
  () => props.requireMfaForResetPassword || props.requireMfaForDeactivate,
203
198
  );
204
199
 
205
- function promptMfa(description: string): Promise<string | null> {
206
- return new Promise((resolve) => {
207
- mfaDescription.value = description;
208
- mfaError.value = '';
209
- showMfa.value = true;
210
- pendingMfaResolve = (code) => {
211
- pendingMfaResolve = null;
212
- resolve(code);
213
- };
214
- });
215
- }
216
-
217
- function onMfaConfirm(code: string): void {
218
- pendingMfaResolve?.(code);
219
- }
220
-
221
- function onMfaDialogVisibility(open: boolean): void {
222
- showMfa.value = open;
223
- if (!open && pendingMfaResolve) {
224
- pendingMfaResolve(null);
225
- }
226
- }
227
-
228
200
  // Stat-pill filter (analogous to plan simulation users.jsx):
229
201
  // all | active | blocked | never-logged-in | super-admin.
230
202
  type StatusFilter = 'all' | 'active' | 'blocked' | 'never' | 'super';
@@ -407,20 +379,20 @@ async function runAction<R>(
407
379
  return;
408
380
  }
409
381
  while (true) {
410
- const code = await promptMfa(actionLabel);
382
+ const code = await mfa.prompt(actionLabel);
411
383
  if (code === null) return;
412
384
  try {
413
385
  const result = await invoke(code);
414
- showMfa.value = false;
386
+ mfa.show.value = false;
415
387
  onSuccess(result);
416
388
  return;
417
389
  } catch (err) {
418
390
  const status = (err as { response?: { status?: number } })?.response?.status;
419
391
  if (status === 401) {
420
- mfaError.value = shell.value.mfa.invalidCode;
392
+ mfa.error.value = shell.value.mfa.invalidCode;
421
393
  continue;
422
394
  }
423
- showMfa.value = false;
395
+ mfa.show.value = false;
424
396
  notify('negative', errMsg(err));
425
397
  return;
426
398
  }
@@ -497,11 +469,6 @@ function onDeactivateClick(row: UserRow): void {
497
469
  </script>
498
470
 
499
471
  <style scoped>
500
- .sa-users {
501
- min-height: calc(100vh - 56px);
502
- background: var(--sa-bg-app, #f1f5f9);
503
- padding: 20px 28px 28px;
504
- }
505
472
  .sa-users__filter {
506
473
  display: flex;
507
474
  gap: 10px;
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <div class="sa-bundles__toolbar">
3
+ <q-select
4
+ v-if="locales.length > 1"
5
+ :model-value="displayLocale"
6
+ :options="localeOptions"
7
+ outlined
8
+ dense
9
+ emit-value
10
+ map-options
11
+ class="sa-bundles__locale"
12
+ :label="msg.header.displayLocale"
13
+ @update:model-value="(value) => emit('update:displayLocale', String(value))"
14
+ >
15
+ <template #prepend><q-icon name="translate" size="18px" /></template>
16
+ </q-select>
17
+ <q-btn
18
+ unelevated
19
+ color="primary"
20
+ icon="add"
21
+ :label="msg.header.newBundle"
22
+ @click="emit('create')"
23
+ />
24
+ <q-btn flat icon="refresh" :loading="loading" @click="emit('refresh')">
25
+ <q-tooltip>{{ msg.header.reload }}</q-tooltip>
26
+ </q-btn>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { computed } from 'vue';
32
+
33
+ import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
34
+
35
+ // The language selector controls which locale feature/quota labels are resolved
36
+ // on the create form and in the detail view (fallback locale → DE → key). The default is the
37
+ // default locale (DE); further options come from the active project locales.
38
+
39
+ const props = defineProps<{
40
+ loading: boolean;
41
+ displayLocale: string;
42
+ locales: string[];
43
+ }>();
44
+
45
+ const emit = defineEmits<{
46
+ create: [];
47
+ refresh: [];
48
+ 'update:displayLocale': [locale: string];
49
+ }>();
50
+
51
+ const msg = useSaMessages('bundles');
52
+
53
+ const localeOptions = computed(() =>
54
+ props.locales.map((locale) => ({ label: locale.toUpperCase(), value: locale })),
55
+ );
56
+ </script>
57
+
58
+ <style scoped>
59
+ .sa-bundles__toolbar {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 12px;
63
+ }
64
+ .sa-bundles__locale {
65
+ min-width: 150px;
66
+ }
67
+ </style>