@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
@@ -4,54 +4,24 @@ import {
4
4
  onMounted,
5
5
  reactive,
6
6
  ref,
7
+ toRef,
7
8
  toValue,
8
- watch,
9
9
  } from "vue";
10
- import { formatMajorUnits } from "./useMoneyFormat";
11
- import {
12
- formatPlanIntervalLabel,
13
- type PlanInterval,
14
- } from "./usePlanIntervalLabel";
15
-
16
- /**
17
- * A plan as the public registration screen sees it.
18
- */
19
- export interface RegistrationPlan {
20
- _id: string;
21
- name: string;
22
- description: string;
23
- price: number;
24
- currency: string;
25
- interval: PlanInterval;
26
- trialDays: number;
27
- audience: string;
28
- borderColor: string | null;
29
- borderLabel: string | null;
30
- order: number;
31
- }
32
10
 
33
11
  export interface RegistrationForm {
34
- customerType: "individual" | "business";
35
12
  email: string;
36
13
  password: string;
37
14
  name: string;
38
- workspaceName: string;
39
- companyName: string;
40
- vatNumber: string;
41
- country: string;
42
- addressLine1: string;
43
- postalCode: string;
44
- city: string;
45
- selectedPlanId: string | null;
46
15
  hasAcceptedLegal: boolean;
16
+ /** The visitor chose to add a card later. Only read under `optional`. */
17
+ skipsPaymentMethod: boolean;
47
18
  }
48
19
 
49
20
  /** Everything the submit guard looks at, independent of how it is bound. */
50
21
  export interface RegistrationRequirements {
51
- selectedPlanId: string | null;
22
+ isPaymentRequired: boolean;
52
23
  isPaymentReady: boolean;
53
24
  hasAcceptedLegal: boolean;
54
- country: string;
55
25
  }
56
26
 
