@saasicat/ui-vue 0.2.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 (193) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +47 -0
  3. package/dist/index.cjs +3776 -0
  4. package/dist/index.d.cts +2395 -0
  5. package/dist/index.d.ts +2395 -0
  6. package/dist/index.js +3659 -0
  7. package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
  8. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  9. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  10. package/dist/testing-e2e/admin-pages-suite.js +109 -0
  11. package/package.json +87 -0
  12. package/src/action-registry.ts +110 -0
  13. package/src/batch-column-fetcher.ts +152 -0
  14. package/src/boot-loader.ts +60 -0
  15. package/src/components/BundleVersionPublishDialog.vue +359 -0
  16. package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
  17. package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
  18. package/src/components/FeatureGate.vue +56 -0
  19. package/src/components/KpiCard.vue +53 -0
  20. package/src/components/KvBlock.vue +36 -0
  21. package/src/components/MarketingPromotionsTab.vue +861 -0
  22. package/src/components/MfaPromptDialog.vue +104 -0
  23. package/src/components/TenantActionConfirmDialog.vue +252 -0
  24. package/src/components/VersionDiffPreview.vue +150 -0
  25. package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
  26. package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
  27. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
  28. package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
  29. package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
  30. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
  31. package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
  32. package/src/components/bundle-editor/bundle-version-status.ts +164 -0
  33. package/src/components/bundle-editor/catalog-i18n.ts +85 -0
  34. package/src/components/dialogs/PilotCreateDialog.vue +780 -0
  35. package/src/components/dialogs/PilotEditDialog.vue +548 -0
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
  37. package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
  38. package/src/components/dialogs/types.ts +126 -0
  39. package/src/components/plan/PlanCycleToggle.vue +99 -0
  40. package/src/components/plan/PlanGrid.vue +211 -0
  41. package/src/components/plan/PriceSummary.vue +293 -0
  42. package/src/components/plan/PromoCodeInput.vue +192 -0
  43. package/src/components/plan/PublicBundleGrid.vue +287 -0
  44. package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
  45. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
  46. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
  47. package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
  48. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
  49. package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
  50. package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
  51. package/src/components/plan-cockpit/types.ts +72 -0
  52. package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
  53. package/src/components/plan-detail/PlanAuditLog.vue +59 -0
  54. package/src/components/plan-detail/PlanDetail.vue +1195 -0
  55. package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
  56. package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
  57. package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
  58. package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
  59. package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
  60. package/src/components/plan-detail/types.ts +59 -0
  61. package/src/components/plan-list/PlanList.vue +1067 -0
  62. package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
  63. package/src/components/plan-review/PlanReview.vue +742 -0
  64. package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
  65. package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
  66. package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
  67. package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
  70. package/src/components/plan-version-editor/types.ts +96 -0
  71. package/src/components/wizard-stepper/WizardStepper.vue +107 -0
  72. package/src/create-admin-routes.ts +68 -0
  73. package/src/create-super-admin-app.ts +345 -0
  74. package/src/entitlement-provider.ts +36 -0
  75. package/src/feature-router-guard.ts +77 -0
  76. package/src/http-json.ts +62 -0
  77. package/src/index.ts +98 -0
  78. package/src/manifest-loader.ts +139 -0
  79. package/src/manifest-store-factory.ts +114 -0
  80. package/src/nav-builder.ts +274 -0
  81. package/src/pages-standard/AdminLayout.vue +411 -0
  82. package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
  83. package/src/pages-standard/AuditPage.vue +223 -0
  84. package/src/pages-standard/BundlesPage.vue +910 -0
  85. package/src/pages-standard/BusinessTypesPage.vue +662 -0
  86. package/src/pages-standard/DashboardPage.vue +595 -0
  87. package/src/pages-standard/DiscoveryPage.vue +663 -0
  88. package/src/pages-standard/EmailHistoryPage.vue +576 -0
  89. package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
  90. package/src/pages-standard/PilotsPage.vue +577 -0
  91. package/src/pages-standard/PlanVersionsPage.vue +216 -0
  92. package/src/pages-standard/PlansPage.vue +1222 -0
  93. package/src/pages-standard/PlatformEmailPage.vue +434 -0
  94. package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
  95. package/src/pages-standard/PromoCodesPage.vue +589 -0
  96. package/src/pages-standard/SubscriptionsPage.vue +159 -0
  97. package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
  98. package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
  99. package/src/pages-standard/TenantDetailPage.vue +432 -0
  100. package/src/pages-standard/TenantsPage.vue +732 -0
  101. package/src/pages-standard/UsersPage.vue +504 -0
  102. package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
  103. package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
  104. package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
  105. package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
  106. package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
  107. package/src/pages-standard/bundles-page/types.ts +15 -0
  108. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
  109. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
  110. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
  111. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
  112. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
  113. package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
  114. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
  115. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
  116. package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
  117. package/src/pages-standard/email-history.types.ts +48 -0
  118. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
  119. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
  120. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
  121. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
  122. package/src/pages-standard/marketing-catalog/types.ts +43 -0
  123. package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
  124. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
  125. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
  126. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
  127. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
  128. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
  129. package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
  130. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
  131. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
  132. package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
  133. package/src/pages-standard/plan-versions/format.ts +53 -0
  134. package/src/pages-standard/plan-versions/types.ts +33 -0
  135. package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
  136. package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
  137. package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
  138. package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
  139. package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
  140. package/src/pages-standard/plans-page/types.ts +18 -0
  141. package/src/pages-standard/platform-email.types.ts +41 -0
  142. package/src/pages-standard/sa-theme.css +223 -0
  143. package/src/pages-standard/tenants/StatusPill.vue +62 -0
  144. package/src/pages-standard/tenants/format.ts +38 -0
  145. package/src/pages-tenant/LimitsRow.vue +67 -0
  146. package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
  147. package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
  148. package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
  149. package/src/pages-tenant/PendingVersionBanner.vue +126 -0
  150. package/src/pages-tenant/PlanChangeWizard.vue +630 -0
  151. package/src/pages-tenant/TenantPlanSection.vue +729 -0
  152. package/src/pages-tenant/UsageBar.vue +107 -0
  153. package/src/pages-tenant/default-i18n.ts +280 -0
  154. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
  155. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
  156. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
  157. package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
  158. package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
  159. package/src/plan-versions-catalog.ts +368 -0
  160. package/src/platform-loaders.ts +68 -0
  161. package/src/project-page-host.ts +108 -0
  162. package/src/testing-e2e/admin-pages-suite.ts +187 -0
  163. package/src/types.ts +58 -0
  164. package/src/use-actions.ts +25 -0
  165. package/src/use-api-list.ts +151 -0
  166. package/src/use-audit-entries.ts +48 -0
  167. package/src/use-batch-columns.ts +59 -0
  168. package/src/use-bulk-publish.ts +150 -0
  169. package/src/use-bundle-versions-map.ts +112 -0
  170. package/src/use-bundles.ts +297 -0
  171. package/src/use-business-types.ts +269 -0
  172. package/src/use-catalog-entries.ts +243 -0
  173. package/src/use-discovery.ts +158 -0
  174. package/src/use-entitlement.ts +87 -0
  175. package/src/use-live-plan-versions.ts +126 -0
  176. package/src/use-manifest.ts +57 -0
  177. package/src/use-marketing-projections.ts +160 -0
  178. package/src/use-nav.ts +33 -0
  179. package/src/use-plan-editor.ts +162 -0
  180. package/src/use-plan-versions.ts +83 -0
  181. package/src/use-plans.ts +375 -0
  182. package/src/use-platform-tenant-actions.ts +286 -0
  183. package/src/use-promotions.ts +126 -0
  184. package/src/use-public-boot.ts +40 -0
  185. package/src/use-subscription-draft.ts +355 -0
  186. package/src/use-super-admin-context.ts +93 -0
  187. package/src/use-tenant-action-flow.ts +238 -0
  188. package/src/use-tenant-billing-catalog.ts +160 -0
  189. package/src/use-tenant-billing.ts +453 -0
  190. package/src/use-tenant-manifest.ts +94 -0
  191. package/src/use-tenant-subscription-bundles.ts +148 -0
  192. package/src/use-tenants.ts +61 -0
  193. package/src/version.ts +10 -0
