@open-mercato/core 0.10.1-develop.7679.1.cb2c13f49e → 0.10.1-develop.7687.1.e1a6f9de51

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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/users/route.js +1 -1
  3. package/dist/modules/auth/api/users/route.js.map +2 -2
  4. package/dist/modules/customer_accounts/api/magic-link/request.js +15 -1
  5. package/dist/modules/customer_accounts/api/magic-link/request.js.map +2 -2
  6. package/dist/modules/customer_accounts/api/password/reset-request.js +15 -1
  7. package/dist/modules/customer_accounts/api/password/reset-request.js.map +2 -2
  8. package/dist/modules/customer_accounts/emails/CustomerAuthLinkEmail.js +26 -0
  9. package/dist/modules/customer_accounts/emails/CustomerAuthLinkEmail.js.map +7 -0
  10. package/dist/modules/customer_accounts/lib/authLinkEmails.js +67 -0
  11. package/dist/modules/customer_accounts/lib/authLinkEmails.js.map +7 -0
  12. package/dist/modules/messages/commands/actions.js +8 -1
  13. package/dist/modules/messages/commands/actions.js.map +2 -2
  14. package/dist/modules/payment_gateways/data/entities.js +4 -0
  15. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  16. package/dist/modules/payment_gateways/lib/session-idempotency.js +29 -5
  17. package/dist/modules/payment_gateways/lib/session-idempotency.js.map +2 -2
  18. package/dist/modules/payment_gateways/migrations/Migration20261018180000_payment_gateways.js +19 -0
  19. package/dist/modules/payment_gateways/migrations/Migration20261018180000_payment_gateways.js.map +7 -0
  20. package/dist/modules/payment_gateways/workers/session-initialization-prune.js +3 -3
  21. package/dist/modules/payment_gateways/workers/session-initialization-prune.js.map +2 -2
  22. package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.js +59 -0
  23. package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.js.map +7 -0
  24. package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.meta.js +11 -0
  25. package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.meta.js.map +7 -0
  26. package/dist/modules/query_index/lib/engine.js +60 -13
  27. package/dist/modules/query_index/lib/engine.js.map +2 -2
  28. package/package.json +7 -7
  29. package/src/modules/auth/api/users/route.ts +1 -1
  30. package/src/modules/customer_accounts/api/magic-link/request.ts +16 -3
  31. package/src/modules/customer_accounts/api/password/reset-request.ts +16 -3
  32. package/src/modules/customer_accounts/emails/CustomerAuthLinkEmail.tsx +45 -0
  33. package/src/modules/customer_accounts/i18n/de.json +12 -0
  34. package/src/modules/customer_accounts/i18n/en.json +12 -0
  35. package/src/modules/customer_accounts/i18n/es.json +12 -0
  36. package/src/modules/customer_accounts/i18n/ko.json +12 -0
  37. package/src/modules/customer_accounts/i18n/pl.json +12 -0
  38. package/src/modules/customer_accounts/lib/authLinkEmails.ts +77 -0
  39. package/src/modules/messages/commands/actions.ts +8 -1
  40. package/src/modules/payment_gateways/data/entities.ts +4 -0
  41. package/src/modules/payment_gateways/lib/session-idempotency.ts +41 -5
  42. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +14 -1
  43. package/src/modules/payment_gateways/migrations/Migration20261018180000_payment_gateways.ts +18 -0
  44. package/src/modules/payment_gateways/workers/session-initialization-prune.ts +3 -3
  45. package/src/modules/portal/frontend/[orgSlug]/portal/magic-link/page.meta.ts +9 -0
  46. package/src/modules/portal/frontend/[orgSlug]/portal/magic-link/page.tsx +71 -0
  47. package/src/modules/portal/i18n/de.json +7 -0
  48. package/src/modules/portal/i18n/en.json +7 -0
  49. package/src/modules/portal/i18n/es.json +7 -0
  50. package/src/modules/portal/i18n/ko.json +7 -0
  51. package/src/modules/portal/i18n/pl.json +7 -0
  52. package/src/modules/query_index/lib/engine.ts +70 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7679.1.cb2c13f49e",
3
+ "version": "0.10.1-develop.7687.1.e1a6f9de51",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -265,16 +265,16 @@
265
265
  "zod": "^4.4.3"
266
266
  },
267
267
  "peerDependencies": {
268
- "@open-mercato/ai-assistant": "0.10.1-develop.7679.1.cb2c13f49e",
269
- "@open-mercato/shared": "0.10.1-develop.7679.1.cb2c13f49e",
270
- "@open-mercato/ui": "0.10.1-develop.7679.1.cb2c13f49e",
268
+ "@open-mercato/ai-assistant": "0.10.1-develop.7687.1.e1a6f9de51",
269
+ "@open-mercato/shared": "0.10.1-develop.7687.1.e1a6f9de51",
270
+ "@open-mercato/ui": "0.10.1-develop.7687.1.e1a6f9de51",
271
271
  "react": "^19.0.0",
272
272
  "react-dom": "^19.0.0"
273
273
  },
