@open-mercato/core 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba

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 (116) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/auth/commands/users.js +14 -1
  4. package/dist/modules/auth/commands/users.js.map +2 -2
  5. package/dist/modules/auth/data/entities.js +2 -2
  6. package/dist/modules/auth/data/entities.js.map +2 -2
  7. package/dist/modules/auth/lib/backendChrome.js +26 -3
  8. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  9. package/dist/modules/auth/lib/profile-sections.js +1 -1
  10. package/dist/modules/auth/lib/profile-sections.js.map +2 -2
  11. package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
  12. package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
  13. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
  14. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
  15. package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
  16. package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
  17. package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
  18. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  19. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
  20. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
  21. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
  22. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
  23. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
  24. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  25. package/dist/modules/customer_accounts/data/entities.js +4 -1
  26. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  27. package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
  28. package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
  29. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
  30. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
  31. package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
  32. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  33. package/dist/modules/customer_accounts/setup.js +2 -6
  34. package/dist/modules/customer_accounts/setup.js.map +2 -2
  35. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
  36. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  37. package/dist/modules/customers/commands/people.js +16 -1
  38. package/dist/modules/customers/commands/people.js.map +2 -2
  39. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
  40. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  41. package/dist/modules/data_sync/lib/sync-schedule-service.js +25 -23
  42. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  43. package/dist/modules/entities/api/records.js +18 -15
  44. package/dist/modules/entities/api/records.js.map +3 -3
  45. package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
  46. package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
  47. package/dist/modules/entities/lib/validation.js +4 -35
  48. package/dist/modules/entities/lib/validation.js.map +2 -2
  49. package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
  50. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  51. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
  52. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  53. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
  54. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
  55. package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
  56. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  57. package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
  58. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  59. package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
  60. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  61. package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
  62. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  63. package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
  64. package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
  65. package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
  66. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  67. package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
  68. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  69. package/package.json +7 -7
  70. package/src/modules/auth/commands/users.ts +14 -1
  71. package/src/modules/auth/data/entities.ts +2 -2
  72. package/src/modules/auth/lib/backendChrome.tsx +46 -2
  73. package/src/modules/auth/lib/profile-sections.tsx +14 -3
  74. package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
  75. package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
  76. package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
  77. package/src/modules/customer_accounts/AGENTS.md +3 -1
  78. package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
  79. package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
  80. package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
  81. package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
  82. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
  83. package/src/modules/customer_accounts/data/entities.ts +5 -1
  84. package/src/modules/customer_accounts/i18n/de.json +4 -2
  85. package/src/modules/customer_accounts/i18n/en.json +4 -2
  86. package/src/modules/customer_accounts/i18n/es.json +4 -2
  87. package/src/modules/customer_accounts/i18n/ko.json +4 -2
  88. package/src/modules/customer_accounts/i18n/pl.json +4 -2
  89. package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
  90. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
  91. package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
  92. package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
  93. package/src/modules/customer_accounts/setup.ts +2 -6
  94. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
  95. package/src/modules/customers/commands/people.ts +15 -0
  96. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
  97. package/src/modules/data_sync/lib/sync-schedule-service.ts +32 -26
  98. package/src/modules/entities/api/records.ts +24 -15
  99. package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
  100. package/src/modules/entities/lib/validation.ts +5 -45
  101. package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
  102. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
  103. package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
  104. package/src/modules/progress/AGENTS.md +1 -1
  105. package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
  106. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
  107. package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
  108. package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
  109. package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
  110. package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
  111. package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
  112. package/src/modules/sales/i18n/de.json +2 -0
  113. package/src/modules/sales/i18n/en.json +2 -0
  114. package/src/modules/sales/i18n/es.json +2 -0
  115. package/src/modules/sales/i18n/ko.json +2 -0
  116. package/src/modules/sales/i18n/pl.json +2 -0