57
27
  export type RegistrationTranslator = (
@@ -72,7 +42,7 @@ export interface UseSaasRegistrationOptions {
72
42
  */
73
43
  paymentElementId?: string;
74
44
  /**
75
- * Whatever the consuming SaaS captures on top of the billing fields, read
45
+ * Whatever the consuming SaaS captures on top of the account fields, read
76
46
  * when the visitor submits. It stays the consumer's data: dms-saas forwards
77
47
  * it inside the provisioning transaction, to the consumer's own
78
48
  * `TENANT_BEING_PROVISIONED` listener, and neither reads nor stores it. A
@@ -89,24 +59,12 @@ interface RegistrationRequirement {
89
59
  isMet: (requirements: RegistrationRequirements) => boolean;
90
60
  }
91
61
 
92
- interface RegistrationAddress {
93
- country: string;
94
- line1?: string;
95
- postalCode?: string;
96
- city?: string;
97
- }
98
-
99
62
  interface RegistrationPayload {
100
63
  email: string;
101
64
  password: string;
102
65
  name: string;
103
66
  workspaceName: string;
104
- planId: string;
105
- customerType: string;
106
- companyName?: string;
107
- vatNumber?: string;
108
- paymentMethodId: string;
109
- address: RegistrationAddress;
67
+ paymentMethodId?: string;
110
68
  extras?: Record<string, unknown>;
111
69
  }
112
70
 
@@ -121,44 +79,41 @@ interface SetupIntentResponse {
121
79
  clientSecret: string | null;
122
80
  }
123
81
 
124
- interface DmsSaasPublicRuntimeConfig {
125
- stripePublishableKey?: string;
126
- admissionMode?: "open" | "invitation-only";
127
- }
82
+ type PaymentStepRule = (skipsPaymentMethod: boolean) => boolean;
128
83
 
129
- const PLANS_ENDPOINT = "/api/saas/plans/public";
130
84
  const SETUP_INTENT_ENDPOINT = "/api/saas/register/setup-intent";
131
85
  const REGISTER_ENDPOINT = "/api/saas/register";
132
86
  const DEFAULT_PAYMENT_ELEMENT_ID = "dms-saas-register-payment-element";
133
87
  const DEFAULT_REDIRECT = "/auth/login";
134
- const ANY_AUDIENCE = "any";
135
- const PLAN_PRICE_KEY = "saas.register.price";
136
- const NO_PLAN_ERROR_KEY = "saas.register.error.no_plan";
88
+ const DEFAULT_PAYMENT_POLICY: RegistrationPaymentMethodPolicy = "required";
89
+ const INVITATION_ONLY = "invitation-only";
90
+ const DEFAULT_WORKSPACE_NAME_KEY = "saas.register.default_workspace_name";
137
91
  const NO_PAYMENT_ERROR_KEY = "saas.register.error.no_payment";
138
92
  const LOAD_ERROR_KEY = "saas.register.error.load";
139
93
  const SUBMIT_ERROR_KEY = "saas.register.error.failed";
94
+ const PASSWORD_POLICY_ERROR_KEY = "saas.errors.registration.password_policy";
95
+
96
+ const PAYMENT_STEP_RULES: Record<RegistrationPaymentMethodPolicy, PaymentStepRule> =
97
+ {
98
+ required: () => true,
99
+ optional: (skipsPaymentMethod) => !skipsPaymentMethod,
100
+ none: () => false,
101
+ };
140
102
 
141
103
  /**
142
- * Checked in the order the visitor can act on them, so a missing plan is never
143
- * reported as a payment failure.
104
+ * Checked in the order the visitor can act on them: the card field comes
105
+ * before the legal checkbox on every screen.
144
106
  */
145
107
  const REGISTRATION_REQUIREMENTS: RegistrationRequirement[] = [
146
- {
147
- errorKey: NO_PLAN_ERROR_KEY,
148
- isMet: (requirements) => Boolean(requirements.selectedPlanId),
149
- },
150
108
  {
151
109
  errorKey: NO_PAYMENT_ERROR_KEY,
152
- isMet: (requirements) => requirements.isPaymentReady,
110
+ isMet: (requirements) =>
111
+ !requirements.isPaymentRequired || requirements.isPaymentReady,
153
112
  },
154
113
  {
155
114
  errorKey: "saas.register.error.legal_required",
156
115
  isMet: (requirements) => requirements.hasAcceptedLegal,
157
116
  },
158
- {
159
- errorKey: "saas.register.error.no_country",
160
- isMet: (requirements) => Boolean(requirements.country),
161
- },
162
117
  ];
163
118
 
164
119
  /**
@@ -177,82 +132,44 @@ export function firstMissingRegistrationRequirement(
177
132
  return missing?.errorKey ?? null;
178
133
  }
179
134
 
180
- function planIntervalLabel(
181
- interval: PlanInterval,
182
- translate: RegistrationTranslator,
183
- ): string {
184
- return formatPlanIntervalLabel(interval, "saas.register.interval", translate);
185
- }
186
-
187
135
  /**
188
- * Price the way a summary line reads it: amount and cadence as one string, in
189
- * the visitor's locale, decimals dropped on whole amounts.
190
- *
191
- * Takes its locale and translator instead of reading them from the component
192
- * context, so a consumer can price a plan outside a `setup()` call.
136
+ * The card policy the deployment published, falling back to the backend's
137
+ * own default when it published none.
193
138
  *
194
- * @param plan Plan to price
195
- * @param locale BCP 47 locale the amount is formatted in
196
- * @param translate Translation function for the price pattern and the cadence
197
- * @returns Localised price, e.g. `20 €/mois`
139
+ * @param config dms-saas public runtime config
140
+ * @returns The configured policy, or `required`
198
141
  */
199
- export function formatRegistrationPlanPrice(
200
- plan: RegistrationPlan,
201
- locale: string,
202
- translate: RegistrationTranslator,
203
- ): string {
204
- const price = formatMajorUnits(plan.price, plan.currency, locale, {
205
- hideWholeAmountDecimals: true,
206
- });
207
- return translate(PLAN_PRICE_KEY, {
208
- price,
209
- interval: planIntervalLabel(plan.interval, translate),
210
- });
142
+ export function resolveRegistrationPaymentPolicy(
143
+ config: DmsSaasPublicRuntimeConfig | undefined,
144
+ ): RegistrationPaymentMethodPolicy {
145
+ const policy = config?.registrationPaymentMethod;
146
+ return policy && policy in PAYMENT_STEP_RULES ? policy : DEFAULT_PAYMENT_POLICY;
211
147
  }
212
148
 
213
149
  /**
214
- * The offer a customer type is entitled to, in the order it should read.
215
- *
216
- * Sorted here because the endpoint returns plans in storage order, and the
217
- * first plan on offer is the one that ends up pre-selected: it has to be the
218
- * one the operator ranked first, not the one created first.
150
+ * Whether the registration still collects a card.
219
151
  *
220
- * @param all Every publicly visible plan
221
- * @param customerType Customer type the visitor declared
222
- * @returns Plans that customer type may subscribe to
152
+ * @param policy Configured card policy
153
+ * @param skipsPaymentMethod Whether the visitor chose to add a card later
154
+ * @returns True when the card step is shown and must be completed
223
155
  */
224
- export function resolveOfferedPlans(
225
- all: RegistrationPlan[],
226
- customerType: string,
227
- ): RegistrationPlan[] {
228
- return all
229
- .filter(
230
- (plan) => plan.audience === ANY_AUDIENCE || plan.audience === customerType,
231
- )
232
- .sort((left, right) => left.order - right.order);
156
+ export function isPaymentStepShown(
157
+ policy: RegistrationPaymentMethodPolicy,
158
+ skipsPaymentMethod: boolean,
159
+ ): boolean {
160
+ return PAYMENT_STEP_RULES[policy](skipsPaymentMethod);
233
161
  }
234
162
 
235
163
  /**
236
- * The plan selection to hold on to now that this is the offer.
164
+ * Whether the deployment has closed public registration to invitations.
237
165
  *
238
- * Switching customer type reshuffles the offer: a selection the visitor can no
239
- * longer subscribe to would travel to the API and come back rejected, so it
240
- * falls back to the first plan they may actually buy.
241
- *
242
- * @param available Plans the current customer type may subscribe to
243
- * @param current Plan id currently selected, if any
244
- * @returns Plan id to select, or null when nothing is on offer
166
+ * @param config dms-saas public runtime config
167
+ * @returns True when only invited accounts may sign up
245
168
  */
246
- export function resolveSelectedPlanId(
247
- available: RegistrationPlan[],
248
- current: string | null,
249
- ): string | null {
250
- const isStillOffered = available.some((plan) => plan._id === current);
251
- return isStillOffered ? current : (available[0]?._id ?? null);
252
- }
253
-
254
- function optionalField(value: string): string | undefined {
255
- return value.trim() || undefined;
169
+ export function isRegistrationClosedBy(
170
+ config: DmsSaasPublicRuntimeConfig | undefined,
171
+ ): boolean {
172
+ return config?.admissionMode === INVITATION_ONLY;
256
173
  }
257
174
 
258
175
  /**
@@ -276,12 +193,16 @@ export function snapshotRegistrationExtras(
276
193
  /**
277
194
  * The public registration flow, without a single line of presentation.
278
195
  *
279
- * dms-saas owns what is dangerous to re-derive — the Stripe setup intent and
280
- * card confirmation, the order the fields are validated in, the shape of the
281
- * provisioning call, the mapping from API failures to a message — and hands
282
- * the consumer plain state to bind and one action to call. Each SaaS then
283
- * writes the markup its conversion funnel needs: the fields it groups, the way
284
- * it presents plans, whether it is one page or three steps, its own copy.
196
+ * Registration is short on purpose: an account, the legal acceptance and —
197
+ * when the deployment asks for one — a card. The workspace it opens lands on
198
+ * the free plan under a default name; plan, customer type and billing address
199
+ * are collected by the upgrade flow.
200
+ *
201
+ * dms-saas owns what is dangerous to re-derive — the card policy, the Stripe
202
+ * setup intent and card confirmation, the order the fields are validated in,
203
+ * the shape of the provisioning call, the mapping from API failures to a
204
+ * message — and hands the consumer plain state to bind and one action to
205
+ * call. Each SaaS then writes the markup its conversion funnel needs.
285
206
  *
286
207
  * The screen shipped in this package is one consumer among others: replace it
287
208
  * by turning it off (`publicScreens.register: false` in the module config) and
@@ -289,12 +210,11 @@ export function snapshotRegistrationExtras(
289
210
  * want on this composable rather than forking its logic.
290
211
  *
291
212
  * @param options Landing route and payment element id
292
- * @returns Bindable form state, plan data, submission action and status
213
+ * @returns Bindable form state, card policy, submission action and status
293
214
  */
294
215
  export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
295
216
  const { $authFetch } = useAuthFetch();
296
217
  const { resolveApiError } = useApiErrorMessage();
297
- const { countryItems } = useBillingCountries();
298
218
  const { $i18n } = useDmsApp();
299
219
  const config = useDmsRuntimeConfig();
300
220
 
@@ -304,24 +224,18 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
304
224
  const paymentElementId =
305
225
  options.paymentElementId ?? DEFAULT_PAYMENT_ELEMENT_ID;
306
226
  const redirectTo = options.redirectTo ?? DEFAULT_REDIRECT;
227
+ const saasConfig = config.public.dmsSaas as
228
+ | DmsSaasPublicRuntimeConfig
229
+ | undefined;
307
230
 
308
231
  const form = reactive<RegistrationForm>({
309
- customerType: "individual",
310
232
  email: "",
311
233
  password: "",
312
234
  name: "",
313
- workspaceName: "",
314
- companyName: "",
315
- vatNumber: "",
316
- country: "",
317
- addressLine1: "",
318
- postalCode: "",
319
- city: "",
320
- selectedPlanId: null,
321
235
  hasAcceptedLegal: false,
236
+ skipsPaymentMethod: false,
322
237
  });
323
238
 
324
- const allPlans = ref<RegistrationPlan[]>([]);
325
239
  const isLoading = ref(true);
326
240
  const isSubmitting = ref(false);
327
241
  const errorMessage = ref<string | null>(null);
@@ -329,19 +243,15 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
329
243
  null,
330
244
  );
331
245
 
332
- const stripePublishableKey = computed<string>(
333
- () =>
334
- (config.public.dmsSaas as DmsSaasPublicRuntimeConfig | undefined)
335
- ?.stripePublishableKey ?? "",
336
- );
337
-
338
- /** Plans the current customer type may subscribe to, in the operator's order. */
339
- const plans = computed(() =>
340
- resolveOfferedPlans(allPlans.value, form.customerType),
341
- );
246
+ /** Registration by invitation only: render the closed state, not the form. */
247
+ const isRegistrationClosed = isRegistrationClosedBy(saasConfig);
248
+ const paymentMethodPolicy = resolveRegistrationPaymentPolicy(saasConfig);
249
+ /** Whether the screen should offer to add the card later. */
250
+ const canSkipPaymentMethod = paymentMethodPolicy === "optional";
342
251
 
343
- const selectedPlan = computed(
344
- () => plans.value.find((plan) => plan._id === form.selectedPlanId) ?? null,
252
+ /** Bind the card field's visibility to this, with `v-show`, not `v-if`. */
253
+ const isPaymentStepVisible = computed(() =>
254
+ isPaymentStepShown(paymentMethodPolicy, form.skipsPaymentMethod),
345
255
  );
346
256
 
347
257
  /**
@@ -350,49 +260,37 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
350
260
  */
351
261
  const isPaymentReady = computed(() => stripeHandle.value !== null);
352
262
 
263
+ /**
264
+ * DMS's password rules, scored for `<DmsPasswordStrength>`. The API holds
265
+ * the password to the same policy.
266
+ */
267
+ const passwordStrength = usePasswordStrength(toRef(form, "password"));
268
+
353
269
  const requirements = computed<RegistrationRequirements>(() => ({
354
- selectedPlanId: form.selectedPlanId,
270
+ isPaymentRequired: isPaymentStepVisible.value,
355
271
  isPaymentReady: isPaymentReady.value,
356
272
  hasAcceptedLegal: form.hasAcceptedLegal,
357
- country: form.country,
358
273
  }));
359
274
 
275
+ // The password comes first: a card confirmed for a registration the API
276
+ // then refuses would be confirmed for nothing.
360
277
  const missingRequirement = computed(() =>
361
- firstMissingRegistrationRequirement(requirements.value),
278
+ passwordSchema.safeParse(form.password).success
279
+ ? firstMissingRegistrationRequirement(requirements.value)
280
+ : PASSWORD_POLICY_ERROR_KEY,
362
281
  );
363
282
 
364
283
  const canSubmit = computed(
365
284
  () => !isSubmitting.value && missingRequirement.value === null,
366
285
  );
367
286
 
368
- function formatPlanPrice(plan: RegistrationPlan): string {
369
- return formatRegistrationPlanPrice(plan, $i18n.locale.value, translate);
370
- }
371
-
372
- // Repaired synchronously, not on the next flush: a consumer that switches
373
- // customer type and submits in the same tick would otherwise send the plan
374
- // the visitor can no longer buy.
375
- watch(
376
- plans,
377
- (available) => {
378
- form.selectedPlanId = resolveSelectedPlanId(
379
- available,
380
- form.selectedPlanId,
381
- );
382
- },
383
- { flush: "sync" },
384
- );
385
-
386
- async function loadPlans(): Promise<void> {
387
- allPlans.value = await $authFetch<RegistrationPlan[]>(PLANS_ENDPOINT);
388
- }
389
-
390
287
  async function mountPaymentElement(): Promise<void> {
391
- if (!stripePublishableKey.value) return;
288
+ const publishableKey = saasConfig?.stripePublishableKey;
289
+ if (!publishableKey) return;
392
290
  const setup = await $authFetch<SetupIntentResponse>(SETUP_INTENT_ENDPOINT);
393
291
  if (!setup.clientSecret) return;
394
292
  stripeHandle.value = useStripePaymentElement({
395
- publishableKey: stripePublishableKey.value,
293
+ publishableKey,
396
294
  clientSecret: setup.clientSecret,
397
295
  containerId: paymentElementId,
398
296
  });
@@ -411,33 +309,23 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
411
309
  *
412
310
  * Taken whole, before the card confirmation: that step can hold a 3-D Secure
413
311
  * challenge open for seconds while the fields stay editable, and a payload
414
- * half read before and half after would pair, say, the plan chosen then with
415
- * the customer type chosen since — which the API rejects, once the card has
416
- * already been confirmed.
312
+ * half read before and half after would not be the one the visitor sent.
417
313
  */
418
- function captureSubmission(planId: string): RegistrationSubmission {
419
- const isBusiness = form.customerType === "business";
314
+ function captureSubmission(): RegistrationSubmission {
420
315
  return {
421
316
  email: form.email,
422
317
  password: form.password,
423
318
  name: form.name,
424
- workspaceName: form.workspaceName,
425
- planId,
426
- customerType: form.customerType,
427
- companyName: isBusiness ? form.companyName : undefined,
428
- vatNumber: isBusiness ? optionalField(form.vatNumber) : undefined,
429
- address: {
430
- country: form.country,
431
- line1: optionalField(form.addressLine1),
432
- postalCode: optionalField(form.postalCode),
433
- city: optionalField(form.city),
434
- },
319
+ workspaceName: translate(DEFAULT_WORKSPACE_NAME_KEY, {
320
+ name: form.name.trim(),
321
+ }),
435
322
  extras: snapshotRegistrationExtras(toValue(options.extras)),
436
323
  };
437
324
  }
438
325
 
439
326
  /**
440
- * Confirm the card, provision the workspace, land on the configured route.
327
+ * Confirm the card when one is collected, provision the workspace, land on
328
+ * the configured route.
441
329
  *
442
330
  * Everything the caller needs to know travels through `errorMessage`:
443
331
  * nothing throws, so a consumer template can bind the action directly.
@@ -449,11 +337,8 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
449
337
  // the first is in flight would run both again.
450
338
  if (isSubmitting.value) return null;
451
339
 
452
- const planId = form.selectedPlanId;
453
- if (missingRequirement.value || !planId) {
454
- errorMessage.value = translate(
455
- missingRequirement.value ?? NO_PLAN_ERROR_KEY,
456
- );
340
+ if (missingRequirement.value) {
341
+ errorMessage.value = translate(missingRequirement.value);
457
342
  return null;
458
343
  }
459
344
 
@@ -463,9 +348,11 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
463
348
  try {
464
349
  // Inside the guard: freezing the capture reads the consumer's object,
465
350
  // and a consumer's object is never guaranteed to be serialisable.
466
- const submission = captureSubmission(planId);
467
- const paymentMethodId = await confirmPaymentMethod();
468
- if (!paymentMethodId) return null;
351
+ const submission = captureSubmission();
352
+ const paymentMethodId = isPaymentStepVisible.value
353
+ ? await confirmPaymentMethod()
354
+ : undefined;
355
+ if (paymentMethodId === null) return null;
469
356
 
470
357
  const result = await $authFetch<RegistrationResult>(REGISTER_ENDPOINT, {
471
358
  method: "POST",
@@ -477,8 +364,8 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
477
364
  return tenantId;
478
365
  } catch (error) {
479
366
  // Once the workspace exists, whatever failed after it is a landing
480
- // problem: calling it a failed registration sends a visitor who has
481
- // already paid back through a flow that would charge them again.
367
+ // problem: calling it a failed registration sends the visitor back
368
+ // through a flow that would create a second account.
482
369
  if (tenantId) return tenantId;
483
370
  errorMessage.value = resolveApiError(error, SUBMIT_ERROR_KEY);
484
371
  return null;
@@ -488,15 +375,14 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
488
375
  }
489
376
 
490
377
  onMounted(async () => {
491
- if (
492
- (config.public.dmsSaas as DmsSaasPublicRuntimeConfig | undefined)
493
- ?.admissionMode === "invitation-only"
494
- ) {
378
+ // Closed registration and a card-less policy never reach Stripe: the
379
+ // setup intent would only answer 403 or 400.
380
+ if (isRegistrationClosed || paymentMethodPolicy === "none") {
495
381
  isLoading.value = false;
496
382
  return;
497
383
  }
498
384
  try {
499
- await Promise.all([loadPlans(), mountPaymentElement()]);
385
+ await mountPaymentElement();
500
386
  } catch (error) {
501
387
  errorMessage.value = resolveApiError(error, LOAD_ERROR_KEY);
502
388
  } finally {
@@ -506,17 +392,18 @@ export function useSaasRegistration(options: UseSaasRegistrationOptions = {}) {
506
392
 
507
393
  return {
508
394
  form,
509
- plans,
510
- selectedPlan,
511
- countryItems,
512
395
  paymentElementId,
396
+ paymentMethodPolicy,
397
+ canSkipPaymentMethod,
398
+ isPaymentStepVisible,
513
399
  isPaymentReady,
400
+ passwordStrength,
401
+ isRegistrationClosed,
514
402
  isLoading,
515
403
  isSubmitting,
516
404
  errorMessage,
517
405
  missingRequirement,
518
406
  canSubmit,
519
- formatPlanPrice,
520
407
  submit,
521
408
  };
522
409
  }
@@ -1,6 +1,12 @@
1
1
  const SEAT_QUOTA_ENDPOINT = "/api/saas/tenant/seats";
2
2
  const SEAT_QUOTA_STATE_KEY = "saas-seat-quota";
3
3
 
4
+ export interface PlatformSupportMember {
5
+ userId: string;
6
+ name: string;
7
+ email: string;
8
+ }
9
+
4
10
  export interface SeatQuotaResponse {
5
11
  members: number;
6
12
  pendingInvites: number;
@@ -8,6 +14,8 @@ export interface SeatQuotaResponse {
8
14
  /** `null` when no plan is in force, i.e. no seat ceiling to enforce. */
9
15
  maxMembers: number | null;
10
16
  isTenantOwner: boolean;
17
+ /** Platform owners with access to the workspace; they hold no seat. */
18
+ platformSupport: PlatformSupportMember[];
11
19
  }
12
20
 
13
21
  export function useSeatQuota(): SharedRequest<SeatQuotaResponse> {
@@ -15,7 +15,9 @@ interface StripePaymentElementHandle {
15
15
  confirmAndGetPaymentMethod: () => Promise<ConfirmResult>;
16
16
  }
17
17
 
18
- const ALLOWED_PAYMENT_METHODS = ["card"];
18
+ // Payment method types are fixed server-side on the SetupIntent: Stripe
19
+ // rejects `paymentMethodTypes` next to a `clientSecret`, since it only applies
20
+ // to an Elements instance created in deferred-intent `mode`.
19
21
  const ALLOWED_CARD_BRANDS = ["visa", "mastercard"];
20
22
 
21
23
  export function useStripePaymentElement(
@@ -32,10 +34,8 @@ export function useStripePaymentElement(
32
34
  }
33
35
  elementsInstance = stripeInstance.elements({
34
36
  clientSecret: options.clientSecret,
35
- paymentMethodTypes: ALLOWED_PAYMENT_METHODS,
36
37
  });
37
38
  const paymentElement = elementsInstance.create("payment", {
38
- paymentMethodTypes: ALLOWED_PAYMENT_METHODS,
39
39
  wallets: { applePay: "never", googlePay: "never" },
40
40
  fields: {
41
41
  billingDetails: { address: "auto", name: "auto", email: "never" },
@@ -14,8 +14,8 @@ export interface CachedWorkspaceAccess {
14
14
 
15
15
  /**
16
16
  * Per-session cache of the current workspace's subscription access, shared
17
- * between the global workspace-suspended middleware (reader/writer) and the
18
- * suspended screen (invalidator once the workspace unblocks).
17
+ * between its readers (the refund card) and the suspended screen (invalidator
18
+ * once the workspace unblocks).
19
19
  */
20
20
  export function useWorkspaceAccessCache() {
21
21
  return useDmsState<CachedWorkspaceAccess | null>(
@@ -23,3 +23,30 @@ export function useWorkspaceAccessCache() {
23
23
  () => null,
24
24
  );
25
25
  }
26
+
27
+ const ACCESS_ENDPOINT = "/api/saas/billing/access";
28
+ const FALLBACK_TENANT_ID = "default";
29
+
30
+ /**
31
+ * The active workspace's access, served from the cache while it still belongs
32
+ * to the active tenant and fetched otherwise.
33
+ *
34
+ * @returns The access, or null on a transport error — callers fail open, the
35
+ * backend gate still denies every tenant surface
36
+ */
37
+ export async function loadWorkspaceAccess(): Promise<WorkspaceAccess | null> {
38
+ const { session } = useUserSession();
39
+ const tenantId =
40
+ (session.value as { activeTenantId?: string } | null)?.activeTenantId ??
41
+ FALLBACK_TENANT_ID;
42
+ const cached = useWorkspaceAccessCache();
43
+ if (cached.value?.tenantId === tenantId) return cached.value.access;
44
+ const { $authFetch } = useAuthFetch();
45
+ try {
46
+ const access = await $authFetch<WorkspaceAccess>(ACCESS_ENDPOINT);
47
+ cached.value = { tenantId, access };
48
+ return access;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }