@antelopejs/dms-saas 0.1.8 → 0.2.1

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 (199) hide show
  1. package/README.md +262 -13
  2. package/dist/auth/subscription-access-gate.d.ts +1 -1
  3. package/dist/billing-state/index.d.ts +1 -0
  4. package/dist/billing-state/index.js +1 -0
  5. package/dist/billing-state/index.js.map +1 -1
  6. package/dist/billing-state/past-due-banner.d.ts +4 -0
  7. package/dist/billing-state/past-due-banner.js +25 -0
  8. package/dist/billing-state/past-due-banner.js.map +1 -0
  9. package/dist/config/runtime.d.ts +6 -1
  10. package/dist/config/runtime.js +50 -1
  11. package/dist/config/runtime.js.map +1 -1
  12. package/dist/crons/recompute-billing-state.js +4 -1
  13. package/dist/crons/recompute-billing-state.js.map +1 -1
  14. package/dist/data-api/platformOwner/index.d.ts +1 -0
  15. package/dist/data-api/platformOwner/index.js +1 -0
  16. package/dist/data-api/platformOwner/index.js.map +1 -1
  17. package/dist/data-api/platformOwner/invitations.d.ts +22 -0
  18. package/dist/data-api/platformOwner/invitations.js +121 -0
  19. package/dist/data-api/platformOwner/invitations.js.map +1 -0
  20. package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
  21. package/dist/data-api/platformOwner/workspaces.js +64 -12
  22. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  23. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  24. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  25. package/dist/hooks/register.js +2 -1
  26. package/dist/hooks/register.js.map +1 -1
  27. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  28. package/dist/implementations/dms-saas/billing.js +6 -0
  29. package/dist/implementations/dms-saas/billing.js.map +1 -1
  30. package/dist/index.js +5 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/invoice-line-items/injection.d.ts +10 -0
  33. package/dist/invoice-line-items/injection.js +7 -3
  34. package/dist/invoice-line-items/injection.js.map +1 -1
  35. package/dist/notifications/subjects.d.ts +19 -19
  36. package/dist/operator-actions/index.d.ts +1 -0
  37. package/dist/operator-actions/index.js +1 -0
  38. package/dist/operator-actions/index.js.map +1 -1
  39. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  40. package/dist/operator-actions/invitation-commands.js +46 -0
  41. package/dist/operator-actions/invitation-commands.js.map +1 -0
  42. package/dist/operator-actions/types.d.ts +1 -1
  43. package/dist/pages/platform/categories.d.ts +6 -6
  44. package/dist/pages/platform/credit-notes.d.ts +1 -1
  45. package/dist/pages/platform/dashboard.d.ts +2 -2
  46. package/dist/pages/platform/dashboard.js +2 -4
  47. package/dist/pages/platform/dashboard.js.map +1 -1
  48. package/dist/pages/platform/features.d.ts +1 -1
  49. package/dist/pages/platform/invoices.d.ts +1 -1
  50. package/dist/pages/platform/plans/edit.d.ts +2 -2
  51. package/dist/pages/platform/plans/index.d.ts +1 -1
  52. package/dist/pages/platform/plans/migrations.d.ts +1 -1
  53. package/dist/pages/platform/segments/index.d.ts +1 -1
  54. package/dist/pages/platform/settings.d.ts +1 -1
  55. package/dist/pages/platform/users/detail.d.ts +1 -1
  56. package/dist/pages/platform/users/index.d.ts +1 -1
  57. package/dist/pages/platform/workspaces/detail.d.ts +1 -1
  58. package/dist/pages/platform/workspaces/detail.js +40 -1
  59. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  60. package/dist/pages/platform/workspaces/index.d.ts +4 -10
  61. package/dist/pages/platform/workspaces/index.js +27 -111
  62. package/dist/pages/platform/workspaces/index.js.map +1 -1
  63. package/dist/pages/public/legal/privacy-policy.js +2 -1
  64. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  65. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  66. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  67. package/dist/pages/public/legal/terms-of-use.js +2 -1
  68. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  69. package/dist/pages/tenant/billing.d.ts +1 -1
  70. package/dist/plans/index.d.ts +1 -0
  71. package/dist/plans/index.js +1 -0
  72. package/dist/plans/index.js.map +1 -1
  73. package/dist/plans/platform-support.d.ts +2 -0
  74. package/dist/plans/platform-support.js +19 -0
  75. package/dist/plans/platform-support.js.map +1 -0
  76. package/dist/plans/seat-capacity.d.ts +9 -2
  77. package/dist/plans/seat-capacity.js +59 -12
  78. package/dist/plans/seat-capacity.js.map +1 -1
  79. package/dist/plans/seat-hooks.js +34 -13
  80. package/dist/plans/seat-hooks.js.map +1 -1
  81. package/dist/routes/platformOwner/billing-settings.d.ts +2 -2
  82. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  83. package/dist/routes/platformOwner/dashboard.js +31 -17
  84. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  85. package/dist/routes/platformOwner/index.d.ts +1 -0
  86. package/dist/routes/platformOwner/index.js +1 -0
  87. package/dist/routes/platformOwner/index.js.map +1 -1
  88. package/dist/routes/platformOwner/plan-deletion.d.ts +1 -1
  89. package/dist/routes/platformOwner/plans.d.ts +5 -5
  90. package/dist/routes/platformOwner/segments.d.ts +1 -1
  91. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  92. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  93. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  94. package/dist/routes/public/legal-documents.d.ts +2 -2
  95. package/dist/routes/public/register.d.ts +7 -3
  96. package/dist/routes/public/register.js +42 -27
  97. package/dist/routes/public/register.js.map +1 -1
  98. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  99. package/dist/routes/tenant/tenant-billing.js +34 -26
  100. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  101. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  102. package/dist/routes/tenant/tenant-plan.js +27 -2
  103. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  104. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  105. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  106. package/dist/routes/tenant/workspaces.d.ts +3 -1
  107. package/dist/routes/tenant/workspaces.js +10 -4
  108. package/dist/routes/tenant/workspaces.js.map +1 -1
  109. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  110. package/dist/stripe/upcoming-invoice.js +42 -0
  111. package/dist/stripe/upcoming-invoice.js.map +1 -0
  112. package/dist/stripe/webhook-dispatch.js +2 -0
  113. package/dist/stripe/webhook-dispatch.js.map +1 -1
  114. package/dist/types/config.d.ts +9 -0
  115. package/dist/types/config.js +7 -0
  116. package/dist/types/config.js.map +1 -1
  117. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  118. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  119. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  120. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  121. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  122. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  123. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  124. package/dist/upcoming-invoice/eligibility.js +52 -0
  125. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  126. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  127. package/dist/upcoming-invoice/invalidation.js +39 -0
  128. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  129. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  130. package/dist/upcoming-invoice/mapping.js +82 -0
  131. package/dist/upcoming-invoice/mapping.js.map +1 -0
  132. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  133. package/dist/upcoming-invoice/outcomes.js +15 -0
  134. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  135. package/dist/upcoming-invoice/preview.d.ts +3 -0
  136. package/dist/upcoming-invoice/preview.js +80 -0
  137. package/dist/upcoming-invoice/preview.js.map +1 -0
  138. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  139. package/dist/upcoming-invoice/pricing.js +84 -0
  140. package/dist/upcoming-invoice/pricing.js.map +1 -0
  141. package/dist/upcoming-invoice/usage.d.ts +14 -0
  142. package/dist/upcoming-invoice/usage.js +53 -0
  143. package/dist/upcoming-invoice/usage.js.map +1 -0
  144. package/dist/utils/invoice-lines-type.d.ts +1 -1
  145. package/dist/workspaces/billing-identity.d.ts +19 -0
  146. package/dist/workspaces/billing-identity.js +51 -0
  147. package/dist/workspaces/billing-identity.js.map +1 -0
  148. package/dist/workspaces/default-plan.d.ts +5 -0
  149. package/dist/workspaces/default-plan.js +109 -0
  150. package/dist/workspaces/default-plan.js.map +1 -0
  151. package/dist/workspaces/index.d.ts +2 -0
  152. package/dist/workspaces/index.js +2 -0
  153. package/dist/workspaces/index.js.map +1 -1
  154. package/dist/workspaces/invitations.d.ts +26 -0
  155. package/dist/workspaces/invitations.js +112 -0
  156. package/dist/workspaces/invitations.js.map +1 -0
  157. package/dist/workspaces/provisioning.d.ts +3 -3
  158. package/dist/workspaces/provisioning.js +39 -11
  159. package/dist/workspaces/provisioning.js.map +1 -1
  160. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  161. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  162. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  163. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  164. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  165. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  166. package/frontend-vue/app/components/RefundButton.vue +10 -1
  167. package/frontend-vue/app/components/Register.vue +49 -92
  168. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  169. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  170. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  171. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  172. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  173. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  174. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  175. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  176. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  177. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  178. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  179. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  180. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  181. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  182. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  183. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  184. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  185. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  186. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  187. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  188. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  189. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  190. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  191. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  192. package/frontend-vue/app/types/runtime-config.ts +13 -0
  193. package/frontend-vue/dms.frontend.ts +10 -4
  194. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  195. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  196. package/frontend-vue/package.json +0 -1
  197. package/frontend-vue/pnpm-lock.yaml +1 -125
  198. package/package.json +15 -15
  199. package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
