@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -2
- package/dist/modules/auth/commands/users.js +14 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +26 -3
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/profile-sections.js +1 -1
- package/dist/modules/auth/lib/profile-sections.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
- package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
- package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
- package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
- package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
- package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/data/entities.js +4 -1
- package/dist/modules/customer_accounts/data/entities.js.map +2 -2
- package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
- package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
- package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
- package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
- package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
- package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
- package/dist/modules/customer_accounts/setup.js +2 -6
- package/dist/modules/customer_accounts/setup.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customers/commands/people.js +16 -1
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-schedule-service.js +25 -23
- package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -15
- package/dist/modules/entities/api/records.js.map +3 -3
- package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
- package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
- package/dist/modules/entities/lib/validation.js +4 -35
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
- package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
- package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
- package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
- package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
- package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/commands/users.ts +14 -1
- package/src/modules/auth/data/entities.ts +2 -2
- package/src/modules/auth/lib/backendChrome.tsx +46 -2
- package/src/modules/auth/lib/profile-sections.tsx +14 -3
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
- package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
- package/src/modules/customer_accounts/AGENTS.md +3 -1
- package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
- package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
- package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
- package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
- package/src/modules/customer_accounts/data/entities.ts +5 -1
- package/src/modules/customer_accounts/i18n/de.json +4 -2
- package/src/modules/customer_accounts/i18n/en.json +4 -2
- package/src/modules/customer_accounts/i18n/es.json +4 -2
- package/src/modules/customer_accounts/i18n/ko.json +4 -2
- package/src/modules/customer_accounts/i18n/pl.json +4 -2
- package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
- package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
- package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
- package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
- package/src/modules/customer_accounts/setup.ts +2 -6
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
- package/src/modules/customers/commands/people.ts +15 -0
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
- package/src/modules/data_sync/lib/sync-schedule-service.ts +32 -26
- package/src/modules/entities/api/records.ts +24 -15
- package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
- package/src/modules/entities/lib/validation.ts +5 -45
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
- package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
- package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
- package/src/modules/progress/AGENTS.md +1 -1
- package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
- package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
- package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
- package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
- package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
- package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/ko.json +2 -0
- 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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx
CHANGED
|
@@ -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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
@
|
|
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
|
|
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.
|
|
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": "
|
|
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.
|
|
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": "
|
|
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.
|
|
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": "데모 자격 증명:
|
|
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.
|
|
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": "
|
|
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.
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|