@@ -0,0 +1,504 @@
1
+ <template>
2
+ <div class="sa-users">
3
+ <header class="sa-page-head">
4
+ <div>
5
+ <h1 class="sa-page-head__title">{{ title }}</h1>
6
+ <p v-if="subtitle" class="sa-page-head__sub">{{ subtitle }}</p>
7
+ </div>
8
+ <div class="sa-page-head__actions">
9
+ <q-btn unelevated color="primary" icon="search" label="Suchen" @click="reload" />
10
+ </div>
11
+ </header>
12
+
13
+ <div class="sa-stats">
14
+ <button
15
+ v-for="tile in statTiles"
16
+ :key="tile.id"
17
+ class="sa-stat"
18
+ :class="[
19
+ tile.tone ? `sa-stat--${tile.tone}` : null,
20
+ statusFilter === tile.id ? 'sa-stat--active' : null,
21
+ ]"
22
+ @click="statusFilter = tile.id"
23
+ >
24
+ <span class="sa-stat__num">{{ tile.count }}</span>
25
+ <span class="sa-stat__label">{{ tile.label }}</span>
26
+ </button>
27
+ </div>
28
+
29
+ <div class="sa-users__filter">
30
+ <q-input
31
+ v-model="filter.q"
32
+ outlined
33
+ dense
34
+ label="E-Mail oder Name"
35
+ clearable
36
+ @keyup.enter="reload"
37
+ @clear="reload"
38
+ />
39
+ <q-input
40
+ v-model="filter.tenant"
41
+ outlined
42
+ dense
43
+ label="Tenant-Slug"
44
+ clearable
45
+ @keyup.enter="reload"
46
+ @clear="reload"
47
+ />
48
+ <slot name="filters-extra" />
49
+ </div>
50
+
51
+ <div class="sa-users__card">
52
+ <q-table
53
+ flat
54
+ :rows="filteredRows"
55
+ :columns="effectiveColumns"
56
+ row-key="id"
57
+ :pagination="{ rowsPerPage: 0 }"
58
+ :loading="loading"
59
+ hide-pagination
60
+ >
61
+ <template #body-cell-status="{ row }">
62
+ <q-td>
63
+ <q-badge
64
+ :color="row.isActive ? 'positive' : 'grey'"
65
+ :label="row.isActive ? 'aktiv' : 'deaktiviert'"
66
+ />
67
+ <q-badge
68
+ v-if="row.invitationStatus === 'PENDING'"
69
+ color="amber-7"
70
+ label="Pending"
71
+ class="q-ml-xs"
72
+ />
73
+ </q-td>
74
+ </template>
75
+ <template #body-cell-actions="{ row }">
76
+ <q-td>
77
+ <slot name="row-actions" :row="row">
78
+ <q-btn
79
+ v-for="action in visibleActions(row)"
80
+ :key="action.id"
81
+ flat
82
+ dense
83
+ :icon="action.icon"
84
+ :title="action.label"
85
+ :color="action.color ?? 'grey-7'"
86
+ @click="action.handler(row)"
87
+ />
88
+ </slot>
89
+ </q-td>
90
+ </template>
91
+ </q-table>
92
+ </div>
93
+
94
+ <MfaPromptDialog
95
+ v-if="needsMfaDialog"
96
+ :model-value="showMfa"
97
+ :description="mfaDescription"
98
+ :error="mfaError"
99
+ :setup-hint="mfaSetupHint"
100
+ @update:model-value="onMfaDialogVisibility"
101
+ @confirm="onMfaConfirm"
102
+ />
103
+ </div>
104
+ </template>
105
+
106
+ <script setup lang="ts">
107
+ import { computed, onMounted, reactive, ref } from 'vue';
108
+ import { useQuasar } from 'quasar';
109
+ import MfaPromptDialog from '../components/MfaPromptDialog.vue';
110
+
111
+ // Platform standard page: user search. Data-agnostic.
112
+ //
113
+ // Optional baked-in flows: enableResetPassword/Deactivate + submit* callbacks.
114
+ // Default actions are APPENDED to consumer actions.
115
+
116
+ export interface UserRow {
117
+ id: string;
118
+ email: string;
119
+ firstName: string;
120
+ lastName: string;
121
+ role: string;
122
+ isActive: boolean;
123
+ invitationStatus?: string;
124
+ tenantSlug?: string | null;
125
+ lastLoginAt?: string | null;
126
+ [extra: string]: unknown;
127
+ }
128
+
129
+ export interface UserListFilter {
130
+ q?: string;
131
+ tenant?: string;
132
+ [extra: string]: unknown;
133
+ }
134
+
135
+ export interface UserRowAction {
136
+ id: string;
137
+ label: string;
138
+ icon: string;
139
+ color?: string;
140
+ condition?: (row: UserRow) => boolean;
141
+ handler: (row: UserRow) => void;
142
+ }
143
+
144
+ const props = withDefaults(
145
+ defineProps<{
146
+ loadUsers: (filter: UserListFilter) => Promise<UserRow[]>;
147
+ title?: string;
148
+ subtitle?: string;
149
+ actions?: readonly UserRowAction[];
150
+ enableResetPassword?: boolean;
151
+ enableDeactivate?: boolean;
152
+ /** Per-flow MFA for reset password — shows MfaPromptDialog after the reason prompt. */
153
+ requireMfaForResetPassword?: boolean;
154
+ /** Per-flow MFA for deactivate — shows MfaPromptDialog after the reason prompt. */
155
+ requireMfaForDeactivate?: boolean;
156
+ mfaSetupHint?: string;
157
+ // Return value: either `{oneTimePassword}` (shows OTP dialog)
158
+ // or `void` (Notify only). The page decides based on the return
159
+ // whether the OTP dialog is shown.
160
+ submitResetPassword?: (
161
+ id: string,
162
+ reason: string,
163
+ mfaCode?: string,
164
+ ) => Promise<{ oneTimePassword: string } | void>;
165
+ submitDeactivate?: (id: string, reason: string, mfaCode?: string) => Promise<void>;
166
+ }>(),
167
+ {
168
+ title: 'Benutzer',
169
+ requireMfaForResetPassword: false,
170
+ requireMfaForDeactivate: false,
171
+ },
172
+ );
173
+
174
+ const q = useQuasar();
175
+ const rows = ref<UserRow[]>([]);
176
+ const loading = ref(false);
177
+ const filter = reactive({ q: '', tenant: '' });
178
+
179
+ // MFA dialog state for per-flow MFA (reset/deactivate). Promise-resolver
180
+ // pattern analogous to PilotsPage: the action handler calls `promptMfa` and
181
+ // waits for `onMfaConfirm` (or cancellation via `update:modelValue=false`).
182
+ const showMfa = ref(false);
183
+ const mfaError = ref('');
184
+ const mfaDescription = ref('');
185
+ let pendingMfaResolve: ((code: string | null) => void) | null = null;
186
+ const needsMfaDialog = computed(
187
+ () => props.requireMfaForResetPassword || props.requireMfaForDeactivate,
188
+ );
189
+
190
+ function promptMfa(description: string): Promise<string | null> {
191
+ return new Promise((resolve) => {
192
+ mfaDescription.value = description;
193
+ mfaError.value = '';
194
+ showMfa.value = true;
195
+ pendingMfaResolve = (code) => {
196
+ pendingMfaResolve = null;
197
+ resolve(code);
198
+ };
199
+ });
200
+ }
201
+
202
+ function onMfaConfirm(code: string): void {
203
+ pendingMfaResolve?.(code);
204
+ }
205
+
206
+ function onMfaDialogVisibility(open: boolean): void {
207
+ showMfa.value = open;
208
+ if (!open && pendingMfaResolve) {
209
+ pendingMfaResolve(null);
210
+ }
211
+ }
212
+
213
+ // Stat-pill filter (analogous to plan simulation users.jsx):
214
+ // all | active | blocked | never-logged-in | super-admin.
215
+ type StatusFilter = 'all' | 'active' | 'blocked' | 'never' | 'super';
216
+ const statusFilter = ref<StatusFilter>('all');
217
+
218
+ function isSuperAdmin(row: UserRow): boolean {
219
+ return (
220
+ row.role === 'SUPER_ADMIN' ||
221
+ row.role === 'PLATFORM_ADMIN' ||
222
+ String(row.role).toLowerCase().includes('super')
223
+ );
224
+ }
225
+
226
+ const filteredRows = computed(() => {
227
+ const f = statusFilter.value;
228
+ if (f === 'all') return rows.value;
229
+ return rows.value.filter((r) => {
230
+ if (f === 'active') return r.isActive;
231
+ if (f === 'blocked') return !r.isActive;
232
+ if (f === 'never') return !r.lastLoginAt;
233
+ if (f === 'super') return isSuperAdmin(r);
234
+ return true;
235
+ });
236
+ });
237
+
238
+ const statTiles = computed<
239
+ Array<{
240
+ id: StatusFilter;
241
+ label: string;
242
+ count: number;
243
+ tone?: 'positive' | 'warn' | 'muted' | 'purple';
244
+ }>
245
+ >(() => {
246
+ let active = 0;
247
+ let blocked = 0;
248
+ let never = 0;
249
+ let supr = 0;
250
+ for (const r of rows.value) {
251
+ if (r.isActive) active++;
252
+ else blocked++;
253
+ if (!r.lastLoginAt) never++;
254
+ if (isSuperAdmin(r)) supr++;
255
+ }
256
+ return [
257
+ { id: 'all', label: 'Alle', count: rows.value.length },
258
+ { id: 'active', label: 'Aktiv', count: active, tone: 'positive' },
259
+ { id: 'blocked', label: 'Gesperrt', count: blocked, tone: 'muted' },
260
+ { id: 'never', label: 'Nie eingeloggt', count: never, tone: 'warn' },
261
+ { id: 'super', label: 'Super-Admins', count: supr, tone: 'purple' },
262
+ ];
263
+ });
264
+
265
+ const baseColumns = [
266
+ {
267
+ name: 'email',
268
+ label: 'E-Mail',
269
+ field: 'email',
270
+ align: 'left' as const,
271
+ sortable: true,
272
+ },
273
+ {
274
+ name: 'name',
275
+ label: 'Name',
276
+ field: (r: UserRow) => `${r.firstName} ${r.lastName}`,
277
+ align: 'left' as const,
278
+ },
279
+ {
280
+ name: 'tenant',
281
+ label: 'Tenant',
282
+ field: (r: UserRow) => r.tenantSlug ?? '— (SuperAdmin)',
283
+ align: 'left' as const,
284
+ },
285
+ { name: 'role', label: 'Rolle', field: 'role', align: 'left' as const },
286
+ { name: 'status', label: 'Status', field: 'isActive', align: 'left' as const },
287
+ {
288
+ name: 'lastLogin',
289
+ label: 'Letzter Login',
290
+ field: (r: UserRow) =>
291
+ r.lastLoginAt
292
+ ? new Date(r.lastLoginAt).toLocaleDateString('de-DE', {
293
+ day: '2-digit',
294
+ month: 'short',
295
+ year: 'numeric',
296
+ })
297
+ : '—',
298
+ align: 'left' as const,
299
+ },
300
+ ];
301
+
302
+ // Built-in default actions — APPENDED to consumer actions, not replaced.
303
+ const bakedActions = computed<UserRowAction[]>(() => {
304
+ const out: UserRowAction[] = [];
305
+ if (props.enableResetPassword && props.submitResetPassword) {
306
+ out.push({
307
+ id: 'reset-password',
308
+ label: 'Passwort zurücksetzen',
309
+ icon: 'lock_reset',
310
+ color: 'primary',
311
+ handler: (row) => onResetPasswordClick(row),
312
+ });
313
+ }
314
+ if (props.enableDeactivate && props.submitDeactivate) {
315
+ out.push({
316
+ id: 'deactivate',
317
+ label: 'Deaktivieren',
318
+ icon: 'block',
319
+ color: 'negative',
320
+ condition: (row) => (row as UserRow & { status?: string }).status !== 'suspended',
321
+ handler: (row) => onDeactivateClick(row),
322
+ });
323
+ }
324
+ return out;
325
+ });
326
+
327
+ const mergedActions = computed<readonly UserRowAction[]>(() => [
328
+ ...(props.actions ?? []),
329
+ ...bakedActions.value,
330
+ ]);
331
+
332
+ const effectiveColumns = computed(() => {
333
+ const cols = [...baseColumns];
334
+ if (mergedActions.value.length > 0) {
335
+ cols.push({
336
+ name: 'actions',
337
+ label: '',
338
+ field: 'id' as never,
339
+ align: 'right' as 'left',
340
+ });
341
+ }
342
+ return cols;
343
+ });
344
+
345
+ function visibleActions(row: UserRow): UserRowAction[] {
346
+ return mergedActions.value.filter((a) => !a.condition || a.condition(row));
347
+ }
348
+
349
+ async function reload() {
350
+ loading.value = true;
351
+ try {
352
+ rows.value = await props.loadUsers({
353
+ q: filter.q || undefined,
354
+ tenant: filter.tenant || undefined,
355
+ });
356
+ } catch (err) {
357
+ rows.value = [];
358
+ console.warn('[UsersPage] loadUsers failed:', err);
359
+ } finally {
360
+ loading.value = false;
361
+ }
362
+ }
363
+
364
+ onMounted(reload);
365
+
366
+ defineExpose({ reload });
367
+
368
+ function errMsg(err: unknown): string {
369
+ return (
370
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
371
+ (err as Error)?.message ??
372
+ 'Aktion fehlgeschlagen'
373
+ );
374
+ }
375
+
376
+ // MFA loop analogous to PilotsPage: on 401 the dialog stays open.
377
+ // `invoke` must accept the code (empty when requireMfa=false) and deliver
378
+ // the `oneTimePassword` result optionally returned by the server.
379
+ async function runAction<R>(
380
+ actionLabel: string,
381
+ requireMfa: boolean,
382
+ invoke: (code: string) => Promise<R>,
383
+ onSuccess: (result: R) => void,
384
+ ): Promise<void> {
385
+ if (!requireMfa) {
386
+ try {
387
+ const result = await invoke('');
388
+ onSuccess(result);
389
+ } catch (err) {
390
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
391
+ }
392
+ return;
393
+ }
394
+ while (true) {
395
+ const code = await promptMfa(actionLabel);
396
+ if (code === null) return;
397
+ try {
398
+ const result = await invoke(code);
399
+ showMfa.value = false;
400
+ onSuccess(result);
401
+ return;
402
+ } catch (err) {
403
+ const status = (err as { response?: { status?: number } })?.response?.status;
404
+ if (status === 401) {
405
+ mfaError.value = 'TOTP-Code ungültig oder MFA nicht eingerichtet.';
406
+ continue;
407
+ }
408
+ showMfa.value = false;
409
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
410
+ return;
411
+ }
412
+ }
413
+ }
414
+
415
+ function onResetPasswordClick(row: UserRow): void {
416
+ if (!props.submitResetPassword) return;
417
+ const submit = props.submitResetPassword;
418
+ q.dialog({
419
+ title: `Passwort für "${row.email}" zurücksetzen`,
420
+ message: 'Grund für Audit (Pflichtfeld):',
421
+ prompt: { model: '', type: 'text' },
422
+ cancel: 'Abbrechen',
423
+ ok: { label: 'Zurücksetzen', color: 'primary' },
424
+ }).onOk(async (reason: string) => {
425
+ if (!reason || reason.trim().length === 0) return;
426
+ await runAction(
427
+ `Passwort für "${row.email}" zurücksetzen — ${reason.trim()}.`,
428
+ !!props.requireMfaForResetPassword,
429
+ (code) => submit(row.id, reason, code),
430
+ (data) => {
431
+ // The server can optionally return `oneTimePassword` — then
432
+ // show the OTP dialog. Backends without OTP return
433
+ // void → Notify only.
434
+ if (data && typeof data === 'object' && 'oneTimePassword' in data) {
435
+ q.dialog({
436
+ title: 'Einmal-Passwort generiert',
437
+ message: `Bitte sicher übermitteln: ${data.oneTimePassword}`,
438
+ ok: { label: 'Verstanden' },
439
+ });
440
+ } else {
441
+ q.notify({
442
+ type: 'positive',
443
+ message: 'Passwort-Reset ausgelöst.',
444
+ position: 'top',
445
+ });
446
+ }
447
+ void reload();
448
+ },
449
+ );
450
+ });
451
+ }
452
+
453
+ function onDeactivateClick(row: UserRow): void {
454
+ if (!props.submitDeactivate) return;
455
+ const submit = props.submitDeactivate;
456
+ q.dialog({
457
+ title: `User "${row.email}" deaktivieren`,
458
+ message: 'Grund für Audit (Pflichtfeld):',
459
+ prompt: { model: '', type: 'text' },
460
+ cancel: 'Abbrechen',
461
+ ok: { label: 'Deaktivieren', color: 'negative' },
462
+ }).onOk(async (reason: string) => {
463
+ if (!reason || reason.trim().length === 0) return;
464
+ await runAction(
465
+ `User "${row.email}" deaktivieren — ${reason.trim()}.`,
466
+ !!props.requireMfaForDeactivate,
467
+ (code) => submit(row.id, reason, code),
468
+ () => {
469
+ q.notify({
470
+ type: 'positive',
471
+ message: `${row.email} deaktiviert.`,
472
+ position: 'top',
473
+ });
474
+ void reload();
475
+ },
476
+ );
477
+ });
478
+ }
479
+ </script>
480
+
481
+ <style scoped>
482
+ .sa-users {
483
+ min-height: calc(100vh - 56px);
484
+ background: var(--sa-bg-app, #f1f5f9);
485
+ padding: 20px 28px 28px;
486
+ }
487
+ .sa-users__filter {
488
+ display: flex;
489
+ gap: 10px;
490
+ margin-bottom: 12px;
491
+ align-items: center;
492
+ flex-wrap: wrap;
493
+ }
494
+ .sa-users__filter > * {
495
+ flex: 1;
496
+ min-width: 200px;
497
+ }
498
+ .sa-users__card {
499
+ background: #fff;
500
+ border: 1px solid var(--sa-border, #e2e8f0);
501
+ border-radius: 12px;
502
+ overflow: hidden;
503
+ }
504
+ </style>
@@ -0,0 +1,116 @@
1
+ <template>
2
+ <div class="sa-bundles__list">
3
+ <div
4
+ v-for="bundle in filteredBundles"
5
+ :key="bundle.id"
6
+ class="sa-bd-card"
7
+ :class="{ open: openKey === bundle.id }"
8
+ >
9
+ <div class="sa-bd-card__head" @click="emit('toggle', bundle)">
10
+ <div class="sa-bd-card__mark"><q-icon name="inventory_2" size="18px" /></div>
11
+ <div class="sa-bd-card__titlewrap">
12
+ <div class="sa-bd-card__titlerow">
13
+ <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
14
+ <span
15
+ class="sa-chip"
16
+ :class="`sa-chip--${statusClass(bundle)}`"
17
+ :title="statusTooltip(bundle)"
18
+ >
19
+ {{ statusLabel(bundle) }}
20
+ </span>
21
+ <span v-if="i18nLocaleCount(bundle) > 0" class="sa-chip sa-chip--info">
22
+ {{ i18nLocaleCount(bundle) }} Übersetzung(en)
23
+ </span>
24
+ </div>
25
+ <div class="sa-bd-card__name">{{ bundle.label }}</div>
26
+ <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
27
+ </div>
28
+ <q-icon
29
+ name="chevron_right"
30
+ class="sa-bd-card__chev"
31
+ :class="{ open: openKey === bundle.id }"
32
+ />
33
+ </div>
34
+
35
+ <div v-if="openKey === bundle.id" class="sa-bd-card__body">
36
+ <slot name="detail" :bundle="bundle" />
37
+ </div>
38
+ </div>
39
+
40
+ <div v-if="bundlesTotal > 0 && filteredBundles.length === 0" class="sa-bd-empty-row">
41
+ Keine Bundles entsprechen der Suche.
42
+ </div>
43
+ </div>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import type { BundleRow } from '@saasicat/types';
48
+ import {
49
+ BUNDLE_STATUS_META,
50
+ type BundleAggregateStatus,
51
+ } from '../../components/bundle-editor/bundle-version-status.js';
52
+
53
+ const props = defineProps<{
54
+ filteredBundles: BundleRow[];
55
+ bundlesTotal: number;
56
+ openKey: string | null;
57
+ aggregateStatusOf: (bundle: BundleRow) => BundleAggregateStatus;
58
+ i18nLocaleCount: (bundle: BundleRow) => number;
59
+ }>();
60
+
61
+ defineSlots<{
62
+ detail(props: { bundle: BundleRow }): unknown;
63
+ }>();
64
+
65
+ const emit = defineEmits<{
66
+ toggle: [bundle: BundleRow];
67
+ }>();
68
+
69
+ function statusClass(bundle: BundleRow): string {
70
+ return BUNDLE_STATUS_META[props.aggregateStatusOf(bundle)]?.cls ?? 'draft';
71
+ }
72
+
73
+ function statusLabel(bundle: BundleRow): string {
74
+ const status = props.aggregateStatusOf(bundle);
75
+ return status === 'retired' ? 'Retired' : (BUNDLE_STATUS_META[status]?.label ?? status);
76
+ }
77
+
78
+ function statusTooltip(bundle: BundleRow): string | undefined {
79
+ return BUNDLE_STATUS_META[props.aggregateStatusOf(bundle)]?.tooltip;
80
+ }
81
+ </script>
82
+
83
+ <style scoped>
84
+ /* Status chips co-located with the markup (analogous to PlanList .pl-chip*).
85
+ Deliberately scoped: the identically named global .sa-chip rule in
86
+ DiscoveryPage.vue would otherwise override .sa-chip--live (same specificity,
87
+ later source order) and render the "Live" tag grey instead of green. */
88
+ .sa-chip {
89
+ font-size: 10px;
90
+ font-weight: 600;
91
+ padding: 2px 7px;
92
+ border-radius: 6px;
93
+ background: #f1f5f9;
94
+ color: #475569;
95
+ }
96
+ .sa-chip--info {
97
+ background: #eff6ff;
98
+ color: #1d4ed8;
99
+ }
100
+ .sa-chip--live {
101
+ background: #dcfce7;
102
+ color: #166534;
103
+ }
104
+ .sa-chip--draft {
105
+ background: #fef3c7;
106
+ color: #92400e;
107
+ }
108
+ .sa-chip--supersed {
109
+ background: #e2e8f0;
110
+ color: #64748b;
111
+ }
112
+ .sa-chip--scheduled {
113
+ background: #fef3c7;
114
+ color: #92400e;
115
+ }
116
+ </style>