@@ -2,6 +2,7 @@
2
2
  import { computed, onMounted, ref } from "vue";
3
3
 
4
4
  const FREE_PLAN_PRICE = 0;
5
+ const SUMMARY_SEPARATOR = " · ";
5
6
  const FULL_DATE_FORMAT: Intl.DateTimeFormatOptions = {
6
7
  year: "numeric",
7
8
  month: "long",
@@ -19,12 +20,17 @@ const {
19
20
  const { data: billingStatus, load: loadBillingStatus } = useBillingStatus();
20
21
  const { formatMajorUnits } = useMoneyFormat();
21
22
  const { t, locale } = useI18n();
23
+ const { formatFeatureValue } = usePlanFeatureFormat();
24
+ const { featureLabel } = usePlanFeatureLabel();
25
+ const { workspace, load: loadWorkspace } = useCurrentWorkspace();
26
+ const { isOpen: isComparisonOpen } = usePlanComparison();
22
27
  const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
23
28
 
24
29
  const data = ref<TenantPlanResponse | null>(null);
25
30
  const isLoading = ref(true);
26
31
  const isCancelling = ref(false);
27
- const isComparisonOpen = ref(false);
32
+ const isUpgradeOpen = ref(false);
33
+ const upgradeTarget = ref<TenantPlanView | null>(null);
28
34
 
29
35
  const current = computed(() => data.value?.current ?? null);
30
36
  const pendingPlan = computed(() => data.value?.pendingPlan ?? null);
@@ -38,6 +44,15 @@ const isAccessBlocked = computed(
38
44
  !data.value?.canRecoverComplimentary,
39
45
  );
40
46
 
47
+ /** Settling an invoice only lifts a block when there is one to settle: an
48
+ * operator suspension, or a member who cannot see the invoice, gets the copy
49
+ * that promises nothing about payment. */
50
+ const blockedHintKey = computed(() =>
51
+ billingStatus.value?.unpaidInvoice
52
+ ? "saas.workspace.plan.blocked_hint"
53
+ : "saas.workspace.plan.blocked_hint_inactive",
54
+ );
55
+
41
56
  /** Plan mutations are owner-only server-side; a member gets the read-only
42
57
  * card instead of buttons that can only end in a 403 toast. */
43
58
  const isTenantOwner = computed(() => !!billingStatus.value?.isTenantOwner);
@@ -68,6 +83,34 @@ const changeAction = computed(() =>
68
83
  : { color: "primary" as const, variant: "solid" as const, key: "go_paid" },
69
84
  );
70
85
 
86
+ /**
87
+ * One line on what the plan includes: the catalogue's own description when it
88
+ * has one, else the plan's key (non-detail) limits.
89
+ */
90
+ const summary = computed(() => {
91
+ if (current.value?.description) return current.value.description;
92
+ const view = data.value?.available.find(
93
+ (plan) => plan._id === current.value?._id,
94
+ );
95
+ if (!view || !data.value) return "";
96
+ return data.value.features
97
+ .filter(
98
+ (feature) =>
99
+ !feature.isDetailRow &&
100
+ view.featureValues[feature.featureId] !== undefined,
101
+ )
102
+ .map(
103
+ (feature) =>
104
+ `${featureLabel(feature)} ${formatFeatureValue(feature, view.featureValues[feature.featureId], view.currency)}`,
105
+ )
106
+ .join(SUMMARY_SEPARATOR);
107
+ });
108
+
109
+ function openUpgrade(plan: TenantPlanView): void {
110
+ upgradeTarget.value = plan;
111
+ isUpgradeOpen.value = true;
112
+ }
113
+
71
114
  const priceLabel = computed(() =>
72
115
  current.value
73
116
  ? formatMajorUnits(current.value.price, current.value.currency)
@@ -111,8 +154,15 @@ async function cancelDowngrade(): Promise<void> {
111
154
  }
112
155
  }
113
156
 
157
+ /** The workspace name only titles the owner's plan comparison, and reading
158
+ * it is owner-only: a member's card never asks. */
159
+ async function loadWorkspaceForOwner(): Promise<void> {
160
+ const status = await loadBillingStatus();
161
+ if (status?.isTenantOwner) await loadWorkspace().catch(() => undefined);
162
+ }
163
+
114
164
  onMounted(() => {
115
- void loadBillingStatus();
165
+ void loadWorkspaceForOwner();
116
166
  return fetchPlan(false);
117
167
  });
118
168
  </script>
@@ -140,11 +190,11 @@ onMounted(() => {
140
190
  <div v-else-if="data" class="flex flex-col gap-4">
141
191
  <div class="flex flex-wrap items-start gap-4">
142
192
  <div class="min-w-60 grow">
143
- <h2 class="text-lg font-semibold">
144
- {{ current?.name ?? $t("saas.workspace.plan.none") }}
193
+ <h2 v-if="current" class="text-lg font-semibold">
194
+ {{ current.name }}
145
195
  </h2>
146
- <p v-if="current?.description" class="text-muted mt-1 text-sm">
147
- {{ current.description }}
196
+ <p v-if="summary" class="text-muted mt-1 text-sm">
197
+ {{ summary }}
148
198
  </p>
149
199
  </div>
150
200
  <div v-if="current" class="text-right">
@@ -167,9 +217,9 @@ onMounted(() => {
167
217
  </div>
168
218
  <div class="mt-2 flex justify-between gap-4 font-semibold">
169
219
  <span>{{ $t("saas.admission.total") }}</span>
170
- <span class="tabular-nums">{{
171
- formatMajorUnits(0, current.currency)
172
- }}</span>
220
+ <span class="tabular-nums">
221
+ {{ formatMajorUnits(0, current.currency) }}
222
+ </span>
173
223
  </div>
174
224
  </div>
175
225
 
@@ -181,7 +231,7 @@ onMounted(() => {
181
231
  </p>
182
232
 
183
233
  <p v-if="isAccessBlocked" class="text-muted text-sm">
184
- {{ $t("saas.workspace.plan.blocked_hint") }}
234
+ {{ $t(blockedHintKey) }}
185
235
  </p>
186
236
 
187
237
  <p v-else-if="data.isPlanChangeLocked" class="text-muted text-sm">
@@ -216,6 +266,14 @@ onMounted(() => {
216
266
  :current-plan-id="current?._id ?? null"
217
267
  :pending-plan-id="pendingPlan?.planId ?? null"
218
268
  :is-recovery="data.canRecoverComplimentary"
269
+ :is-current-paid="isPaid"
270
+ :workspace-name="workspace?.name ?? null"
271
+ @changed="refresh"
272
+ @upgrade="openUpgrade"
273
+ />
274
+ <DmsSaasPlanUpgradeModal
275
+ v-model:open="isUpgradeOpen"
276
+ :plan="upgradeTarget"
219
277
  @changed="refresh"
220
278
  />
221
279
  </div>
@@ -20,12 +20,19 @@ interface UserSummaryData {
20
20
  workspaces: Array<{ isTenantOwner: boolean }>;
21
21
  }
22
22
 
23
+ const MISSING_DATE = "—";
24
+ const DAY_FORMAT: Intl.DateTimeFormatOptions = {
25
+ day: "2-digit",
26
+ month: "short",
27
+ year: "numeric",
28
+ };
29
+
23
30
  const props = defineProps<{
24
31
  routeParams?: Record<string, string>;
25
32
  }>();
26
33
 
27
34
  const { $authFetch } = useAuthFetch();
28
- const { t } = useI18n();
35
+ const { t, locale } = useI18n();
29
36
 
30
37
  const userId = computed(() => props.routeParams?.id ?? "");
31
38
  const user = ref<UserSummaryData | null>(null);
@@ -48,13 +55,8 @@ const memberCount = computed(
48
55
  () => user.value?.workspaces.filter((w) => !w.isTenantOwner).length ?? 0,
49
56
  );
50
57
 
51
- function formatDate(value: string | null): string {
52
- if (!value) return "—";
53
- return new Date(value).toLocaleDateString("fr-FR", {
54
- day: "2-digit",
55
- month: "short",
56
- year: "numeric",
57
- });
58
+ function formatDay(value: string | null): string {
59
+ return formatDate(value, locale.value, DAY_FORMAT) ?? MISSING_DATE;
58
60
  }
59
61
 
60
62
  async function load(): Promise<void> {
@@ -114,11 +116,11 @@ onMounted(load);
114
116
  <dl class="grid grid-cols-2 gap-3 text-sm lg:grid-cols-4">
115
117
  <div>
116
118
  <dt class="text-muted">{{ $t("saas.users.column.created_at") }}</dt>
117
- <dd class="mt-0.5">{{ formatDate(user.createdAt) }}</dd>
119
+ <dd class="mt-0.5">{{ formatDay(user.createdAt) }}</dd>
118
120
  </div>
119
121
  <div>
120
122
  <dt class="text-muted">{{ $t("saas.users.info.last_active") }}</dt>
121
- <dd class="mt-0.5">{{ formatDate(user.lastActiveAt) }}</dd>
123
+ <dd class="mt-0.5">{{ formatDay(user.lastActiveAt) }}</dd>
122
124
  </div>
123
125
  <div>
124
126
  <dt class="text-muted">{{ $t("saas.users.info.language") }}</dt>
@@ -0,0 +1,202 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from "vue";
3
+
4
+ interface PlanOption {
5
+ _id: string;
6
+ name: string;
7
+ isActive: boolean;
8
+ isDeleted: boolean;
9
+ }
10
+
11
+ type CreatedOwner =
12
+ | { kind: "added"; userId: string }
13
+ | { kind: "invited"; inviteId: string };
14
+
15
+ interface CreatedWorkspace {
16
+ tenantId: string;
17
+ owner: CreatedOwner;
18
+ invitationEmail: "sent" | "failed" | null;
19
+ }
20
+
21
+ const props = defineProps<{
22
+ onSuccessCallback?: () => void;
23
+ onCancelCallback?: () => void;
24
+ }>();
25
+
26
+ const PLANS_ENDPOINT = "/api/saas/plans";
27
+ const CREATE_ENDPOINT = "/modules/saas/customers/workspaces/create";
28
+ const WARNING_TOAST_DURATION = 0;
29
+
30
+ const { $authFetch } = useAuthFetch();
31
+ const nuxtApp = useDmsApp();
32
+ const toast = useToast();
33
+ const { resolveApiError } = useApiErrorMessage();
34
+ const { fetchAndCopy } = useInvitationLink();
35
+
36
+ const plans = ref<PlanOption[]>([]);
37
+ const name = ref("");
38
+ const planId = ref<string | undefined>(undefined);
39
+ const ownerEmail = ref("");
40
+ const freeUntil = ref("");
41
+ const isLoading = ref(true);
42
+ const isSubmitting = ref(false);
43
+ const errorMessage = ref<string | null>(null);
44
+
45
+ const planItems = computed(() =>
46
+ plans.value
47
+ .filter((plan) => plan.isActive && !plan.isDeleted)
48
+ .map((plan) => ({ value: plan._id, label: plan.name })),
49
+ );
50
+
51
+ const canSubmit = computed(
52
+ () => !!name.value.trim() && !!planId.value && !!ownerEmail.value.trim(),
53
+ );
54
+
55
+ async function load(): Promise<void> {
56
+ try {
57
+ plans.value = await $authFetch<PlanOption[]>(PLANS_ENDPOINT);
58
+ } catch (error) {
59
+ errorMessage.value = resolveApiError(
60
+ error,
61
+ "saas.workspaces.admin.create.error.load",
62
+ );
63
+ } finally {
64
+ isLoading.value = false;
65
+ }
66
+ }
67
+
68
+ function notifyCreated(created: CreatedWorkspace): void {
69
+ const t = nuxtApp.$i18n.t;
70
+ const { owner } = created;
71
+ if (created.invitationEmail !== "failed" || owner.kind !== "invited") {
72
+ toast.add({
73
+ title: t("saas.workspaces.admin.create.success"),
74
+ color: "success",
75
+ icon: "i-ph-check-circle",
76
+ });
77
+ return;
78
+ }
79
+ // The workspace exists and the owner is invited, but nobody told them: the
80
+ // operator has to hand the link over, so the notice stays until dismissed.
81
+ toast.add({
82
+ title: t("saas.workspaces.admin.create.email_failed.title"),
83
+ description: t("saas.workspaces.admin.create.email_failed.description"),
84
+ color: "warning",
85
+ icon: "i-ph-warning",
86
+ duration: WARNING_TOAST_DURATION,
87
+ actions: [
88
+ {
89
+ label: t("saas.workspaces.invitations.action.copy_link"),
90
+ icon: "i-ph-link",
91
+ onClick: () =>
92
+ fetchAndCopy({ tenantId: created.tenantId, inviteId: owner.inviteId }),
93
+ },
94
+ ],
95
+ });
96
+ }
97
+
98
+ async function submit(): Promise<void> {
99
+ if (isSubmitting.value || !canSubmit.value) return;
100
+ errorMessage.value = null;
101
+ isSubmitting.value = true;
102
+ try {
103
+ const created = await $authFetch<CreatedWorkspace>(CREATE_ENDPOINT, {
104
+ method: "POST",
105
+ body: {
106
+ name: name.value.trim(),
107
+ planId: planId.value,
108
+ ownerEmail: ownerEmail.value.trim(),
109
+ freeUntil: freeUntil.value || null,
110
+ },
111
+ });
112
+ notifyCreated(created);
113
+ props.onSuccessCallback?.();
114
+ } catch (error) {
115
+ errorMessage.value = resolveApiError(
116
+ error,
117
+ "saas.workspaces.admin.create.error.failed",
118
+ );
119
+ } finally {
120
+ isSubmitting.value = false;
121
+ }
122
+ }
123
+
124
+ onMounted(load);
125
+ </script>
126
+
127
+ <template>
128
+ <div v-if="isLoading" class="flex flex-col gap-3">
129
+ <USkeleton class="h-10 w-full" />
130
+ <USkeleton class="h-10 w-full" />
131
+ </div>
132
+ <form v-else class="flex flex-col gap-4" @submit.prevent="submit">
133
+ <UFormField
134
+ :label="$t('saas.workspaces.admin.create.field.name')"
135
+ :description="$t('saas.workspaces.admin.create.field.name_description')"
136
+ required
137
+ >
138
+ <UInput
139
+ v-model="name"
140
+ :placeholder="$t('saas.workspaces.admin.create.placeholder.name')"
141
+ class="w-full"
142
+ autofocus
143
+ />
144
+ </UFormField>
145
+ <UFormField
146
+ :label="$t('saas.workspaces.admin.create.field.plan')"
147
+ :description="$t('saas.workspaces.admin.create.field.plan_description')"
148
+ required
149
+ >
150
+ <USelect
151
+ v-model="planId"
152
+ :items="planItems"
153
+ :placeholder="$t('saas.workspaces.admin.create.placeholder.plan')"
154
+ class="w-full"
155
+ />
156
+ </UFormField>
157
+ <UFormField
158
+ :label="$t('saas.workspaces.admin.create.field.owner_email')"
159
+ :description="
160
+ $t('saas.workspaces.admin.create.field.owner_email_description')
161
+ "
162
+ required
163
+ >
164
+ <UInput
165
+ v-model="ownerEmail"
166
+ type="email"
167
+ :placeholder="$t('saas.workspaces.admin.create.placeholder.owner_email')"
168
+ class="w-full"
169
+ />
170
+ </UFormField>
171
+ <UFormField
172
+ :label="$t('saas.workspaces.admin.create.field.free_until')"
173
+ :description="
174
+ $t('saas.workspaces.admin.create.field.free_until_description')
175
+ "
176
+ >
177
+ <UInput v-model="freeUntil" type="date" class="w-full" />
178
+ </UFormField>
179
+
180
+ <p v-if="errorMessage" class="text-error text-sm">{{ errorMessage }}</p>
181
+
182
+ <div class="flex justify-end gap-2">
183
+ <UButton
184
+ v-if="props.onCancelCallback"
185
+ color="neutral"
186
+ variant="ghost"
187
+ :disabled="isSubmitting"
188
+ @click="props.onCancelCallback()"
189
+ >
190
+ {{ $t("common.cancel") }}
191
+ </UButton>
192
+ <UButton
193
+ type="submit"
194
+ color="primary"
195
+ :loading="isSubmitting"
196
+ :disabled="!canSubmit"
197
+ >
198
+ {{ $t("saas.workspaces.admin.create.submit") }}
199
+ </UButton>
200
+ </div>
201
+ </form>
202
+ </template>
@@ -21,6 +21,7 @@ interface CreateOptions {
21
21
 
22
22
  const props = defineProps<{
23
23
  onSuccessCallback?: (tenantId: string) => void;
24
+ onCancelCallback?: () => void;
24
25
  }>();
25
26
 
26
27
  const PLANS_ENDPOINT = "/api/saas/plans/public";
@@ -52,6 +53,7 @@ const freePlan = ref<FreePlanAvailability>({
52
53
  const isLoading = ref(true);
53
54
  const isSubmitting = ref(false);
54
55
  const errorMessage = ref<string | null>(null);
56
+ const nameError = ref<string | null>(null);
55
57
  const cardError = ref<string | null>(null);
56
58
  const stripeHandle = ref<ReturnType<typeof useStripePaymentElement> | null>(
57
59
  null,
@@ -94,6 +96,11 @@ const selectablePlans = computed(() =>
94
96
  plans.value.filter((plan) => !isPlanDisabled(plan)),
95
97
  );
96
98
 
99
+ // Only once the catalogue actually loaded: a failed load reports itself.
100
+ const hasNoPlanOnOffer = computed(
101
+ () => !isLoading.value && !errorMessage.value && plans.value.length === 0,
102
+ );
103
+
97
104
  function selectPlan(plan: PlanOption): void {
98
105
  if (isPlanDisabled(plan)) return;
99
106
  selectedPlanId.value = plan._id;
@@ -125,6 +132,8 @@ async function load(): Promise<void> {
125
132
  plans.value = loadedPlans;
126
133
  freePlan.value = options.freePlan;
127
134
  selectedPlanId.value = selectablePlans.value[0]?._id ?? null;
135
+ // Nothing to pay for, so no card to set up.
136
+ if (loadedPlans.length === 0) return;
128
137
  } catch (error) {
129
138
  errorMessage.value = resolveApiError(
130
139
  error,
@@ -147,10 +156,10 @@ async function load(): Promise<void> {
147
156
  }
148
157
 
149
158
  function validate(): boolean {
150
- if (!workspaceName.value.trim()) {
151
- errorMessage.value = t("saas.workspaces.create.error.name");
152
- return false;
153
- }
159
+ nameError.value = workspaceName.value.trim()
160
+ ? null
161
+ : t("saas.workspaces.create.error.name");
162
+ if (nameError.value) return false;
154
163
  if (!selectedPlanId.value) {
155
164
  errorMessage.value = t("saas.workspaces.create.error.plan");
156
165
  return false;
@@ -201,17 +210,28 @@ onMounted(load);
201
210
  <USkeleton class="h-24 w-full" />
202
211
  </div>
203
212
  <form v-else class="flex flex-col gap-4" @submit.prevent="submit">
204
- <UFormField :label="$t('saas.workspaces.create.field.name')">
213
+ <UFormField
214
+ :label="$t('saas.workspaces.create.field.name')"
215
+ :error="nameError ?? false"
216
+ >
205
217
  <UInput
206
218
  v-model="workspaceName"
207
219
  :placeholder="$t('saas.workspaces.create.placeholder.name')"
208
220
  class="w-full"
209
221
  autofocus
210
- required
222
+ @update:model-value="nameError = null"
211
223
  />
212
224
  </UFormField>
213
225
 
214
- <div class="flex flex-col gap-2">
226
+ <UAlert
227
+ v-if="hasNoPlanOnOffer"
228
+ color="neutral"
229
+ variant="subtle"
230
+ icon="i-ph-info"
231
+ :description="$t('saas.workspaces.create.self_serve.no_plans')"
232
+ />
233
+
234
+ <div v-else class="flex flex-col gap-2">
215
235
  <span class="text-sm font-medium">
216
236
  {{ $t("saas.workspaces.create.field.plan") }}
217
237
  <span class="text-muted font-normal">
@@ -240,7 +260,10 @@ onMounted(load);
240
260
  </button>
241
261
  </div>
242
262
 
243
- <UFormField :label="$t('saas.workspaces.create.self_serve.card')">
263
+ <UFormField
264
+ v-if="!hasNoPlanOnOffer"
265
+ :label="$t('saas.workspaces.create.self_serve.card')"
266
+ >
244
267
  <div :id="PAYMENT_ELEMENT_ID" class="border-default rounded-md border p-4" />
245
268
  <p v-if="cardError" class="text-error mt-1 text-sm">{{ cardError }}</p>
246
269
  </UFormField>
@@ -248,7 +271,21 @@ onMounted(load);
248
271
  <p v-if="errorMessage" class="text-error text-sm">{{ errorMessage }}</p>
249
272
 
250
273
  <div class="flex justify-end gap-2">
251
- <UButton type="submit" color="primary" :loading="isSubmitting">
274
+ <UButton
275
+ v-if="props.onCancelCallback"
276
+ color="neutral"
277
+ variant="ghost"
278
+ :disabled="isSubmitting"
279
+ @click="props.onCancelCallback()"
280
+ >
281
+ {{ $t("common.cancel") }}
282
+ </UButton>
283
+ <UButton
284
+ type="submit"
285
+ color="primary"
286
+ :loading="isSubmitting"
287
+ :disabled="hasNoPlanOnOffer"
288
+ >
252
289
  {{ $t("saas.workspaces.create.self_serve.submit") }}
253
290
  </UButton>
254
291
  </div>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from "vue";
3
+
4
+ interface InvitationRow {
5
+ _id: string;
6
+ _instance: string;
7
+ email?: string;
8
+ }
9
+
10
+ const props = defineProps<{
11
+ rowData?: InvitationRow;
12
+ onSuccessCallback?: () => void;
13
+ }>();
14
+
15
+ const nuxtApp = useDmsApp();
16
+ const { resolveApiError } = useApiErrorMessage();
17
+ const { fetchInvitationLink, copyLink } = useInvitationLink();
18
+
19
+ const link = ref<string | null>(null);
20
+ const expiresAt = ref<string | null>(null);
21
+ const errorMessage = ref<string | null>(null);
22
+ const isLoading = ref(true);
23
+
24
+ function formatDate(value: string): string {
25
+ return new Intl.DateTimeFormat(nuxtApp.$i18n.locale.value, {
26
+ dateStyle: "medium",
27
+ timeStyle: "short",
28
+ }).format(new Date(value));
29
+ }
30
+
31
+ async function load(): Promise<void> {
32
+ if (!props.rowData) return;
33
+ try {
34
+ const invitation = await fetchInvitationLink({
35
+ tenantId: props.rowData._instance,
36
+ inviteId: props.rowData._id,
37
+ });
38
+ link.value = invitation.link;
39
+ expiresAt.value = invitation.expiresAt;
40
+ } catch (error) {
41
+ errorMessage.value = resolveApiError(
42
+ error,
43
+ "saas.workspaces.invitations.link.error",
44
+ );
45
+ } finally {
46
+ isLoading.value = false;
47
+ }
48
+ }
49
+
50
+ async function copy(): Promise<void> {
51
+ if (!link.value) return;
52
+ await copyLink(link.value);
53
+ }
54
+
55
+ onMounted(load);
56
+ </script>
57
+
58
+ <template>
59
+ <div class="flex flex-col gap-4">
60
+ <USkeleton v-if="isLoading" class="h-10 w-full" />
61
+ <UAlert
62
+ v-else-if="errorMessage"
63
+ color="error"
64
+ variant="subtle"
65
+ icon="i-ph-warning-circle"
66
+ :description="errorMessage"
67
+ />
68
+ <template v-else-if="link">
69
+ <UFormField
70
+ :label="$t('saas.workspaces.invitations.link.label')"
71
+ :help="
72
+ expiresAt
73
+ ? $t('saas.workspaces.invitations.link.expires', {
74
+ date: formatDate(expiresAt),
75
+ })
76
+ : undefined
77
+ "
78
+ >
79
+ <div class="flex gap-2">
80
+ <UInput
81
+ :model-value="link"
82
+ readonly
83
+ class="w-full"
84
+ data-testid="invitation-link"
85
+ @focus="($event.target as HTMLInputElement).select()"
86
+ />
87
+ <UButton icon="i-ph-copy" color="primary" @click="copy">
88
+ {{ $t("saas.workspaces.invitations.link.copy") }}
89
+ </UButton>
90
+ </div>
91
+ </UFormField>
92
+ <p class="text-muted text-xs">
93
+ {{ $t("saas.workspaces.invitations.link.warning") }}
94
+ </p>
95
+ </template>
96
+ <div class="flex justify-end">
97
+ <UButton
98
+ color="neutral"
99
+ variant="ghost"
100
+ @click="props.onSuccessCallback?.()"
101
+ >
102
+ {{ $t("saas.workspaces.invitations.link.close") }}
103
+ </UButton>
104
+ </div>
105
+ </div>
106
+ </template>