@open-mercato/core 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (350) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/bootstrap.js +46 -6
  4. package/dist/bootstrap.js.map +2 -2
  5. package/dist/generated/entities/organization/index.js +2 -0
  6. package/dist/generated/entities/organization/index.js.map +2 -2
  7. package/dist/generated/entity-fields-registry.js +1 -0
  8. package/dist/generated/entity-fields-registry.js.map +2 -2
  9. package/dist/helpers/integration/crmFixtures.js +4 -0
  10. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  11. package/dist/modules/attachments/api/library/route.js +2 -2
  12. package/dist/modules/attachments/api/library/route.js.map +2 -2
  13. package/dist/modules/attachments/api/route.js +2 -0
  14. package/dist/modules/attachments/api/route.js.map +2 -2
  15. package/dist/modules/attachments/components/AttachmentContentPreview.js +9 -5
  16. package/dist/modules/attachments/components/AttachmentContentPreview.js.map +2 -2
  17. package/dist/modules/attachments/lib/access.js +18 -0
  18. package/dist/modules/attachments/lib/access.js.map +2 -2
  19. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +3 -2
  20. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
  21. package/dist/modules/audit_logs/data/entities.js +2 -1
  22. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  23. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  24. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  25. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  26. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  27. package/dist/modules/auth/api/admin/nav.js +9 -0
  28. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  29. package/dist/modules/auth/api/login.js +4 -13
  30. package/dist/modules/auth/api/login.js.map +2 -2
  31. package/dist/modules/auth/commands/users.js +20 -14
  32. package/dist/modules/auth/commands/users.js.map +2 -2
  33. package/dist/modules/auth/data/entities.js +4 -2
  34. package/dist/modules/auth/data/entities.js.map +2 -2
  35. package/dist/modules/auth/lib/backendChrome.js +35 -2
  36. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  37. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  38. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  39. package/dist/modules/auth/migrations/Migration20260610120000.js +30 -0
  40. package/dist/modules/auth/migrations/Migration20260610120000.js.map +7 -0
  41. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  42. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  43. package/dist/modules/auth/services/authService.js +5 -3
  44. package/dist/modules/auth/services/authService.js.map +2 -2
  45. package/dist/modules/auth/services/rbacService.js +3 -2
  46. package/dist/modules/auth/services/rbacService.js.map +2 -2
  47. package/dist/modules/catalog/ai-tools/configuration-pack.js.map +1 -1
  48. package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +1 -1
  49. package/dist/modules/catalog/ai-tools/products-pack.js.map +1 -1
  50. package/dist/modules/catalog/ai-tools/variants-pack.js.map +1 -1
  51. package/dist/modules/communication_channels/data/entities.js.map +1 -1
  52. package/dist/modules/communication_channels/encryption.js.map +1 -1
  53. package/dist/modules/communication_channels/lib/thread-matcher.js.map +1 -1
  54. package/dist/modules/communication_channels/lib/thread-token.js.map +1 -1
  55. package/dist/modules/currencies/api/currencies/route.js +4 -3
  56. package/dist/modules/currencies/api/currencies/route.js.map +2 -2
  57. package/dist/modules/customer_accounts/api/admin/roles.js +2 -1
  58. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  59. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  60. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  61. package/dist/modules/customer_accounts/events.js +1 -1
  62. package/dist/modules/customer_accounts/events.js.map +1 -1
  63. package/dist/modules/customer_accounts/lib/resolveTenantContext.js.map +1 -1
  64. package/dist/modules/customers/acl.js +1 -1
  65. package/dist/modules/customers/acl.js.map +1 -1
  66. package/dist/modules/customers/ai-tools/companies-pack.js.map +1 -1
  67. package/dist/modules/customers/ai-tools/deals-pack.js.map +1 -1
  68. package/dist/modules/customers/ai-tools/people-pack.js.map +1 -1
  69. package/dist/modules/customers/api/companies/route.js +4 -4
  70. package/dist/modules/customers/api/companies/route.js.map +2 -2
  71. package/dist/modules/customers/api/deals/route.js +43 -2
  72. package/dist/modules/customers/api/deals/route.js.map +2 -2
  73. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  74. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  75. package/dist/modules/customers/api/people/route.js +4 -4
  76. package/dist/modules/customers/api/people/route.js.map +2 -2
  77. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  78. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  79. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  80. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  81. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  82. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  83. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  84. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  85. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  86. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  87. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  88. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  89. package/dist/modules/customers/cli.js +15 -9
  90. package/dist/modules/customers/cli.js.map +2 -2
  91. package/dist/modules/customers/commands/addresses.js +5 -5
  92. package/dist/modules/customers/commands/addresses.js.map +2 -2
  93. package/dist/modules/customers/commands/comments.js +5 -5
  94. package/dist/modules/customers/commands/comments.js.map +2 -2
  95. package/dist/modules/customers/commands/deals.js +2 -2
  96. package/dist/modules/customers/commands/deals.js.map +2 -2
  97. package/dist/modules/customers/commands/entity-roles.js +2 -1
  98. package/dist/modules/customers/commands/entity-roles.js.map +2 -2
  99. package/dist/modules/customers/commands/interactions.js +8 -5
  100. package/dist/modules/customers/commands/interactions.js.map +2 -2
  101. package/dist/modules/customers/commands/shared.js +21 -6
  102. package/dist/modules/customers/commands/shared.js.map +2 -2
  103. package/dist/modules/customers/commands/tags.js +3 -3
  104. package/dist/modules/customers/commands/tags.js.map +2 -2
  105. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  106. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  107. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  108. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  109. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  110. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  111. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  112. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  113. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  114. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  115. package/dist/modules/customers/components/detail/assignableStaff.js +21 -8
  116. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  117. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  118. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  119. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  120. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  121. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +1 -1
  122. package/dist/modules/data_sync/api/run.js +1 -1
  123. package/dist/modules/data_sync/api/run.js.map +2 -2
  124. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  125. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  126. package/dist/modules/directory/api/organizations/route.js +7 -0
  127. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  128. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  129. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  130. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  131. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  132. package/dist/modules/directory/commands/organizations.js +8 -1
  133. package/dist/modules/directory/commands/organizations.js.map +2 -2
  134. package/dist/modules/directory/data/entities.js +3 -0
  135. package/dist/modules/directory/data/entities.js.map +2 -2
  136. package/dist/modules/directory/data/validators.js +9 -0
  137. package/dist/modules/directory/data/validators.js.map +2 -2
  138. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  139. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  140. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  141. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  142. package/dist/modules/directory/utils/organizationScope.js +59 -27
  143. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  144. package/dist/modules/entities/api/definitions.batch.js +2 -1
  145. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  146. package/dist/modules/entities/api/entities.js +7 -0
  147. package/dist/modules/entities/api/entities.js.map +2 -2
  148. package/dist/modules/entities/api/records.js +26 -15
  149. package/dist/modules/entities/api/records.js.map +2 -2
  150. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  151. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  152. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  153. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  154. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  155. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  156. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  157. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  158. package/dist/modules/payment_gateways/api/transactions/route.js +2 -4
  159. package/dist/modules/payment_gateways/api/transactions/route.js.map +2 -2
  160. package/dist/modules/progress/api/jobs/[id]/route.js +7 -2
  161. package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
  162. package/dist/modules/progress/api/jobs/route.js +1 -1
  163. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  164. package/dist/modules/progress/lib/progressServiceImpl.js +8 -2
  165. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  166. package/dist/modules/query_index/data/entities.js +2 -1
  167. package/dist/modules/query_index/data/entities.js.map +2 -2
  168. package/dist/modules/query_index/lib/engine.js +4 -2
  169. package/dist/modules/query_index/lib/engine.js.map +2 -2
  170. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  171. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  172. package/dist/modules/resources/api/resources.js +2 -3
  173. package/dist/modules/resources/api/resources.js.map +2 -2
  174. package/dist/modules/sales/api/documents/factory.js +2 -2
  175. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  176. package/dist/modules/sales/commands/documents.js +7 -5
  177. package/dist/modules/sales/commands/documents.js.map +2 -2
  178. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  179. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  180. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  181. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  182. package/dist/modules/staff/api/team-members.js +9 -2
  183. package/dist/modules/staff/api/team-members.js.map +2 -2
  184. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  185. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  186. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  187. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  188. package/dist/modules/staff/commands/team-members.js +1 -1
  189. package/dist/modules/staff/commands/team-members.js.map +2 -2
  190. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  191. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  192. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  193. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  194. package/dist/modules/sync_excel/api/import/route.js +1 -1
  195. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  196. package/dist/modules/workflows/api/definitions/route.js +3 -2
  197. package/dist/modules/workflows/api/definitions/route.js.map +2 -2
  198. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  199. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  200. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  201. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  202. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  203. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  204. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  205. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  206. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  207. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  208. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  209. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  210. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  211. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  212. package/generated/entities/organization/index.ts +1 -0
  213. package/generated/entity-fields-registry.ts +1 -0
  214. package/package.json +11 -12
  215. package/src/bootstrap.ts +65 -7
  216. package/src/helpers/integration/crmFixtures.ts +21 -1
  217. package/src/modules/attachments/AGENTS.md +79 -0
  218. package/src/modules/attachments/api/library/route.ts +2 -2
  219. package/src/modules/attachments/api/route.ts +2 -0
  220. package/src/modules/attachments/components/AttachmentContentPreview.tsx +6 -6
  221. package/src/modules/attachments/lib/access.ts +36 -0
  222. package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +14 -2
  223. package/src/modules/audit_logs/data/entities.ts +1 -0
  224. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  225. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  226. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  227. package/src/modules/auth/api/admin/nav.ts +9 -0
  228. package/src/modules/auth/api/login.ts +13 -13
  229. package/src/modules/auth/commands/users.ts +32 -15
  230. package/src/modules/auth/data/entities.ts +13 -1
  231. package/src/modules/auth/i18n/de.json +0 -1
  232. package/src/modules/auth/i18n/en.json +0 -1
  233. package/src/modules/auth/i18n/es.json +0 -1
  234. package/src/modules/auth/i18n/pl.json +0 -1
  235. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  236. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  237. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -10
  238. package/src/modules/auth/migrations/Migration20260610120000.ts +53 -0
  239. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  240. package/src/modules/auth/services/authService.ts +24 -4
  241. package/src/modules/auth/services/rbacService.ts +11 -2
  242. package/src/modules/catalog/ai-tools/configuration-pack.ts +1 -1
  243. package/src/modules/catalog/ai-tools/prices-offers-pack.ts +1 -1
  244. package/src/modules/catalog/ai-tools/products-pack.ts +1 -1
  245. package/src/modules/catalog/ai-tools/variants-pack.ts +1 -1
  246. package/src/modules/communication_channels/data/entities.ts +2 -2
  247. package/src/modules/communication_channels/encryption.ts +1 -1
  248. package/src/modules/communication_channels/lib/adapter.ts +1 -1
  249. package/src/modules/communication_channels/lib/thread-matcher.ts +1 -1
  250. package/src/modules/communication_channels/lib/thread-token.ts +1 -1
  251. package/src/modules/currencies/api/currencies/route.ts +4 -3
  252. package/src/modules/customer_accounts/api/admin/roles.ts +2 -1
  253. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  254. package/src/modules/customer_accounts/events.ts +1 -1
  255. package/src/modules/customer_accounts/lib/resolveTenantContext.ts +2 -2
  256. package/src/modules/customers/acl.ts +1 -1
  257. package/src/modules/customers/ai-tools/companies-pack.ts +1 -1
  258. package/src/modules/customers/ai-tools/deals-pack.ts +1 -1
  259. package/src/modules/customers/ai-tools/people-pack.ts +1 -1
  260. package/src/modules/customers/api/companies/route.ts +4 -4
  261. package/src/modules/customers/api/deals/route.ts +51 -2
  262. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  263. package/src/modules/customers/api/people/route.ts +4 -4
  264. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  265. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  266. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  267. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  268. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  269. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  270. package/src/modules/customers/cli.ts +15 -15
  271. package/src/modules/customers/commands/addresses.ts +5 -5
  272. package/src/modules/customers/commands/comments.ts +5 -5
  273. package/src/modules/customers/commands/deals.ts +2 -2
  274. package/src/modules/customers/commands/entity-roles.ts +2 -1
  275. package/src/modules/customers/commands/interactions.ts +8 -5
  276. package/src/modules/customers/commands/shared.ts +26 -4
  277. package/src/modules/customers/commands/tags.ts +3 -3
  278. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  279. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  280. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  281. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  282. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  283. package/src/modules/customers/components/detail/assignableStaff.ts +32 -8
  284. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  285. package/src/modules/customers/i18n/de.json +43 -0
  286. package/src/modules/customers/i18n/en.json +43 -0
  287. package/src/modules/customers/i18n/es.json +43 -0
  288. package/src/modules/customers/i18n/pl.json +43 -0
  289. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  290. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +1 -1
  291. package/src/modules/data_sync/api/run.ts +1 -1
  292. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  293. package/src/modules/directory/api/organizations/route.ts +7 -0
  294. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  295. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  296. package/src/modules/directory/commands/organizations.ts +9 -1
  297. package/src/modules/directory/data/entities.ts +3 -0
  298. package/src/modules/directory/data/validators.ts +12 -0
  299. package/src/modules/directory/i18n/de.json +21 -0
  300. package/src/modules/directory/i18n/en.json +21 -0
  301. package/src/modules/directory/i18n/es.json +21 -0
  302. package/src/modules/directory/i18n/pl.json +21 -0
  303. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  304. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  305. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  306. package/src/modules/directory/utils/organizationScope.ts +85 -30
  307. package/src/modules/entities/api/definitions.batch.ts +11 -7
  308. package/src/modules/entities/api/entities.ts +11 -0
  309. package/src/modules/entities/api/records.ts +46 -25
  310. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  311. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  312. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  313. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  314. package/src/modules/entities/i18n/de.json +1 -0
  315. package/src/modules/entities/i18n/en.json +1 -0
  316. package/src/modules/entities/i18n/es.json +1 -0
  317. package/src/modules/entities/i18n/pl.json +1 -0
  318. package/src/modules/payment_gateways/api/transactions/route.ts +2 -5
  319. package/src/modules/progress/api/jobs/[id]/route.ts +6 -1
  320. package/src/modules/progress/api/jobs/route.ts +1 -1
  321. package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
  322. package/src/modules/query_index/data/entities.ts +1 -0
  323. package/src/modules/query_index/lib/engine.ts +11 -5
  324. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  325. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  326. package/src/modules/resources/api/resources.ts +2 -3
  327. package/src/modules/sales/api/documents/factory.ts +2 -2
  328. package/src/modules/sales/commands/documents.ts +7 -5
  329. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  330. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  331. package/src/modules/staff/AGENTS.md +1 -1
  332. package/src/modules/staff/api/team-members.ts +9 -2
  333. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  334. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  335. package/src/modules/staff/commands/team-members.ts +5 -2
  336. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  337. package/src/modules/staff/i18n/de.json +1 -0
  338. package/src/modules/staff/i18n/en.json +1 -0
  339. package/src/modules/staff/i18n/es.json +1 -0
  340. package/src/modules/staff/i18n/pl.json +1 -0
  341. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  342. package/src/modules/sync_excel/api/import/route.ts +1 -1
  343. package/src/modules/workflows/api/definitions/route.ts +3 -2
  344. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  345. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  346. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  347. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  348. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  349. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  350. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -0,0 +1,79 @@
