@antelopejs/dms-saas 0.2.0 → 0.3.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 (195) hide show
  1. package/README.md +307 -33
  2. package/dist/billing-state/index.d.ts +1 -0
  3. package/dist/billing-state/index.js +1 -0
  4. package/dist/billing-state/index.js.map +1 -1
  5. package/dist/billing-state/past-due-banner.d.ts +4 -0
  6. package/dist/billing-state/past-due-banner.js +25 -0
  7. package/dist/billing-state/past-due-banner.js.map +1 -0
  8. package/dist/config/runtime.d.ts +6 -1
  9. package/dist/config/runtime.js +50 -1
  10. package/dist/config/runtime.js.map +1 -1
  11. package/dist/crons/recompute-billing-state.js +4 -1
  12. package/dist/crons/recompute-billing-state.js.map +1 -1
  13. package/dist/data-api/platformOwner/index.d.ts +1 -0
  14. package/dist/data-api/platformOwner/index.js +1 -0
  15. package/dist/data-api/platformOwner/index.js.map +1 -1
  16. package/dist/data-api/platformOwner/invitations.d.ts +22 -0
  17. package/dist/data-api/platformOwner/invitations.js +121 -0
  18. package/dist/data-api/platformOwner/invitations.js.map +1 -0
  19. package/dist/data-api/platformOwner/invoice-options.js +3 -2
  20. package/dist/data-api/platformOwner/invoice-options.js.map +1 -1
  21. package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
  22. package/dist/data-api/platformOwner/workspaces.js +64 -12
  23. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  24. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  25. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  26. package/dist/hooks/register.js +2 -1
  27. package/dist/hooks/register.js.map +1 -1
  28. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  29. package/dist/implementations/dms-saas/billing.js +6 -0
  30. package/dist/implementations/dms-saas/billing.js.map +1 -1
  31. package/dist/index.js +5 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/invoice-line-items/billing-window.js +2 -2
  34. package/dist/invoice-line-items/billing-window.js.map +1 -1
  35. package/dist/invoice-line-items/injection.d.ts +10 -0
  36. package/dist/invoice-line-items/injection.js +7 -3
  37. package/dist/invoice-line-items/injection.js.map +1 -1
  38. package/dist/operator-actions/db/lifecycle-delivery.model.js +1 -3
  39. package/dist/operator-actions/db/lifecycle-delivery.model.js.map +1 -1
  40. package/dist/operator-actions/db/lifecycle-delivery.table.d.ts +1 -1
  41. package/dist/operator-actions/db/lifecycle-delivery.table.js.map +1 -1
  42. package/dist/operator-actions/db/operator-action.model.js +1 -3
  43. package/dist/operator-actions/db/operator-action.model.js.map +1 -1
  44. package/dist/operator-actions/db/operator-action.table.d.ts +1 -1
  45. package/dist/operator-actions/db/operator-action.table.js.map +1 -1
  46. package/dist/operator-actions/index.d.ts +1 -0
  47. package/dist/operator-actions/index.js +1 -0
  48. package/dist/operator-actions/index.js.map +1 -1
  49. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  50. package/dist/operator-actions/invitation-commands.js +46 -0
  51. package/dist/operator-actions/invitation-commands.js.map +1 -0
  52. package/dist/operator-actions/types.d.ts +1 -1
  53. package/dist/pages/platform/dashboard.js +2 -4
  54. package/dist/pages/platform/dashboard.js.map +1 -1
  55. package/dist/pages/platform/workspaces/detail.js +40 -1
  56. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  57. package/dist/pages/platform/workspaces/index.d.ts +2 -8
  58. package/dist/pages/platform/workspaces/index.js +27 -111
  59. package/dist/pages/platform/workspaces/index.js.map +1 -1
  60. package/dist/pages/public/legal/privacy-policy.js +2 -1
  61. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  62. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  63. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  64. package/dist/pages/public/legal/terms-of-use.js +2 -1
  65. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  66. package/dist/plans/index.d.ts +1 -0
  67. package/dist/plans/index.js +1 -0
  68. package/dist/plans/index.js.map +1 -1
  69. package/dist/plans/platform-support.d.ts +2 -0
  70. package/dist/plans/platform-support.js +19 -0
  71. package/dist/plans/platform-support.js.map +1 -0
  72. package/dist/plans/seat-capacity.d.ts +9 -2
  73. package/dist/plans/seat-capacity.js +59 -12
  74. package/dist/plans/seat-capacity.js.map +1 -1
  75. package/dist/plans/seat-hooks.js +34 -13
  76. package/dist/plans/seat-hooks.js.map +1 -1
  77. package/dist/routes/platformOwner/credit-notes-issue.js +2 -1
  78. package/dist/routes/platformOwner/credit-notes-issue.js.map +1 -1
  79. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  80. package/dist/routes/platformOwner/dashboard.js +31 -17
  81. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  82. package/dist/routes/platformOwner/index.d.ts +1 -0
  83. package/dist/routes/platformOwner/index.js +1 -0
  84. package/dist/routes/platformOwner/index.js.map +1 -1
  85. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  86. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  87. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  88. package/dist/routes/public/register.d.ts +7 -3
  89. package/dist/routes/public/register.js +42 -27
  90. package/dist/routes/public/register.js.map +1 -1
  91. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  92. package/dist/routes/tenant/tenant-billing.js +34 -26
  93. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  94. package/dist/routes/tenant/tenant-plan-ops.js +2 -2
  95. package/dist/routes/tenant/tenant-plan-ops.js.map +1 -1
  96. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  97. package/dist/routes/tenant/tenant-plan.js +27 -2
  98. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  99. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  100. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  101. package/dist/routes/tenant/workspaces.d.ts +2 -0
  102. package/dist/routes/tenant/workspaces.js +10 -4
  103. package/dist/routes/tenant/workspaces.js.map +1 -1
  104. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  105. package/dist/stripe/upcoming-invoice.js +42 -0
  106. package/dist/stripe/upcoming-invoice.js.map +1 -0
  107. package/dist/stripe/webhook-dispatch.js +2 -0
  108. package/dist/stripe/webhook-dispatch.js.map +1 -1
  109. package/dist/types/config.d.ts +9 -0
  110. package/dist/types/config.js +7 -0
  111. package/dist/types/config.js.map +1 -1
  112. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  113. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  114. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  115. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  116. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  117. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  118. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  119. package/dist/upcoming-invoice/eligibility.js +52 -0
  120. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  121. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  122. package/dist/upcoming-invoice/invalidation.js +39 -0
  123. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  124. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  125. package/dist/upcoming-invoice/mapping.js +82 -0
  126. package/dist/upcoming-invoice/mapping.js.map +1 -0
  127. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  128. package/dist/upcoming-invoice/outcomes.js +15 -0
  129. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  130. package/dist/upcoming-invoice/preview.d.ts +3 -0
  131. package/dist/upcoming-invoice/preview.js +80 -0
  132. package/dist/upcoming-invoice/preview.js.map +1 -0
  133. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  134. package/dist/upcoming-invoice/pricing.js +84 -0
  135. package/dist/upcoming-invoice/pricing.js.map +1 -0
  136. package/dist/upcoming-invoice/usage.d.ts +14 -0
  137. package/dist/upcoming-invoice/usage.js +53 -0
  138. package/dist/upcoming-invoice/usage.js.map +1 -0
  139. package/dist/workspaces/billing-identity.d.ts +19 -0
  140. package/dist/workspaces/billing-identity.js +51 -0
  141. package/dist/workspaces/billing-identity.js.map +1 -0
  142. package/dist/workspaces/default-plan.d.ts +5 -0
  143. package/dist/workspaces/default-plan.js +110 -0
  144. package/dist/workspaces/default-plan.js.map +1 -0
  145. package/dist/workspaces/index.d.ts +2 -0
  146. package/dist/workspaces/index.js +2 -0
  147. package/dist/workspaces/index.js.map +1 -1
  148. package/dist/workspaces/invitations.d.ts +26 -0
  149. package/dist/workspaces/invitations.js +112 -0
  150. package/dist/workspaces/invitations.js.map +1 -0
  151. package/dist/workspaces/provisioning.d.ts +3 -3
  152. package/dist/workspaces/provisioning.js +49 -11
  153. package/dist/workspaces/provisioning.js.map +1 -1
  154. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  155. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  156. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  157. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  158. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  159. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  160. package/frontend-vue/app/components/RefundButton.vue +10 -1
  161. package/frontend-vue/app/components/Register.vue +49 -92
  162. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  163. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  164. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  165. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  166. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  167. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  168. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  169. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  170. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  171. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  172. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  173. package/frontend-vue/app/composables/useBillingDocumentType.ts +9 -5
  174. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  175. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  176. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  177. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  178. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  179. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  180. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  181. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  182. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  183. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  184. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  185. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  186. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  187. package/frontend-vue/app/plugins/billing-data-types.ts +4 -2
  188. package/frontend-vue/app/types/runtime-config.ts +13 -0
  189. package/frontend-vue/dms.frontend.ts +10 -4
  190. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  191. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  192. package/frontend-vue/package.json +0 -1
  193. package/frontend-vue/pnpm-lock.yaml +1 -125
  194. package/package.json +5 -5
  195. package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
