@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
- package/dist/modules/onboarding/api/get/onboarding/status.js +35 -17
- package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +10 -1
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/data/entities.js +3 -0
- package/dist/modules/onboarding/data/entities.js.map +2 -2
- package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
- package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
- package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
- package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
- package/dist/modules/onboarding/lib/deferred-provisioning.js +235 -0
- package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
- package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
- package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
- package/dist/modules/onboarding/lib/provisioning.js +18 -0
- package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
- package/dist/modules/onboarding/lib/service.js +31 -0
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
- package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
- package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
- package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
- package/generated/entities/onboarding_request/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +4 -5
- package/src/__tests__/deferred-provisioning.test.ts +210 -0
- package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
- package/src/__tests__/provisioning.test.ts +89 -0
- package/src/__tests__/status-endpoint-auth.test.ts +130 -3
- package/src/__tests__/verify-base-url.test.ts +65 -0
- package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
- package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
- package/src/modules/onboarding/api/get/onboarding/status.ts +46 -17
- package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
- package/src/modules/onboarding/api/post/onboarding.ts +9 -0
- package/src/modules/onboarding/data/entities.ts +3 -0
- package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
- package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
- package/src/modules/onboarding/i18n/de.json +10 -1
- package/src/modules/onboarding/i18n/en.json +10 -1
- package/src/modules/onboarding/i18n/es.json +10 -1
- package/src/modules/onboarding/i18n/pl.json +10 -1
- package/src/modules/onboarding/lib/deferred-provisioning.ts +295 -0
- package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
- package/src/modules/onboarding/lib/provisioning.ts +35 -0
- package/src/modules/onboarding/lib/service.ts +33 -0
- package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
- 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 {
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
organizationId: string
|
|
89
|
+
async function completeProvisionedRequest(args: {
|
|
90
|
+
service: OnboardingService
|
|
91
|
+
request: OnboardingRequest
|
|
203
92
|
}) {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
390
|
-
// all-or-nothing
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|