@@ -3,11 +3,15 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { invitationAcceptSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
- import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
6
+ import {
7
+ CustomerInvitationService,
8
+ isCustomerInvitationEmailConflictError,
9
+ } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
7
10
  import { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'
8
11
  import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
9
12
  import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
10
13
  import { getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
14
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
15
 
12
16
  export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
13
17
 
@@ -29,11 +33,23 @@ export async function POST(req: Request) {
29
33
  const customerSessionService = container.resolve('customerSessionService') as CustomerSessionService
30
34
  const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService
31
35
 
32
- const result = await customerInvitationService.acceptInvitation(
33
- parsed.data.token,
34
- parsed.data.password,
35
- parsed.data.displayName,
36
- )
36
+ let result: Awaited<ReturnType<typeof customerInvitationService.acceptInvitation>>
37
+ try {
38
+ result = await customerInvitationService.acceptInvitation(
39
+ parsed.data.token,
40
+ parsed.data.password,
41
+ parsed.data.displayName,
42
+ )
43
+ } catch (error) {
44
+ if (isCustomerInvitationEmailConflictError(error)) {
45
+ const { translate } = await resolveTranslations()
46
+ return NextResponse.json({
47
+ ok: false,
48
+ error: translate('customer_accounts.errors.emailAlreadyExists', 'An account with this email address already exists'),
49
+ }, { status: 409 })
50
+ }
51
+ throw error
52
+ }
37
53
  if (!result) {
38
54
  return NextResponse.json({ ok: false, error: 'Invalid or expired invitation' }, { status: 400 })
39
55
  }
@@ -117,6 +133,7 @@ const methodDoc: OpenApiMethodDoc = {
117
133
  ],
118
134
  errors: [
119
135
  { status: 400, description: 'Invalid or expired invitation', schema: errorSchema },
136
+ { status: 409, description: 'An account with this email address already exists', schema: errorSchema },
120
137
  ],
121
138
  }
122
139
 
@@ -6,12 +6,7 @@ import { FormHeader } from '@open-mercato/ui/backend/forms'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { buildPortalRootUrl, buildPortalUrlPattern } from '../../../lib/portalUrl'
9
-
10
- const DEMO_CREDENTIALS = [
11
- { email: 'alice.johnson@example.com', password: 'Password123!', role: 'Portal Admin' },
12
- { email: 'bob.smith@example.com', password: 'Password123!', role: 'Buyer' },
13
- { email: 'carol.white@example.com', password: 'Password123!', role: 'Viewer' },
14
- ] as const
9
+ import { useDemoPortalAccounts } from '../useDemoPortalAccounts'
15
10
 
16
11
  export type CustomerAccountsSettingsPageClientProps = {
17
12
  portalOrigin: string
@@ -20,6 +15,7 @@ export type CustomerAccountsSettingsPageClientProps = {
20
15
 
21
16
  export function CustomerAccountsSettingsPageClient({ portalOrigin, portalOrgSlug = null }: CustomerAccountsSettingsPageClientProps) {
22
17
  const t = useT()
18
+ const { accounts: demoAccounts } = useDemoPortalAccounts()
23
19
 
24
20
  const portalUrl = useMemo(() => buildPortalUrlPattern(portalOrigin), [portalOrigin])
25
21
  const portalRootUrl = useMemo(
@@ -73,43 +69,49 @@ export function CustomerAccountsSettingsPageClient({ portalOrigin, portalOrgSlug
73
69
  </p>
74
70
  </div>
75
71
 
76
- <div className="rounded-lg border p-4 space-y-3">
77
- <h3 className="text-sm font-semibold">
78
- {t('customer_accounts.settings.demo_credentials.title', 'Demo Credentials')}
79
- </h3>
80
- <div className="overflow-x-auto">
81
- <table className="w-full text-sm">
82
- <thead>
83
- <tr className="border-b text-left">
84
- <th className="pb-2 pr-4 font-medium text-muted-foreground">
85
- {t('customer_accounts.settings.demo_credentials.email', 'Email')}
86
- </th>
87
- <th className="pb-2 pr-4 font-medium text-muted-foreground">
88
- {t('customer_accounts.settings.demo_credentials.password', 'Password')}
89
- </th>
90
- <th className="pb-2 font-medium text-muted-foreground">
91
- {t('customer_accounts.settings.demo_credentials.role', 'Role')}
92
- </th>
93
- </tr>
94
- </thead>
95
- <tbody>
96
- {DEMO_CREDENTIALS.map((cred) => (
97
- <tr key={cred.email} className="border-b last:border-0">
98
- <td className="py-2 pr-4 font-mono text-xs">{cred.email}</td>
99
- <td className="py-2 pr-4 font-mono text-xs">{cred.password}</td>
100
- <td className="py-2 text-xs">{cred.role}</td>
72
+ {demoAccounts.length > 0 ? (
73
+ <div className="rounded-lg border p-4 space-y-3">
74
+ <h3 className="text-sm font-semibold">
75
+ {t('customer_accounts.settings.demo_credentials.title', 'Demo Credentials')}
76
+ </h3>
77
+ <div className="overflow-x-auto">
78
+ <table className="w-full text-sm">
79
+ <thead>
80
+ <tr className="border-b text-left">
81
+ <th className="pb-2 pr-4 font-medium text-muted-foreground">
82
+ {t('customer_accounts.settings.demo_credentials.email', 'Email')}
83
+ </th>
84
+ <th className="pb-2 pr-4 font-medium text-muted-foreground">
85
+ {t('customer_accounts.settings.demo_credentials.password', 'Password')}
86
+ </th>
87
+ <th className="pb-2 font-medium text-muted-foreground">
88
+ {t('customer_accounts.settings.demo_credentials.role', 'Role')}
89
+ </th>
101
90
  </tr>
102
- ))}
103
- </tbody>
104
- </table>
91
+ </thead>
92
+ <tbody>
93
+ {demoAccounts.map((account) => (
94
+ <tr key={account.email} className="border-b last:border-0">
95
+ <td className="py-2 pr-4 font-mono text-xs">{account.email}</td>
96
+ <td className="py-2 pr-4 font-mono text-xs">{account.password}</td>
97
+ <td className="py-2 text-xs">
98
+ {account.roles.length > 0
99
+ ? account.roles.map((role) => role.name).join(', ')
100
+ : t('customer_accounts.settings.demo_credentials.no_role', 'No role assigned')}
101
+ </td>
102
+ </tr>
103
+ ))}
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ <p className="text-xs text-muted-foreground">
108
+ {t(
109
+ 'customer_accounts.settings.demo_credentials.note',
110
+ 'These accounts were created automatically for this organization by the example-data seeding step during setup.',
111
+ )}
112
+ </p>
105
113
  </div>
106
- <p className="text-xs text-muted-foreground">
107
- {t(
108
- 'customer_accounts.settings.demo_credentials.note',
109
- 'These credentials are only available if example data was seeded during setup.',
110
- )}
111
- </p>
112
- </div>
114
+ ) : null}
113
115
 
114
116
  <div className="rounded-lg border p-4 space-y-3">
115
117
  <h3 className="text-sm font-semibold">
@@ -0,0 +1,42 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
+
6
+ export type DemoPortalAccount = {
7
+ email: string
8
+ password: string
9
+ roles: Array<{ id: string; name: string; slug: string }>
10
+ }
11
+
12
+ type DemoAccountsResponse = {
13
+ items?: DemoPortalAccount[]
14
+ }
15
+
16
+ /**
17
+ * Loads the example-data portal accounts that exist in the current organization.
18
+ * Callers render demo credentials only for what comes back, so an installation
19
+ * started with `--no-examples` shows nothing at all (#5669). A failed lookup
20
+ * resolves to an empty list — never to a hardcoded fallback.
21
+ */
22
+ export function useDemoPortalAccounts(): { accounts: DemoPortalAccount[] } {
23
+ const [accounts, setAccounts] = React.useState<DemoPortalAccount[]>([])
24
+
25
+ React.useEffect(() => {
26
+ let cancelled = false
27
+ async function load() {
28
+ try {
29
+ const call = await apiCall<DemoAccountsResponse>('/api/customer_accounts/admin/demo-accounts')
30
+ if (cancelled) return
31
+ const items = call.ok && Array.isArray(call.result?.items) ? call.result!.items : []
32
+ setAccounts(items)
33
+ } catch {
34
+ if (!cancelled) setAccounts([])
35
+ }
36
+ }
37
+ load()
38
+ return () => { cancelled = true }
39
+ }, [])
40
+
41
+ return { accounts }
42
+ }
@@ -22,6 +22,7 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
22
22
  import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
23
23
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
24
24
  import { buildPortalRootUrl, buildPortalUrlPattern } from '../../../lib/portalUrl'
25
+ import { useDemoPortalAccounts } from '../useDemoPortalAccounts'
25
26
 
26
27
  type UserRow = {
27
28
  id: string
@@ -248,6 +249,7 @@ export function PortalUsersPageClient({ portalOrigin, portalOrgSlug = null }: Po
248
249
  const [reloadToken, setReloadToken] = React.useState(0)
249
250
  const [roleOptions, setRoleOptions] = React.useState<Array<{ value: string; label: string; id: string }>>([])
250
251
  const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
252
+ const { accounts: demoAccounts } = useDemoPortalAccounts()
251
253
 
252
254
  const { runMutation, retryLastMutation } = useGuardedMutation<{
253
255
  entityType: string
@@ -506,9 +508,14 @@ export function PortalUsersPageClient({ portalOrigin, portalOrgSlug = null }: Po
506
508
  url: buildPortalUrlPattern(portalOrigin),
507
509
  })}
508
510
  </p>
509
- <p className="mt-0.5 text-xs text-status-info-text">
510
- {t('customer_accounts.admin.portalInfo.credentials', 'Demo credentials: alice.johnson@example.com / Password123!')}
511
- </p>
511
+ {demoAccounts.length > 0 ? (
512
+ <p className="mt-0.5 text-xs text-status-info-text">
513
+ {t('customer_accounts.admin.portalInfo.credentials', 'Seeded demo credentials: {email} / {password}', {
514
+ email: demoAccounts[0].email,
515
+ password: demoAccounts[0].password,
516
+ })}
517
+ </p>
518
+ ) : null}
512
519
  </div>
513
520
  <div className="flex shrink-0 flex-col gap-2">
514
521
  <Button
@@ -13,7 +13,11 @@ export const DOMAIN_STATUSES: readonly DomainStatus[] = [
13
13
  ] as const
14
14
 
15
15
  @Entity({ tableName: 'customer_users' })
16
- @Unique({ properties: ['tenantId', 'emailHash'], name: 'customer_users_tenant_email_hash_uniq' })
16
+ @Index({
17
+ name: 'customer_users_tenant_email_hash_uniq',
18
+ expression:
19
+ 'create unique index "customer_users_tenant_email_hash_uniq" on "customer_users" ("tenant_id", "email_hash") where "deleted_at" is null',
20
+ })
17
21
  export class CustomerUser {
18
22
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
19
23
  id!: string
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Ermöglicht das Bearbeiten von Rollen und das Entfernen von Teammitgliedern",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "Teammitglieder anzeigen",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Ermöglicht das Anzeigen anderer Teammitglieder in der Organisation",
136
- "customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Geseedete Demo-Zugangsdaten: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
138
138
  "customer_accounts.admin.portalInfo.open": "Open Portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
@@ -274,6 +274,7 @@
274
274
  "customer_accounts.domainMapping.tls.diagnostics.title": "SSL certificate issue",
275
275
  "customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
276
276
  "customer_accounts.domainMapping.verifyNow": "Check Now",
277
+ "customer_accounts.errors.emailAlreadyExists": "Es existiert bereits ein Konto mit dieser E-Mail-Adresse",
277
278
  "customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
278
279
  "customer_accounts.invitation.email.cta": "Accept invitation",
279
280
  "customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Kundenbenutzer",
308
309
  "customer_accounts.settings.back": "Kundenbenutzer",
309
310
  "customer_accounts.settings.demo_credentials.email": "Email",
310
- "customer_accounts.settings.demo_credentials.note": "These credentials are only available if example data was seeded during setup.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "Keine Rolle zugewiesen",
312
+ "customer_accounts.settings.demo_credentials.note": "Diese Konten wurden während der Einrichtung automatisch für diese Organisation durch den Schritt zum Seeden der Beispieldaten erstellt.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Password",
312
314
  "customer_accounts.settings.demo_credentials.role": "Role",
313
315
  "customer_accounts.settings.demo_credentials.title": "Demo Credentials",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Allows editing roles and removing team members",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "View team members",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Allows viewing other team members in the organization",
136
- "customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Seeded demo credentials: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
138
138
  "customer_accounts.admin.portalInfo.open": "Open Portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
@@ -274,6 +274,7 @@
274
274
  "customer_accounts.domainMapping.tls.diagnostics.title": "SSL certificate issue",
275
275
  "customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
276
276
  "customer_accounts.domainMapping.verifyNow": "Check Now",
277
+ "customer_accounts.errors.emailAlreadyExists": "An account with this email address already exists",
277
278
  "customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
278
279
  "customer_accounts.invitation.email.cta": "Accept invitation",
279
280
  "customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Customer user",
308
309
  "customer_accounts.settings.back": "Customer Users",
309
310
  "customer_accounts.settings.demo_credentials.email": "Email",
310
- "customer_accounts.settings.demo_credentials.note": "These credentials are only available if example data was seeded during setup.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "No role assigned",
312
+ "customer_accounts.settings.demo_credentials.note": "These accounts were created automatically for this organization by the example-data seeding step during setup.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Password",
312
314
  "customer_accounts.settings.demo_credentials.role": "Role",
313
315
  "customer_accounts.settings.demo_credentials.title": "Demo Credentials",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Permite editar roles y eliminar miembros del equipo",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "Ver miembros del equipo",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Permite ver a otros miembros del equipo en la organización",
136
- "customer_accounts.admin.portalInfo.credentials": "Demo credentials: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Credenciales de demostración sembradas: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Manage customer portal accounts. Customers can self-register, log in, and access orders, quotes, and invoices through the portal.",
138
138
  "customer_accounts.admin.portalInfo.open": "Open Portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Open Configuration",
@@ -274,6 +274,7 @@
274
274
  "customer_accounts.domainMapping.tls.diagnostics.title": "SSL certificate issue",
275
275
  "customer_accounts.domainMapping.tls.retryButton": "Retry SSL",
276
276
  "customer_accounts.domainMapping.verifyNow": "Check Now",
277
+ "customer_accounts.errors.emailAlreadyExists": "Ya existe una cuenta con esta dirección de correo electrónico",
277
278
  "customer_accounts.invitation.email.body": "Use this secure invitation link to create your password and access the customer portal.",
278
279
  "customer_accounts.invitation.email.cta": "Accept invitation",
279
280
  "customer_accounts.invitation.email.hint": "This invitation link expires in 72 hours. If you did not expect this invitation, you can ignore this email.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Usuario de cliente",
308
309
  "customer_accounts.settings.back": "Usuarios de clientes",
309
310
  "customer_accounts.settings.demo_credentials.email": "Email",
310
- "customer_accounts.settings.demo_credentials.note": "These credentials are only available if example data was seeded during setup.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "Sin rol asignado",
312
+ "customer_accounts.settings.demo_credentials.note": "Estas cuentas se crearon automáticamente para esta organización mediante el paso de carga de datos de ejemplo durante la configuración.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Password",
312
314
  "customer_accounts.settings.demo_credentials.role": "Role",
313
315
  "customer_accounts.settings.demo_credentials.title": "Demo Credentials",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "역할 편집 및 팀 구성원 제거 허용",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "팀원 보기",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "조직의 다른 팀 구성원을 볼 수 있습니다.",
136
- "customer_accounts.admin.portalInfo.credentials": "데모 자격 증명: alice.johnson@example.com / password123",
136
+ "customer_accounts.admin.portalInfo.credentials": "시드된 데모 자격 증명: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "고객 포털 계정을 관리합니다. 고객은 셀프 등록, 로그인을 할 수 있으며 포털을 통해 주문, 견적, 인보이스에 접근할 수 있습니다.",
138
138
  "customer_accounts.admin.portalInfo.open": "포털 열기",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "구성 열기",
@@ -274,6 +274,7 @@
274
274
  "customer_accounts.domainMapping.tls.diagnostics.title": "SSL 인증서 문제",
275
275
  "customer_accounts.domainMapping.tls.retryButton": "SSL 재시도",
276
276
  "customer_accounts.domainMapping.verifyNow": "지금 확인",
277
+ "customer_accounts.errors.emailAlreadyExists": "이 이메일 주소로 등록된 계정이 이미 있습니다",
277
278
  "customer_accounts.invitation.email.body": "이 보안 초대 링크를 사용하여 비밀번호를 생성하고 고객 포털에 액세스하세요.",
278
279
  "customer_accounts.invitation.email.cta": "초대 수락",
279
280
  "customer_accounts.invitation.email.hint": "이 초대 링크는 72시간 후에 만료됩니다. 이 초대를 예상하지 못했다면 이 이메일을 무시해도 됩니다.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "고객 사용자",
308
309
  "customer_accounts.settings.back": "고객 사용자",
309
310
  "customer_accounts.settings.demo_credentials.email": "이메일",
310
- "customer_accounts.settings.demo_credentials.note": "이 자격 증명은 설정 중 예제 데이터가 시드된 경우에만 사용할 수 있습니다.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "할당된 역할 없음",
312
+ "customer_accounts.settings.demo_credentials.note": "이 계정은 설정 중 예제 데이터 시딩 단계에서 이 조직을 위해 자동으로 생성되었습니다.",
311
313
  "customer_accounts.settings.demo_credentials.password": "비밀번호",
312
314
  "customer_accounts.settings.demo_credentials.role": "역할",
313
315
  "customer_accounts.settings.demo_credentials.title": "데모 자격 증명",
@@ -133,7 +133,7 @@
133
133
  "customer_accounts.admin.portalFeatures.users.manage.description": "Umożliwia edycję ról i usuwanie członków zespołu",
134
134
  "customer_accounts.admin.portalFeatures.users.view": "Wyświetlanie członków zespołu",
135
135
  "customer_accounts.admin.portalFeatures.users.view.description": "Umożliwia przeglądanie innych członków zespołu w organizacji",
136
- "customer_accounts.admin.portalInfo.credentials": "Dane demonstracyjne: alice.johnson@example.com / Password123!",
136
+ "customer_accounts.admin.portalInfo.credentials": "Zaseedowane dane demonstracyjne: {email} / {password}",
137
137
  "customer_accounts.admin.portalInfo.description": "Zarządzaj kontami portalu klienta. Klienci mogą sami się rejestrować, logować oraz przeglądać zamówienia, oferty i faktury w portalu.",
138
138
  "customer_accounts.admin.portalInfo.open": "Otwórz portal",
139
139
  "customer_accounts.admin.portalInfo.openConfiguration": "Otwórz konfigurację",
@@ -274,6 +274,7 @@
274
274
  "customer_accounts.domainMapping.tls.diagnostics.title": "Problem z certyfikatem SSL",
275
275
  "customer_accounts.domainMapping.tls.retryButton": "Ponów SSL",
276
276
  "customer_accounts.domainMapping.verifyNow": "Sprawdź teraz",
277
+ "customer_accounts.errors.emailAlreadyExists": "Konto z tym adresem e-mail już istnieje",
277
278
  "customer_accounts.invitation.email.body": "Użyj bezpiecznego linku zaproszenia, aby utworzyć hasło i uzyskać dostęp do portalu klienta.",
278
279
  "customer_accounts.invitation.email.cta": "Przyjmij zaproszenie",
279
280
  "customer_accounts.invitation.email.hint": "Ten link zaproszenia wygasa po 72 godzinach. Jeśli nie spodziewasz się tego zaproszenia, możesz zignorować tę wiadomość.",
@@ -307,7 +308,8 @@
307
308
  "customer_accounts.search.badge.customerUser": "Użytkownik klienta",
308
309
  "customer_accounts.settings.back": "Użytkownicy klientów",
309
310
  "customer_accounts.settings.demo_credentials.email": "E-mail",
310
- "customer_accounts.settings.demo_credentials.note": "Te dane są dostępne tylko wtedy, gdy podczas konfiguracji zaseedowano dane przykładowe.",
311
+ "customer_accounts.settings.demo_credentials.no_role": "Brak przypisanej roli",
312
+ "customer_accounts.settings.demo_credentials.note": "Te konta zostały utworzone automatycznie dla tej organizacji przez krok seedowania danych przykładowych podczas konfiguracji.",
311
313
  "customer_accounts.settings.demo_credentials.password": "Hasło",
312
314
  "customer_accounts.settings.demo_credentials.role": "Rola",
313
315
  "customer_accounts.settings.demo_credentials.title": "Dane demonstracyjne",
@@ -0,0 +1,18 @@
1
+ export type ExamplePortalAccount = {
2
+ email: string
3
+ displayName: string
4
+ password: string
5
+ roleSlug: string
6
+ }
7
+
8
+ /**
9
+ * Portal accounts created by the module's `seedExamples` hook. Setup seeds from
10
+ * this list and the admin demo-credentials endpoint reports against it, so the
11
+ * advertised credentials can never drift from the seeded ones (#3198) and are
12
+ * only surfaced for accounts that actually exist in the current scope (#5669).
13
+ */
14
+ export const EXAMPLE_PORTAL_ACCOUNTS: readonly ExamplePortalAccount[] = [
15
+ { email: 'alice.johnson@example.com', displayName: 'Alice Johnson', password: 'Password123!', roleSlug: 'portal_admin' },
16
+ { email: 'bob.smith@example.com', displayName: 'Bob Smith', password: 'Password123!', roleSlug: 'buyer' },
17
+ { email: 'carol.white@example.com', displayName: 'Carol White', password: 'Password123!', roleSlug: 'viewer' },
18
+ ] as const
@@ -1821,15 +1821,13 @@
1821
1821
  "unique": true
1822
1822
  },
1823
1823
  {
1824
- "columnNames": [
1825
- "tenant_id",
1826
- "email_hash"
1827
- ],
1828
- "composite": true,
1829
- "constraint": true,
1830
1824
  "keyName": "customer_users_tenant_email_hash_uniq",
1825
+ "columnNames": [],
1826
+ "composite": false,
1827
+ "constraint": false,
1831
1828
  "primary": false,
1832
- "unique": true
1829
+ "unique": false,
1830
+ "expression": "create unique index \"customer_users_tenant_email_hash_uniq\" on \"customer_users\" (\"tenant_id\", \"email_hash\") where \"deleted_at\" is null"
1833
1831
  }
1834
1832
  ],
1835
1833
  "checks": [],
@@ -0,0 +1,23 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260827160000_customer_accounts extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "customer_users" drop constraint "customer_users_tenant_email_hash_uniq";`);
7
+ this.addSql(`create unique index "customer_users_tenant_email_hash_uniq" on "customer_users" ("tenant_id", "email_hash") where "deleted_at" is null;`);
8
+ }
9
+
10
+ override async down(): Promise<void> {
11
+ this.addSql(`drop index if exists "customer_users_tenant_email_hash_uniq";`);
12
+ // Once the partial index has been live, a soft-deleted row legitimately shares
13
+ // (tenant_id, email_hash) with a newer row — which is the point of this change —
14
+ // so the full constraint cannot be restored while both exist. Purge the losing
15
+ // soft-deleted duplicates first: this is destructive, but it only removes rows
16
+ // that were already logically deleted, and without it `down()` fails on exactly
17
+ // the data the migration enables. The surviving row is the active one, else the
18
+ // most recently created.
19
+ this.addSql(`delete from "customer_users" losing using "customer_users" winner where losing."deleted_at" is not null and losing."tenant_id" = winner."tenant_id" and losing."email_hash" = winner."email_hash" and losing."id" <> winner."id" and (winner."deleted_at" is null or winner."created_at" > losing."created_at" or (winner."created_at" = losing."created_at" and winner."id" > losing."id"));`);
20
+ this.addSql(`alter table "customer_users" add constraint "customer_users_tenant_email_hash_uniq" unique ("tenant_id", "email_hash");`);
21
+ }
22
+
23
+ }
@@ -1,3 +1,4 @@
1
+ import { UniqueConstraintViolationException } from '@mikro-orm/core'
1
2
  import { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { hash } from 'bcryptjs'
3
4
  import {
@@ -7,12 +8,57 @@ import {
7
8
  CustomerRole,
8
9
  } from '@open-mercato/core/modules/customer_accounts/data/entities'
9
10
  import { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'
10
- import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'
11
+ import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
11
12
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
13
 
13
14
  const BCRYPT_COST = 10
14
15
  const INVITATION_TTL_MS = 72 * 60 * 60 * 1000 // 72 hours
15
16
 
17
+ // Use Symbol.for so the marker survives module duplication across bundle
18
+ // boundaries: the accept route and this service are bundled into separate
19
+ // chunks, so `instanceof` between them is false and the 409 mapping would
20
+ // silently degrade back into the raw 500 this error exists to prevent.
21
+ const CUSTOMER_INVITATION_EMAIL_CONFLICT_MARKER = Symbol.for('@open-mercato/CustomerInvitationEmailConflictError')
22
+
23
+ export class CustomerInvitationEmailConflictError extends Error {
24
+ readonly [CUSTOMER_INVITATION_EMAIL_CONFLICT_MARKER] = true
25
+
26
+ constructor(public readonly email: string) {
27
+ super('[internal] An account with this email address already exists')
28
+ this.name = 'CustomerInvitationEmailConflictError'
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Bundle-safe check for {@link CustomerInvitationEmailConflictError}. Always prefer
34
+ * this over `instanceof` — API routes reach this service through DI and resolve a
35
+ * different copy of this module.
36
+ */
37
+ export function isCustomerInvitationEmailConflictError(
38
+ error: unknown,
39
+ ): error is CustomerInvitationEmailConflictError {
40
+ return !!error
41
+ && typeof error === 'object'
42
+ && (error as Record<symbol, unknown>)[CUSTOMER_INVITATION_EMAIL_CONFLICT_MARKER] === true
43
+ }
44
+
45
+ const POSTGRES_UNIQUE_VIOLATION = '23505'
46
+
47
+ /**
48
+ * Unique-constraint detection that does not depend on `instanceof`: the MikroORM
49
+ * copy this module is bundled with is not necessarily the one the driver throws
50
+ * from, so fall back to the exception name and the Postgres SQLSTATE.
51
+ */
52
+ function isUniqueConstraintViolation(error: unknown): boolean {
53
+ if (error instanceof UniqueConstraintViolationException) return true
54
+ if (!error || typeof error !== 'object') return false
55
+ const candidate = error as { name?: unknown; code?: unknown; cause?: unknown }
56
+ if (candidate.name === 'UniqueConstraintViolationException') return true
57
+ if (candidate.code === POSTGRES_UNIQUE_VIOLATION) return true
58
+ const cause = candidate.cause as { code?: unknown } | undefined
59
+ return !!cause && typeof cause === 'object' && cause.code === POSTGRES_UNIQUE_VIOLATION
60
+ }
61
+
16
62
  export type CustomerInvitationRollbackState = {
17
63
  email: string
18
64
  token: string
@@ -85,11 +131,16 @@ export class CustomerInvitationService {
85
131
  existing.email = normalizedEmail
86
132
  existing.token = tokenHashed
87
133
  existing.customerEntityId = options.customerEntityId || null
88
- existing.personEntityId = options.personEntityId || null
134
+ // Keep the CRM person link and the recipient's display name when the
135
+ // re-invite does not carry them: the portal invite route and the admin users
136
+ // page only ever know the company, so overwriting with null would strip an
137
+ // invitation raised from a person card off that person's account-status card
138
+ // and drop the personalization from the invitation email (#5499).
139
+ existing.personEntityId = options.personEntityId || existing.personEntityId || null
89
140
  existing.roleIdsJson = options.roleIds
90
141
  existing.invitedByUserId = options.invitedByUserId || null
91
142
  existing.invitedByCustomerUserId = options.invitedByCustomerUserId || null
92
- existing.displayName = options.displayName || null
143
+ existing.displayName = options.displayName || existing.displayName || null
93
144
  existing.expiresAt = expiresAt
94
145
  await this.em.flush()
95
146
  return { invitation: existing, rawToken: token, reused: true, rollbackState }
@@ -157,9 +208,31 @@ export class CustomerInvitationService {
157
208
  const invitation = await this.findByToken(token)
158
209
  if (!invitation) return null
159
210
 
160
- const passwordHash = await hash(password, BCRYPT_COST)
161
211
  const emailHash = hashForLookup(invitation.email)
162
212
 
213
+ // A soft-deleted CustomerUser row does not block re-invitation (the unique
214
+ // index only applies to non-deleted rows), but an active account with the
215
+ // same email is a genuine conflict — fail with a domain error here instead
216
+ // of letting the DB unique-constraint violation surface as a raw 500.
217
+ // The lookup matches both hash formats: rows written before the keyed digest
218
+ // still carry the legacy hash, and the partial index cannot relate the two.
219
+ const existingActiveUser = await findOneWithDecryption(
220
+ this.em,
221
+ CustomerUser,
222
+ {
223
+ tenantId: invitation.tenantId,
224
+ emailHash: { $in: lookupHashCandidates(invitation.email) },
225
+ deletedAt: null,
226
+ } as any,
227
+ undefined,
228
+ { tenantId: invitation.tenantId, organizationId: invitation.organizationId },
229
+ )
230
+ if (existingActiveUser) {
231
+ throw new CustomerInvitationEmailConflictError(invitation.email)
232
+ }
233
+
234
+ const passwordHash = await hash(password, BCRYPT_COST)
235
+
163
236
  // Create user
164
237
  const user = this.em.create(CustomerUser, {
165
238
  email: invitation.email,
@@ -205,7 +278,18 @@ export class CustomerInvitationService {
205
278
  // Mark invitation as accepted
206
279
  invitation.acceptedAt = new Date()
207
280
 
208
- await this.em.flush()
281
+ // The guard above is only the friendly fast path: it and this write are
282
+ // separated by a bcrypt hash and a role query, so two concurrent accepts can
283
+ // both pass it. The partial unique index is the real authority — translate
284
+ // its violation onto the same domain error so both paths answer 409.
285
+ try {
286
+ await this.em.flush()
287
+ } catch (error) {
288
+ if (isUniqueConstraintViolation(error)) {
289
+ throw new CustomerInvitationEmailConflictError(invitation.email)
290
+ }
291
+ throw error
292
+ }
209
293
  return { user, invitation }
210
294
  }
211
295
  }
@@ -3,6 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import type { Module } from '@open-mercato/shared/modules/registry'
4
4
  import { hash } from 'bcryptjs'
5
5
  import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'
6
+ import { EXAMPLE_PORTAL_ACCOUNTS } from '@open-mercato/core/modules/customer_accounts/lib/exampleAccounts'
6
7
  import {
7
8
  CustomerRole,
8
9
  CustomerRoleAcl,
@@ -256,13 +257,8 @@ export const setup: ModuleSetupConfig = {
256
257
 
257
258
  async seedExamples({ em, tenantId, organizationId }) {
258
259
  const BCRYPT_COST = 10
259
- const exampleUsers = [
260
- { email: 'alice.johnson@example.com', displayName: 'Alice Johnson', password: 'Password123!', roleSlug: 'portal_admin' },
261
- { email: 'bob.smith@example.com', displayName: 'Bob Smith', password: 'Password123!', roleSlug: 'buyer' },
262
- { email: 'carol.white@example.com', displayName: 'Carol White', password: 'Password123!', roleSlug: 'viewer' },
263
- ]
264
260
 
265
- for (const entry of exampleUsers) {
261
+ for (const entry of EXAMPLE_PORTAL_ACCOUNTS) {
266
262
  const emailHash = hashForLookup(entry.email)
267
263
  const existing = await em.findOne(CustomerUser, { emailHash, tenantId, deletedAt: null })
268
264
  if (existing) continue