@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 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 (237) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/bootstrap.js +46 -6
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/organization/index.js +2 -0
  5. package/dist/generated/entities/organization/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/crmFixtures.js +4 -0
  9. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +2 -0
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/lib/access.js +18 -0
  13. package/dist/modules/attachments/lib/access.js.map +2 -2
  14. package/dist/modules/audit_logs/data/entities.js +2 -1
  15. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  16. package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
  17. package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
  18. package/dist/modules/audit_logs/services/accessLogService.js +10 -0
  19. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  20. package/dist/modules/auth/api/admin/nav.js +9 -0
  21. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  22. package/dist/modules/auth/api/login.js +4 -13
  23. package/dist/modules/auth/api/login.js.map +2 -2
  24. package/dist/modules/auth/data/entities.js +3 -1
  25. package/dist/modules/auth/data/entities.js.map +2 -2
  26. package/dist/modules/auth/lib/backendChrome.js +35 -2
  27. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  28. package/dist/modules/auth/lib/consentIntegrity.js +3 -3
  29. package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
  30. package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
  31. package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
  32. package/dist/modules/auth/services/authService.js +5 -3
  33. package/dist/modules/auth/services/authService.js.map +2 -2
  34. package/dist/modules/auth/services/rbacService.js +3 -2
  35. package/dist/modules/auth/services/rbacService.js.map +2 -2
  36. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  37. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  38. package/dist/modules/customers/api/deals/route.js +43 -2
  39. package/dist/modules/customers/api/deals/route.js.map +2 -2
  40. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  41. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  45. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  46. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  47. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  48. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  49. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  50. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  51. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  52. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
  53. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  54. package/dist/modules/customers/cli.js +15 -9
  55. package/dist/modules/customers/cli.js.map +2 -2
  56. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  57. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  58. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  59. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  60. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  61. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  62. package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
  63. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  64. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  65. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  66. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  67. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  68. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  69. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  70. package/dist/modules/directory/api/organization-branding/route.js +214 -0
  71. package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
  72. package/dist/modules/directory/api/organizations/route.js +7 -0
  73. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  74. package/dist/modules/directory/backend/directory/branding/page.js +214 -0
  75. package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
  76. package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
  77. package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
  78. package/dist/modules/directory/commands/organizations.js +8 -1
  79. package/dist/modules/directory/commands/organizations.js.map +2 -2
  80. package/dist/modules/directory/data/entities.js +3 -0
  81. package/dist/modules/directory/data/entities.js.map +2 -2
  82. package/dist/modules/directory/data/validators.js +9 -0
  83. package/dist/modules/directory/data/validators.js.map +2 -2
  84. package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
  85. package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
  86. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  87. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  88. package/dist/modules/directory/utils/organizationScope.js +59 -27
  89. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  90. package/dist/modules/entities/api/definitions.batch.js +2 -1
  91. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  92. package/dist/modules/entities/api/entities.js +7 -0
  93. package/dist/modules/entities/api/entities.js.map +2 -2
  94. package/dist/modules/entities/api/records.js +26 -15
  95. package/dist/modules/entities/api/records.js.map +2 -2
  96. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  97. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  98. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  99. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  100. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  101. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  102. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  103. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  104. package/dist/modules/query_index/data/entities.js +2 -1
  105. package/dist/modules/query_index/data/entities.js.map +2 -2
  106. package/dist/modules/query_index/lib/engine.js +4 -2
  107. package/dist/modules/query_index/lib/engine.js.map +2 -2
  108. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
  109. package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
  110. package/dist/modules/sales/commands/documents.js +7 -5
  111. package/dist/modules/sales/commands/documents.js.map +2 -2
  112. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
  113. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  114. package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
  115. package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
  116. package/dist/modules/staff/api/team-members.js +9 -2
  117. package/dist/modules/staff/api/team-members.js.map +2 -2
  118. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  119. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  120. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  121. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  122. package/dist/modules/staff/commands/team-members.js +1 -1
  123. package/dist/modules/staff/commands/team-members.js.map +2 -2
  124. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  125. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  126. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  127. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  128. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  129. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  130. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  131. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  132. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  133. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  134. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  135. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  136. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  137. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  138. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  139. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  140. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  141. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  142. package/generated/entities/organization/index.ts +1 -0
  143. package/generated/entity-fields-registry.ts +1 -0
  144. package/package.json +11 -12
  145. package/src/bootstrap.ts +65 -7
  146. package/src/helpers/integration/crmFixtures.ts +21 -1
  147. package/src/modules/attachments/AGENTS.md +79 -0
  148. package/src/modules/attachments/api/route.ts +2 -0
  149. package/src/modules/attachments/lib/access.ts +36 -0
  150. package/src/modules/audit_logs/data/entities.ts +1 -0
  151. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
  152. package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
  153. package/src/modules/audit_logs/services/accessLogService.ts +15 -0
  154. package/src/modules/auth/api/admin/nav.ts +9 -0
  155. package/src/modules/auth/api/login.ts +13 -13
  156. package/src/modules/auth/data/entities.ts +2 -0
  157. package/src/modules/auth/i18n/de.json +0 -1
  158. package/src/modules/auth/i18n/en.json +0 -1
  159. package/src/modules/auth/i18n/es.json +0 -1
  160. package/src/modules/auth/i18n/pl.json +0 -1
  161. package/src/modules/auth/lib/backendChrome.tsx +37 -1
  162. package/src/modules/auth/lib/consentIntegrity.ts +6 -3
  163. package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -0
  164. package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
  165. package/src/modules/auth/services/authService.ts +24 -4
  166. package/src/modules/auth/services/rbacService.ts +11 -2
  167. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  168. package/src/modules/customers/api/deals/route.ts +51 -2
  169. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  170. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  171. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  172. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  173. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  174. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  175. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
  176. package/src/modules/customers/cli.ts +15 -15
  177. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  178. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  179. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  180. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
  181. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  182. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  183. package/src/modules/customers/i18n/de.json +43 -0
  184. package/src/modules/customers/i18n/en.json +43 -0
  185. package/src/modules/customers/i18n/es.json +43 -0
  186. package/src/modules/customers/i18n/pl.json +43 -0
  187. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  188. package/src/modules/directory/api/organization-branding/route.ts +238 -0
  189. package/src/modules/directory/api/organizations/route.ts +7 -0
  190. package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
  191. package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
  192. package/src/modules/directory/commands/organizations.ts +9 -1
  193. package/src/modules/directory/data/entities.ts +3 -0
  194. package/src/modules/directory/data/validators.ts +12 -0
  195. package/src/modules/directory/i18n/de.json +21 -0
  196. package/src/modules/directory/i18n/en.json +21 -0
  197. package/src/modules/directory/i18n/es.json +21 -0
  198. package/src/modules/directory/i18n/pl.json +21 -0
  199. package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
  200. package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
  201. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  202. package/src/modules/directory/utils/organizationScope.ts +85 -30
  203. package/src/modules/entities/api/definitions.batch.ts +11 -7
  204. package/src/modules/entities/api/entities.ts +11 -0
  205. package/src/modules/entities/api/records.ts +46 -25
  206. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  207. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  208. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  209. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  210. package/src/modules/entities/i18n/de.json +1 -0
  211. package/src/modules/entities/i18n/en.json +1 -0
  212. package/src/modules/entities/i18n/es.json +1 -0
  213. package/src/modules/entities/i18n/pl.json +1 -0
  214. package/src/modules/query_index/data/entities.ts +1 -0
  215. package/src/modules/query_index/lib/engine.ts +11 -5
  216. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
  217. package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
  218. package/src/modules/sales/commands/documents.ts +7 -5
  219. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
  220. package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
  221. package/src/modules/staff/api/team-members.ts +9 -2
  222. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  223. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  224. package/src/modules/staff/commands/team-members.ts +5 -2
  225. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  226. package/src/modules/staff/i18n/de.json +1 -0
  227. package/src/modules/staff/i18n/en.json +1 -0
  228. package/src/modules/staff/i18n/es.json +1 -0
  229. package/src/modules/staff/i18n/pl.json +1 -0
  230. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  231. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  232. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  233. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  234. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  235. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  236. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  237. 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
