@open-mercato/onboarding 0.7.1-develop.7106.1.53fe9dd1b1 → 0.7.1-develop.7112.1.d486e6f1b2
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-003-signup-account-privacy.spec.js +124 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-003-signup-account-privacy.spec.js.map +7 -0
- package/dist/modules/onboarding/api/post/onboarding.js +59 -36
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/emails/ExistingAccountEmail.js +36 -0
- package/dist/modules/onboarding/emails/ExistingAccountEmail.js.map +7 -0
- package/dist/modules/onboarding/lib/existing-account-notice.js +47 -0
- package/dist/modules/onboarding/lib/existing-account-notice.js.map +7 -0
- package/package.json +3 -3
- package/src/__tests__/existing-account-notice-rate-limit.test.ts +134 -0
- package/src/__tests__/onboarding-start-account-privacy.test.ts +277 -0
- package/src/modules/onboarding/__integration__/TC-ONB-003-signup-account-privacy.spec.ts +176 -0
- package/src/modules/onboarding/api/post/onboarding.ts +76 -35
- package/src/modules/onboarding/emails/ExistingAccountEmail.tsx +54 -0
- package/src/modules/onboarding/i18n/de.json +8 -3
- package/src/modules/onboarding/i18n/en.json +8 -3
- package/src/modules/onboarding/i18n/es.json +8 -3
- package/src/modules/onboarding/i18n/ko.json +8 -3
- package/src/modules/onboarding/i18n/pl.json +8 -3
- package/src/modules/onboarding/lib/existing-account-notice.ts +109 -0
|
@@ -12,6 +12,7 @@ import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translat
|
|
|
12
12
|
import { sendEmail } from '@open-mercato/shared/lib/email/send'
|
|
13
13
|
import { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'
|
|
14
14
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
15
|
+
import { sendExistingAccountNotice } from '@open-mercato/onboarding/modules/onboarding/lib/existing-account-notice'
|
|
15
16
|
import VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'
|
|
16
17
|
import AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'
|
|
17
18
|
import { User } from '@open-mercato/core/modules/auth/data/entities'
|
|
@@ -105,10 +106,36 @@ export async function POST(req: Request) {
|
|
|
105
106
|
}, { status: 400 })
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// An unauthenticated caller must not be able to tell whether the submitted address
|
|
110
|
+
// already has an account (#5505), so every accepted submission answers with this exact
|
|
111
|
+
// body and a caller-visible email failure looks the same on both branches.
|
|
112
|
+
const acceptedResponse = () => NextResponse.json({ ok: true, email: parsed.data.email })
|
|
113
|
+
const emailSendFailedResponse = () => NextResponse.json({
|
|
114
|
+
ok: false,
|
|
115
|
+
error: translate(
|
|
116
|
+
'onboarding.errors.emailSendFailed',
|
|
117
|
+
'We could not send the verification email. Please try again or contact support.',
|
|
118
|
+
),
|
|
119
|
+
}, { status: 502 })
|
|
120
|
+
|
|
108
121
|
try {
|
|
109
122
|
const container = await createRequestContainer()
|
|
110
123
|
const em = (container.resolve('em') as EntityManager)
|
|
111
124
|
|
|
125
|
+
// Resolved before the account lookup so a misconfigured APP_URL fails the same way for
|
|
126
|
+
// both branches instead of becoming a second way to probe for an existing account.
|
|
127
|
+
let baseUrl: string
|
|
128
|
+
try {
|
|
129
|
+
baseUrl = getSecurityEmailBaseUrl(req)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
132
|
+
scope: 'onboarding.start',
|
|
133
|
+
configMessage: 'Self-service onboarding is not configured.',
|
|
134
|
+
})
|
|
135
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
const existingUser = await findOneWithDecryption(em, User, {
|
|
113
140
|
deletedAt: null,
|
|
114
141
|
$or: [
|
|
@@ -117,12 +144,45 @@ export async function POST(req: Request) {
|
|
|
117
144
|
],
|
|
118
145
|
})
|
|
119
146
|
if (existingUser) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
// The notice goes to the account owner, not to the submitter, so it is rendered in the
|
|
148
|
+
// instance default locale rather than the one this request carried — the submitter must
|
|
149
|
+
// not get to choose which language lands in someone else's inbox.
|
|
150
|
+
const noticeTranslate = locale === defaultLocale
|
|
151
|
+
? translate
|
|
152
|
+
: createFallbackTranslator(await loadDictionary(defaultLocale))
|
|
153
|
+
// Never create an onboarding request for an address that already has an account —
|
|
154
|
+
// the submitter's name and organization must not reach the account owner's inbox.
|
|
155
|
+
// The neutral notice below is the only, out-of-band acknowledgement of the account.
|
|
156
|
+
try {
|
|
157
|
+
await sendExistingAccountNotice({
|
|
158
|
+
container,
|
|
159
|
+
email: parsed.data.email,
|
|
160
|
+
loginUrl: `${baseUrl}/login`,
|
|
161
|
+
subject: noticeTranslate('onboarding.existingAccountEmail.subject', 'About your Open Mercato account'),
|
|
162
|
+
copy: {
|
|
163
|
+
preview: noticeTranslate(
|
|
164
|
+
'onboarding.existingAccountEmail.preview',
|
|
165
|
+
'Someone tried to create an Open Mercato workspace with this email address.',
|
|
166
|
+
),
|
|
167
|
+
heading: noticeTranslate('onboarding.existingAccountEmail.heading', 'You already have an account'),
|
|
168
|
+
greeting: noticeTranslate('onboarding.existingAccountEmail.greeting', 'Hello,'),
|
|
169
|
+
body: noticeTranslate(
|
|
170
|
+
'onboarding.existingAccountEmail.body',
|
|
171
|
+
'Someone just used this email address to start creating an Open Mercato workspace. This address already has an account, so we did not create a new one.',
|
|
172
|
+
),
|
|
173
|
+
cta: noticeTranslate('onboarding.existingAccountEmail.cta', 'Sign in'),
|
|
174
|
+
ignore: noticeTranslate(
|
|
175
|
+
'onboarding.existingAccountEmail.ignore',
|
|
176
|
+
"If this was you, sign in or reset your password. If it wasn't, you can safely ignore this message — nothing about your account has changed.",
|
|
177
|
+
),
|
|
178
|
+
footer: noticeTranslate('onboarding.existingAccountEmail.footer', 'Open Mercato · Onboarding service'),
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
} catch (err) {
|
|
182
|
+
logger.error('Existing account notice email failed', { err })
|
|
183
|
+
return emailSendFailedResponse()
|
|
184
|
+
}
|
|
185
|
+
return acceptedResponse()
|
|
126
186
|
}
|
|
127
187
|
|
|
128
188
|
const service = new OnboardingService(em)
|
|
@@ -133,28 +193,15 @@ export async function POST(req: Request) {
|
|
|
133
193
|
token = result.token
|
|
134
194
|
} catch (err) {
|
|
135
195
|
if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
fieldErrors: { email: message },
|
|
142
|
-
}, { status: 409 })
|
|
196
|
+
// The pending-request window is an anti-spam throttle, not a caller-visible state:
|
|
197
|
+
// reporting it would let two probes rebuild the enumeration oracle, because the
|
|
198
|
+
// window only ever exists for an address that has no account.
|
|
199
|
+
logger.info('Onboarding request throttled by pending verification')
|
|
200
|
+
return acceptedResponse()
|
|
143
201
|
}
|
|
144
202
|
throw err
|
|
145
203
|
}
|
|
146
204
|
|
|
147
|
-
let baseUrl: string
|
|
148
|
-
try {
|
|
149
|
-
baseUrl = getSecurityEmailBaseUrl(req)
|
|
150
|
-
} catch (error) {
|
|
151
|
-
const mapped = mapSecurityEmailUrlError(error, {
|
|
152
|
-
scope: 'onboarding.start',
|
|
153
|
-
configMessage: 'Self-service onboarding is not configured.',
|
|
154
|
-
})
|
|
155
|
-
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
156
|
-
throw error
|
|
157
|
-
}
|
|
158
205
|
const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`
|
|
159
206
|
|
|
160
207
|
const firstName = request.firstName || parsed.data.firstName
|
|
@@ -187,13 +234,7 @@ export async function POST(req: Request) {
|
|
|
187
234
|
request.lastEmailSentAt = null
|
|
188
235
|
await em.flush()
|
|
189
236
|
logger.error('Verification email failed', { err })
|
|
190
|
-
return
|
|
191
|
-
ok: false,
|
|
192
|
-
error: translate(
|
|
193
|
-
'onboarding.errors.emailSendFailed',
|
|
194
|
-
'We could not send the verification email. Please try again or contact support.',
|
|
195
|
-
),
|
|
196
|
-
}, { status: 502 })
|
|
237
|
+
return emailSendFailedResponse()
|
|
197
238
|
}
|
|
198
239
|
|
|
199
240
|
const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'
|
|
@@ -220,7 +261,7 @@ export async function POST(req: Request) {
|
|
|
220
261
|
logger.error('Admin email failed', { err })
|
|
221
262
|
}
|
|
222
263
|
|
|
223
|
-
return
|
|
264
|
+
return acceptedResponse()
|
|
224
265
|
} catch (error) {
|
|
225
266
|
logger.error('Onboarding start failed', { err: error })
|
|
226
267
|
return NextResponse.json({
|
|
@@ -247,7 +288,7 @@ const onboardingErrorSchema = z.object({
|
|
|
247
288
|
|
|
248
289
|
const onboardingPostDoc: OpenApiMethodDoc = {
|
|
249
290
|
summary: 'Submit onboarding request',
|
|
250
|
-
description: 'Accepts a self-service onboarding form submission and triggers email verification.',
|
|
291
|
+
description: 'Accepts a self-service onboarding form submission and triggers email verification. The response never reveals whether the submitted address already has an account: an address that does receives an out-of-band notice email instead of a verification link, and the caller sees the same accepted response either way. The guarantee covers the response status and body, not response latency — the new-address branch hashes a password and sends two emails where the existing-account branch sends one, so a caller that measures timing can still tell them apart. Deployments should also keep rate limiting enabled (and use the redis strategy when running more than one replica): the per-address window on the existing-account notice is backed by the rate limiter, so with rate limiting disabled the address owner receives one notice per accepted submission instead of one per ten minutes.',
|
|
251
292
|
tags: [onboardingTag],
|
|
252
293
|
requestBody: {
|
|
253
294
|
contentType: 'application/json',
|
|
@@ -255,14 +296,14 @@ const onboardingPostDoc: OpenApiMethodDoc = {
|
|
|
255
296
|
description: 'Onboarding form payload with contact and organization information.',
|
|
256
297
|
},
|
|
257
298
|
responses: [
|
|
258
|
-
{ status: 200, description: 'Onboarding request accepted.', schema: onboardingSuccessSchema },
|
|
299
|
+
{ status: 200, description: 'Onboarding request accepted. Returned for a new address, for an address that already has an account, and for a submission inside the pending-verification window.', schema: onboardingSuccessSchema },
|
|
259
300
|
],
|
|
260
301
|
errors: [
|
|
261
302
|
{ status: 400, description: 'Validation failed', schema: onboardingErrorSchema },
|
|
262
303
|
{ status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },
|
|
263
|
-
{ status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },
|
|
264
304
|
{ status: 429, description: 'Too many onboarding submissions from this IP', schema: rateLimitErrorSchema },
|
|
265
305
|
{ status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },
|
|
306
|
+
{ status: 502, description: 'Outbound email could not be delivered', schema: onboardingErrorSchema },
|
|
266
307
|
],
|
|
267
308
|
}
|
|
268
309
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Html, Head, Preview, Body, Container, Heading, Text, Section, Button, Hr } from '@react-email/components'
|
|
3
|
+
|
|
4
|
+
export type ExistingAccountEmailCopy = {
|
|
5
|
+
preview: string
|
|
6
|
+
heading: string
|
|
7
|
+
greeting: string
|
|
8
|
+
body: string
|
|
9
|
+
cta: string
|
|
10
|
+
ignore: string
|
|
11
|
+
footer: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ExistingAccountEmailProps = {
|
|
15
|
+
loginUrl: string
|
|
16
|
+
copy: ExistingAccountEmailCopy
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ExistingAccountEmail({ loginUrl, copy }: ExistingAccountEmailProps) {
|
|
20
|
+
return (
|
|
21
|
+
<Html>
|
|
22
|
+
<Head>
|
|
23
|
+
<title>{copy.heading}</title>
|
|
24
|
+
</Head>
|
|
25
|
+
<Preview>{copy.preview}</Preview>
|
|
26
|
+
<Body style={{ backgroundColor: '#f1f5f9', fontFamily: 'Helvetica, Arial, sans-serif', padding: '24px 0' }}>
|
|
27
|
+
<Container style={{ backgroundColor: '#ffffff', padding: '32px', borderRadius: '12px', margin: '0 auto', maxWidth: '520px' }}>
|
|
28
|
+
<Heading style={{ fontSize: '24px', fontWeight: 600, margin: '0 0 16px' }}>{copy.heading}</Heading>
|
|
29
|
+
<Text style={{ fontSize: '16px', color: '#334155', marginBottom: '16px' }}>{copy.greeting}</Text>
|
|
30
|
+
<Text style={{ fontSize: '16px', color: '#334155', marginBottom: '16px', lineHeight: '24px' }}>{copy.body}</Text>
|
|
31
|
+
<Section style={{ textAlign: 'center', margin: '32px 0' }}>
|
|
32
|
+
<Button
|
|
33
|
+
href={loginUrl}
|
|
34
|
+
style={{
|
|
35
|
+
backgroundColor: '#111827',
|
|
36
|
+
color: '#ffffff',
|
|
37
|
+
padding: '12px 24px',
|
|
38
|
+
borderRadius: '8px',
|
|
39
|
+
fontSize: '15px',
|
|
40
|
+
textDecoration: 'none',
|
|
41
|
+
display: 'inline-block',
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{copy.cta}
|
|
45
|
+
</Button>
|
|
46
|
+
</Section>
|
|
47
|
+
<Text style={{ fontSize: '14px', color: '#64748b', marginBottom: '16px', lineHeight: '22px' }}>{copy.ignore}</Text>
|
|
48
|
+
<Hr style={{ borderColor: '#e2e8f0', margin: '24px 0' }} />
|
|
49
|
+
<Text style={{ fontSize: '12px', color: '#94a3b8' }}>{copy.footer}</Text>
|
|
50
|
+
</Container>
|
|
51
|
+
</Body>
|
|
52
|
+
</Html>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"onboarding.email.marketingConsentYes": "Marketing-Einwilligung: Ja",
|
|
38
38
|
"onboarding.email.preview": "Bestätigen Sie Ihre E-Mail, um Ihren Open Mercato-Arbeitsbereich zu aktivieren",
|
|
39
39
|
"onboarding.email.subject": "Bestätigen Sie Ihre E-Mail, um das Onboarding abzuschließen",
|
|
40
|
-
"onboarding.errors.emailExists": "Es existiert bereits ein Konto mit dieser E-Mail-Adresse. Versuchen Sie, sich anzumelden oder Ihr Passwort zurückzusetzen.",
|
|
41
40
|
"onboarding.errors.emailInvalid": "Geben Sie eine gültige geschäftliche E-Mail-Adresse ein.",
|
|
42
41
|
"onboarding.errors.emailSendFailed": "Die Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.",
|
|
43
42
|
"onboarding.errors.firstNameRequired": "Vorname ist erforderlich.",
|
|
@@ -45,11 +44,17 @@
|
|
|
45
44
|
"onboarding.errors.organizationNameRequired": "Organisationsname ist erforderlich.",
|
|
46
45
|
"onboarding.errors.passwordMismatch": "Die Passwörter müssen übereinstimmen.",
|
|
47
46
|
"onboarding.errors.passwordRequired": "Das Passwort muss die Anforderungen erfüllen: {requirements}.",
|
|
48
|
-
"onboarding.errors.pendingRequest": "Es liegt bereits eine ausstehende Verifizierung vor. Bitte versuchen Sie es in etwa {minutes} Minuten erneut oder kontaktieren Sie den Administrator.",
|
|
49
47
|
"onboarding.errors.termsRequired": "Bitte akzeptieren Sie die Nutzungsbedingungen, um fortzufahren.",
|
|
48
|
+
"onboarding.existingAccountEmail.body": "Jemand hat gerade mit dieser E-Mail-Adresse begonnen, einen Open Mercato-Arbeitsbereich zu erstellen. Für diese Adresse existiert bereits ein Konto, daher haben wir kein neues angelegt.",
|
|
49
|
+
"onboarding.existingAccountEmail.cta": "Anmelden",
|
|
50
|
+
"onboarding.existingAccountEmail.footer": "Open Mercato · Onboarding-Service",
|
|
51
|
+
"onboarding.existingAccountEmail.greeting": "Hallo,",
|
|
52
|
+
"onboarding.existingAccountEmail.heading": "Sie haben bereits ein Konto",
|
|
53
|
+
"onboarding.existingAccountEmail.ignore": "Wenn Sie das waren, melden Sie sich an oder setzen Sie Ihr Passwort zurück. Andernfalls können Sie diese Nachricht ignorieren — an Ihrem Konto hat sich nichts geändert.",
|
|
54
|
+
"onboarding.existingAccountEmail.preview": "Jemand hat versucht, mit dieser E-Mail-Adresse einen Open Mercato-Arbeitsbereich zu erstellen.",
|
|
55
|
+
"onboarding.existingAccountEmail.subject": "Zu Ihrem Open Mercato-Konto",
|
|
50
56
|
"onboarding.form.confirmPassword": "Passwort bestätigen",
|
|
51
57
|
"onboarding.form.email": "Geschäftliche E-Mail",
|
|
52
|
-
"onboarding.form.emailExists": "Es existiert bereits ein Konto mit dieser E-Mail-Adresse. Versuchen Sie, sich anzumelden oder Ihr Passwort zurückzusetzen.",
|
|
53
58
|
"onboarding.form.firstName": "Vorname",
|
|
54
59
|
"onboarding.form.genericError": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.",
|
|
55
60
|
"onboarding.form.lastName": "Nachname",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"onboarding.email.marketingConsentYes": "Marketing consent: Yes",
|
|
38
38
|
"onboarding.email.preview": "Confirm your email to activate your Open Mercato workspace",
|
|
39
39
|
"onboarding.email.subject": "Confirm your email to finish onboarding",
|
|
40
|
-
"onboarding.errors.emailExists": "We already have an account with this email. Try signing in or resetting your password.",
|
|
41
40
|
"onboarding.errors.emailInvalid": "Enter a valid work email.",
|
|
42
41
|
"onboarding.errors.emailSendFailed": "We could not send the verification email. Please try again or contact support.",
|
|
43
42
|
"onboarding.errors.firstNameRequired": "First name is required.",
|
|
@@ -45,11 +44,17 @@
|
|
|
45
44
|
"onboarding.errors.organizationNameRequired": "Organization name is required.",
|
|
46
45
|
"onboarding.errors.passwordMismatch": "Passwords must match.",
|
|
47
46
|
"onboarding.errors.passwordRequired": "Password must meet the requirements: {requirements}.",
|
|
48
|
-
"onboarding.errors.pendingRequest": "We already have a pending verification. Please try again in about {minutes} minutes or contact the administrator.",
|
|
49
47
|
"onboarding.errors.termsRequired": "Please accept the terms to continue.",
|
|
48
|
+
"onboarding.existingAccountEmail.body": "Someone just used this email address to start creating an Open Mercato workspace. This address already has an account, so we did not create a new one.",
|
|
49
|
+
"onboarding.existingAccountEmail.cta": "Sign in",
|
|
50
|
+
"onboarding.existingAccountEmail.footer": "Open Mercato · Onboarding service",
|
|
51
|
+
"onboarding.existingAccountEmail.greeting": "Hello,",
|
|
52
|
+
"onboarding.existingAccountEmail.heading": "You already have an account",
|
|
53
|
+
"onboarding.existingAccountEmail.ignore": "If this was you, sign in or reset your password. If it wasn't, you can safely ignore this message — nothing about your account has changed.",
|
|
54
|
+
"onboarding.existingAccountEmail.preview": "Someone tried to create an Open Mercato workspace with this email address.",
|
|
55
|
+
"onboarding.existingAccountEmail.subject": "About your Open Mercato account",
|
|
50
56
|
"onboarding.form.confirmPassword": "Confirm password",
|
|
51
57
|
"onboarding.form.email": "Work email",
|
|
52
|
-
"onboarding.form.emailExists": "We already have an account with this email. Try signing in or resetting your password.",
|
|
53
58
|
"onboarding.form.firstName": "First name",
|
|
54
59
|
"onboarding.form.genericError": "Something went wrong. Please try again or contact support.",
|
|
55
60
|
"onboarding.form.lastName": "Last name",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"onboarding.email.marketingConsentYes": "Consentimiento de marketing: Sí",
|
|
38
38
|
"onboarding.email.preview": "Confirme su correo electrónico para activar su espacio de trabajo en Open Mercato",
|
|
39
39
|
"onboarding.email.subject": "Confirme su correo electrónico para finalizar la incorporación",
|
|
40
|
-
"onboarding.errors.emailExists": "Ya tenemos una cuenta con este correo electrónico. Intente iniciar sesión o restablecer su contraseña.",
|
|
41
40
|
"onboarding.errors.emailInvalid": "Ingrese un correo electrónico de trabajo válido.",
|
|
42
41
|
"onboarding.errors.emailSendFailed": "No pudimos enviar el correo de verificación. Intente de nuevo o comuníquese con soporte.",
|
|
43
42
|
"onboarding.errors.firstNameRequired": "El nombre es obligatorio.",
|
|
@@ -45,11 +44,17 @@
|
|
|
45
44
|
"onboarding.errors.organizationNameRequired": "El nombre de la organización es obligatorio.",
|
|
46
45
|
"onboarding.errors.passwordMismatch": "Las contraseñas deben coincidir.",
|
|
47
46
|
"onboarding.errors.passwordRequired": "La contraseña debe cumplir los requisitos: {requirements}.",
|
|
48
|
-
"onboarding.errors.pendingRequest": "Ya tenemos una verificación pendiente. Intente de nuevo en aproximadamente {minutes} minutos o comuníquese con el administrador.",
|
|
49
47
|
"onboarding.errors.termsRequired": "Acepte los términos para continuar.",
|
|
48
|
+
"onboarding.existingAccountEmail.body": "Alguien acaba de usar este correo electrónico para empezar a crear un espacio de trabajo de Open Mercato. Esta dirección ya tiene una cuenta, así que no creamos ninguna nueva.",
|
|
49
|
+
"onboarding.existingAccountEmail.cta": "Iniciar sesión",
|
|
50
|
+
"onboarding.existingAccountEmail.footer": "Open Mercato · Servicio de onboarding",
|
|
51
|
+
"onboarding.existingAccountEmail.greeting": "Hola:",
|
|
52
|
+
"onboarding.existingAccountEmail.heading": "Ya tiene una cuenta",
|
|
53
|
+
"onboarding.existingAccountEmail.ignore": "Si fue usted, inicie sesión o restablezca su contraseña. Si no, puede ignorar este mensaje: no ha cambiado nada en su cuenta.",
|
|
54
|
+
"onboarding.existingAccountEmail.preview": "Alguien intentó crear un espacio de trabajo de Open Mercato con este correo electrónico.",
|
|
55
|
+
"onboarding.existingAccountEmail.subject": "Sobre su cuenta de Open Mercato",
|
|
50
56
|
"onboarding.form.confirmPassword": "Confirmar contraseña",
|
|
51
57
|
"onboarding.form.email": "Correo electrónico de trabajo",
|
|
52
|
-
"onboarding.form.emailExists": "Ya tenemos una cuenta con este correo electrónico. Intente iniciar sesión o restablecer su contraseña.",
|
|
53
58
|
"onboarding.form.firstName": "Nombre",
|
|
54
59
|
"onboarding.form.genericError": "Algo salió mal. Intente de nuevo o comuníquese con soporte.",
|
|
55
60
|
"onboarding.form.lastName": "Apellido",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"onboarding.email.marketingConsentYes": "마케팅 동의: 예",
|
|
38
38
|
"onboarding.email.preview": "Open Mercato 워크스페이스를 활성화하려면 이메일을 확인하세요",
|
|
39
39
|
"onboarding.email.subject": "온보딩을 완료하려면 이메일을 확인하세요",
|
|
40
|
-
"onboarding.errors.emailExists": "이미 이 이메일로 등록된 계정이 있습니다. 로그인하거나 비밀번호를 재설정해 보세요.",
|
|
41
40
|
"onboarding.errors.emailInvalid": "유효한 업무용 이메일을 입력하세요.",
|
|
42
41
|
"onboarding.errors.emailSendFailed": "확인 이메일을 보내지 못했습니다. 다시 시도하거나 지원팀에 문의하세요.",
|
|
43
42
|
"onboarding.errors.firstNameRequired": "이름은 필수입니다.",
|
|
@@ -45,11 +44,17 @@
|
|
|
45
44
|
"onboarding.errors.organizationNameRequired": "조직 이름은 필수입니다.",
|
|
46
45
|
"onboarding.errors.passwordMismatch": "비밀번호가 일치해야 합니다.",
|
|
47
46
|
"onboarding.errors.passwordRequired": "비밀번호는 다음 요구 사항을 충족해야 합니다: {requirements}.",
|
|
48
|
-
"onboarding.errors.pendingRequest": "이미 대기 중인 검증이 있습니다. 약 {minutes}분 후에 다시 시도하거나 관리자에게 문의하세요.",
|
|
49
47
|
"onboarding.errors.termsRequired": "계속하려면 약관에 동의해 주세요.",
|
|
48
|
+
"onboarding.existingAccountEmail.body": "방금 누군가 이 이메일 주소로 Open Mercato 워크스페이스 생성을 시작했습니다. 이 주소에는 이미 계정이 있어 새 계정을 만들지 않았습니다.",
|
|
49
|
+
"onboarding.existingAccountEmail.cta": "로그인",
|
|
50
|
+
"onboarding.existingAccountEmail.footer": "Open Mercato · 온보딩 서비스",
|
|
51
|
+
"onboarding.existingAccountEmail.greeting": "안녕하세요,",
|
|
52
|
+
"onboarding.existingAccountEmail.heading": "이미 계정이 있습니다",
|
|
53
|
+
"onboarding.existingAccountEmail.ignore": "본인이 요청하셨다면 로그인하거나 비밀번호를 재설정하세요. 그렇지 않다면 이 메시지를 무시하셔도 됩니다. 계정에는 아무런 변경이 없습니다.",
|
|
54
|
+
"onboarding.existingAccountEmail.preview": "누군가 이 이메일 주소로 Open Mercato 워크스페이스를 만들려고 했습니다.",
|
|
55
|
+
"onboarding.existingAccountEmail.subject": "Open Mercato 계정 안내",
|
|
50
56
|
"onboarding.form.confirmPassword": "비밀번호 확인",
|
|
51
57
|
"onboarding.form.email": "업무용 이메일",
|
|
52
|
-
"onboarding.form.emailExists": "이미 이 이메일로 등록된 계정이 있습니다. 로그인하거나 비밀번호를 재설정해 보세요.",
|
|
53
58
|
"onboarding.form.firstName": "이름",
|
|
54
59
|
"onboarding.form.genericError": "문제가 발생했습니다. 다시 시도하거나 지원팀에 문의하세요.",
|
|
55
60
|
"onboarding.form.lastName": "성",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"onboarding.email.marketingConsentYes": "Zgoda marketingowa: Tak",
|
|
38
38
|
"onboarding.email.preview": "Potwierdź swój email, aby aktywować obszar roboczy Open Mercato",
|
|
39
39
|
"onboarding.email.subject": "Potwierdź swój email, aby dokończyć onboarding",
|
|
40
|
-
"onboarding.errors.emailExists": "Mamy już konto z tym adresem email. Spróbuj się zalogować lub zresetować hasło.",
|
|
41
40
|
"onboarding.errors.emailInvalid": "Podaj prawidłowy służbowy adres email.",
|
|
42
41
|
"onboarding.errors.emailSendFailed": "Nie udało się wysłać emaila weryfikacyjnego. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.",
|
|
43
42
|
"onboarding.errors.firstNameRequired": "Imię jest wymagane.",
|
|
@@ -45,11 +44,17 @@
|
|
|
45
44
|
"onboarding.errors.organizationNameRequired": "Nazwa organizacji jest wymagana.",
|
|
46
45
|
"onboarding.errors.passwordMismatch": "Hasła muszą się zgadzać.",
|
|
47
46
|
"onboarding.errors.passwordRequired": "Hasło musi spełniać wymagania: {requirements}.",
|
|
48
|
-
"onboarding.errors.pendingRequest": "Mamy już oczekującą weryfikację. Spróbuj ponownie za około {minutes} minut lub skontaktuj się z administratorem.",
|
|
49
47
|
"onboarding.errors.termsRequired": "Proszę zaakceptować regulamin, aby kontynuować.",
|
|
48
|
+
"onboarding.existingAccountEmail.body": "Ktoś właśnie użył tego adresu email, aby rozpocząć tworzenie obszaru roboczego Open Mercato. Ten adres ma już konto, więc nie utworzyliśmy nowego.",
|
|
49
|
+
"onboarding.existingAccountEmail.cta": "Zaloguj się",
|
|
50
|
+
"onboarding.existingAccountEmail.footer": "Open Mercato · Usługa onboardingu",
|
|
51
|
+
"onboarding.existingAccountEmail.greeting": "Dzień dobry,",
|
|
52
|
+
"onboarding.existingAccountEmail.heading": "Masz już konto",
|
|
53
|
+
"onboarding.existingAccountEmail.ignore": "Jeśli to Ty, zaloguj się lub zresetuj hasło. Jeśli nie, możesz bezpiecznie zignorować tę wiadomość — w Twoim koncie nic się nie zmieniło.",
|
|
54
|
+
"onboarding.existingAccountEmail.preview": "Ktoś próbował utworzyć obszar roboczy Open Mercato z tym adresem email.",
|
|
55
|
+
"onboarding.existingAccountEmail.subject": "W sprawie Twojego konta Open Mercato",
|
|
50
56
|
"onboarding.form.confirmPassword": "Potwierdź hasło",
|
|
51
57
|
"onboarding.form.email": "Służbowy email",
|
|
52
|
-
"onboarding.form.emailExists": "Mamy już konto z tym adresem email. Spróbuj się zalogować lub zresetować hasło.",
|
|
53
58
|
"onboarding.form.firstName": "Imię",
|
|
54
59
|
"onboarding.form.genericError": "Coś poszło nie tak. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.",
|
|
55
60
|
"onboarding.form.lastName": "Nazwisko",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'
|
|
2
|
+
import { sendEmail } from '@open-mercato/shared/lib/email/send'
|
|
3
|
+
import type { RateLimitConfig } from '@open-mercato/shared/lib/ratelimit/types'
|
|
4
|
+
import ExistingAccountEmail, { type ExistingAccountEmailCopy } from '../emails/ExistingAccountEmail'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Best-effort ten-minute anti-spam window on the notice, backed by `rateLimiterService`.
|
|
8
|
+
*
|
|
9
|
+
* The duration matches the window `OnboardingService.createOrUpdateRequest` enforces on
|
|
10
|
+
* verification emails, but the mechanism is deliberately weaker and must not be read as
|
|
11
|
+
* equivalent: that window is a database column (`OnboardingRequest.lastEmailSentAt`), which is
|
|
12
|
+
* authoritative, shared by every process and independent of the rate-limiting configuration.
|
|
13
|
+
* This one is a limiter counter, so it is per-process under the default `memory` strategy
|
|
14
|
+
* (`RATE_LIMIT_STRATEGY=redis` restores a shared window across replicas) and absent entirely
|
|
15
|
+
* when rate limiting is switched off — `RateLimiterService.consume()` returns `allowed: true`
|
|
16
|
+
* unconditionally while `RATE_LIMIT_ENABLED=false`. An instance exposing self-service
|
|
17
|
+
* onboarding should therefore keep rate limiting enabled, or the address owner can be mailed
|
|
18
|
+
* once per accepted submission instead of once per window.
|
|
19
|
+
*
|
|
20
|
+
* Failing closed when the limiter is unavailable is not an option here: skipping the send would
|
|
21
|
+
* make the has-account branch answer 200 while the no-account branch still answers 502 on an
|
|
22
|
+
* instance with broken mail delivery, which re-opens the enumeration oracle (#5505) the uniform
|
|
23
|
+
* response shape closes. The window is a spam brake, never a disclosure control.
|
|
24
|
+
*/
|
|
25
|
+
const NOTICE_WINDOW_SECONDS = 10 * 60
|
|
26
|
+
|
|
27
|
+
const noticeRateLimitConfig: RateLimitConfig = {
|
|
28
|
+
points: 1,
|
|
29
|
+
duration: NOTICE_WINDOW_SECONDS,
|
|
30
|
+
blockDuration: 0,
|
|
31
|
+
keyPrefix: 'onboarding-existing-account',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ExistingAccountNoticeOutcome = 'sent' | 'throttled'
|
|
35
|
+
|
|
36
|
+
type RateLimiterLike = {
|
|
37
|
+
consume: (key: string, config: RateLimitConfig) => Promise<{ allowed: boolean }>
|
|
38
|
+
delete: (key: string, config: RateLimitConfig) => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ContainerLike = { resolve: (name: string) => unknown }
|
|
42
|
+
|
|
43
|
+
export type SendExistingAccountNoticeArgs = {
|
|
44
|
+
container: ContainerLike
|
|
45
|
+
email: string
|
|
46
|
+
loginUrl: string
|
|
47
|
+
subject: string
|
|
48
|
+
copy: ExistingAccountEmailCopy
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The limiter is registered only when rate limiting is configured, so an unregistered token
|
|
53
|
+
* is a normal deployment state rather than an error. Resolved from the request container
|
|
54
|
+
* instead of the core bootstrap singleton so this route keeps its lean module graph.
|
|
55
|
+
*
|
|
56
|
+
* Returning `null` means the notice is sent unthrottled — the fail-open half of the trade-off
|
|
57
|
+
* documented on `NOTICE_WINDOW_SECONDS`, chosen because a skipped send would answer differently
|
|
58
|
+
* from the no-account branch and re-open the enumeration oracle. The same reasoning is why
|
|
59
|
+
* `consume()`'s `degraded` flag is not read: a degraded limiter still has to send.
|
|
60
|
+
*/
|
|
61
|
+
function resolveRateLimiter(container: ContainerLike): RateLimiterLike | null {
|
|
62
|
+
try {
|
|
63
|
+
const resolved = container.resolve('rateLimiterService')
|
|
64
|
+
if (!resolved || typeof resolved !== 'object') return null
|
|
65
|
+
const candidate = resolved as Partial<RateLimiterLike>
|
|
66
|
+
if (typeof candidate.consume !== 'function' || typeof candidate.delete !== 'function') return null
|
|
67
|
+
return candidate as RateLimiterLike
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Tell the address owner — out of band — that someone tried to create a workspace with an
|
|
75
|
+
* email that already has an account. The HTTP caller never learns which branch ran, so the
|
|
76
|
+
* copy carries nothing the submitter supplied (no name, no organization name) and the mail
|
|
77
|
+
* is the only place the existing account is acknowledged.
|
|
78
|
+
*
|
|
79
|
+
* Throws whatever `sendEmail` throws so the caller can answer with the same 502 body the
|
|
80
|
+
* verification-email branch already returns.
|
|
81
|
+
*/
|
|
82
|
+
export async function sendExistingAccountNotice(
|
|
83
|
+
args: SendExistingAccountNoticeArgs,
|
|
84
|
+
): Promise<ExistingAccountNoticeOutcome> {
|
|
85
|
+
const rateLimiterService = resolveRateLimiter(args.container)
|
|
86
|
+
const throttleKey = hashForLookup(args.email)
|
|
87
|
+
|
|
88
|
+
if (rateLimiterService) {
|
|
89
|
+
const consumed = await rateLimiterService.consume(throttleKey, noticeRateLimitConfig)
|
|
90
|
+
if (!consumed.allowed) return 'throttled'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await sendEmail({
|
|
95
|
+
to: args.email,
|
|
96
|
+
subject: args.subject,
|
|
97
|
+
react: ExistingAccountEmail({ loginUrl: args.loginUrl, copy: args.copy }),
|
|
98
|
+
})
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Release the window the same way the verification branch clears `lastEmailSentAt` on a
|
|
101
|
+
// failed send, so a retry behaves identically on both branches of a broken-mail instance.
|
|
102
|
+
if (rateLimiterService) {
|
|
103
|
+
await rateLimiterService.delete(throttleKey, noticeRateLimitConfig).catch(() => {})
|
|
104
|
+
}
|
|
105
|
+
throw error
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return 'sent'
|
|
109
|
+
}
|