@open-mercato/core 0.6.6-develop.5483.1.a1129165ea → 0.6.6-develop.5503.1.6cdc4dda5f

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.5483.1.a1129165ea",
3
+ "version": "0.6.6-develop.5503.1.6cdc4dda5f",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -245,16 +245,16 @@
245
245
  "zod": "^4.4.3"
246
246
  },
247
247
  "peerDependencies": {
248
- "@open-mercato/ai-assistant": "0.6.6-develop.5483.1.a1129165ea",
249
- "@open-mercato/shared": "0.6.6-develop.5483.1.a1129165ea",
250
- "@open-mercato/ui": "0.6.6-develop.5483.1.a1129165ea",
248
+ "@open-mercato/ai-assistant": "0.6.6-develop.5503.1.6cdc4dda5f",
249
+ "@open-mercato/shared": "0.6.6-develop.5503.1.6cdc4dda5f",
250
+ "@open-mercato/ui": "0.6.6-develop.5503.1.6cdc4dda5f",
251
251
  "react": "^19.0.0",
252
252
  "react-dom": "^19.0.0"
253
253
  },
254
254
  "devDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.6-develop.5483.1.a1129165ea",
256
- "@open-mercato/shared": "0.6.6-develop.5483.1.a1129165ea",
257
- "@open-mercato/ui": "0.6.6-develop.5483.1.a1129165ea",
255
+ "@open-mercato/ai-assistant": "0.6.6-develop.5503.1.6cdc4dda5f",
256
+ "@open-mercato/shared": "0.6.6-develop.5503.1.6cdc4dda5f",
257
+ "@open-mercato/ui": "0.6.6-develop.5503.1.6cdc4dda5f",
258
258
  "@testing-library/dom": "^10.4.1",
259
259
  "@testing-library/jest-dom": "^6.9.1",
260
260
  "@testing-library/react": "^16.3.1",
@@ -108,21 +108,21 @@ export async function POST(req: Request) {
108
108
  user = await auth.findUserByEmailAndTenant(parsed.data.email, tenantId)
109
109
  } else {
110
110
  const users = await auth.findUsersByEmail(parsed.data.email)
111
- if (users.length > 1) {
112
- return NextResponse.json({
113
- ok: false,
114
- error: translate('auth.login.errors.tenantRequired', 'Use the login link provided with your tenant activation to continue.'),
115
- }, { status: 400 })
116
- }
117
- user = users[0] ?? null
118
- }
119
- if (!user || !user.passwordHash) {
120
- void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason: 'invalid_credentials' }).catch(() => undefined)
121
- return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })
111
+ // Never disclose that an email is registered across multiple tenants — a
112
+ // password-independent 400-vs-401 response is an account/topology oracle
113
+ // (issue #2242). Treat an ambiguous match as no resolvable user and fall
114
+ // through to the uniform invalid-credentials path; tenant-selection
115
+ // guidance is delivered out-of-band via the activation/login link.
116
+ user = users.length === 1 ? users[0] : null
122
117
  }
118
+ // Always verify the password — verifyPassword runs a constant-time bcrypt
119
+ // comparison even when the user is missing or has no hash — so unknown-email,
120
+ // wrong-password, and multi-tenant cases return an identical 401 with
121
+ // identical latency.
123
122
  const ok = await auth.verifyPassword(user, parsed.data.password)
124
- if (!ok) {
125
- void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason: 'invalid_password' }).catch(() => undefined)
123
+ if (!user || !ok) {
124
+ const reason = user?.passwordHash ? 'invalid_password' : 'invalid_credentials'
125
+ void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason }).catch(() => undefined)
126
126
  return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })
127
127
  }
128
128
  // Optional role requirement
@@ -43,7 +43,6 @@
43
43
  "auth.login.errors.invalidCredentials": "Ungültige E-Mail oder ungültiges Passwort",
44
44
  "auth.login.errors.permissionDenied": "Du hast keine Berechtigung, auf diesen Bereich zuzugreifen. Bitte wende dich an deine Administration.",
45
45
  "auth.login.errors.tenantInvalid": "Tenant nicht gefunden. Entferne die Tenant-Auswahl und versuche es erneut.",
46
- "auth.login.errors.tenantRequired": "Nutze den Login-Link aus deiner Tenant-Aktivierung, um fortzufahren.",
47
46
  "auth.login.featureDenied": "Du hast keinen Zugriff auf diese Funktion ({feature}). Bitte wende dich an deine Administration.",
48
47
  "auth.login.forgotPassword": "Passwort vergessen?",
49
48
  "auth.login.loading": "Wird geladen ...",
@@ -43,7 +43,6 @@
43
43
  "auth.login.errors.invalidCredentials": "Invalid email or password",
