@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.
- package/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- 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>
|