@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,434 @@
1
+ <template>
2
+ <div class="sa-pemail">
3
+ <header class="sa-page-head">
4
+ <div>
5
+ <h1 class="sa-page-head__title">{{ title }}</h1>
6
+ <p class="sa-page-head__sub">
7
+ Zentraler Absender für Registrierungs- und System-Mails dieser Plattform.
8
+ </p>
9
+ </div>
10
+ <div class="sa-page-head__actions">
11
+ <q-btn
12
+ v-if="rows.length === 0"
13
+ unelevated
14
+ color="primary"
15
+ icon="add"
16
+ label="Absender"
17
+ @click="openCreate"
18
+ />
19
+ <q-btn flat icon="refresh" label="Neu laden" @click="reload" />
20
+ </div>
21
+ </header>
22
+
23
+ <div class="sa-pemail__card">
24
+ <q-table
25
+ flat
26
+ :rows="rows"
27
+ :columns="columns"
28
+ row-key="id"
29
+ :loading="loading"
30
+ :pagination="{ rowsPerPage: 0 }"
31
+ hide-pagination
32
+ >
33
+ <template #body-cell-active="{ row }">
34
+ <q-td>
35
+ <q-badge
36
+ :color="row.active ? 'positive' : 'grey'"
37
+ :label="row.active ? 'aktiv' : 'inaktiv'"
38
+ />
39
+ </q-td>
40
+ </template>
41
+ <template #body-cell-actions="{ row }">
42
+ <q-td>
43
+ <q-btn
44
+ flat
45
+ dense
46
+ icon="send"
47
+ color="primary"
48
+ title="Test-Mail senden"
49
+ @click="openTest(row)"
50
+ />
51
+ <q-btn
52
+ flat
53
+ dense
54
+ icon="edit"
55
+ color="grey-7"
56
+ title="Bearbeiten"
57
+ @click="openEdit(row)"
58
+ />
59
+ <q-btn
60
+ flat
61
+ dense
62
+ icon="delete"
63
+ color="negative"
64
+ title="Löschen"
65
+ @click="onDelete(row)"
66
+ />
67
+ </q-td>
68
+ </template>
69
+ </q-table>
70
+ </div>
71
+
72
+ <q-dialog v-model="showForm">
73
+ <q-card class="sa-pemail__dialog">
74
+ <q-card-section>
75
+ <div class="text-h6">
76
+ {{ editing ? 'Absender bearbeiten' : 'Absender anlegen' }}
77
+ </div>
78
+ </q-card-section>
79
+ <q-card-section class="q-gutter-sm">
80
+ <q-input v-model="form.name" outlined dense label="Name" />
81
+ <q-input v-model="form.smtpHost" outlined dense label="SMTP-Host" />
82
+ <q-input
83
+ v-model.number="form.smtpPort"
84
+ outlined
85
+ dense
86
+ type="number"
87
+ label="SMTP-Port"
88
+ />
89
+ <q-input v-model="form.smtpUser" outlined dense label="SMTP-Benutzer" />
90
+ <q-input
91
+ v-model="form.smtpPassword"
92
+ outlined
93
+ dense
94
+ type="password"
95
+ :label="editing ? 'SMTP-Passwort (leer = unverändert)' : 'SMTP-Passwort'"
96
+ />
97
+ <q-select
98
+ v-model="form.encryption"
99
+ outlined
100
+ dense
101
+ label="Verschlüsselung"
102
+ :options="encryptionOptions"
103
+ />
104
+ <q-input v-model="form.fromEmail" outlined dense label="Absender-Adresse" />
105
+ <q-input v-model="form.fromName" outlined dense label="Absender-Name" />
106
+ <q-toggle v-if="editing" v-model="form.active" label="Aktiv" />
107
+ </q-card-section>
108
+ <q-card-actions align="right">
109
+ <q-btn flat label="Abbrechen" @click="showForm = false" />
110
+ <q-btn unelevated color="primary" :label="editing ? 'Speichern' : 'Anlegen'" @click="onSubmit" />
111
+ </q-card-actions>
112
+ </q-card>
113
+ </q-dialog>
114
+
115
+ <q-dialog v-model="showTest">
116
+ <q-card class="sa-pemail__dialog">
117
+ <q-card-section>
118
+ <div class="text-h6">Test-Mail senden</div>
119
+ <div class="text-caption text-grey-7">{{ testTarget?.name }}</div>
120
+ </q-card-section>
121
+ <q-card-section class="q-gutter-sm">
122
+ <q-input v-model="testForm.toEmail" outlined dense label="Empfänger" type="email" />
123
+ <q-input v-model="testForm.subject" outlined dense label="Betreff (optional)" />
124
+ <q-banner v-if="testResult" :class="testResult.success ? 'bg-green-1' : 'bg-red-1'">
125
+ {{ testResult.message }}
126
+ </q-banner>
127
+ </q-card-section>
128
+ <q-card-actions align="right">
129
+ <q-btn flat label="Schließen" @click="showTest = false" />
130
+ <q-btn
131
+ unelevated
132
+ color="primary"
133
+ label="Senden"
134
+ :loading="testing"
135
+ @click="onTest"
136
+ />
137
+ </q-card-actions>
138
+ </q-card>
139
+ </q-dialog>
140
+
141
+ <MfaPromptDialog
142
+ v-if="requireMfaForWrite"
143
+ :model-value="showMfa"
144
+ :description="mfaDescription"
145
+ :error="mfaError"
146
+ :setup-hint="mfaSetupHint"
147
+ @update:model-value="onMfaDialogVisibility"
148
+ @confirm="onMfaConfirm"
149
+ />
150
+ </div>
151
+ </template>
152
+
153
+ <script setup lang="ts">
154
+ import { reactive, ref } from 'vue';
155
+ import { useQuasar } from 'quasar';
156
+ import MfaPromptDialog from '../components/MfaPromptDialog.vue';
157
+ import type {
158
+ PlatformEmailProvider,
159
+ PlatformEmailWriteInput,
160
+ PlatformEmailTestInput,
161
+ PlatformEmailTestResult,
162
+ } from './platform-email.types';
163
+
164
+ // Platform standard page: system/platform email sender. Like all standard
165
+ // pages, data-agnostic — the app passes the API calls in as props (with its
166
+ // own auth/MFA wiring). Create/update/delete require MFA, the test send does
167
+ // not.
168
+
169
+ const props = withDefaults(
170
+ defineProps<{
171
+ loadProviders: () => Promise<PlatformEmailProvider[]>;
172
+ createProvider: (input: PlatformEmailWriteInput, mfaCode?: string) => Promise<unknown>;
173
+ updateProvider: (
174
+ id: string,
175
+ input: PlatformEmailWriteInput,
176
+ mfaCode?: string,
177
+ ) => Promise<unknown>;
178
+ deleteProvider: (id: string, mfaCode?: string) => Promise<unknown>;
179
+ testProvider: (id: string, input: PlatformEmailTestInput) => Promise<PlatformEmailTestResult>;
180
+ title?: string;
181
+ requireMfaForWrite?: boolean;
182
+ mfaSetupHint?: string;
183
+ }>(),
184
+ {
185
+ title: 'Plattform-E-Mail',
186
+ requireMfaForWrite: false,
187
+ },
188
+ );
189
+
190
+ const q = useQuasar();
191
+ const rows = ref<PlatformEmailProvider[]>([]);
192
+ const loading = ref(false);
193
+
194
+ const encryptionOptions = ['NONE', 'SSL', 'TLS', 'STARTTLS'];
195
+
196
+ const columns = [
197
+ { name: 'name', label: 'Name', field: 'name', align: 'left' as const },
198
+ {
199
+ name: 'host',
200
+ label: 'Host',
201
+ field: (r: PlatformEmailProvider) => `${r.smtpHost}:${r.smtpPort}`,
202
+ align: 'left' as const,
203
+ },
204
+ { name: 'fromEmail', label: 'Absender', field: 'fromEmail', align: 'left' as const },
205
+ { name: 'encryption', label: 'Krypto', field: 'encryption', align: 'left' as const },
206
+ { name: 'active', label: 'Status', field: 'active', align: 'left' as const },
207
+ { name: 'actions', label: '', field: 'id' as never, align: 'right' as 'left' },
208
+ ];
209
+
210
+ const showForm = ref(false);
211
+ const editing = ref<PlatformEmailProvider | null>(null);
212
+ const form = reactive<PlatformEmailWriteInput>(emptyForm());
213
+
214
+ const showTest = ref(false);
215
+ const testTarget = ref<PlatformEmailProvider | null>(null);
216
+ const testForm = reactive<PlatformEmailTestInput>({ toEmail: '', subject: '' });
217
+ const testResult = ref<PlatformEmailTestResult | null>(null);
218
+ const testing = ref(false);
219
+
220
+ // MFA loop analogous to UsersPage (promise-resolver pattern).
221
+ const showMfa = ref(false);
222
+ const mfaError = ref('');
223
+ const mfaDescription = ref('');
224
+ let pendingMfaResolve: ((code: string | null) => void) | null = null;
225
+
226
+ function emptyForm(): PlatformEmailWriteInput {
227
+ return {
228
+ name: '',
229
+ smtpHost: '',
230
+ smtpPort: 587,
231
+ smtpUser: '',
232
+ smtpPassword: '',
233
+ encryption: 'TLS',
234
+ fromEmail: '',
235
+ fromName: '',
236
+ active: true,
237
+ };
238
+ }
239
+
240
+ function promptMfa(description: string): Promise<string | null> {
241
+ return new Promise((resolve) => {
242
+ mfaDescription.value = description;
243
+ mfaError.value = '';
244
+ showMfa.value = true;
245
+ pendingMfaResolve = (code) => {
246
+ pendingMfaResolve = null;
247
+ resolve(code);
248
+ };
249
+ });
250
+ }
251
+
252
+ function onMfaConfirm(code: string): void {
253
+ pendingMfaResolve?.(code);
254
+ }
255
+
256
+ function onMfaDialogVisibility(open: boolean): void {
257
+ showMfa.value = open;
258
+ if (!open && pendingMfaResolve) {
259
+ pendingMfaResolve(null);
260
+ }
261
+ }
262
+
263
+ function errMsg(err: unknown): string {
264
+ return (
265
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
266
+ (err as Error)?.message ??
267
+ 'Aktion fehlgeschlagen'
268
+ );
269
+ }
270
+
271
+ async function reload(): Promise<void> {
272
+ loading.value = true;
273
+ try {
274
+ rows.value = await props.loadProviders();
275
+ } catch (err) {
276
+ rows.value = [];
277
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
278
+ } finally {
279
+ loading.value = false;
280
+ }
281
+ }
282
+
283
+ void reload();
284
+ defineExpose({ reload });
285
+
286
+ function openCreate(): void {
287
+ editing.value = null;
288
+ Object.assign(form, emptyForm());
289
+ showForm.value = true;
290
+ }
291
+
292
+ function openEdit(row: PlatformEmailProvider): void {
293
+ editing.value = row;
294
+ Object.assign(form, {
295
+ name: row.name,
296
+ smtpHost: row.smtpHost,
297
+ smtpPort: row.smtpPort,
298
+ smtpUser: row.smtpUser,
299
+ smtpPassword: '',
300
+ encryption: row.encryption,
301
+ fromEmail: row.fromEmail,
302
+ fromName: row.fromName ?? '',
303
+ active: row.active,
304
+ });
305
+ showForm.value = true;
306
+ }
307
+
308
+ // MFA loop: on 401 the dialog stays open and asks again.
309
+ async function runWrite(label: string, invoke: (code: string) => Promise<unknown>): Promise<boolean> {
310
+ if (!props.requireMfaForWrite) {
311
+ try {
312
+ await invoke('');
313
+ return true;
314
+ } catch (err) {
315
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
316
+ return false;
317
+ }
318
+ }
319
+ for (;;) {
320
+ const code = await promptMfa(label);
321
+ if (code === null) return false;
322
+ try {
323
+ await invoke(code);
324
+ showMfa.value = false;
325
+ return true;
326
+ } catch (err) {
327
+ const status = (err as { response?: { status?: number } })?.response?.status;
328
+ if (status === 401) {
329
+ mfaError.value = 'TOTP-Code ungültig oder MFA nicht eingerichtet.';
330
+ continue;
331
+ }
332
+ showMfa.value = false;
333
+ q.notify({ type: 'negative', message: errMsg(err), position: 'top' });
334
+ return false;
335
+ }
336
+ }
337
+ }
338
+
339
+ function buildWriteInput(): PlatformEmailWriteInput {
340
+ const input: PlatformEmailWriteInput = {
341
+ name: form.name,
342
+ smtpHost: form.smtpHost,
343
+ smtpPort: form.smtpPort,
344
+ smtpUser: form.smtpUser,
345
+ encryption: form.encryption,
346
+ fromEmail: form.fromEmail,
347
+ fromName: form.fromName || undefined,
348
+ };
349
+ // Only send the password when set — on update, empty means "unchanged".
350
+ if (form.smtpPassword && form.smtpPassword.length > 0) {
351
+ input.smtpPassword = form.smtpPassword;
352
+ }
353
+ if (editing.value) {
354
+ input.active = form.active;
355
+ }
356
+ return input;
357
+ }
358
+
359
+ async function onSubmit(): Promise<void> {
360
+ const current = editing.value;
361
+ const input = buildWriteInput();
362
+ const ok = await runWrite(
363
+ current ? `Absender "${input.name}" speichern.` : `Absender "${input.name}" anlegen.`,
364
+ (code) =>
365
+ current
366
+ ? props.updateProvider(current.id, input, code || undefined)
367
+ : props.createProvider(input, code || undefined),
368
+ );
369
+ if (ok) {
370
+ showForm.value = false;
371
+ q.notify({ type: 'positive', message: 'Gespeichert.', position: 'top' });
372
+ void reload();
373
+ }
374
+ }
375
+
376
+ function onDelete(row: PlatformEmailProvider): void {
377
+ q.dialog({
378
+ title: 'Absender löschen',
379
+ message: `"${row.name}" wirklich löschen?`,
380
+ cancel: 'Abbrechen',
381
+ ok: { label: 'Löschen', color: 'negative' },
382
+ }).onOk(async () => {
383
+ const ok = await runWrite(`Absender "${row.name}" löschen.`, (code) =>
384
+ props.deleteProvider(row.id, code || undefined),
385
+ );
386
+ if (ok) {
387
+ q.notify({ type: 'positive', message: 'Gelöscht.', position: 'top' });
388
+ void reload();
389
+ }
390
+ });
391
+ }
392
+
393
+ function openTest(row: PlatformEmailProvider): void {
394
+ testTarget.value = row;
395
+ testForm.toEmail = '';
396
+ testForm.subject = '';
397
+ testResult.value = null;
398
+ showTest.value = true;
399
+ }
400
+
401
+ async function onTest(): Promise<void> {
402
+ const target = testTarget.value;
403
+ if (!target) return;
404
+ testing.value = true;
405
+ testResult.value = null;
406
+ try {
407
+ testResult.value = await props.testProvider(target.id, {
408
+ toEmail: testForm.toEmail,
409
+ subject: testForm.subject || undefined,
410
+ });
411
+ } catch (err) {
412
+ testResult.value = { success: false, message: errMsg(err) };
413
+ } finally {
414
+ testing.value = false;
415
+ }
416
+ }
417
+ </script>
418
+
419
+ <style scoped>
420
+ .sa-pemail {
421
+ min-height: calc(100vh - 56px);
422
+ background: var(--sa-bg-app, #f1f5f9);
423
+ padding: 20px 28px 28px;
424
+ }
425
+ .sa-pemail__card {
426
+ background: #fff;
427
+ border: 1px solid var(--sa-border, #e2e8f0);
428
+ border-radius: 12px;
429
+ overflow: hidden;
430
+ }
431
+ .sa-pemail__dialog {
432
+ min-width: 420px;
433
+ }
434
+ </style>