@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/users/route.js +1 -1
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/customer_accounts/api/magic-link/request.js +15 -1
- package/dist/modules/customer_accounts/api/magic-link/request.js.map +2 -2
- package/dist/modules/customer_accounts/api/password/reset-request.js +15 -1
- package/dist/modules/customer_accounts/api/password/reset-request.js.map +2 -2
- package/dist/modules/customer_accounts/emails/CustomerAuthLinkEmail.js +26 -0
- package/dist/modules/customer_accounts/emails/CustomerAuthLinkEmail.js.map +7 -0
- package/dist/modules/customer_accounts/lib/authLinkEmails.js +67 -0
- package/dist/modules/customer_accounts/lib/authLinkEmails.js.map +7 -0
- package/dist/modules/messages/commands/actions.js +8 -1
- package/dist/modules/messages/commands/actions.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +4 -0
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/lib/session-idempotency.js +29 -5
- package/dist/modules/payment_gateways/lib/session-idempotency.js.map +2 -2
- package/dist/modules/payment_gateways/migrations/Migration20261018180000_payment_gateways.js +19 -0
- package/dist/modules/payment_gateways/migrations/Migration20261018180000_payment_gateways.js.map +7 -0
- package/dist/modules/payment_gateways/workers/session-initialization-prune.js +3 -3
- package/dist/modules/payment_gateways/workers/session-initialization-prune.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.js +59 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.js.map +7 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.meta.js +11 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/magic-link/page.meta.js.map +7 -0
- package/dist/modules/query_index/lib/engine.js +60 -13
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/users/route.ts +1 -1
- package/src/modules/customer_accounts/api/magic-link/request.ts +16 -3
- package/src/modules/customer_accounts/api/password/reset-request.ts +16 -3
- package/src/modules/customer_accounts/emails/CustomerAuthLinkEmail.tsx +45 -0
- package/src/modules/customer_accounts/i18n/de.json +12 -0
- package/src/modules/customer_accounts/i18n/en.json +12 -0
- package/src/modules/customer_accounts/i18n/es.json +12 -0
- package/src/modules/customer_accounts/i18n/ko.json +12 -0
- package/src/modules/customer_accounts/i18n/pl.json +12 -0
- package/src/modules/customer_accounts/lib/authLinkEmails.ts +77 -0
- package/src/modules/messages/commands/actions.ts +8 -1
- package/src/modules/payment_gateways/data/entities.ts +4 -0
- package/src/modules/payment_gateways/lib/session-idempotency.ts +41 -5
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +14 -1
- package/src/modules/payment_gateways/migrations/Migration20261018180000_payment_gateways.ts +18 -0
- package/src/modules/payment_gateways/workers/session-initialization-prune.ts +3 -3
- package/src/modules/portal/frontend/[orgSlug]/portal/magic-link/page.meta.ts +9 -0
- package/src/modules/portal/frontend/[orgSlug]/portal/magic-link/page.tsx +71 -0
- package/src/modules/portal/i18n/de.json +7 -0
- package/src/modules/portal/i18n/en.json +7 -0
- package/src/modules/portal/i18n/es.json +7 -0
- package/src/modules/portal/i18n/ko.json +7 -0
- package/src/modules/portal/i18n/pl.json +7 -0
- 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.
|
|
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.
|
|
269
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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.
|
|
276
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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
|
|
54
|
+
logger.info('Pruned expired payment-session initialization claims', {
|
|
55
55
|
deleted,
|
|
56
56
|
tenantId,
|
|
57
57
|
organizationId,
|
|
@@ -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
|
+
}
|