44
44
  "auth.login.errors.permissionDenied": "You do not have permission to access this area. Please contact your administrator.",
45
45
  "auth.login.errors.tenantInvalid": "Tenant not found. Clear the tenant selection and try again.",
46
- "auth.login.errors.tenantRequired": "Use the login link provided with your tenant activation to continue.",
47
46
  "auth.login.featureDenied": "You don't have access to this feature ({feature}). Please contact your administrator.",
48
47
  "auth.login.forgotPassword": "Forgot password?",
49
48
  "auth.login.loading": "Loading...",
@@ -43,7 +43,6 @@
43
43
  "auth.login.errors.invalidCredentials": "Correo electrónico o contraseña no válidos",
44
44
  "auth.login.errors.permissionDenied": "No tienes permiso para acceder a esta área. Ponte en contacto con tu administrador.",
45
45
  "auth.login.errors.tenantInvalid": "No se encontró el inquilino. Borra la selección e inténtalo de nuevo.",
46
- "auth.login.errors.tenantRequired": "Usa el enlace de inicio de sesión proporcionado con la activación de tu inquilino para continuar.",
47
46
  "auth.login.featureDenied": "No tienes acceso a esta funcionalidad ({feature}). Ponte en contacto con tu administrador.",
48
47
  "auth.login.forgotPassword": "¿Olvidaste tu contraseña?",
49
48
  "auth.login.loading": "Cargando...",
@@ -43,7 +43,6 @@
43
43
  "auth.login.errors.invalidCredentials": "Nieprawidłowy email lub hasło",
44
44
  "auth.login.errors.permissionDenied": "Nie masz uprawnień do tego obszaru. Skontaktuj się z administratorem.",
45
45
  "auth.login.errors.tenantInvalid": "Nie znaleziono najemcy. Wyczyść wybór i spróbuj ponownie.",
46
- "auth.login.errors.tenantRequired": "Użyj linku logowania z aktywacji najemcy, aby kontynuować.",
47
46
  "auth.login.featureDenied": "Nie masz dostępu do tej funkcji ({feature}). Skontaktuj się z administratorem.",
48
47
  "auth.login.forgotPassword": "Nie pamiętasz hasła?",
49
48
  "auth.login.loading": "Ładowanie...",