1
+ # Attachments Module — Agent Guidelines
2
+
3
+ The `attachments` module owns file uploads, storage drivers, partitions, OCR, and
4
+ the `attachments` table. Every attachment row carries a `tenant_id` /
5
+ `organization_id` scope pair that governs cross-tenant access.
6
+
7
+ ## Scope & Access Policy
8
+
9
+ `attachments.tenant_id` and `attachments.organization_id` are **nullable** at the
10
+ DB level, but only two scope shapes are valid:
11
+
12
+ | Shape | `tenant_id` | `organization_id` | Meaning | Who can read it |
13
+ |-------|-------------|-------------------|---------|-----------------|
14
+ | **Scoped** | set | set | Belongs to one tenant + org | Same-scope principals + superadmin |
15
+ | **Global** | null | null | Legacy "global attachment" | Any authenticated principal (and unauthenticated only on a `is_public` partition) |
16
+ | **Partial-null** ❌ | set / null | null / set | **Invalid — never create** | Nobody (fail-closed) except superadmin |
17
+
18
+ The "both-or-neither" rule is the legitimate-unscoped use case referenced by
19
+ [#2109](https://github.com/open-mercato/open-mercato/issues/2109): a fully-global
20
+ (both-null) attachment is intentionally supported by `isSameScope`, so the columns
21
+ cannot simply be made `NOT NULL` without breaking that semantic or backfilling a
22
+ sentinel tenant onto legacy rows.
23
+
24
+ ### Why partial-null is dangerous
25
+
26
+ `isSameScope` (`lib/access.ts`) deliberately **fails closed** on partial-null rows
27
+ (#2107): a row with one scope column set and the other null matches no principal's
28
+ auth and is unreadable by everyone except a superadmin. Such a row is therefore
29
+ *dead data* — it can only ever leak through a future code path that reads or
30
+ exports attachments **without** going through `checkAttachmentAccess` (a new export
31
+ endpoint, webhook delivery, OCR worker, or migration backfill). That is exactly the
32
+ fail-open class the access fix closed at read time; the creation guard closes it at
33
+ write time.
34
+
35
+ ## Always
36
+
37
+ - **MUST call `assertAttachmentScopeInvariant({ tenantId, organizationId })` from
38
+ `lib/access.ts` before persisting any new `Attachment` row.** It throws on a
39
+ partial-null scope and accepts both fully-scoped and fully-global rows. The
40
+ attachments upload route (`api/route.ts`) already guards its creation site.
41
+ - **MUST gate every attachment read through `checkAttachmentAccess`** (`lib/access.ts`)
42
+ so tenant scoping and partition visibility are enforced consistently.
43
+ - When copying/cloning attachments across records, **carry the source row's scope
44
+ pair as a unit** (both columns together) rather than overriding one column with a
45
+ possibly-null value.
46
+
47
+ ## Never
48
+
49
+ - **Never create a partial-null attachment** (one scope column set, the other null).
50
+ - **Never read or expose attachment rows without `checkAttachmentAccess`** — bypassing
51
+ it reintroduces the cross-tenant fail-open class.
52
+
53
+ ## Known cross-module creation paths
54
+
55
+ These paths create `Attachment` rows from other modules and must preserve the
56
+ both-or-neither invariant (audited for #2109):
57
+
58
+ - `packages/core/src/modules/attachments/api/route.ts` — primary upload; scope comes
59
+ from authenticated request context (both set). **Guarded.**
60
+ - `packages/core/src/modules/sync_excel/lib/upload-storage.ts` — both scopes are
61
+ required inputs (type-enforced). Safe.
62
+ - `packages/core/src/modules/catalog/seed/examples.ts` — both scopes required on
63
+ `SeedScope`. Safe.
64
+ - `packages/core/src/modules/catalog/commands/variants.ts` — clones variant media to
65
+ the product; inherits the source/variant scope pair (`?? null` only collapses to
66
+ the global both-null shape).
67
+ - `packages/core/src/modules/messages/lib/attachments.ts`
68
+ (`copyAttachmentsForForwardMessages`) — copies forwarded message attachments and
69
+ accepts a nullable `targetOrganizationId` with a non-null `tenantId`. This is the
70
+ one path that can construct a partial-null row; copy the **source attachment's**
71
+ scope pair when wiring new callers, and apply the creation guard if this path is
72
+ refactored.
73
+
74
+ ## Validation Commands
75
+
76
+ ```bash
77
+ yarn workspace @open-mercato/core test -- access
78
+ yarn workspace @open-mercato/core build
79
+ ```
@@ -10,7 +10,7 @@ import { buildAttachmentImageUrl, slugifyAttachmentFileName } from '../../lib/im
10
10
  import { readAttachmentMetadata } from '../../lib/metadata'
11
11
  import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
12
12
  import { applyAssignmentEnrichments, resolveAssignmentEnrichments } from '../../lib/assignmentDetails'
13
- import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
13
+ import { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'
14
14
  import { ensureDefaultPartitions } from '../../lib/partitions'
15
15
  import {
16
16
  attachmentsTag,
@@ -85,7 +85,7 @@ export async function GET(req: Request) {
85
85
  }
86
86
  qb.where(baseFilter)
87
87
  if (search && search.trim().length > 0) {
88
- qb.andWhere({ fileName: { $ilike: `%${escapeLikePattern(search.trim())}%` } })
88
+ qb.andWhere({ fileName: { $ilike: buildIlikeTerm(search.trim()) } })
89
89
  }
90
90
  if (partition && partition.trim().length > 0) {
91
91
  qb.andWhere({ partitionCode: partition.trim() })
@@ -12,6 +12,7 @@ import { requestOcrProcessing } from '../lib/ocrQueue'
12
12
  import { StorageDriverFactory } from '../lib/drivers'
13
13
  import { OcrService, shouldUseLlmOcr } from '../lib/ocrService'
14
14
  import { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'
15
+ import { assertAttachmentScopeInvariant } from '../lib/access'
15
16
  import {
16
17
  mergeAttachmentMetadata,
17
18
  normalizeAttachmentAssignments,
@@ -421,6 +422,7 @@ export async function POST(req: Request) {
421
422
  }
422
423
  const metadata = mergeAttachmentMetadata(null, { assignments, tags })
423
424
  const attachmentId = randomUUID()
425
+ assertAttachmentScopeInvariant({ tenantId: auth.tenantId, organizationId: auth.orgId })
424
426
  const att = em.create(Attachment, {
425
427
  id: attachmentId,
426
428
  entityId,
@@ -1,7 +1,5 @@
1
1
  import * as React from 'react'
2
- import ReactMarkdown from 'react-markdown'
3
- import remarkGfm from 'remark-gfm'
4
- import type { PluggableList } from 'unified'
2
+ import { MarkdownContent } from '@open-mercato/ui/backend/markdown'
5
3
  import { Button } from '@open-mercato/ui/primitives/button'
6
4
 
7
5
  type Props = {
@@ -26,7 +24,6 @@ export function AttachmentContentPreview({
26
24
  const [expanded, setExpanded] = React.useState(false)
27
25
  const [tab, setTab] = React.useState<'source' | 'preview'>('source')
28
26
  const text = (content ?? '').trim()
29
- const markdownPlugins = React.useMemo<PluggableList>(() => [remarkGfm], [])
30
27
 
31
28
  // ARIA IDs for accessibility
32
29
  const sourceTabId = 'attachment-content-preview-tab-source'
@@ -96,9 +93,12 @@ export function AttachmentContentPreview({
96
93
  id={previewPanelId}
97
94
  aria-labelledby={previewTabId}
98
95
  data-testid="markdown-preview"
99
- className="text-sm text-muted-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs"
100
96
  >
101
- <ReactMarkdown remarkPlugins={markdownPlugins}>{text}</ReactMarkdown>
97
+ <MarkdownContent
98
+ body={text}
99
+ format="markdown"
100
+ className="text-sm text-muted-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs"
101
+ />
102
102
  </div>
103
103
  )}
104
104
 
@@ -1,6 +1,42 @@
1
1
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
2
2
  import type { Attachment, AttachmentPartition } from '../data/entities'
3
3
 
4
+ export type AttachmentScope = {
5
+ tenantId?: string | null
6
+ organizationId?: string | null
7
+ }
8
+
9
+ function normalizeScopeValue(value: string | null | undefined): string | null {
10
+ if (typeof value !== 'string') return null
11
+ const trimmed = value.trim()
12
+ return trimmed.length > 0 ? trimmed : null
13
+ }
14
+
15
+ /**
16
+ * Enforce the attachments scope invariant at every creation boundary:
17
+ * an attachment is either fully **global** (both `tenant_id` and
18
+ * `organization_id` null) or fully **scoped** (both set) — never partial.
19
+ *
20
+ * `isSameScope` deliberately treats a partial-null row as inaccessible to
21
+ * every non-superadmin principal (fail-closed, #2107), so a partial-null row
22
+ * is dead data that can only ever leak through a future code path that skips
23
+ * the access check. Guarding creation keeps that class of fail-open bug from
24
+ * re-emerging (#2109). Call this before persisting any `Attachment`.
25
+ */
26
+ export function assertAttachmentScopeInvariant(scope: AttachmentScope): void {
27
+ const tenantId = normalizeScopeValue(scope.tenantId)
28
+ const organizationId = normalizeScopeValue(scope.organizationId)
29
+ const tenantSet = tenantId !== null
30
+ const organizationSet = organizationId !== null
31
+ if (tenantSet !== organizationSet) {
32
+ const missing = tenantSet ? 'organization_id' : 'tenant_id'
33
+ throw new Error(
34
+ `[internal] Attachment scope invariant violated: ${missing} is null while the other scope column is set. ` +
35
+ 'Attachments must be either fully scoped (both tenant_id and organization_id) or fully global (both null).',
36
+ )
37
+ }
38
+ }
39
+
4
40
  export function isSuperAdminAuth(auth: AuthContext | null | undefined): boolean {
5
41
  if (!auth) return false
6
42
  if ((auth as any).isSuperAdmin === true) return true
@@ -70,10 +70,22 @@ export async function POST(req: Request) {
70
70
  if (log.actorUserId && log.actorUserId !== auth.sub && !canRedoTenant) {
71
71
  return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })
72
72
  }
73
- if (log.tenantId && auth.tenantId && log.tenantId !== auth.tenantId) {
73
+ // Fail closed on tenant scope: `audit_logs.redo_tenant` only widens scope WITHIN a
74
+ // tenant, never across tenants, so a tenant-scoped target always requires a caller
75
+ // bound to that same tenant. A caller whose tenantId is null (tenant-less global
76
+ // account or unscoped API key) must never redo a tenant-scoped row. Mirrors the
77
+ // hardened undo route (issue #2685, ported in #2931).
78
+ if (log.tenantId && log.tenantId !== (auth.tenantId ?? null)) {
74
79
  return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })
75
80
  }
76
- if (log.organizationId && scopedOrgId && log.organizationId !== scopedOrgId) {
81
+ // Tenant-level redoers may redo across organizations within the tenant, so an
82
+ // unresolved (null) caller org is allowed and only an explicit mismatch is rejected.
83
+ // Every other caller must resolve to the target's own organization — a null caller
84
+ // org must not bypass an org-scoped target (issue #2685, ported in #2931).
85
+ const orgScopeMismatch = canRedoTenant
86
+ ? Boolean(log.organizationId && scopedOrgId && log.organizationId !== scopedOrgId)
87
+ : Boolean(log.organizationId && log.organizationId !== scopedOrgId)
88
+ if (orgScopeMismatch) {
77
89
  return NextResponse.json({ error: 'Redo target not available' }, { status: 400 })
78
90
  }
79
91
 
@@ -96,6 +96,7 @@ export class ActionLog {
96
96
  @Entity({ tableName: 'access_logs' })
97
97
  @Index({ name: 'access_logs_tenant_idx', properties: ['tenantId', 'createdAt'] })
98
98
  @Index({ name: 'access_logs_actor_idx', properties: ['actorUserId', 'createdAt'] })
99
+ @Index({ name: 'access_logs_created_at_idx', properties: ['createdAt'] })
99
100
  export class AccessLog {
100
101
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
101
102
  id!: string
@@ -208,6 +208,16 @@
208
208
  "primary": false,
209
209
  "unique": false
210
210
  },
211
+ {
212
+ "keyName": "access_logs_created_at_idx",
213
+ "columnNames": [
214
+ "created_at"
215
+ ],
216
+ "composite": false,
217
+ "constraint": false,
218
+ "primary": false,
219
+ "unique": false
220
+ },
211
221
  {
212
222
  "keyName": "access_logs_pkey",
213
223
  "columnNames": [
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260611104500 extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`create index "access_logs_created_at_idx" on "access_logs" ("created_at");`);
7
+ }
8
+
9
+ override down(): void | Promise<void> {
10
+ this.addSql(`drop index "access_logs_created_at_idx";`);
11
+ }
12
+
13
+ }
@@ -20,10 +20,23 @@ function toPositiveNumber(value: string | undefined, fallback: number): number {
20
20
  return parsed
21
21
  }
22
22
 
23
+ function toNonNegativeNumber(value: string | undefined, fallback: number): number {
24
+ if (value === undefined || value === '') return fallback
25
+ const parsed = Number(value)
26
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback
27
+ return parsed
28
+ }
29
+
23
30
  const CORE_RETENTION_DAYS = toPositiveNumber(process.env.AUDIT_LOGS_CORE_RETENTION_DAYS, 7)
24
31
  const NON_CORE_RETENTION_HOURS = toPositiveNumber(process.env.AUDIT_LOGS_NON_CORE_RETENTION_HOURS, 8)
25
32
  const CORE_RETENTION_MS = CORE_RETENTION_DAYS * 24 * 60 * 60 * 1000
26
33
  const NON_CORE_RETENTION_MS = NON_CORE_RETENTION_HOURS * 60 * 60 * 1000
34
+ // Rotation runs after every successful write; without a gate that means two
35
+ // DELETE statements per CRUD GET. Amortize to one rotation per interval per
36
+ // process — `0` opts back into rotate-on-every-write (test harnesses).
37
+ const ROTATE_INTERVAL_MS = toNonNegativeNumber(process.env.AUDIT_LOGS_ROTATE_INTERVAL_MS, 60_000)
38
+
39
+ let lastRotatedAt: number | null = null
27
40
  const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
28
41
  // Postgres has a hard limit of 65k bind parameters per statement. Each access
29
42
  // log row uses 10 bind values (see INSERT below), so 500 rows × 10 = 5 000
@@ -380,6 +393,8 @@ export class AccessLogService {
380
393
 
381
394
  private async rotate(fork: EntityManager) {
382
395
  const now = Date.now()
396
+ if (ROTATE_INTERVAL_MS > 0 && lastRotatedAt !== null && now - lastRotatedAt < ROTATE_INTERVAL_MS) return
397
+ lastRotatedAt = now
383
398
  const coreCutoff = new Date(now - CORE_RETENTION_MS)
384
399
  const nonCoreCutoff = new Date(now - NON_CORE_RETENTION_MS)
385
400
  try {
@@ -69,6 +69,13 @@ const sectionGroupSchema = z.object({
69
69
  })
70
70
 
71
71
  const adminNavResponseSchema = z.object({
72
+ brand: z.object({
73
+ name: z.string().optional(),
74
+ logo: z.object({
75
+ src: z.string(),
76
+ alt: z.string().optional(),
77
+ }).nullable().optional(),
78
+ }).nullable().optional(),
72
79
  groups: z.array(
73
80
  z.object({
74
81
  id: z.string().optional(),
@@ -160,6 +167,8 @@ export async function GET(req: Request) {
160
167
  `nav:entities:${cacheScopeTenantId || 'null'}`,
161
168
  `nav:locale:${locale}`,
162
169
  `nav:sidebar:user:${auth.sub}`,
170
+ cacheScopeTenantId ? `nav:sidebar:tenant:${cacheScopeTenantId}` : undefined,
171
+ cacheScopeOrganizationId ? `nav:sidebar:organization:${cacheScopeOrganizationId}` : undefined,
163
172
  `nav:sidebar:scope:${auth.sub}:${cacheScopeTenantId || 'null'}:${cacheScopeOrganizationId || 'null'}:${locale}`,
164
173
  ...((Array.isArray(auth.roles) ? auth.roles : []).map((role) => `nav:sidebar:role:${role}`)),
165
174
  ].filter(Boolean) as string[]
@@ -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
@@ -204,9 +204,14 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
204
204
  { tenantId: null, organizationId: parsed.organizationId },
205
205
  )
206
206
  if (!organization) throw new CrudHttpError(400, { error: 'Organization not found' })
207
+ const tenantId = organization.tenant?.id ? String(organization.tenant.id) : null
207
208
 
208
209
  const emailHash = computeEmailHash(parsed.email)
209
- const duplicate = await findOneWithDecryption(em, User, { $or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }], deletedAt: null } as any, {}, { tenantId: null, organizationId: null })
210
+ // Email is unique per-tenant, not globally (see Migration20260610120000:
211
+ // users_tenant_email_hash_uniq). Scope the duplicate check to the target tenant so the same
212
+ // email may legitimately exist in other tenants without blocking creation or leaking
213
+ // cross-tenant account existence (#2934).
214
+ const duplicate = await findOneWithDecryption(em, User, { $or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }], deletedAt: null, tenantId } as any, {}, { tenantId: null, organizationId: null })
210
215
  if (duplicate) await throwDuplicateEmailError()
211
216
 
212
217
  let passwordHash: string | null = null
@@ -214,7 +219,6 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
214
219
  const { hash } = await import('bcryptjs')
215
220
  passwordHash = await hash(parsed.password, 10)
216
221
  }
217
- const tenantId = organization.tenant?.id ? String(organization.tenant.id) : null
218
222
 
219
223
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
220
224
  let user: User
@@ -518,13 +522,34 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
518
522
  ? await loadUserRoleNames(em, parsed.id)
519
523
  : null
520
524
 
525
+ // Resolve the tenant the user will belong to after this update first, so the email
526
+ // duplicate check below can be scoped to it. Email is unique per-tenant, not globally
527
+ // (see Migration20260610120000: users_tenant_email_hash_uniq) — a matching email in another
528
+ // tenant must not block the update or leak cross-tenant account existence (#2934).
529
+ let tenantId: string | null | undefined
530
+ if (parsed.organizationId !== undefined) {
531
+ const organization = await findOneWithDecryption(
532
+ em,
533
+ Organization,
534
+ { id: parsed.organizationId },
535
+ { populate: ['tenant'] },
536
+ { tenantId: null, organizationId: parsed.organizationId ?? null },
537
+ )
538
+ if (!organization) throw new CrudHttpError(400, { error: 'Organization not found' })
539
+ tenantId = organization.tenant?.id ? String(organization.tenant.id) : null
540
+ }
541
+
521
542
  if (parsed.email !== undefined) {
543
+ const targetTenantId = tenantId !== undefined
544
+ ? tenantId
545
+ : await resolveUserTenantId(em, parsed.id)
522
546
  const duplicate = await findOneWithDecryption(
523
547
  em,
524
548
  User,
525
549
  {
526
550
  $or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }],
527
551
  deletedAt: null,
552
+ tenantId: targetTenantId,
528
553
  id: { $ne: parsed.id } as any,
529
554
  } as FilterQuery<User>,
530
555
  {},
@@ -543,19 +568,6 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
543
568
  emailHash = computeEmailHash(parsed.email)
544
569
  }
545
570
 
546
- let tenantId: string | null | undefined
547
- if (parsed.organizationId !== undefined) {
548
- const organization = await findOneWithDecryption(
549
- em,
550
- Organization,
551
- { id: parsed.organizationId },
552
- { populate: ['tenant'] },
553
- { tenantId: null, organizationId: parsed.organizationId ?? null },
554
- )
555
- if (!organization) throw new CrudHttpError(400, { error: 'Organization not found' })
556
- tenantId = organization.tenant?.id ? String(organization.tenant.id) : null
557
- }
558
-
559
571
  const actorTenantScope = resolveActorTenantScope(ctx)
560
572
  const updateWhere: Record<string, unknown> = { id: parsed.id, deletedAt: null }
561
573
  if (actorTenantScope) updateWhere.tenantId = actorTenantScope
@@ -1069,6 +1081,11 @@ function arrayEquals(left: string[] | undefined, right: string[]): boolean {
1069
1081
  return left.every((value, idx) => value === right[idx])
1070
1082
  }
1071
1083
 
1084
+ async function resolveUserTenantId(em: EntityManager, id: string): Promise<string | null> {
1085
+ const existing = await findOneWithDecryption(em, User, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
1086
+ return existing?.tenantId ? String(existing.tenantId) : null
1087
+ }
1088
+
1072
1089
  async function throwDuplicateEmailError(): Promise<never> {
1073
1090
  const { translate } = await resolveTranslations()
1074
1091
  const message = translate('auth.users.errors.emailExists', 'Email already in use')
@@ -1,6 +1,16 @@
1
1
  import { Entity, Index, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
2
2
 
3
3
  @Entity({ tableName: 'users' })
4
+ // Email uniqueness is per-tenant, enforced by a partial unique index
5
+ // (`users_tenant_email_hash_uniq`) on `(tenant_id, email_hash)` over live rows
6
+ // (`WHERE deleted_at IS NULL AND email_hash IS NOT NULL`), owned by raw SQL in
7
+ // Migration20260610120000. It keys on the deterministic `email_hash`, not `email`, because
8
+ // `email` is encrypted at rest with a per-row IV (see encryption.ts) — its ciphertext is
9
+ // non-deterministic, so a unique index on it would not detect duplicates. A `@Unique`
10
+ // decorator can't express a partial, tenant-scoped index, so the entity omits it — the
11
+ // migration is the source of truth. A global unique constraint contradicts the multi-tenant
12
+ // login flow and leaks cross-tenant account existence (#2934). Mirrors
13
+ // `customer_users_tenant_email_hash_uniq`.
4
14
  export class User {
5
15
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
6
16
  id!: string
@@ -11,7 +21,7 @@ export class User {
11
21
  @Property({ name: 'organization_id', type: 'uuid', nullable: true })
12
22
  organizationId?: string | null
13
23
 
14
- @Property({ type: 'text', unique: true })
24
+ @Property({ type: 'text' })
15
25
  email!: string
16
26
 
17
27
  @Property({ name: 'email_hash', type: 'text', nullable: true })
@@ -168,6 +178,8 @@ export class SidebarVariant {
168
178
  }
169
179
 
170
180
  @Entity({ tableName: 'user_roles' })
181
+ @Index({ name: 'user_roles_user_id_idx', properties: ['user'] })
182
+ @Index({ name: 'user_roles_role_id_idx', properties: ['role'] })
171
183
  export class UserRole {
172
184
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
173
185
  id!: string
@@ -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...",
@@ -22,9 +22,15 @@ import { resolveRegisteredLucideIconNode } from '@open-mercato/ui/backend/icons/
22
22
  import { profilePathPrefixes, profileSections } from './profile-sections'
23
23
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
24
24
  import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
25
- import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope'
25
+ import {
26
+ getSelectedOrganizationFromRequest,
27
+ resolveFeatureCheckContext,
28
+ } from '@open-mercato/core/modules/directory/utils/organizationScope'
29
+ import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
30
+ import { Organization } from '@open-mercato/core/modules/directory/data/entities'
26
31
  import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
27
32
  import { Role } from '@open-mercato/core/modules/auth/data/entities'
33
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
28
34
  import {
29
35
  applySidebarPreference,
30
36
  loadFirstRoleSidebarPreference,
@@ -397,6 +403,35 @@ export async function resolveBackendChromePayload({
397
403
  ),
398
404
  )
399
405
 
406
+ const requestOrganizationId = request ? getSelectedOrganizationFromRequest(request) : null
407
+ const fallbackOrganizationId = selectedOrganizationId ?? requestOrganizationId ?? auth.orgId ?? null
408
+ const brandOrganizationId = scopedOrganizationId
409
+ ?? (fallbackOrganizationId && !isAllOrganizationsSelection(fallbackOrganizationId) ? fallbackOrganizationId : null)
410
+
411
+ let brand: BackendChromePayload['brand'] = null
412
+ if (brandOrganizationId && scopedTenantId) {
413
+ try {
414
+ const organization = await findOneWithDecryption(
415
+ em,
416
+ Organization,
417
+ { id: brandOrganizationId, tenant: scopedTenantId, deletedAt: null },
418
+ undefined,
419
+ { tenantId: scopedTenantId, organizationId: brandOrganizationId },
420
+ )
421
+ if (organization?.logoUrl) {
422
+ brand = {
423
+ name: organization.name,
424
+ logo: {
425
+ src: organization.logoUrl,
426
+ alt: `${organization.name} logo`,
427
+ },
428
+ }
429
+ }
430
+ } catch {
431
+ brand = null
432
+ }
433
+ }
434
+
400
435
  return {
401
436
  groups: appliedGroups.map(({ weight: _weight, ...group }) => group),
402
437
  settingsSections,
@@ -405,5 +440,6 @@ export async function resolveBackendChromePayload({
405
440
  profilePathPrefixes,
406
441
  grantedFeatures,
407
442
  roles: Array.isArray(auth.roles) ? auth.roles : [],
443
+ brand,
408
444
  }
409
445
  }
@@ -14,18 +14,21 @@ const DEV_ONLY_SECRET = 'om-consent-integrity-dev-only-secret'
14
14
  let missingSecretWarned = false
15
15
 
16
16
  function getSecret(): string {
17
- const secret = process.env.CONSENT_INTEGRITY_SECRET || process.env.NEXTAUTH_SECRET
17
+ const secret = process.env.CONSENT_INTEGRITY_SECRET
18
+ || process.env.AUTH_SECRET
19
+ || process.env.NEXTAUTH_SECRET
20
+ || process.env.JWT_SECRET
18
21
  if (!secret) {
19
22
  if (process.env.NODE_ENV === 'production') {
20
23
  throw new Error(
21
- '[consentIntegrity] No CONSENT_INTEGRITY_SECRET/NEXTAUTH_SECRET set. ' +
24
+ '[consentIntegrity] No CONSENT_INTEGRITY_SECRET/AUTH_SECRET/NEXTAUTH_SECRET/JWT_SECRET set. ' +
22
25
  'Refusing to compute or verify consent integrity hashes in production without a real secret.',
23
26
  )
24
27
  }
25
28
  if (!missingSecretWarned) {
26
29
  missingSecretWarned = true
27
30
  console.warn(
28
- '[consentIntegrity] No CONSENT_INTEGRITY_SECRET/NEXTAUTH_SECRET set — ' +
31
+ '[consentIntegrity] No CONSENT_INTEGRITY_SECRET/AUTH_SECRET/NEXTAUTH_SECRET/JWT_SECRET set — ' +
29
32
  'using insecure dev-only default. Set a secret before deploying to production.',
30
33
  )
31
34
  }
@@ -668,16 +668,6 @@
668
668
  }
669
669
  },
670
670
  "indexes": [
671
- {
672
- "columnNames": [
673
- "email"
674
- ],
675
- "composite": false,
676
- "keyName": "users_email_unique",
677
- "constraint": true,
678
- "primary": false,
679
- "unique": true
680
- },
681
671
  {
682
672
  "columnNames": [
683
673
  "email_hash"
@@ -1742,6 +1732,26 @@
1742
1732
  }
1743
1733
  },
1744
1734
  "indexes": [
1735
+ {
1736
+ "keyName": "user_roles_user_id_idx",
1737
+ "columnNames": [
1738
+ "user_id"
1739
+ ],
1740
+ "composite": false,
1741
+ "constraint": false,
1742
+ "primary": false,
1743
+ "unique": false
1744
+ },
1745
+ {
1746
+ "keyName": "user_roles_role_id_idx",
1747
+ "columnNames": [
1748
+ "role_id"
1749
+ ],
1750
+ "composite": false,
1751
+ "constraint": false,
1752
+ "primary": false,
1753
+ "unique": false
1754
+ },
1745
1755
  {
1746
1756
  "keyName": "user_roles_pkey",
1747
1757
  "columnNames": [