@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.
- package/dist/{catalog-JXdKdIqG.d.cts → catalog-BY_t9prv.d.cts} +1 -0
- package/dist/{catalog-JXdKdIqG.d.ts → catalog-BY_t9prv.d.ts} +1 -0
- package/dist/{chunk-RVG6LQHL.js → chunk-7IFKVGZV.js} +1 -1
- package/dist/{chunk-DPTIOQXX.js → chunk-JN6HZGLV.js} +2 -0
- package/dist/{chunk-ORX3ZG47.js → chunk-OTP3OSSW.js} +1 -1
- package/dist/client/index.cjs +2 -0
- package/dist/client/index.d.cts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/quasar/index.cjs +2 -0
- package/dist/quasar/index.d.cts +2 -2
- package/dist/quasar/index.d.ts +2 -2
- package/dist/quasar/index.js +2 -2
- package/dist/{use-super-admin-i18n-1nCOkvgi.d.cts → use-super-admin-i18n-DlJRGIVq.d.cts} +1 -1
- package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-c_Eqsu9u.d.ts} +1 -1
- package/package.json +2 -2
- package/src/client/i18n/messages/common.ts +2 -0
- package/src/components/admin-page/AdminBody.vue +40 -0
- package/src/components/admin-page/AdminHero.vue +32 -0
- package/src/components/admin-page/AdminPage.vue +16 -0
- package/src/components/admin-page/AdminSection.vue +38 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +15 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +8 -8
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +10 -11
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +8 -8
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +9 -11
- package/src/components/plan-detail/PlanAuditLog.vue +7 -8
- package/src/components/plan-detail/PlanDetail.vue +14 -1
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
- package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
- package/src/components/plan-list/PlanList.vue +224 -257
- package/src/pages-standard/AdminLayout.vue +4 -2
- package/src/pages-standard/AuditPage.vue +18 -43
- package/src/pages-standard/BundlesPage.vue +17 -34
- package/src/pages-standard/DashboardPage.vue +20 -51
- package/src/pages-standard/DiscoveryPage.vue +38 -51
- package/src/pages-standard/EmailHistoryPage.vue +20 -71
- package/src/pages-standard/MarketingCatalogPage.vue +304 -315
- package/src/pages-standard/PilotsPage.vue +24 -56
- package/src/pages-standard/PlansPage.vue +25 -22
- package/src/pages-standard/PlatformEmailPage.vue +19 -51
- package/src/pages-standard/PromoCodeDetailPage.vue +39 -50
- package/src/pages-standard/PromoCodesPage.vue +8 -15
- package/src/pages-standard/SubscriptionsPage.vue +9 -35
- package/src/pages-standard/SuperAdminSetupWizard.vue +2 -1
- package/src/pages-standard/TenantDetailPage.vue +79 -174
- package/src/pages-standard/TenantsPage.vue +8 -33
- package/src/pages-standard/UsersPage.vue +21 -54
- package/src/pages-standard/bundles-page/BundlesToolbar.vue +67 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +54 -62
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
- package/src/pages-standard/plans-page/PlansPageToast.vue +5 -5
- package/src/pages-standard/sa-theme.css +85 -29
- package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
- package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
- package/src/pages-standard/tenant-detail/TenantUsers.vue +21 -0
- package/src/pages-standard/tenant-detail/types.ts +38 -0
- package/src/vue/use-mfa-prompt.ts +72 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
<
|
|
4
|
-
<
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
</
|
|
52
|
+
</TenantMasterData>
|
|
72
53
|
</slot>
|
|
73
|
-
</
|
|
54
|
+
</AdminSection>
|
|
74
55
|
|
|
75
56
|
<!-- Usage -->
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
</
|
|
76
|
+
</AdminBody>
|
|
104
77
|
|
|
105
78
|
<!-- Manifest-driven action flow dialogs -->
|
|
106
79
|
<MfaPromptDialog
|
|
107
|
-
|
|
108
|
-
:description="
|
|
109
|
-
:error="
|
|
110
|
-
@update:model-value="
|
|
111
|
-
@confirm="
|
|
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
|
-
</
|
|
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
|
|
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
|
|
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
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
<
|
|
3
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
3
|
-
<
|
|
4
|
-
<
|
|
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
|
-
</
|
|
17
|
-
</
|
|
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="
|
|
103
|
-
:description="
|
|
104
|
-
:error="
|
|
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="
|
|
107
|
-
@confirm="
|
|
102
|
+
@update:model-value="mfa.onVisibility"
|
|
103
|
+
@confirm="mfa.onConfirm"
|
|
108
104
|
/>
|
|
109
|
-
</
|
|
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
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
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
|
|
382
|
+
const code = await mfa.prompt(actionLabel);
|
|
411
383
|
if (code === null) return;
|
|
412
384
|
try {
|
|
413
385
|
const result = await invoke(code);
|
|
414
|
-
|
|
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
|
-
|
|
392
|
+
mfa.error.value = shell.value.mfa.invalidCode;
|
|
421
393
|
continue;
|
|
422
394
|
}
|
|
423
|
-
|
|
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>
|