@open-mercato/onboarding 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
  4. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
  5. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
  6. package/dist/modules/onboarding/api/get/onboarding/status.js +35 -17
  7. package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
  8. package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
  9. package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
  10. package/dist/modules/onboarding/api/post/onboarding.js +10 -1
  11. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  12. package/dist/modules/onboarding/data/entities.js +3 -0
  13. package/dist/modules/onboarding/data/entities.js.map +2 -2
  14. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
  15. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
  16. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
  17. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
  18. package/dist/modules/onboarding/lib/deferred-provisioning.js +235 -0
  19. package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
  20. package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
  21. package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
  22. package/dist/modules/onboarding/lib/provisioning.js +18 -0
  23. package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
  24. package/dist/modules/onboarding/lib/service.js +31 -0
  25. package/dist/modules/onboarding/lib/service.js.map +2 -2
  26. package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
  27. package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
  28. package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
  29. package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
  30. package/generated/entities/onboarding_request/index.ts +1 -0
  31. package/generated/entity-fields-registry.ts +1 -0
  32. package/package.json +4 -5
  33. package/src/__tests__/deferred-provisioning.test.ts +210 -0
  34. package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
  35. package/src/__tests__/provisioning.test.ts +89 -0
  36. package/src/__tests__/status-endpoint-auth.test.ts +130 -3
  37. package/src/__tests__/verify-base-url.test.ts +65 -0
  38. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
  39. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
  40. package/src/modules/onboarding/api/get/onboarding/status.ts +46 -17
  41. package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
  42. package/src/modules/onboarding/api/post/onboarding.ts +9 -0
  43. package/src/modules/onboarding/data/entities.ts +3 -0
  44. package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
  45. package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
  46. package/src/modules/onboarding/i18n/de.json +10 -1
  47. package/src/modules/onboarding/i18n/en.json +10 -1
  48. package/src/modules/onboarding/i18n/es.json +10 -1
  49. package/src/modules/onboarding/i18n/pl.json +10 -1
  50. package/src/modules/onboarding/lib/deferred-provisioning.ts +295 -0
  51. package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
  52. package/src/modules/onboarding/lib/provisioning.ts +35 -0
  53. package/src/modules/onboarding/lib/service.ts +33 -0
  54. package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
  55. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
  56. package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
