@antelopejs/dms-saas 0.2.0 → 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 (177) hide show
  1. package/README.md +257 -10
  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/workspaces.d.ts +3 -0
  20. package/dist/data-api/platformOwner/workspaces.js +64 -12
  21. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  22. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  23. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  24. package/dist/hooks/register.js +2 -1
  25. package/dist/hooks/register.js.map +1 -1
  26. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  27. package/dist/implementations/dms-saas/billing.js +6 -0
  28. package/dist/implementations/dms-saas/billing.js.map +1 -1
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/invoice-line-items/injection.d.ts +10 -0
  32. package/dist/invoice-line-items/injection.js +7 -3
  33. package/dist/invoice-line-items/injection.js.map +1 -1
  34. package/dist/operator-actions/index.d.ts +1 -0
  35. package/dist/operator-actions/index.js +1 -0
  36. package/dist/operator-actions/index.js.map +1 -1
  37. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  38. package/dist/operator-actions/invitation-commands.js +46 -0
  39. package/dist/operator-actions/invitation-commands.js.map +1 -0
  40. package/dist/operator-actions/types.d.ts +1 -1
  41. package/dist/pages/platform/dashboard.js +2 -4
  42. package/dist/pages/platform/dashboard.js.map +1 -1
  43. package/dist/pages/platform/workspaces/detail.js +40 -1
  44. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  45. package/dist/pages/platform/workspaces/index.d.ts +2 -8
  46. package/dist/pages/platform/workspaces/index.js +27 -111
  47. package/dist/pages/platform/workspaces/index.js.map +1 -1
  48. package/dist/pages/public/legal/privacy-policy.js +2 -1
  49. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  50. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  51. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  52. package/dist/pages/public/legal/terms-of-use.js +2 -1
  53. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  54. package/dist/plans/index.d.ts +1 -0
  55. package/dist/plans/index.js +1 -0
  56. package/dist/plans/index.js.map +1 -1
  57. package/dist/plans/platform-support.d.ts +2 -0
  58. package/dist/plans/platform-support.js +19 -0
  59. package/dist/plans/platform-support.js.map +1 -0
  60. package/dist/plans/seat-capacity.d.ts +9 -2
  61. package/dist/plans/seat-capacity.js +59 -12
  62. package/dist/plans/seat-capacity.js.map +1 -1
  63. package/dist/plans/seat-hooks.js +34 -13
  64. package/dist/plans/seat-hooks.js.map +1 -1
  65. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  66. package/dist/routes/platformOwner/dashboard.js +31 -17
  67. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  68. package/dist/routes/platformOwner/index.d.ts +1 -0
  69. package/dist/routes/platformOwner/index.js +1 -0
  70. package/dist/routes/platformOwner/index.js.map +1 -1
  71. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  72. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  73. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  74. package/dist/routes/public/register.d.ts +7 -3
  75. package/dist/routes/public/register.js +42 -27
  76. package/dist/routes/public/register.js.map +1 -1
  77. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  78. package/dist/routes/tenant/tenant-billing.js +34 -26
  79. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  80. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  81. package/dist/routes/tenant/tenant-plan.js +27 -2
  82. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  83. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  84. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  85. package/dist/routes/tenant/workspaces.d.ts +2 -0
  86. package/dist/routes/tenant/workspaces.js +10 -4
  87. package/dist/routes/tenant/workspaces.js.map +1 -1
  88. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  89. package/dist/stripe/upcoming-invoice.js +42 -0
  90. package/dist/stripe/upcoming-invoice.js.map +1 -0
  91. package/dist/stripe/webhook-dispatch.js +2 -0
  92. package/dist/stripe/webhook-dispatch.js.map +1 -1
  93. package/dist/types/config.d.ts +9 -0
  94. package/dist/types/config.js +7 -0
  95. package/dist/types/config.js.map +1 -1
  96. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  97. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  98. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  99. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  100. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  101. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  102. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  103. package/dist/upcoming-invoice/eligibility.js +52 -0
  104. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  105. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  106. package/dist/upcoming-invoice/invalidation.js +39 -0
  107. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  108. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  109. package/dist/upcoming-invoice/mapping.js +82 -0
  110. package/dist/upcoming-invoice/mapping.js.map +1 -0
  111. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  112. package/dist/upcoming-invoice/outcomes.js +15 -0
  113. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  114. package/dist/upcoming-invoice/preview.d.ts +3 -0
  115. package/dist/upcoming-invoice/preview.js +80 -0
  116. package/dist/upcoming-invoice/preview.js.map +1 -0
  117. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  118. package/dist/upcoming-invoice/pricing.js +84 -0
  119. package/dist/upcoming-invoice/pricing.js.map +1 -0
  120. package/dist/upcoming-invoice/usage.d.ts +14 -0
  121. package/dist/upcoming-invoice/usage.js +53 -0
  122. package/dist/upcoming-invoice/usage.js.map +1 -0
  123. package/dist/workspaces/billing-identity.d.ts +19 -0
  124. package/dist/workspaces/billing-identity.js +51 -0
  125. package/dist/workspaces/billing-identity.js.map +1 -0
  126. package/dist/workspaces/default-plan.d.ts +5 -0
  127. package/dist/workspaces/default-plan.js +109 -0
  128. package/dist/workspaces/default-plan.js.map +1 -0
  129. package/dist/workspaces/index.d.ts +2 -0
  130. package/dist/workspaces/index.js +2 -0
  131. package/dist/workspaces/index.js.map +1 -1
  132. package/dist/workspaces/invitations.d.ts +26 -0
  133. package/dist/workspaces/invitations.js +112 -0
  134. package/dist/workspaces/invitations.js.map +1 -0
  135. package/dist/workspaces/provisioning.d.ts +3 -3
  136. package/dist/workspaces/provisioning.js +39 -11
  137. package/dist/workspaces/provisioning.js.map +1 -1
  138. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  139. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  140. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  141. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  142. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  143. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  144. package/frontend-vue/app/components/RefundButton.vue +10 -1
  145. package/frontend-vue/app/components/Register.vue +49 -92
  146. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  147. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  148. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  149. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  150. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  151. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  152. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  153. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  154. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  155. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  156. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  157. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  158. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  159. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  160. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  161. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  162. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  163. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  164. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  165. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  166. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  167. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  168. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  169. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  170. package/frontend-vue/app/types/runtime-config.ts +13 -0
  171. package/frontend-vue/dms.frontend.ts +10 -4
  172. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  173. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  174. package/frontend-vue/package.json +0 -1
  175. package/frontend-vue/pnpm-lock.yaml +1 -125
  176. package/package.json +5 -5
  177. package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
@@ -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
  }
@@ -0,0 +1,141 @@
1
+ const BILLING_INFO_ENDPOINT = "/api/saas/tenant/billing-info";
2
+ const BUSINESS_CUSTOMER_TYPE = "business";
3
+ const COUNTRY_CODE_PATTERN = /^[A-Z]{2}$/;
4
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5
+
6
+ export type CustomerType = "individual" | "business";
7
+ export type VatVerificationStatus = "pending" | "verified" | "unverified";
8
+
9
+ export type BillingIdentityField =
10
+ | "customerType"
11
+ | "companyName"
12
+ | "country"
13
+ | "line1"
14
+ | "postalCode"
15
+ | "city"
16
+ | "billingEmail";
17
+
18
+ /** The editable billing identity, as the form holds it. */
19
+ export interface BillingIdentityDraft {
20
+ customerType: CustomerType | null;
21
+ companyName: string;
22
+ vatNumber: string;
23
+ billingEmail: string;
24
+ country: string;
25
+ line1: string;
26
+ postalCode: string;
27
+ city: string;
28
+ }
29
+
30
+ interface BillingAddress {
31
+ line1: string | null;
32
+ line2: string | null;
33
+ postalCode: string | null;
34
+ city: string | null;
35
+ state: string | null;
36
+ country: string | null;
37
+ }
38
+
39
+ export interface BillingInfoResponse {
40
+ customerType: CustomerType | null;
41
+ companyName: string | null;
42
+ vatNumber: string | null;
43
+ vatVerificationStatus: VatVerificationStatus | null;
44
+ billingEmail: string | null;
45
+ address: BillingAddress | null;
46
+ missingFields: BillingIdentityField[];
47
+ }
48
+
49
+ type FieldCheck = (draft: BillingIdentityDraft) => boolean;
50
+
51
+ function isFilled(value: string): boolean {
52
+ return value.trim().length > 0;
53
+ }
54
+
55
+ // Mirrors the server-side rules (src/workspaces/billing-identity.ts): the
56
+ // form flags a field before the request, the server refuses it regardless.
57
+ const FIELD_CHECKS: Record<BillingIdentityField, FieldCheck> = {
58
+ customerType: (draft) => draft.customerType !== null,
59
+ companyName: (draft) =>
60
+ draft.customerType !== BUSINESS_CUSTOMER_TYPE ||
61
+ isFilled(draft.companyName),
62
+ country: (draft) => COUNTRY_CODE_PATTERN.test(draft.country.trim()),
63
+ line1: (draft) => isFilled(draft.line1),
64
+ postalCode: (draft) => isFilled(draft.postalCode),
65
+ city: (draft) => isFilled(draft.city),
66
+ billingEmail: (draft) => EMAIL_PATTERN.test(draft.billingEmail.trim()),
67
+ };
68
+
69
+ /**
70
+ * Required fields still missing or malformed, in form order; empty when the
71
+ * identity can be saved. The VAT number is optional, even for a business.
72
+ */
73
+ export function findMissingBillingFields(
74
+ draft: BillingIdentityDraft,
75
+ ): BillingIdentityField[] {
76
+ return (Object.keys(FIELD_CHECKS) as BillingIdentityField[]).filter(
77
+ (field) => !FIELD_CHECKS[field](draft),
78
+ );
79
+ }
80
+
81
+ export function emptyBillingIdentityDraft(): BillingIdentityDraft {
82
+ return {
83
+ customerType: null,
84
+ companyName: "",
85
+ vatNumber: "",
86
+ billingEmail: "",
87
+ country: "",
88
+ line1: "",
89
+ postalCode: "",
90
+ city: "",
91
+ };
92
+ }
93
+
94
+ export function toBillingIdentityDraft(
95
+ info: BillingInfoResponse,
96
+ ): BillingIdentityDraft {
97
+ return {
98
+ customerType: info.customerType,
99
+ companyName: info.companyName ?? "",
100
+ vatNumber: info.vatNumber ?? "",
101
+ billingEmail: info.billingEmail ?? "",
102
+ country: info.address?.country ?? "",
103
+ line1: info.address?.line1 ?? "",
104
+ postalCode: info.address?.postalCode ?? "",
105
+ city: info.address?.city ?? "",
106
+ };
107
+ }
108
+
109
+ function toRequestBody(draft: BillingIdentityDraft) {
110
+ const isBusiness = draft.customerType === BUSINESS_CUSTOMER_TYPE;
111
+ return {
112
+ customerType: draft.customerType,
113
+ companyName: isBusiness ? draft.companyName : null,
114
+ vatNumber: isBusiness ? draft.vatNumber : null,
115
+ billingEmail: draft.billingEmail,
116
+ address: {
117
+ country: draft.country,
118
+ line1: draft.line1,
119
+ postalCode: draft.postalCode,
120
+ city: draft.city,
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Reads and saves the workspace billing identity. */
126
+ export function useBillingIdentity() {
127
+ const { $authFetch } = useAuthFetch();
128
+
129
+ function load(): Promise<BillingInfoResponse> {
130
+ return $authFetch<BillingInfoResponse>(BILLING_INFO_ENDPOINT);
131
+ }
132
+
133
+ function save(draft: BillingIdentityDraft): Promise<BillingInfoResponse> {
134
+ return $authFetch<BillingInfoResponse>(BILLING_INFO_ENDPOINT, {
135
+ method: "PUT",
136
+ body: toRequestBody(draft),
137
+ });
138
+ }
139
+
140
+ return { load, save };
141
+ }
@@ -0,0 +1,69 @@
1
+ export interface InvitationLink {
2
+ link: string;
3
+ expiresAt: string;
4
+ }
5
+
6
+ export interface InvitationRef {
7
+ tenantId: string;
8
+ inviteId: string;
9
+ }
10
+
11
+ const INVITATIONS_ENDPOINT = "/api/saas/workspaces";
12
+
13
+ /**
14
+ * Retrieves an invitation's signup link (each retrieval is journaled server
15
+ * side) and puts it on the clipboard, reporting the outcome with a toast.
16
+ */
17
+ export function useInvitationLink() {
18
+ const { $authFetch } = useAuthFetch();
19
+ const nuxtApp = useDmsApp();
20
+ const toast = useToast();
21
+ const { resolveApiError } = useApiErrorMessage();
22
+
23
+ function fetchInvitationLink(ref: InvitationRef): Promise<InvitationLink> {
24
+ return $authFetch<InvitationLink>(
25
+ `${INVITATIONS_ENDPOINT}/${ref.tenantId}/invitations/${ref.inviteId}/link`,
26
+ { method: "POST" },
27
+ );
28
+ }
29
+
30
+ /** Returns whether the link reached the clipboard. */
31
+ async function copyLink(link: string): Promise<boolean> {
32
+ const t = nuxtApp.$i18n.t;
33
+ try {
34
+ await navigator.clipboard.writeText(link);
35
+ toast.add({
36
+ title: t("saas.workspaces.invitations.link.copied"),
37
+ color: "success",
38
+ icon: "i-ph-check-circle",
39
+ });
40
+ return true;
41
+ } catch {
42
+ // The clipboard can be refused (permissions, insecure origin): the link
43
+ // stays readable so it can still be copied by hand.
44
+ toast.add({
45
+ title: t("saas.workspaces.invitations.link.copy_failed"),
46
+ description: link,
47
+ color: "warning",
48
+ icon: "i-ph-warning",
49
+ duration: 0,
50
+ });
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function fetchAndCopy(ref: InvitationRef): Promise<void> {
56
+ try {
57
+ const { link } = await fetchInvitationLink(ref);
58
+ await copyLink(link);
59
+ } catch (error) {
60
+ toast.add({
61
+ title: resolveApiError(error, "saas.workspaces.invitations.link.error"),
62
+ color: "error",
63
+ icon: "i-ph-warning-circle",
64
+ });
65
+ }
66
+ }
67
+
68
+ return { fetchInvitationLink, copyLink, fetchAndCopy };
69
+ }
@@ -0,0 +1,20 @@
1
+ import type { BillingStatusResponse } from "./useBillingStatus";
2
+
3
+ /**
4
+ * What the past-due banner offers: the invoice itself when Stripe hands out
5
+ * its payment page, else the billing page to the owner, who can settle from
6
+ * there; a member only gets the notice, since the owner pays.
7
+ */
8
+ export type PastDueBannerAction = "settle" | "open_billing" | "none";
9
+
10
+ type PastDueBannerStatus = Pick<
11
+ BillingStatusResponse,
12
+ "isTenantOwner" | "unpaidInvoice"
13
+ >;
14
+
15
+ export function resolvePastDueBannerAction(
16
+ status: PastDueBannerStatus | null,
17
+ ): PastDueBannerAction {
18
+ if (status?.unpaidInvoice?.hostedInvoiceUrl) return "settle";
19
+ return status?.isTenantOwner ? "open_billing" : "none";
20
+ }
@@ -0,0 +1,15 @@
1
+ const OPEN_STATE_KEY = "saas-plan-comparison-open";
2
+
3
+ /**
4
+ * Open state of the plan comparison modal hosted by the plan card, shared so
5
+ * other billing blocks (the payment method card's "add a card") can open it.
6
+ */
7
+ export function usePlanComparison() {
8
+ const isOpen = useDmsState<boolean>(OPEN_STATE_KEY, () => false);
9
+
10
+ function open(): void {
11
+ isOpen.value = true;
12
+ }
13
+
14
+ return { isOpen, open };
15
+ }