@@ -5,6 +5,13 @@ import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/email
5
5
  import { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'
6
6
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
7
 
8
+ // A fixed, valid bcrypt hash (cost 10) of a throwaway value no real password
9
+ // can match. verifyPassword compares against it whenever the user is missing or
10
+ // has no password hash, so a failed login spends the same bcrypt CPU time
11
+ // regardless of whether the account exists — closing the timing side channel
12
+ // for account enumeration (issue #2242).
13
+ const TIMING_EQUALIZER_PASSWORD_HASH = '$2b$10$OcZrhmZpIzJOjkfwUrk7d.Nl0eHNzOvalBcBlt5Ran.4lj8R3HZg6'
14
+
8
15
  export class AuthService {
9
16
  constructor(private em: EntityManager) {}
10
17
 
@@ -48,9 +55,13 @@ export class AuthService {
48
55
  )
49
56
  }
50
57
 
51
- async verifyPassword(user: User, password: string) {
52
- if (!user.passwordHash) return false
53
- return compare(password, user.passwordHash)
58
+ async verifyPassword(user: User | null, password: string) {
59
+ const storedHash = user?.passwordHash ?? null
60
+ // Always run a bcrypt comparison — against a fixed dummy hash when the user
61
+ // is absent or has no password — so login latency does not reveal whether
62
+ // the account exists (timing-based enumeration, issue #2242).
63
+ const matched = await compare(password, storedHash ?? TIMING_EQUALIZER_PASSWORD_HASH)
64
+ return storedHash !== null && matched
54
65
  }
55
66
 
56
67
  async updateLastLoginAt(user: User) {
@@ -37,6 +37,7 @@ import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../
37
37
  import { PersonDetailHeader } from '../../../../components/detail/PersonDetailHeader'
38
38
  import { ChangelogTab } from '../../../../components/detail/ChangelogTab'
39
39
  import { PersonDetailTabs, resolveLegacyTab, type PersonTabId } from '../../../../components/detail/PersonDetailTabs'
40
+ import { AddressesSection } from '../../../../components/detail/AddressesSection'
40
41
  import { PersonCompaniesSection } from '../../../../components/detail/PersonCompaniesSection'
41
42
  import { MobilePersonDetail } from '../../../../components/detail/MobilePersonDetail'
42
43
  import type { TagsSectionController } from '@open-mercato/ui/backend/detail'
@@ -541,6 +542,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
541
542
  activitiesCount={interactionCount}
542
543
  dealsCount={dealCount}
543
544
  companiesCount={companyCount}
545
+ addressesCount={data?.counts?.addresses ?? 0}
544
546
  tasksCount={todoCount}
545
547
  sectionAction={sectionAction}
546
548
  >
@@ -619,6 +621,22 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
619
621
  )
620
622
  }
621
623
 
624
+ if (activeTab === 'addresses') {
625
+ return (
626
+ <AddressesSection
627
+ entityId={personId}
628
+ emptyLabel={t('customers.people.detail.empty.addresses', 'No addresses linked to this person.')}
629
+ addActionLabel={t('customers.people.detail.addresses.add', 'Add address')}
630
+ emptyState={{
631
+ title: t('customers.people.detail.emptyState.addresses.title', 'No addresses yet'),
632
+ actionLabel: t('customers.people.detail.emptyState.addresses.action', 'Add address'),
633
+ }}
634
+ onActionChange={handleSectionActionChange}
635
+ translator={detailTranslator}
636
+ />
637
+ )
638
+ }
639
+
622
640
  if (activeTab === 'tasks') {
623
641
  return (
624
642
  <TasksSection
@@ -13,6 +13,7 @@ import {
13
13
  History,
14
14
  Paperclip,
15
15
  Plus,
16
+ MapPin,
16
17
  } from 'lucide-react'
17
18
  import type { SectionAction } from '@open-mercato/ui/backend/detail'
18
19
 
@@ -21,6 +22,7 @@ export type PersonTabId =
21
22
  | 'emails'
22
23
  | 'deals'
23
24
  | 'companies'
25
+ | 'addresses'
24
26
  | 'tasks'
25
27
  | 'changelog'
26
28
  | 'files'
@@ -40,13 +42,14 @@ type PersonDetailTabsProps = {
40
42
  activitiesCount?: number
41
43
  dealsCount?: number
42
44
  companiesCount?: number
45
+ addressesCount?: number
43
46
  tasksCount?: number
44
47
  filesCount?: number
45
48
  sectionAction?: SectionAction | null
46
49
  children: React.ReactNode
47
50
  }
48
51
 
49
- const SUPPORTED_TAB_IDS = new Set<PersonTabId>(['activities', 'emails', 'deals', 'companies', 'tasks', 'changelog', 'files'])
52
+ const SUPPORTED_TAB_IDS = new Set<PersonTabId>(['activities', 'emails', 'deals', 'companies', 'addresses', 'tasks', 'changelog', 'files'])
50
53
 
51
54
  export function resolveLegacyTab(tab: string | null | undefined): PersonTabId {
52
55
  if (!tab) return 'activities'
@@ -77,6 +80,7 @@ export function PersonDetailTabs({
77
80
  activitiesCount = 0,
78
81
  dealsCount = 0,
79
82
  companiesCount = 0,
83
+ addressesCount = 0,
80
84
  tasksCount = 0,
81
85
  filesCount = 0,
82
86
  sectionAction = null,
@@ -109,6 +113,12 @@ export function PersonDetailTabs({
109
113
  icon: <Building2 className="size-4" />,
110
114
  badge: <CountBadge count={companiesCount} />,
111
115
  },
116
+ {
117
+ id: 'addresses',
118
+ label: t('customers.people.detail.tabs.addresses', 'Addresses'),
119
+ icon: <MapPin className="size-4" />,
120
+ badge: <CountBadge count={addressesCount} />,
121
+ },
112
122
  {
113
123
  id: 'tasks',
114
124
  label: t('customers.people.detail.tabs.tasks', 'Tasks'),
@@ -128,7 +138,7 @@ export function PersonDetailTabs({
128
138
  badge: <CountBadge count={filesCount} />,
129
139
  },
130
140
  ],
131
- [t, activitiesCount, dealsCount, companiesCount, tasksCount, filesCount],
141
+ [t, activitiesCount, dealsCount, companiesCount, addressesCount, tasksCount, filesCount],
132
142
  )
133
143
 
134
144
  const allTabs: TabDef[] = React.useMemo(
@@ -719,11 +719,13 @@ async function resolveAddressSnapshot(
719
719
  addressId?: string | null,
720
720
  ): Promise<Record<string, unknown> | null> {
721
721
  if (!addressId) return null;
722
- const address = await em.findOne(CustomerAddress, {
723
- id: addressId,
724
- organizationId,
725
- tenantId,
726
- });
722
+ const address = await findOneWithDecryption(
723
+ em,
724
+ CustomerAddress,
725
+ { id: addressId, organizationId, tenantId },
726
+ undefined,
727
+ { tenantId, organizationId },
728
+ );
727
729
  if (!address) return null;
728
730
 
729
731
  return {