@@ -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>
@@ -9,10 +9,17 @@ interface WorkspaceSummaryData {
9
9
  currency: string;
10
10
  mrr: number;
11
11
  membersCount: number;
12
+ platformSupportCount: number;
13
+ pendingInvitationsCount: number;
12
14
  createdAt: string;
13
15
  subscription: { stripeCustomerId: string | null } | null;
14
16
  }
15
17
 
18
+ interface PeopleCount {
19
+ key: string;
20
+ count: number;
21
+ }
22
+
16
23
  type BadgeColor =
17
24
  | "primary"
18
25
  | "secondary"
@@ -34,7 +41,16 @@ const props = defineProps<{
34
41
  routeParams?: Record<string, string>;
35
42
  }>();
36
43
 
44
+ const PEOPLE_SEPARATOR = " · ";
45
+ const MISSING_DATE = "—";
46
+ const DAY_FORMAT: Intl.DateTimeFormatOptions = {
47
+ day: "2-digit",
48
+ month: "short",
49
+ year: "numeric",
50
+ };
51
+
37
52
  const { $authFetch } = useAuthFetch();
53
+ const { t, locale } = useI18n();
38
54
 
39
55
  const tenantId = computed(() => props.routeParams?.id ?? "");
40
56
  const { triggerRef } = useDetailRefresh(tenantId.value);
@@ -55,13 +71,27 @@ const formattedMrr = computed(() => {
55
71
  }).format(workspace.value.mrr);
56
72
  });
57
73
 
58
- function formatDate(value: string | null): string {
59
- if (!value) return "—";
60
- return new Date(value).toLocaleDateString("fr-FR", {
61
- day: "2-digit",
62
- month: "short",
63
- year: "numeric",
64
- });
74
+ /** Seated members, then platform support and pending invitations, if any. */
75
+ const peopleLabel = computed(() => {
76
+ if (!workspace.value) return "";
77
+ const { membersCount, platformSupportCount, pendingInvitationsCount } =
78
+ workspace.value;
79
+ const optionalCounts: PeopleCount[] = [
80
+ { key: "platform_support", count: platformSupportCount },
81
+ { key: "pending_invitations", count: pendingInvitationsCount },
82
+ ];
83
+ return [
84
+ t("saas.workspaces.summary.members", { count: membersCount }, membersCount),
85
+ ...optionalCounts
86
+ .filter(({ count }) => count > 0)
87
+ .map(({ key, count }) =>
88
+ t(`saas.workspaces.summary.${key}`, { count }, count),
89
+ ),
90
+ ].join(PEOPLE_SEPARATOR);
91
+ });
92
+
93
+ function formatDay(value: string | null): string {
94
+ return formatDate(value, locale.value, DAY_FORMAT) ?? MISSING_DATE;
65
95
  }
66
96
 
67
97
  async function load(): Promise<void> {
@@ -110,13 +140,7 @@ watch(triggerRef, () => {
110
140
  <div class="mt-2 flex flex-col gap-1 text-sm text-muted">
111
141
  <div class="flex items-center gap-2">
112
142
  <UIcon name="i-ph-users" />
113
- <span>
114
- {{
115
- $t("saas.workspaces.summary.members", {
116
- count: workspace.membersCount,
117
- })
118
- }}
119
- </span>
143
+ <span>{{ peopleLabel }}</span>
120
144
  </div>
121
145
  </div>
122
146
  </div>
@@ -145,7 +169,7 @@ watch(triggerRef, () => {
145
169
  <dt class="text-muted">
146
170
  {{ $t("saas.workspaces.column.created_at") }}
147
171
  </dt>
148
- <dd class="mt-0.5">{{ formatDate(workspace.createdAt) }}</dd>
172
+ <dd class="mt-0.5">{{ formatDay(workspace.createdAt) }}</dd>
149
173
  </div>
150
174
  <div>
151
175
  <dt class="text-muted">
@@ -37,11 +37,9 @@ async function loadAccess(): Promise<void> {
37
37
  const result = await $authFetch<WorkspaceAccess>(ACCESS_ENDPOINT);
38
38
  access.value = result;
39
39
  if (!result.blocked) {
40
- // Drop the middleware's cached blocked state before navigating,
41
- // otherwise it redirects straight back here and the recovery path
42
- // never resolves. The shared billing payloads cached while blocked go
43
- // with it: an SPA navigation keeps useState alive, and the billing page
44
- // would keep announcing a suspension the user just settled.
40
+ // Drop the access and billing payloads cached while blocked before
41
+ // navigating: an SPA navigation keeps useState alive, and the billing
42
+ // page would keep announcing a suspension the user just settled.
45
43
  cachedAccess.value = null;
46
44
  billingStatusState.value = null;
47
45
  tenantPlanState.value = null;
@@ -5,10 +5,6 @@ const props = defineProps<{
5
5
  collapsed?: boolean
6
6
  }>()
7
7
 
8
- interface DmsSaasPublicRuntimeConfig {
9
- admissionMode?: 'open' | 'invitation-only'
10
- }
11
-
12
8
  const toast = useToast()
13
9
  const { t } = useI18n()
14
10
  const { resolveApiError } = useApiErrorMessage()
@@ -102,6 +98,10 @@ function openCreateModal(): void {
102
98
  isCreateOpen.value = true
103
99
  }
104
100
 
101
+ function closeCreateModal(): void {
102
+ isCreateOpen.value = false
103
+ }
104
+
105
105
  async function onWorkspaceCreated(tenantId: string): Promise<void> {
106
106
  isCreateOpen.value = false
107
107
  try {
@@ -300,7 +300,10 @@ onMounted(load)
300
300
  :description="$t('saas.workspaces.create.self_serve.description')"
301
301
  >
302
302
  <template #body>
303
- <DmsSaasWorkspaceCreateModal :on-success-callback="onWorkspaceCreated" />
303
+ <DmsSaasWorkspaceCreateModal
304
+ :on-success-callback="onWorkspaceCreated"
305
+ :on-cancel-callback="closeCreateModal"
306
+ />
304
307
  </template>
305
308
  </UModal>
306
309
  </template>
@@ -1,10 +1,12 @@
1
+ import { computed, type ComputedRef } from "vue";
2
+
1
3
  export interface BillingCountryItem {
2
4
  value: string;
3
5
  label: string;
4
6
  }
5
7
 
6
8
  export interface BillingCountries {
7
- countryItems: BillingCountryItem[];
9
+ countryItems: ComputedRef<BillingCountryItem[]>;
8
10
  }
9
11
 
10
12
  const BILLING_COUNTRY_CODES = [
@@ -44,11 +46,29 @@ const BILLING_COUNTRY_CODES = [
44
46
  "NZ",
45
47
  ];
46
48
 
49
+ /**
50
+ * Country choices labelled with their full name in the given locale, sorted
51
+ * alphabetically for that locale. The stored value stays the ISO code Stripe
52
+ * expects.
53
+ *
54
+ * @param locale BCP 47 locale the labels are rendered in
55
+ */
56
+ export function buildBillingCountryItems(locale: string): BillingCountryItem[] {
57
+ const names = new Intl.DisplayNames([locale], { type: "region" });
58
+ return BILLING_COUNTRY_CODES.map((code) => ({
59
+ value: code,
60
+ label: names.of(code) ?? code,
61
+ })).sort((left, right) => left.label.localeCompare(right.label, locale));
62
+ }
63
+
64
+ /** Full country name for an ISO code, or the code itself when unknown. */
65
+ export function formatBillingCountry(code: string, locale: string): string {
66
+ return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
67
+ }
68
+
47
69
  export function useBillingCountries(): BillingCountries {
70
+ const { locale } = useI18n();
48
71
  return {
49
- countryItems: BILLING_COUNTRY_CODES.map((code) => ({
50
- value: code,
51
- label: code,
52
- })),
72
+ countryItems: computed(() => buildBillingCountryItems(locale.value)),
53
73
  };
54
74
  }
@@ -1,7 +1,11 @@
1
- const CREDIT_NOTE_TYPE = "credit_note";
1
+ const BILLING_DOCUMENT_TYPE_KEYS: Record<string, string> = {
2
+ invoice: "saas.invoices.type.invoice",
3
+ credit_note: "saas.invoices.type.credit_note",
4
+ };
2
5
 
3
- /** Resolves the translation key for current and pre-discriminant invoice rows. */
4
- export function billingDocumentTypeKey(value: unknown): string {
5
- const type = value === CREDIT_NOTE_TYPE ? CREDIT_NOTE_TYPE : "invoice";
6
- return `saas.invoices.type.${type}`;
6
+ /** Resolves the translation key of a billing document type, if it is known. */
7
+ export function billingDocumentTypeKey(value: unknown): string | null {
8
+ return typeof value === "string"
9
+ ? (BILLING_DOCUMENT_TYPE_KEYS[value] ?? null)
10
+ : null;
7
11
  }