@@ -1,14 +1,9 @@
1
- import { after } from 'next/server'
1
+ import { after, NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
- import type { SearchIndexer } from '@open-mercato/search'
5
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
- import {
7
- AppOriginConfigurationError,
8
- AppOriginRejectedError,
9
- getSecurityEmailBaseUrl,
10
- } from '@open-mercato/shared/lib/url'
11
5
  import { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'
6
+ import type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'
12
7
  import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
13
8
  import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
14
9
  import {
@@ -16,16 +11,18 @@ import {
16
11
  redirectToPreparing,
17
12
  redirectWithStatus,
18
13
  } from '@open-mercato/onboarding/modules/onboarding/lib/verify-redirects'
14
+ import { resolveVerifyRedirectBaseUrl } from '@open-mercato/onboarding/modules/onboarding/lib/verify-base-url'
15
+ import {
16
+ resolveProvisioningIds,
17
+ runDeferredProvisioning,
18
+ } from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
19
19
  import { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'
20
20
  import { UserConsent } from '@open-mercato/core/modules/auth/data/entities'
21
21
  import { computeConsentIntegrityHash } from '@open-mercato/core/modules/auth/lib/consentIntegrity'
22
22
  import { resolveConsentClientIp } from '@open-mercato/onboarding/modules/onboarding/lib/consentClientIp'
23
- import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'
24
- import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
25
- import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
26
- import { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'
27
- import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
23
+ import { runBestEffortProvisioningStep } from '@open-mercato/onboarding/modules/onboarding/lib/provisioning'
28
24
  import { getModules } from '@open-mercato/shared/lib/modules/registry'
25
+ import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
29
26
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
30
27
 
31
28
  export const metadata = {
@@ -35,24 +32,7 @@ export const metadata = {
35
32
  },
36
33
  }
37
34
 
38
- function resolveTrustedBaseUrl(req: Request): string {
39
- try {
40
- return getSecurityEmailBaseUrl(req)
41
- } catch (error) {
42
- if (error instanceof AppOriginRejectedError || error instanceof AppOriginConfigurationError) {
43
- console.error('[onboarding.verify] rejected request origin for redirect base', {
44
- requestUrl: req.url,
45
- reason: error.message,
46
- })
47
- return new URL(req.url).origin
48
- }
49
- throw error
50
- }
51
- }
52
-
53
- const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000
54
35
  const SEED_DEFAULTS_TIMEOUT_MS = 15_000
55
- const SEED_EXAMPLES_TIMEOUT_MS = 15_000
56
36
 
57
37
  function createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {
58
38
  return new Promise((_, reject) => {
@@ -83,191 +63,59 @@ async function runModuleSetupHook(args: {
83
63
  durationMs: Math.max(0, Date.now() - startedAt),
84
64
  })
85
65
  } catch (error) {
86
- console.error('[onboarding.verify] module hook failed', {
87
- moduleId: args.moduleId,
88
- phase: args.phase,
89
- durationMs: Math.max(0, Date.now() - startedAt),
90
- timeoutMs: args.timeoutMs,
91
- error,
92
- })
93
- throw error
94
- }
95
- }
96
-
97
- async function markWorkspaceReady(args: {
98
- requestId: string
99
- }) {
100
- const container = await createRequestContainer()
101
- const em = container.resolve('em') as EntityManager
102
- const service = new OnboardingService(em)
103
- const request = await service.findById(args.requestId)
104
- if (!request || request.preparationCompletedAt) return
105
- await service.markPreparationCompleted(request, new Date())
106
- }
107
-
108
- async function enqueueVectorReindex(args: {
109
- container: { resolve: <T = unknown>(name: string) => T }
110
- tenantId: string
111
- organizationId: string
112
- }) {
113
- let searchIndexer: SearchIndexer | null = null
114
- try {
115
- searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')
116
- } catch {
117
- searchIndexer = null
118
- }
119
- if (!searchIndexer) return
120
-
121
- await Promise.race([
122
- searchIndexer.reindexAllToVector({
123
- tenantId: args.tenantId,
124
- organizationId: args.organizationId,
125
- purgeFirst: true,
126
- useQueue: true,
127
- }),
128
- createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),
129
- ])
130
- }
131
-
132
- async function rebuildTenantQueryIndexes(args: {
133
- em: EntityManager
134
- tenantId: string
135
- organizationId: string
136
- }) {
137
- const coverageRefreshKeys = new Set<string>()
138
- try {
139
- const allEntities = getEntityIds()
140
- const entityIds = flattenSystemEntityIds(allEntities)
141
- for (const entityType of entityIds) {
142
- try {
143
- await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })
144
- } catch (error) {
145
- console.error('[onboarding.verify] failed to purge query index scope', {
146
- entityType,
147
- tenantId: args.tenantId,
148
- error,
149
- })
150
- }
151
- try {
152
- await reindexEntity(args.em, {
153
- entityType,
154
- tenantId: args.tenantId,
155
- force: true,
156
- emitVectorizeEvents: false,
157
- vectorService: null,
158
- })
159
- } catch (error) {
160
- console.error('[onboarding.verify] failed to reindex entity', {
161
- entityType,
162
- tenantId: args.tenantId,
163
- error,
164
- })
165
- }
166
- coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)
167
- coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)
168
- }
169
- } catch (error) {
170
- console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })
171
- }
172
-
173
- if (!coverageRefreshKeys.size) return
174
-
175
- for (const entry of coverageRefreshKeys) {
176
- const [entityType, tenantKey, orgKey] = entry.split('|')
177
- const orgScope = orgKey === '__null__' ? null : orgKey
178
- try {
179
- await refreshCoverageSnapshot(
180
- args.em,
181
- {
182
- entityType,
183
- tenantId: tenantKey,
184
- organizationId: orgScope,
185
- withDeleted: false,
186
- },
187
- )
188
- } catch (error) {
189
- console.error('[onboarding.verify] failed to refresh coverage snapshot', {
190
- entityType,
191
- tenantId: tenantKey,
192
- organizationId: orgScope,
66
+ if (isUniqueViolation(error)) {
67
+ // A concurrent verify (or a re-verify after the request was already
68
+ // provisioned) re-runs seedDefaults against rows that exist. seed hooks
69
+ // are not fully idempotent, so the collision is expected and harmless —
70
+ // the workspace already exists. Log at info so real failures stand out.
71
+ console.info('[onboarding.verify] module hook skipped (already seeded)', {
72
+ moduleId: args.moduleId,
73
+ phase: args.phase,
74
+ durationMs: Math.max(0, Date.now() - startedAt),
75
+ })
76
+ } else {
77
+ console.error('[onboarding.verify] module hook failed', {
78
+ moduleId: args.moduleId,
79
+ phase: args.phase,
80
+ durationMs: Math.max(0, Date.now() - startedAt),
81
+ timeoutMs: args.timeoutMs,
193
82
  error,
194
83
  })
195
84
  }
85
+ throw error
196
86
  }
197
87
  }
198
88
 
199
- async function runDeferredProvisioning(args: {
200
- requestId: string
201
- tenantId: string
202
- organizationId: string
89
+ async function completeProvisionedRequest(args: {
90
+ service: OnboardingService
91
+ request: OnboardingRequest
203
92
  }) {
204
- const container = await createRequestContainer()
205
- const em = container.resolve('em') as EntityManager
206
- const modules = getModules()
207
-
208
- for (const mod of modules) {
209
- if (!mod.setup?.seedExamples) continue
210
- try {
211
- await runModuleSetupHook({
212
- moduleId: mod.id,
213
- phase: 'seedExamples',
214
- timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
215
- run: () => mod.setup!.seedExamples!({
216
- em,
217
- tenantId: args.tenantId,
218
- organizationId: args.organizationId,
219
- container,
220
- }),
221
- })
222
- } catch (error) {
223
- console.error('[onboarding.verify] deferred seedExamples failed', {
224
- moduleId: mod.id,
225
- tenantId: args.tenantId,
226
- organizationId: args.organizationId,
227
- error,
228
- })
229
- }
230
- }
231
-
232
- await markWorkspaceReady({
233
- requestId: args.requestId,
234
- })
235
-
236
- await sendWorkspaceReadyEmail({
237
- requestId: args.requestId,
238
- tenantId: args.tenantId,
239
- }).catch((error) => {
240
- console.error('[onboarding.verify] ready email failed', {
241
- requestId: args.requestId,
242
- tenantId: args.tenantId,
243
- organizationId: args.organizationId,
244
- error,
245
- })
246
- throw error
247
- })
248
-
249
- await rebuildTenantQueryIndexes({
250
- em,
251
- tenantId: args.tenantId,
252
- organizationId: args.organizationId,
253
- })
254
-
255
- await enqueueVectorReindex({
256
- container,
257
- tenantId: args.tenantId,
258
- organizationId: args.organizationId,
259
- }).catch((error) => {
260
- console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {
261
- tenantId: args.tenantId,
262
- organizationId: args.organizationId,
263
- reason: error instanceof Error ? error.message : String(error),
93
+ const ids = resolveProvisioningIds(args.request)
94
+ if (!ids) return null
95
+ await args.service.markCompleted(args.request, ids)
96
+ after(async () => {
97
+ await runDeferredProvisioning({
98
+ requestId: args.request.id,
99
+ tenantId: ids.tenantId,
100
+ organizationId: ids.organizationId,
264
101
  })
265
102
  })
103
+ return ids
266
104
  }
267
105
 
268
106
  export async function GET(req: Request) {
269
107
  const url = new URL(req.url)
270
- const baseUrl = resolveTrustedBaseUrl(req)
108
+ const baseUrlResult = resolveVerifyRedirectBaseUrl(req)
109
+ if (!baseUrlResult.ok) {
110
+ if (baseUrlResult.redirectOrigin) {
111
+ return redirectWithStatus(baseUrlResult.redirectOrigin, baseUrlResult.status)
112
+ }
113
+ return NextResponse.json(
114
+ { ok: false, error: baseUrlResult.message },
115
+ { status: baseUrlResult.httpStatus },
116
+ )
117
+ }
118
+ const baseUrl = baseUrlResult.baseUrl
271
119
  const token = url.searchParams.get('token') ?? ''
272
120
  const parsed = onboardingVerifySchema.safeParse({ token })
273
121
  if (!parsed.success) {
@@ -309,9 +157,13 @@ export async function GET(req: Request) {
309
157
  const processingStartedAt = request.processingStartedAt?.getTime() ?? 0
310
158
  const processingFresh = request.status === 'processing' && processingStartedAt > Date.now() - lockWindowMs
311
159
  if (processingFresh) {
160
+ const recovered = await completeProvisionedRequest({ service, request })
161
+ if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)
312
162
  return redirectToPreparing(baseUrl, request.tenantId ?? null)
313
163
  }
314
164
  if (request.status === 'processing' && !processingFresh) {
165
+ const recovered = await completeProvisionedRequest({ service, request })
166
+ if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)
315
167
  await service.resetProcessing(request)
316
168
  }
317
169
  if (request.status !== 'pending') {
@@ -323,12 +175,17 @@ export async function GET(req: Request) {
323
175
  // Re-read on a fresh fork — the request EM's identity map still holds the stale
324
176
  // pre-claim copy — and route off the winner's committed state instead of re-running
325
177
  // provisioning, which would throw USER_EXISTS and could strand the request (#2742).
326
- const current = await new OnboardingService(em.fork()).findById(request.id)
178
+ const currentService = new OnboardingService(em.fork())
179
+ const current = await currentService.findById(request.id)
327
180
  if (current?.status === 'completed' && current.tenantId) {
328
181
  return current.preparationCompletedAt
329
182
  ? redirectToLogin(baseUrl, current.tenantId)
330
183
  : redirectToPreparing(baseUrl, current.tenantId)
331
184
  }
185
+ if (current?.status === 'processing') {
186
+ const recovered = await completeProvisionedRequest({ service: currentService, request: current })
187
+ if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)
188
+ }
332
189
  return redirectToPreparing(baseUrl, current?.tenantId ?? request.tenantId ?? null)
333
190
  }
334
191
  if (!request.passwordHash) {
@@ -386,43 +243,55 @@ export async function GET(req: Request) {
386
243
  ipAddress: clientIp,
387
244
  source: 'onboarding',
388
245
  })
389
- // Wrap the request-em consent write in a transaction so it commits
390
- // all-or-nothing. setupInitialTenant already manages its own internal
391
- // transactions, and the seedDefaults hooks below resolve container
392
- // services that fork their own EM (and may enqueue work), so neither can
393
- // join this transaction only the direct request-em writes are wrapped.
394
- await em.transactional(async (txEm) => {
395
- txEm.create(UserConsent, {
396
- userId: resolvedUserId,
397
- tenantId: resolvedTenantId,
398
- organizationId: resolvedOrganizationId,
399
- consentType: 'marketing_email',
400
- isGranted: true,
401
- grantedAt: now,
402
- source: 'onboarding',
403
- ipAddress: clientIp,
404
- integrityHash,
405
- createdAt: now,
406
- })
407
- })
246
+ // Persist the marketing consent on an isolated EM fork wrapped in a
247
+ // transaction so it commits all-or-nothing AND a failure here can neither
248
+ // abort provisioning nor poison the request EM's unit of work before
249
+ // markCompleted runs. Recording the consent is best-effort: the workspace
250
+ // is already provisioned, and a lost consent record fails safe (treated as
251
+ // not granted) and is logged for follow-up rather than stranding the user.
252
+ await runBestEffortProvisioningStep('marketing-consent', () =>
253
+ em.fork().transactional(async (txEm) => {
254
+ txEm.create(UserConsent, {
255
+ userId: resolvedUserId,
256
+ tenantId: resolvedTenantId,
257
+ organizationId: resolvedOrganizationId,
258
+ consentType: 'marketing_email',
259
+ isGranted: true,
260
+ grantedAt: now,
261
+ source: 'onboarding',
262
+ ipAddress: clientIp,
263
+ integrityHash,
264
+ createdAt: now,
265
+ })
266
+ }),
267
+ )
408
268
  }
409
269
 
410
- // Call module seedDefaults + seedExamples hooks
270
+ // Call module seedDefaults hooks. Each hook is best-effort and runs on its
271
+ // own isolated EM fork: a single module's throw or 15s timeout must not
272
+ // strand the freshly provisioned workspace — the tenant/org/user already
273
+ // exist and the request must still reach markCompleted so the user can sign
274
+ // in. A per-module fork also keeps a failed module's unflushed unit of work
275
+ // from leaking into (or aborting) the next module's flush. Failures are
276
+ // logged for follow-up (deferred seedExamples is already non-fatal in the
277
+ // same way).
411
278
  const modules = getModules()
412
279
  for (const mod of modules) {
413
- if (mod.setup?.seedDefaults) {
414
- await runModuleSetupHook({
280
+ if (!mod.setup?.seedDefaults) continue
281
+ const seedEm = em.fork()
282
+ await runBestEffortProvisioningStep(`seedDefaults:${mod.id}`, () =>
283
+ runModuleSetupHook({
415
284
  moduleId: mod.id,
416
285
  phase: 'seedDefaults',
417
286
  timeoutMs: SEED_DEFAULTS_TIMEOUT_MS,
418
287
  run: () => mod.setup!.seedDefaults!({
419
- em,
288
+ em: seedEm,
420
289
  tenantId: resolvedTenantId,
421
290
  organizationId: resolvedOrganizationId,
422
291
  container,
423
292
  }),
424
- })
425
- }
293
+ }),
294
+ )
426
295
  }
427
296
  await service.markCompleted(request, {
428
297
  tenantId: resolvedTenantId,
@@ -15,11 +15,19 @@ import { User } from '@open-mercato/core/modules/auth/data/entities'
15
15
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
16
16
  import { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'
17
17
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
18
+ import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
19
+ import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
18
20
 
19
21
  export const metadata = {
20
22
  path: '/onboarding/onboarding',
21
23
  POST: {
22
24
  requireAuth: false,
25
+ rateLimit: readEndpointRateLimitConfig('ONBOARDING', {
26
+ points: 10,
27
+ duration: 60,
28
+ blockDuration: 60,
29
+ keyPrefix: 'onboarding',
30
+ }),
23
31
  },
24
32
  }
25
33
 
@@ -242,6 +250,7 @@ const onboardingPostDoc: OpenApiMethodDoc = {
242
250
  { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },
243
251
  { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },
244
252
  { status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },
253
+ { status: 429, description: 'Too many onboarding submissions from this IP', schema: rateLimitErrorSchema },
245
254
  { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },
246
255
  ],
247
256
  }
@@ -60,6 +60,9 @@ export class OnboardingRequest {
60
60
  @Property({ name: 'last_email_sent_at', type: Date, nullable: true })
61
61
  lastEmailSentAt?: Date | null
62
62
 
63
+ @Property({ name: 'preparation_started_at', type: Date, nullable: true })
64
+ preparationStartedAt?: Date | null
65
+
63
66
  @Property({ name: 'preparation_completed_at', type: Date, nullable: true })
64
67
  preparationCompletedAt?: Date | null
65
68
 
@@ -3,6 +3,7 @@
3
3
  import Image from 'next/image'
4
4
  import Link from 'next/link'
5
5
  import { useState } from 'react'
6
+ import { useSearchParams } from 'next/navigation'
6
7
  import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
7
8
  import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
8
9
  import { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'
@@ -31,6 +32,7 @@ export default function OnboardingPageClient({ onboardingEnabled }: Props) {
31
32
  const translate = (key: string, fallback: string, params?: Record<string, string | number>) =>
32
33
  translateWithFallback(t, key, fallback, params)
33
34
  const locale = useLocale()
35
+ const searchParams = useSearchParams()
34
36
  const [state, setState] = useState<SubmissionState>('idle')
35
37
  const [globalError, setGlobalError] = useState<string | null>(null)
36
38
  const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
@@ -152,6 +154,34 @@ export default function OnboardingPageClient({ onboardingEnabled }: Props) {
152
154
  const onboardingDisabled = !onboardingEnabled
153
155
  const submitting = state === 'loading'
154
156
  const disabled = onboardingDisabled || submitting || state === 'success'
157
+ const verifyStatusMessages: Record<string, string> = {
158
+ redirect_misconfigured: translate(
159
+ 'onboarding.verifyStatus.redirectMisconfigured',
160
+ 'The verification link cannot redirect safely because APP_URL does not match the URL that handled the request. Check APP_URL and APP_ALLOWED_ORIGINS, then open the verification link again.',
161
+ ),
162
+ origin_not_allowed: translate(
163
+ 'onboarding.verifyStatus.originNotAllowed',
164
+ 'The verification link was opened from an origin that is not allowed. Check APP_URL and APP_ALLOWED_ORIGINS, then open the verification link again.',
165
+ ),
166
+ url_not_configured: translate(
167
+ 'onboarding.verifyStatus.urlNotConfigured',
168
+ 'Onboarding verification is not configured. APP_URL must be set before verification links can be used.',
169
+ ),
170
+ already_exists: translate(
171
+ 'onboarding.verifyStatus.alreadyExists',
172
+ 'We already have an account with this email. Try signing in or resetting your password.',
173
+ ),
174
+ invalid: translate(
175
+ 'onboarding.verifyStatus.invalid',
176
+ 'The verification link is invalid or expired. Submit the onboarding form again to receive a new link.',
177
+ ),
178
+ error: translate(
179
+ 'onboarding.verifyStatus.error',
180
+ 'We could not complete verification. Please try again or contact support.',
181
+ ),
182
+ }
183
+ const verifyStatus = searchParams.get('status') ?? ''
184
+ const verifyStatusError = verifyStatusMessages[verifyStatus] ?? null
155
185
 
156
186
  return (
157
187
  <div className="relative flex min-h-svh items-center justify-center bg-muted/50 px-4 pb-24">
@@ -204,9 +234,9 @@ export default function OnboardingPageClient({ onboardingEnabled }: Props) {
204
234
  </p>
205
235
  </div>
206
236
  )}
207
- {state !== 'success' && globalError && (
237
+ {state !== 'success' && (globalError || verifyStatusError) && (
208
238
  <div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" role="alert" aria-live="assertive">
209
- {globalError}
239
+ {globalError || verifyStatusError}
210
240
  </div>
211
241
  )}
212
242
  <form className="grid gap-4" onSubmit={onSubmit} noValidate>
@@ -20,6 +20,7 @@ export default function PreparingPageClient() {
20
20
  const tenantId = (searchParams.get('tenant') || '').trim()
21
21
  const [tenantName, setTenantName] = useState<string | null>(null)
22
22
  const [redirecting, setRedirecting] = useState(false)
23
+ const [statusError, setStatusError] = useState<string | null>(null)
23
24
 
24
25
  useEffect(() => {
25
26
  if (!tenantId) {
@@ -47,15 +48,36 @@ export default function PreparingPageClient() {
47
48
  if (!tenantId) return
48
49
  let active = true
49
50
  let timeoutId: ReturnType<typeof setTimeout> | null = null
51
+ const genericStatusError = translate(
52
+ 'onboarding.preparing.statusErrorBody',
53
+ 'We could not check the workspace status. The workspace may still be preparing; this page will keep retrying.',
54
+ )
55
+ function schedulePoll() {
56
+ if (!active) return
57
+ timeoutId = setTimeout(() => {
58
+ void poll()
59
+ }, 3000)
60
+ }
50
61
 
51
- const poll = async () => {
62
+ async function poll() {
52
63
  try {
53
64
  const { result } = await apiCall<{
54
65
  ok?: boolean
55
66
  ready?: boolean
56
67
  loginUrl?: string | null
68
+ error?: string
57
69
  }>(`/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`)
58
- if (!active || !result?.ok) return
70
+ if (!active) return
71
+ if (!result?.ok) {
72
+ setStatusError(
73
+ typeof result?.error === 'string' && result.error.trim()
74
+ ? result.error
75
+ : genericStatusError,
76
+ )
77
+ schedulePoll()
78
+ return
79
+ }
80
+ setStatusError(null)
59
81
  if (result.ready && result.loginUrl) {
60
82
  setRedirecting(true)
61
83
  router.replace(result.loginUrl)
@@ -63,11 +85,9 @@ export default function PreparingPageClient() {
63
85
  }
64
86
  } catch {
65
87
  if (!active) return
88
+ setStatusError(genericStatusError)
66
89
  }
67
- if (!active) return
68
- timeoutId = setTimeout(() => {
69
- void poll()
70
- }, 3000)
90
+ schedulePoll()
71
91
  }
72
92
 
73
93
  void poll()
@@ -116,6 +136,18 @@ export default function PreparingPageClient() {
116
136
  'You do not need to keep this page open. We will email you when everything is ready.',
117
137
  )}
118
138
  </div>
139
+ {statusError ? (
140
+ <div
141
+ className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-left text-sm text-red-700"
142
+ role="alert"
143
+ aria-live="assertive"
144
+ >
145
+ <strong className="block text-sm font-medium">
146
+ {translate('onboarding.preparing.statusErrorTitle', 'Workspace status check failed')}
147
+ </strong>
148
+ <p className="mt-1">{statusError}</p>
149
+ </div>
150
+ ) : null}
119
151
  <div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
120
152
  <Button asChild>
121
153
  <Link href="/">
@@ -77,7 +77,10 @@
77
77
  "onboarding.preparing.descriptionWithTenant": "Wir schließen die Vorbereitung der Demo-Umgebung für {tenant} ab. Sobald alles bereit ist, senden wir Ihnen eine E-Mail mit dem korrekten Tenant-Login-Link.",
78
78
  "onboarding.preparing.emailNotice": "Sie müssen diese Seite nicht geöffnet lassen. Wir senden Ihnen eine E-Mail, sobald alles bereit ist.",
79
79
  "onboarding.preparing.homeCta": "Zur Startseite",
80
+ "onboarding.preparing.loginCta": "Tenant-Login öffnen",
80
81
  "onboarding.preparing.redirecting": "Ihr Arbeitsbereich ist bereit. Wir leiten Sie jetzt zur Tenant-Anmeldung weiter.",
82
+ "onboarding.preparing.statusErrorBody": "Der Arbeitsbereichstatus konnte nicht geprüft werden. Der Arbeitsbereich wird möglicherweise noch vorbereitet; diese Seite versucht es weiter.",
83
+ "onboarding.preparing.statusErrorTitle": "Statusprüfung fehlgeschlagen",
81
84
  "onboarding.preparing.title": "Wir bereiten Ihren Arbeitsbereich vor",
82
85
  "onboarding.readyEmail.body": "Ihr Open Mercato-Arbeitsbereich für {organizationName} ist fertig vorbereitet. Verwenden Sie den sicheren Link unten, um sich anzumelden.",
83
86
  "onboarding.readyEmail.cta": "Login öffnen",
@@ -87,5 +90,11 @@
87
90
  "onboarding.readyEmail.preview": "Ihr Arbeitsbereich ist bereit. Verwenden Sie Ihren sicheren Login-Link zur Anmeldung.",
88
91
  "onboarding.readyEmail.subject": "Ihr Open Mercato-Arbeitsbereich ist bereit",
89
92
  "onboarding.subtitle": "Erzählen Sie uns ein wenig über sich und wir richten alles für Sie ein.",
90
- "onboarding.title": "Erstellen Sie Ihren Open Mercato-Arbeitsbereich"
93
+ "onboarding.title": "Erstellen Sie Ihren Open Mercato-Arbeitsbereich",
94
+ "onboarding.verifyStatus.alreadyExists": "Es existiert bereits ein Konto mit dieser E-Mail-Adresse. Versuchen Sie, sich anzumelden oder Ihr Passwort zurückzusetzen.",
95
+ "onboarding.verifyStatus.error": "Die Verifizierung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.",
96
+ "onboarding.verifyStatus.invalid": "Der Verifizierungslink ist ungültig oder abgelaufen. Senden Sie das Onboarding-Formular erneut, um einen neuen Link zu erhalten.",
97
+ "onboarding.verifyStatus.originNotAllowed": "Der Verifizierungslink wurde von einem nicht erlaubten Origin geöffnet. Prüfen Sie APP_URL und APP_ALLOWED_ORIGINS und öffnen Sie den Link erneut.",
98
+ "onboarding.verifyStatus.redirectMisconfigured": "Der Verifizierungslink kann nicht sicher weiterleiten, weil APP_URL nicht zur URL passt, die die Anfrage verarbeitet hat. Prüfen Sie APP_URL und APP_ALLOWED_ORIGINS und öffnen Sie den Link erneut.",
99
+ "onboarding.verifyStatus.urlNotConfigured": "Die Onboarding-Verifizierung ist nicht konfiguriert. APP_URL muss gesetzt sein, bevor Verifizierungslinks verwendet werden können."
91
100
  }
@@ -77,7 +77,10 @@
77
77
  "onboarding.preparing.descriptionWithTenant": "We are finishing the demo environment for {tenant}. We will send you an email with the correct tenant login link as soon as it is ready.",
78
78
  "onboarding.preparing.emailNotice": "You do not need to keep this page open. We will email you when everything is ready.",
79
79
  "onboarding.preparing.homeCta": "Go to home page",
80
+ "onboarding.preparing.loginCta": "Open tenant login",
80
81
  "onboarding.preparing.redirecting": "Your workspace is ready. Redirecting you to the tenant login page now.",
82
+ "onboarding.preparing.statusErrorBody": "We could not check the workspace status. The workspace may still be preparing; this page will keep retrying.",
83
+ "onboarding.preparing.statusErrorTitle": "Workspace status check failed",
81
84
  "onboarding.preparing.title": "We are preparing your workspace",
82
85
  "onboarding.readyEmail.body": "Your Open Mercato workspace for {organizationName} has finished preparing. Use the secure link below to sign in.",
83
86
  "onboarding.readyEmail.cta": "Open login",
@@ -87,5 +90,11 @@
87
90
  "onboarding.readyEmail.preview": "Your workspace is ready. Use your secure login link to sign in.",
88
91
  "onboarding.readyEmail.subject": "Your Open Mercato workspace is ready",
89
92
  "onboarding.subtitle": "Tell us a bit about you and we will set everything up.",
90
- "onboarding.title": "Create your Open Mercato workspace"
93
+ "onboarding.title": "Create your Open Mercato workspace",
94
+ "onboarding.verifyStatus.alreadyExists": "We already have an account with this email. Try signing in or resetting your password.",
95
+ "onboarding.verifyStatus.error": "We could not complete verification. Please try again or contact support.",
96
+ "onboarding.verifyStatus.invalid": "The verification link is invalid or expired. Submit the onboarding form again to receive a new link.",
97
+ "onboarding.verifyStatus.originNotAllowed": "The verification link was opened from an origin that is not allowed. Check APP_URL and APP_ALLOWED_ORIGINS, then open the verification link again.",
98
+ "onboarding.verifyStatus.redirectMisconfigured": "The verification link cannot redirect safely because APP_URL does not match the URL that handled the request. Check APP_URL and APP_ALLOWED_ORIGINS, then open the verification link again.",
99
+ "onboarding.verifyStatus.urlNotConfigured": "Onboarding verification is not configured. APP_URL must be set before verification links can be used."
91
100
  }