+ ```
@@ -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,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
@@ -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
@@ -178,6 +178,8 @@ export class SidebarVariant {
178
178
  }
179
179
 
180
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'] })
181
183
  export class UserRole {
182
184
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
183
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
  }
@@ -1732,6 +1732,26 @@
1732
1732
  }
1733
1733
  },
1734
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
+ },
1735
1755
  {
1736
1756
  "keyName": "user_roles_pkey",
1737
1757
  "columnNames": [
@@ -0,0 +1,21 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // #2966: user_roles carries only its FK constraints and Postgres does not
4
+ // auto-index FK columns, so RBAC scans it sequentially by user_id on every
5
+ // ACL cache miss (rbacService super-admin check + ACL aggregation) and by
6
+ // role_id on user-list filtering and role rename/delete guards. Index both
7
+ // FK columns so these hot paths become index scans. The table is small
8
+ // relative to search_tokens, so a plain (transactional) build is safe.
9
+ export class Migration20260611103000 extends Migration {
10
+
11
+ override up(): void | Promise<void> {
12
+ this.addSql(`create index if not exists "user_roles_user_id_idx" on "user_roles" ("user_id");`);
13
+ this.addSql(`create index if not exists "user_roles_role_id_idx" on "user_roles" ("role_id");`);
14
+ }
15
+
16
+ override down(): void | Promise<void> {
17
+ this.addSql(`drop index if exists "user_roles_user_id_idx";`);
18
+ this.addSql(`drop index if exists "user_roles_role_id_idx";`);
19
+ }
20
+
21
+ }
@@ -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) {
@@ -70,7 +81,16 @@ export class AuthService {
70
81
  { populate: ['role'] },
71
82
  { tenantId: resolvedTenantId, organizationId: user.organizationId ?? null },
72
83
  )
73
- return links.map((l) => l.role.name)
84
+ // A populated `role` can still be null when the link points at a soft-deleted
85
+ // role (the Role soft-delete filter suppresses hydration), e.g. an admin link
86
+ // orphaned by a re-seed during interrupted-provisioning recovery. Dropping such
87
+ // links keeps role resolution from throwing on the login / session-refresh hot
88
+ // path, mirroring resolveCanonicalStaffAuthContext in lib/sessionIntegrity.ts.
89
+ return links
90
+ .map((l) => l.role)
91
+ .filter((role): role is Role => !!role)
92
+ .map((role) => role.name)
93
+ .filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
74
94
  }
75
95
 
76
96
 
@@ -4,6 +4,7 @@ import { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'
4
4
  import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
5
5
  import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
6
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
+ import { buildOrgScopeUserCacheTag, buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'
7
8
  import { matchFeature as sharedMatchFeature, hasAllFeatures as sharedHasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
8
9
  import { filterGrantsByEnabledModules, getOwningModuleId, getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'
9
10
 
@@ -130,7 +131,12 @@ export class RbacService {
130
131
  */
131
132
  async invalidateUserCache(userId: string): Promise<void> {
132
133
  this.globalSuperAdminCache.delete(userId)
133
- await this.deleteCacheByTags([this.getUserTag(userId)])
134
+ // Also drop the directory OrganizationScope cache for this user. That scope's
135
+ // accessible-org set is derived from this user's ACL/role grants, so any
136
+ // permission change that invalidates the RBAC cache must invalidate the
137
+ // resolved scope too. This is the missing `org-scope:user:*` caller required
138
+ // before the cross-request scope TTL can be safely enabled (issue #2259).
139
+ await this.deleteCacheByTags([this.getUserTag(userId), buildOrgScopeUserCacheTag(userId)])
134
140
  }
135
141
 
136
142
  /**
@@ -142,7 +148,10 @@ export class RbacService {
142
148
  */
143
149
  async invalidateTenantCache(tenantId: string): Promise<void> {
144
150
  this.globalSuperAdminCache.clear()
145
- await this.deleteCacheByTags([this.getTenantTag(tenantId)], [tenantId])
151
+ // Role ACL changes invalidate every user in the tenant; the resolved
152
+ // OrganizationScope for those users derives from the same grants, so drop
153
+ // the tenant-tagged scope entries alongside the RBAC ones (issue #2259).
154
+ await this.deleteCacheByTags([this.getTenantTag(tenantId), buildOrgScopeTenantCacheTag(tenantId)], [tenantId])
146
155
  }
147
156
 
148
157
  /**
@@ -1,7 +1,6 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { AlertTriangle } from 'lucide-react'
5
4
  import { Alert, AlertTitle, AlertDescription } from '@open-mercato/ui/primitives/alert'
6
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
6
  import type { DomainMappingRow } from './types'
@@ -15,7 +14,6 @@ export function DnsDiagnostics({ mapping }: DiagnosticsProps) {
15
14
  if (mapping.status !== 'dns_failed') return null
16
15
  return (
17
16
  <Alert variant="destructive">
18
- <AlertTriangle className="h-4 w-4" aria-hidden />
19
17
  <AlertTitle>
20
18
  {t('customer_accounts.domainMapping.dns.diagnostics.title', 'DNS configuration issue')}
21
19
  </AlertTitle>
@@ -41,7 +39,6 @@ export function TlsDiagnostics({ mapping }: DiagnosticsProps) {
41
39
  if (mapping.status !== 'tls_failed') return null
42
40
  return (
43
41
  <Alert variant="warning">
44
- <AlertTriangle className="h-4 w-4" aria-hidden />
45
42
  <AlertTitle>
46
43
  {t('customer_accounts.domainMapping.tls.diagnostics.title', 'SSL certificate issue')}
47
44
  </AlertTitle>