274
274
  "devDependencies": {
275
- "@open-mercato/ai-assistant": "0.10.1-develop.7679.1.cb2c13f49e",
276
- "@open-mercato/shared": "0.10.1-develop.7679.1.cb2c13f49e",
277
- "@open-mercato/ui": "0.10.1-develop.7679.1.cb2c13f49e",
275
+ "@open-mercato/ai-assistant": "0.10.1-develop.7687.1.e1a6f9de51",
276
+ "@open-mercato/shared": "0.10.1-develop.7687.1.e1a6f9de51",
277
+ "@open-mercato/ui": "0.10.1-develop.7687.1.e1a6f9de51",
278
278
  "@testing-library/dom": "^10.4.1",
279
279
  "@testing-library/jest-dom": "^7.0.1",
280
280
  "@testing-library/react": "^16.3.3",
@@ -415,7 +415,7 @@ export async function GET(req: Request) {
415
415
  { user: { $in: userIds as any }, deletedAt: null } as any,
416
416
  { populate: ['role'] },
417
417
  {
418
- tenantId: effectiveTenantId ?? auth.tenantId ?? null,
418
+ tenantId: effectiveTenantId,
419
419
  organizationId: scopeOrganizationId,
420
420
  },
421
421
  )
@@ -16,6 +16,11 @@ import {
16
16
  customerMagicLinkIpRateLimitConfig,
17
17
  } from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'
18
18
  import { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'
19
+ import { sendCustomerMagicLinkEmail } from '@open-mercato/core/modules/customer_accounts/lib/authLinkEmails'
20
+ import { createLogger } from '@open-mercato/shared/lib/logger'
21
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
22
+
23
+ const logger = createLogger('customer_accounts').child({ component: 'magic-link-request' })
19
24
 
20
25
  export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
21
26
 
@@ -59,9 +64,17 @@ export async function POST(req: Request) {
59
64
 
60
65
  const user = await customerUserService.findByEmail(email, tenantId)
61
66
  if (user) {
62
- await customerTokenService.createMagicLink(user.id, tenantId)
63
- // Token is stored in DB; email delivery should be handled by a direct service call,
64
- // NOT via the event bus — raw tokens must never travel through events.
67
+ const rawToken = await customerTokenService.createMagicLink(user.id, tenantId)
68
+ await sendCustomerMagicLinkEmail({
69
+ container,
70
+ tenantId,
71
+ organizationId: user.organizationId,
72
+ email: user.email,
73
+ rawToken,
74
+ }).catch((error) => {
75
+ logger.error('Magic link email failed', { err: error })
76
+ getTelemetryRuntime()?.reportError(error, { module: 'customer_accounts', code: 'customer_accounts.magic_link_email_failed' })
77
+ })
65
78
  void import('@open-mercato/core/modules/customer_accounts/events').then(({ emitCustomerAccountsEvent }) =>
66
79
  emitCustomerAccountsEvent('customer_accounts.magic_link.requested', {
67
80
  userId: user.id,
@@ -16,6 +16,11 @@ import {
16
16
  customerPasswordResetIpRateLimitConfig,
17
17
  } from '@open-mercato/core/modules/customer_accounts/lib/rateLimiter'
18
18
  import { readNormalizedEmailFromJsonRequest } from '@open-mercato/core/modules/customer_accounts/lib/rateLimitIdentifier'
19
+ import { sendCustomerPasswordResetEmail } from '@open-mercato/core/modules/customer_accounts/lib/authLinkEmails'
20
+ import { createLogger } from '@open-mercato/shared/lib/logger'
21
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
22
+
23
+ const logger = createLogger('customer_accounts').child({ component: 'password-reset-request' })
19
24
 
20
25
  export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
21
26
 
@@ -60,9 +65,17 @@ export async function POST(req: Request) {
60
65
 
61
66
  const user = await customerUserService.findByEmail(email, tenantId)
62
67
  if (user) {
63
- await customerTokenService.createPasswordReset(user.id, tenantId)
64
- // Token is stored in DB; email delivery should be handled by a direct service call,
65
- // NOT via the event bus — raw tokens must never travel through events.
68
+ const rawToken = await customerTokenService.createPasswordReset(user.id, tenantId)
69
+ await sendCustomerPasswordResetEmail({
70
+ container,
71
+ tenantId,
72
+ organizationId: user.organizationId,
73
+ email: user.email,
74
+ rawToken,
75
+ }).catch((error) => {
76
+ logger.error('Password reset email failed', { err: error })
77
+ getTelemetryRuntime()?.reportError(error, { module: 'customer_accounts', code: 'customer_accounts.password_reset_email_failed' })
78
+ })
66
79
  void import('@open-mercato/core/modules/customer_accounts/events').then(({ emitCustomerAccountsEvent }) =>
67
80
  emitCustomerAccountsEvent('customer_accounts.password_reset.requested', {
68
81
  userId: user.id,
@@ -0,0 +1,45 @@
1
+ import * as React from 'react'
2
+ import { Html, Head, Preview, Body, Container, Text, Section, Link } from '@react-email/components'
3
+
4
+ export type CustomerAuthLinkEmailCopy = {
5
+ preview: string
6
+ title: string
7
+ body: string
8
+ cta: string
9
+ hint: string
10
+ }
11
+
12
+ export type CustomerAuthLinkEmailProps = {
13
+ linkUrl: string
14
+ copy: CustomerAuthLinkEmailCopy
15
+ }
16
+
17
+ export function CustomerAuthLinkEmail({ linkUrl, copy }: CustomerAuthLinkEmailProps) {
18
+ return (
19
+ <Html>
20
+ <Head />
21
+ <Preview>{copy.preview}</Preview>
22
+ <Body style={body}>
23
+ <Container style={container}>
24
+ <Section>
25
+ <Text style={title}>{copy.title}</Text>
26
+ <Text style={paragraph}>{copy.body}</Text>
27
+ <Text>
28
+ <Link href={linkUrl} style={button}>{copy.cta}</Link>
29
+ </Text>
30
+ <Text style={hint}>{copy.hint}</Text>
31
+ </Section>
32
+ </Container>
33
+ </Body>
34
+ </Html>
35
+ )
36
+ }
37
+
38
+ const body: React.CSSProperties = { backgroundColor: '#f9fafb', margin: 0, padding: '24px 0' }
39
+ const container: React.CSSProperties = { backgroundColor: '#ffffff', borderRadius: 12, padding: 24, margin: '0 auto', maxWidth: 520, boxShadow: '0 1px 4px rgba(0,0,0,0.06)' }
40
+ const title: React.CSSProperties = { fontSize: 20, fontWeight: 600, color: '#111827', margin: '0 0 12px' }
41
+ const paragraph: React.CSSProperties = { fontSize: 14, color: '#4b5563', lineHeight: '20px', margin: '0 0 16px' }
42
+ const button: React.CSSProperties = { display: 'inline-block', backgroundColor: '#111827', color: '#ffffff', padding: '10px 14px', borderRadius: 8, textDecoration: 'none', fontSize: 14 }
43
+ const hint: React.CSSProperties = { fontSize: 12, color: '#6b7280', marginTop: 16 }
44
+
45
+ export default CustomerAuthLinkEmail
@@ -288,6 +288,12 @@
288
288
  "customer_accounts.invitation.email.preview": "Accept your invitation to finish setting up portal access.",
289
289
  "customer_accounts.invitation.email.subject": "You have been invited to the customer portal",
290
290
  "customer_accounts.invitation.email.title": "You have been invited",
291
+ "customer_accounts.magicLink.email.body": "Verwenden Sie diesen sicheren Link, um sich ohne Passwort bei Ihrem Portalkonto anzumelden.",
292
+ "customer_accounts.magicLink.email.cta": "Anmelden",
293
+ "customer_accounts.magicLink.email.hint": "Dieser Link ist 15 Minuten gültig und kann einmal verwendet werden. Wenn Sie ihn nicht angefordert haben, können Sie diese E-Mail ignorieren.",
294
+ "customer_accounts.magicLink.email.preview": "Verwenden Sie diesen Link, um sich im Kundenportal anzumelden.",
295
+ "customer_accounts.magicLink.email.subject": "Ihr Anmeldelink für das Portal",
296
+ "customer_accounts.magicLink.email.title": "Im Portal anmelden",
291
297
  "customer_accounts.nav.group": "Customers",
292
298
  "customer_accounts.nav.role_create": "Rolle erstellen",
293
299
  "customer_accounts.nav.role_detail": "Rollendetails",
@@ -306,6 +312,12 @@
306
312
  "customer_accounts.notifications.user.locked.title": "Customer account locked",
307
313
  "customer_accounts.notifications.user.signup.body": "A new customer account was created.",
308
314
  "customer_accounts.notifications.user.signup.title": "New customer signup",
315
+ "customer_accounts.passwordReset.email.body": "Wir haben eine Anfrage zum Zurücksetzen des Passworts für Ihr Portalkonto erhalten. Verwenden Sie diesen sicheren Link, um ein neues festzulegen.",
316
+ "customer_accounts.passwordReset.email.cta": "Passwort zurücksetzen",
317
+ "customer_accounts.passwordReset.email.hint": "Dieser Link ist 60 Minuten gültig und kann einmal verwendet werden. Wenn Sie kein Zurücksetzen angefordert haben, können Sie diese E-Mail ignorieren.",
318
+ "customer_accounts.passwordReset.email.preview": "Verwenden Sie diesen Link, um ein neues Portal-Passwort festzulegen.",
319
+ "customer_accounts.passwordReset.email.subject": "Setzen Sie Ihr Portal-Passwort zurück",
320
+ "customer_accounts.passwordReset.email.title": "Passwort zurücksetzen",
309
321
  "customer_accounts.portal_ai_assistant.sheet.composerPlaceholder": "Frage zu deinem Konto stellen...",
310
322
  "customer_accounts.portal_ai_assistant.sheet.description": "Read-only-Assistent für Portalkunden. Fragen zu deinem Konto und zur aktuellen Aktivität.",
311
323
  "customer_accounts.portal_ai_assistant.sheet.title": "Portal-AI-Assistent",
@@ -288,6 +288,12 @@
288
288
  "customer_accounts.invitation.email.preview": "Accept your invitation to finish setting up portal access.",
289
289
  "customer_accounts.invitation.email.subject": "You have been invited to the customer portal",
290
290
  "customer_accounts.invitation.email.title": "You have been invited",
291
+ "customer_accounts.magicLink.email.body": "Use this secure link to sign in to your portal account without a password.",
292
+ "customer_accounts.magicLink.email.cta": "Sign in",
293
+ "customer_accounts.magicLink.email.hint": "This link expires in 15 minutes and can be used once. If you did not request it, you can ignore this email.",
294
+ "customer_accounts.magicLink.email.preview": "Use this link to sign in to the customer portal.",
295
+ "customer_accounts.magicLink.email.subject": "Your portal sign-in link",
296
+ "customer_accounts.magicLink.email.title": "Sign in to the portal",
291
297
  "customer_accounts.nav.group": "Customers",
292
298
  "customer_accounts.nav.role_create": "Create Role",
293
299
  "customer_accounts.nav.role_detail": "Role Detail",
@@ -306,6 +312,12 @@
306
312
  "customer_accounts.notifications.user.locked.title": "Customer account locked",
307
313
  "customer_accounts.notifications.user.signup.body": "A new customer account was created.",
308
314
  "customer_accounts.notifications.user.signup.title": "New customer signup",
315
+ "customer_accounts.passwordReset.email.body": "We received a request to reset the password for your portal account. Use this secure link to choose a new one.",
316
+ "customer_accounts.passwordReset.email.cta": "Reset password",
317
+ "customer_accounts.passwordReset.email.hint": "This link expires in 60 minutes and can be used once. If you did not request a password reset, you can ignore this email.",
318
+ "customer_accounts.passwordReset.email.preview": "Use this link to choose a new portal password.",
319
+ "customer_accounts.passwordReset.email.subject": "Reset your portal password",
320
+ "customer_accounts.passwordReset.email.title": "Reset your password",
309
321
  "customer_accounts.portal_ai_assistant.sheet.composerPlaceholder": "Ask about your account...",
310
322
  "customer_accounts.portal_ai_assistant.sheet.description": "Read-only assistant for portal customers. Ask about your account and recent activity.",
311
323
  "customer_accounts.portal_ai_assistant.sheet.title": "Portal AI assistant",
@@ -288,6 +288,12 @@
288
288
  "customer_accounts.invitation.email.preview": "Accept your invitation to finish setting up portal access.",
289
289
  "customer_accounts.invitation.email.subject": "You have been invited to the customer portal",
290
290
  "customer_accounts.invitation.email.title": "You have been invited",
291
+ "customer_accounts.magicLink.email.body": "Usa este enlace seguro para iniciar sesión en tu cuenta del portal sin contraseña.",
292
+ "customer_accounts.magicLink.email.cta": "Iniciar sesión",
293
+ "customer_accounts.magicLink.email.hint": "Este enlace caduca en 15 minutos y solo se puede usar una vez. Si no lo solicitaste, puedes ignorar este correo.",
294
+ "customer_accounts.magicLink.email.preview": "Usa este enlace para iniciar sesión en el portal de clientes.",
295
+ "customer_accounts.magicLink.email.subject": "Tu enlace de inicio de sesión del portal",
296
+ "customer_accounts.magicLink.email.title": "Inicia sesión en el portal",
291
297
  "customer_accounts.nav.group": "Customers",
292
298
  "customer_accounts.nav.role_create": "Create Role",
293
299
  "customer_accounts.nav.role_detail": "Role Detail",
@@ -306,6 +312,12 @@
306
312
  "customer_accounts.notifications.user.locked.title": "Customer account locked",
307
313
  "customer_accounts.notifications.user.signup.body": "A new customer account was created.",
308
314
  "customer_accounts.notifications.user.signup.title": "New customer signup",
315
+ "customer_accounts.passwordReset.email.body": "Recibimos una solicitud para restablecer la contraseña de tu cuenta del portal. Usa este enlace seguro para elegir una nueva.",
316
+ "customer_accounts.passwordReset.email.cta": "Restablecer contraseña",
317
+ "customer_accounts.passwordReset.email.hint": "Este enlace caduca en 60 minutos y solo se puede usar una vez. Si no solicitaste restablecer la contraseña, puedes ignorar este correo.",
318
+ "customer_accounts.passwordReset.email.preview": "Usa este enlace para elegir una nueva contraseña del portal.",
319
+ "customer_accounts.passwordReset.email.subject": "Restablece tu contraseña del portal",
320
+ "customer_accounts.passwordReset.email.title": "Restablece tu contraseña",
309
321
  "customer_accounts.portal_ai_assistant.sheet.composerPlaceholder": "Pregunta sobre tu cuenta...",
310
322
  "customer_accounts.portal_ai_assistant.sheet.description": "Asistente de solo lectura para clientes del portal. Pregunta sobre tu cuenta y actividad reciente.",
311
323
  "customer_accounts.portal_ai_assistant.sheet.title": "Asistente AI del portal",
@@ -288,6 +288,12 @@
288
288
  "customer_accounts.invitation.email.preview": "포털 액세스 설정을 완료하려면 초대를 수락하세요.",
289
289
  "customer_accounts.invitation.email.subject": "고객 포털에 초대되었습니다",
290
290
  "customer_accounts.invitation.email.title": "귀하는 초대되었습니다",
291
+ "customer_accounts.magicLink.email.body": "이 보안 링크를 사용하여 비밀번호 없이 포털 계정에 로그인하세요.",
292
+ "customer_accounts.magicLink.email.cta": "로그인",
293
+ "customer_accounts.magicLink.email.hint": "이 링크는 15분 후에 만료되며 한 번만 사용할 수 있습니다. 요청하지 않았다면 이 이메일을 무시하셔도 됩니다.",
294
+ "customer_accounts.magicLink.email.preview": "이 링크를 사용하여 고객 포털에 로그인하세요.",
295
+ "customer_accounts.magicLink.email.subject": "포털 로그인 링크",
296
+ "customer_accounts.magicLink.email.title": "포털에 로그인",
291
297
  "customer_accounts.nav.group": "고객",
292
298
  "customer_accounts.nav.role_create": "역할 생성",
293
299
  "customer_accounts.nav.role_detail": "역할 상세 정보",
@@ -306,6 +312,12 @@
306
312
  "customer_accounts.notifications.user.locked.title": "고객 계정 잠김",
307
313
  "customer_accounts.notifications.user.signup.body": "새 고객 계정이 생성되었습니다.",
308
314
  "customer_accounts.notifications.user.signup.title": "새 고객 가입",
315
+ "customer_accounts.passwordReset.email.body": "포털 계정의 비밀번호 재설정 요청을 받았습니다. 이 보안 링크를 사용하여 새 비밀번호를 설정하세요.",
316
+ "customer_accounts.passwordReset.email.cta": "비밀번호 재설정",
317
+ "customer_accounts.passwordReset.email.hint": "이 링크는 60분 후에 만료되며 한 번만 사용할 수 있습니다. 비밀번호 재설정을 요청하지 않았다면 이 이메일을 무시하셔도 됩니다.",
318
+ "customer_accounts.passwordReset.email.preview": "이 링크를 사용하여 새 포털 비밀번호를 설정하세요.",
319
+ "customer_accounts.passwordReset.email.subject": "포털 비밀번호 재설정",
320
+ "customer_accounts.passwordReset.email.title": "비밀번호 재설정",
309
321
  "customer_accounts.portal_ai_assistant.sheet.composerPlaceholder": "계정에 대해 질문해 보세요...",
310
322
  "customer_accounts.portal_ai_assistant.sheet.description": "포털 고객을 위한 읽기 전용 어시스턴트입니다. 계정 및 최근 활동에 대해 질문하세요.",
311
323
  "customer_accounts.portal_ai_assistant.sheet.title": "포털 AI 어시스턴트",
@@ -288,6 +288,12 @@
288
288
  "customer_accounts.invitation.email.preview": "Przyjmij zaproszenie, aby dokończyć konfigurację dostępu do portalu.",
289
289
  "customer_accounts.invitation.email.subject": "Masz zaproszenie do portalu klienta",
290
290
  "customer_accounts.invitation.email.title": "Masz zaproszenie",
291
+ "customer_accounts.magicLink.email.body": "Użyj tego bezpiecznego linku, aby zalogować się do konta w portalu bez hasła.",
292
+ "customer_accounts.magicLink.email.cta": "Zaloguj się",
293
+ "customer_accounts.magicLink.email.hint": "Ten link wygasa po 15 minutach i można go użyć tylko raz. Jeśli o niego nie prosiłeś, zignoruj tę wiadomość.",
294
+ "customer_accounts.magicLink.email.preview": "Użyj tego linku, aby zalogować się do portalu klienta.",
295
+ "customer_accounts.magicLink.email.subject": "Twój link do logowania w portalu",
296
+ "customer_accounts.magicLink.email.title": "Zaloguj się do portalu",
291
297
  "customer_accounts.nav.group": "Klienci",
292
298
  "customer_accounts.nav.role_create": "Utwórz rolę",
293
299
  "customer_accounts.nav.role_detail": "Szczegóły roli",
@@ -306,6 +312,12 @@
306
312
  "customer_accounts.notifications.user.locked.title": "Konto klienta zablokowane",
307
313
  "customer_accounts.notifications.user.signup.body": "Utworzono nowe konto klienta.",
308
314
  "customer_accounts.notifications.user.signup.title": "Nowa rejestracja klienta",
315
+ "customer_accounts.passwordReset.email.body": "Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta w portalu. Użyj tego bezpiecznego linku, aby ustawić nowe.",
316
+ "customer_accounts.passwordReset.email.cta": "Zresetuj hasło",
317
+ "customer_accounts.passwordReset.email.hint": "Ten link wygasa po 60 minutach i można go użyć tylko raz. Jeśli nie prosiłeś o reset hasła, zignoruj tę wiadomość.",
318
+ "customer_accounts.passwordReset.email.preview": "Użyj tego linku, aby ustawić nowe hasło do portalu.",
319
+ "customer_accounts.passwordReset.email.subject": "Zresetuj hasło do portalu",
320
+ "customer_accounts.passwordReset.email.title": "Zresetuj hasło",
309
321
  "customer_accounts.portal_ai_assistant.sheet.composerPlaceholder": "Zapytaj o swoje konto...",
310
322
  "customer_accounts.portal_ai_assistant.sheet.description": "Asystent tylko do odczytu dla klientów portalu. Zapytaj o swoje konto i ostatnią aktywność.",
311
323
  "customer_accounts.portal_ai_assistant.sheet.title": "Asystent AI portalu",
@@ -0,0 +1,77 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
3
+ import { sendEmail } from '@open-mercato/shared/lib/email/send'
4
+ import CustomerAuthLinkEmail from '@open-mercato/core/modules/customer_accounts/emails/CustomerAuthLinkEmail'
5
+ import { urlForCustomerOrg } from '@open-mercato/core/modules/customer_accounts/lib/customerUrl'
6
+
7
+ export type CustomerAuthLinkEmailInput = {
8
+ container: AppContainer
9
+ tenantId: string
10
+ organizationId: string
11
+ email: string
12
+ rawToken: string
13
+ }
14
+
15
+ export async function sendCustomerPasswordResetEmail(input: CustomerAuthLinkEmailInput): Promise<void> {
16
+ const { translate } = await resolveTranslations()
17
+ const linkUrl = await urlForCustomerOrg(
18
+ input.organizationId,
19
+ `/reset-password?token=${encodeURIComponent(input.rawToken)}`,
20
+ { container: input.container },
21
+ )
22
+
23
+ const subject = translate('customer_accounts.passwordReset.email.subject', 'Reset your portal password')
24
+ const copy = {
25
+ preview: translate('customer_accounts.passwordReset.email.preview', 'Use this link to choose a new portal password.'),
26
+ title: translate('customer_accounts.passwordReset.email.title', 'Reset your password'),
27
+ body: translate(
28
+ 'customer_accounts.passwordReset.email.body',
29
+ 'We received a request to reset the password for your portal account. Use this secure link to choose a new one.',
30
+ ),
31
+ cta: translate('customer_accounts.passwordReset.email.cta', 'Reset password'),
32
+ hint: translate(
33
+ 'customer_accounts.passwordReset.email.hint',
34
+ 'This link expires in 60 minutes and can be used once. If you did not request a password reset, you can ignore this email.',
35
+ ),
36
+ }
37
+
38
+ await sendEmail({
39
+ to: input.email,
40
+ subject,
41
+ react: CustomerAuthLinkEmail({ linkUrl, copy }),
42
+ tenantId: input.tenantId,
43
+ organizationId: input.organizationId,
44
+ })
45
+ }
46
+
47
+ export async function sendCustomerMagicLinkEmail(input: CustomerAuthLinkEmailInput): Promise<void> {
48
+ const { translate } = await resolveTranslations()
49
+ const linkUrl = await urlForCustomerOrg(
50
+ input.organizationId,
51
+ `/magic-link?token=${encodeURIComponent(input.rawToken)}`,
52
+ { container: input.container },
53
+ )
54
+
55
+ const subject = translate('customer_accounts.magicLink.email.subject', 'Your portal sign-in link')
56
+ const copy = {
57
+ preview: translate('customer_accounts.magicLink.email.preview', 'Use this link to sign in to the customer portal.'),
58
+ title: translate('customer_accounts.magicLink.email.title', 'Sign in to the portal'),
59
+ body: translate(
60
+ 'customer_accounts.magicLink.email.body',
61
+ 'Use this secure link to sign in to your portal account without a password.',
62
+ ),
63
+ cta: translate('customer_accounts.magicLink.email.cta', 'Sign in'),
64
+ hint: translate(
65
+ 'customer_accounts.magicLink.email.hint',
66
+ 'This link expires in 15 minutes and can be used once. If you did not request it, you can ignore this email.',
67
+ ),
68
+ }
69
+
70
+ await sendEmail({
71
+ to: input.email,
72
+ subject,
73
+ react: CustomerAuthLinkEmail({ linkUrl, copy }),
74
+ tenantId: input.tenantId,
75
+ organizationId: input.organizationId,
76
+ })
77
+ }
@@ -3,6 +3,9 @@ import { z } from 'zod'
3
3
  import { registerCommand, type CommandHandler } from '@open-mercato/shared/lib/commands'
4
4
  import { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'
5
5
  import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
6
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
+ import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
8
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
6
9
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
10
  import { Message, MessageObject, MessageRecipient } from '../data/entities'
8
11
  import { emitMessagesEvent } from '../events'
@@ -374,10 +377,14 @@ const executeActionCommand: CommandHandler<
374
377
  result = (commandResult.result as Record<string, unknown>) ?? {}
375
378
  operationLogEntry = commandResult.logEntry ?? null
376
379
  } catch (err) {
377
- logger.error('executeActionCommand sub-command failed', { err })
378
380
  // The target command never completed — release the reservation so the
379
381
  // action stays retryable, matching the pre-claim failure semantics.
380
382
  await releaseTerminalClaim()
383
+ if (isCrudHttpError(err) || getCommandInterceptorHttpRejection(err)) {
384
+ throw err
385
+ }
386
+ logger.error('executeActionCommand sub-command failed', { err })
387
+ getTelemetryRuntime()?.reportError(err, { module: 'messages', code: 'messages.action_failed' })
381
388
  throw new Error('Action failed')
382
389
  }
383
390
  } else if (action.href) {
@@ -150,6 +150,10 @@ export class GatewayPaymentOperation {
150
150
  expression:
151
151
  'create index "gateway_session_initializations_prune_idx" on "gateway_session_initializations" ("tenant_id", "organization_id", "updated_at") where "gateway_transaction_id" is not null',
152
152
  })
153
+ @Index({
154
+ name: 'gateway_session_initializations_retention_idx',
155
+ properties: ['tenantId', 'organizationId', 'updatedAt', 'id'],
156
+ })
153
157
  export class GatewaySessionInitialization {
154
158
  [OptionalProps]?: 'claimToken' | 'claimedAt' | 'gatewayTransactionId' | 'createdAt' | 'updatedAt'
155
159
 
@@ -150,6 +150,23 @@ export async function pruneCompletedPaymentSessionInitializations(
150
150
  em: EntityManager,
151
151
  scope: Scope,
152
152
  options: { now?: Date; batchSize?: number } = {},
153
+ ): Promise<number> {
154
+ return prunePaymentSessionInitializations(em, scope, options, false)
155
+ }
156
+
157
+ export async function pruneExpiredPaymentSessionInitializations(
158
+ em: EntityManager,
159
+ scope: Scope,
160
+ options: { now?: Date; batchSize?: number } = {},
161
+ ): Promise<number> {
162
+ return prunePaymentSessionInitializations(em, scope, options, true)
163
+ }
164
+
165
+ async function prunePaymentSessionInitializations(
166
+ em: EntityManager,
167
+ scope: Scope,
168
+ options: { now?: Date; batchSize?: number },
169
+ includeIncomplete: boolean,
153
170
  ): Promise<number> {
154
171
  const now = options.now ?? new Date()
155
172
  const batchSize = options.batchSize ?? PAYMENT_SESSION_INITIALIZATION_PRUNE_BATCH_SIZE
@@ -157,21 +174,40 @@ export async function pruneCompletedPaymentSessionInitializations(
157
174
  throw new Error('[internal] Payment-session initialization prune batch size must be a positive integer')
158
175
  }
159
176
  const cutoff = new Date(now.getTime() - PAYMENT_SESSION_REPLAY_WINDOW_MS)
177
+ const eligibleState = includeIncomplete
178
+ ? `(gateway_transaction_id is not null or (
179
+ gateway_transaction_id is null and (
180
+ (claim_token is null and claimed_at is null)
181
+ or (claim_token is not null and claimed_at < ?)
182
+ )
183
+ ))`
184
+ : 'gateway_transaction_id is not null'
160
185
  const connection = em.getConnection()
161
186
  const result = await connection.execute(
162
187
  `
163
- delete from gateway_session_initializations
164
- where id in (
188
+ with expired_claims as materialized (
165
189
  select id from gateway_session_initializations
166
190
  where tenant_id = ?
167
191
  and organization_id = ?
168
- and gateway_transaction_id is not null
169
192
  and updated_at < ?
170
- order by updated_at asc
193
+ and ${eligibleState}
194
+ order by updated_at asc, id asc
171
195
  limit ?
196
+ for update skip locked
172
197
  )
198
+ delete from gateway_session_initializations
199
+ where tenant_id = ? and organization_id = ?
200
+ and id in (select id from expired_claims)
173
201
  `,
174
- [scope.tenantId, scope.organizationId, cutoff, batchSize],
202
+ [
203
+ scope.tenantId,
204
+ scope.organizationId,
205
+ cutoff,
206
+ ...(includeIncomplete ? [cutoff] : []),
207
+ batchSize,
208
+ scope.tenantId,
209
+ scope.organizationId,
210
+ ],
175
211
  'run',
176
212
  ) as { affectedRows?: number; rowCount?: number } | undefined
177
213
  return result?.affectedRows ?? result?.rowCount ?? 0
@@ -558,6 +558,19 @@
558
558
  "keyName": "gateway_session_initializations_scope_operation_unique",
559
559
  "primary": false,
560
560
  "unique": true
561
+ },
562
+ {
563
+ "columnNames": [
564
+ "tenant_id",
565
+ "organization_id",
566
+ "updated_at",
567
+ "id"
568
+ ],
569
+ "composite": true,
570
+ "constraint": false,
571
+ "keyName": "gateway_session_initializations_retention_idx",
572
+ "primary": false,
573
+ "unique": false
561
574
  }
562
575
  ],
563
576
  "checks": [],
@@ -1171,4 +1184,4 @@
1171
1184
  ],
1172
1185
  "views": [],
1173
1186
  "nativeEnums": {}
1174
- }
1187
+ }
@@ -0,0 +1,18 @@
1
+ import { Migration } from '@mikro-orm/migrations'
2
+
3
+ export class Migration20261018180000_payment_gateways extends Migration {
4
+ override isTransactional(): boolean {
5
+ return false
6
+ }
7
+
8
+ override up(): void | Promise<void> {
9
+ this.addSql('drop index concurrently if exists "gateway_session_initializations_retention_idx";')
10
+ this.addSql(
11
+ 'create index concurrently "gateway_session_initializations_retention_idx" on "gateway_session_initializations" ("tenant_id", "organization_id", "updated_at", "id");',
12
+ )
13
+ }
14
+
15
+ override down(): void | Promise<void> {
16
+ this.addSql('drop index concurrently if exists "gateway_session_initializations_retention_idx";')
17
+ }
18
+ }
@@ -4,7 +4,7 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
4
4
  import {
5
5
  PAYMENT_SESSION_INITIALIZATION_PRUNE_QUEUE,
6
6
  PAYMENT_SESSION_INITIALIZATION_PRUNE_BATCH_SIZE,
7
- pruneCompletedPaymentSessionInitializations,
7
+ pruneExpiredPaymentSessionInitializations,
8
8
  } from '../lib/session-idempotency'
9
9
 
10
10
  type PrunePayload = {
@@ -45,13 +45,13 @@ export default async function handle(
45
45
  }
46
46
 
47
47
  const em = ctx.resolve<EntityManager>('em').fork()
48
- const deleted = await pruneCompletedPaymentSessionInitializations(
48
+ const deleted = await pruneExpiredPaymentSessionInitializations(
49
49
  em,
50
50
  { tenantId, organizationId },
51
51
  { batchSize: PAYMENT_SESSION_INITIALIZATION_PRUNE_BATCH_SIZE },
52
52
  )
53
53
  if (deleted > 0) {
54
- logger.info('Pruned completed payment-session initialization claims', {
54
+ logger.info('Pruned expired payment-session initialization claims', {
55
55
  deleted,
56
56
  tenantId,
57
57
  organizationId,
@@ -0,0 +1,9 @@
1
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
2
+
3
+ export const metadata: PageMetadata = {
4
+ titleKey: 'portal.title',
5
+ title: 'Sign In',
6
+ navHidden: true,
7
+ }
8
+
9
+ export default metadata
@@ -0,0 +1,71 @@
1
+ "use client"
2
+ import { useCallback, useState } from 'react'
3
+ import Link from 'next/link'
4
+ import { useSearchParams } from 'next/navigation'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
9
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
10
+ import { replaceWithPageReload } from '@open-mercato/core/modules/portal/lib/navigation'
11
+
12
+ type Props = { params: { orgSlug: string } }
13
+
14
+ export default function PortalMagicLinkPage({ params }: Props) {
15
+ const t = useT()
16
+ const orgSlug = params.orgSlug
17
+ const searchParams = useSearchParams()
18
+ const token = searchParams.get('token') ?? ''
19
+ const [submitting, setSubmitting] = useState(false)
20
+ const [error, setError] = useState<string | null>(
21
+ token ? null : t('portal.magicLink.error.missingToken', 'Sign-in link is invalid or incomplete.'),
22
+ )
23
+
24
+ const handleSignIn = useCallback(async () => {
25
+ if (!token) return
26
+ setSubmitting(true)
27
+ setError(null)
28
+ try {
29
+ const result = await apiCall<{ ok: boolean; error?: string }>('/api/customer_accounts/magic-link/verify', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ credentials: 'include',
33
+ body: JSON.stringify({ token }),
34
+ })
35
+ if (result.ok && result.result?.ok) {
36
+ replaceWithPageReload(`/${orgSlug}/portal/dashboard`)
37
+ return
38
+ }
39
+ setError(t('portal.magicLink.error.invalidToken', 'This sign-in link is invalid or has expired. Request a new one.'))
40
+ } catch {
41
+ setError(t('portal.magicLink.error.generic', 'Sign-in failed. Please try again.'))
42
+ } finally {
43
+ setSubmitting(false)
44
+ }
45
+ }, [token, orgSlug, t])
46
+
47
+ return (
48
+ <div className="mx-auto flex w-full max-w-sm flex-col gap-4 py-12">
49
+ <div className="text-center">
50
+ <h1 className="text-2xl font-bold tracking-tight">{t('portal.magicLink.title', 'Sign in with a link')}</h1>
51
+ <p className="mt-1.5 text-sm text-muted-foreground">
52
+ {t('portal.magicLink.description', 'Continue to sign in to the portal with the link from your email.')}
53
+ </p>
54
+ </div>
55
+ {error ? (
56
+ <Alert status="error">
57
+ <AlertDescription>{error}</AlertDescription>
58
+ </Alert>
59
+ ) : null}
60
+ {token ? (
61
+ <Button type="button" onClick={() => void handleSignIn()} disabled={submitting} className="w-full rounded-lg">
62
+ {submitting ? <Spinner /> : null}
63
+ {t('portal.magicLink.submit', 'Sign in')}
64
+ </Button>
65
+ ) : null}
66
+ <Button asChild variant="outline" className="rounded-lg">
67
+ <Link href={`/${orgSlug}/portal/login`}>{t('portal.magicLink.backToLogin', 'Back to sign in')}</Link>
68
+ </Button>
69
+ </div>
70
+ )
71
+ }