@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -1
- package/dist/bootstrap.js +46 -6
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/organization/index.js +2 -0
- package/dist/generated/entities/organization/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/crmFixtures.js +4 -0
- package/dist/helpers/integration/crmFixtures.js.map +2 -2
- package/dist/modules/attachments/api/library/route.js +2 -2
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +2 -0
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/components/AttachmentContentPreview.js +9 -5
- package/dist/modules/attachments/components/AttachmentContentPreview.js.map +2 -2
- package/dist/modules/attachments/lib/access.js +18 -0
- package/dist/modules/attachments/lib/access.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +3 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
- package/dist/modules/audit_logs/data/entities.js +2 -1
- package/dist/modules/audit_logs/data/entities.js.map +2 -2
- package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
- package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
- package/dist/modules/audit_logs/services/accessLogService.js +10 -0
- package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
- package/dist/modules/auth/api/admin/nav.js +9 -0
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/login.js +4 -13
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/commands/users.js +20 -14
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js +4 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +35 -2
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/consentIntegrity.js +3 -3
- package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260610120000.js +30 -0
- package/dist/modules/auth/migrations/Migration20260610120000.js.map +7 -0
- package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
- package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
- package/dist/modules/auth/services/authService.js +5 -3
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +3 -2
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/catalog/ai-tools/configuration-pack.js.map +1 -1
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +1 -1
- package/dist/modules/catalog/ai-tools/products-pack.js.map +1 -1
- package/dist/modules/catalog/ai-tools/variants-pack.js.map +1 -1
- package/dist/modules/communication_channels/data/entities.js.map +1 -1
- package/dist/modules/communication_channels/encryption.js.map +1 -1
- package/dist/modules/communication_channels/lib/thread-matcher.js.map +1 -1
- package/dist/modules/communication_channels/lib/thread-token.js.map +1 -1
- package/dist/modules/currencies/api/currencies/route.js +4 -3
- package/dist/modules/currencies/api/currencies/route.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/roles.js +2 -1
- package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
- package/dist/modules/customer_accounts/events.js +1 -1
- package/dist/modules/customer_accounts/events.js.map +1 -1
- package/dist/modules/customer_accounts/lib/resolveTenantContext.js.map +1 -1
- package/dist/modules/customers/acl.js +1 -1
- package/dist/modules/customers/acl.js.map +1 -1
- package/dist/modules/customers/ai-tools/companies-pack.js.map +1 -1
- package/dist/modules/customers/ai-tools/deals-pack.js.map +1 -1
- package/dist/modules/customers/ai-tools/people-pack.js.map +1 -1
- package/dist/modules/customers/api/companies/route.js +4 -4
- package/dist/modules/customers/api/companies/route.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +43 -2
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +402 -0
- package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
- package/dist/modules/customers/api/people/route.js +4 -4
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +221 -56
- package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/cli.js +15 -9
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/addresses.js +5 -5
- package/dist/modules/customers/commands/addresses.js.map +2 -2
- package/dist/modules/customers/commands/comments.js +5 -5
- package/dist/modules/customers/commands/comments.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +2 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/entity-roles.js +2 -1
- package/dist/modules/customers/commands/entity-roles.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +8 -5
- package/dist/modules/customers/commands/interactions.js.map +2 -2
- package/dist/modules/customers/commands/shared.js +21 -6
- package/dist/modules/customers/commands/shared.js.map +2 -2
- package/dist/modules/customers/commands/tags.js +3 -3
- package/dist/modules/customers/commands/tags.js.map +2 -2
- package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
- package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +100 -17
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/assignableStaff.js +21 -8
- package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
- package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
- package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
- package/dist/modules/customers/lib/dealsMetrics.js +82 -0
- package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +1 -1
- package/dist/modules/data_sync/api/run.js +1 -1
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +214 -0
- package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
- package/dist/modules/directory/api/organizations/route.js +7 -0
- package/dist/modules/directory/api/organizations/route.js.map +3 -3
- package/dist/modules/directory/backend/directory/branding/page.js +214 -0
- package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
- package/dist/modules/directory/commands/organizations.js +8 -1
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/data/entities.js +3 -0
- package/dist/modules/directory/data/entities.js.map +2 -2
- package/dist/modules/directory/data/validators.js +9 -0
- package/dist/modules/directory/data/validators.js.map +2 -2
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +59 -27
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +2 -1
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/entities.js +7 -0
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +26 -15
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
- package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
- package/dist/modules/payment_gateways/api/transactions/route.js +2 -4
- package/dist/modules/payment_gateways/api/transactions/route.js.map +2 -2
- package/dist/modules/progress/api/jobs/[id]/route.js +7 -2
- package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
- package/dist/modules/progress/api/jobs/route.js +1 -1
- package/dist/modules/progress/api/jobs/route.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +8 -2
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/query_index/data/entities.js +2 -1
- package/dist/modules/query_index/data/entities.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +4 -2
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
- package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
- package/dist/modules/resources/api/resources.js +2 -3
- package/dist/modules/resources/api/resources.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +2 -2
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +7 -5
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
- package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
- package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
- package/dist/modules/staff/api/team-members.js +9 -2
- package/dist/modules/staff/api/team-members.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
- package/dist/modules/staff/commands/team-members.js +1 -1
- package/dist/modules/staff/commands/team-members.js.map +2 -2
- package/dist/modules/staff/components/TeamMemberForm.js +1 -1
- package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
- package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
- package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
- package/dist/modules/sync_excel/api/import/route.js +1 -1
- package/dist/modules/sync_excel/api/import/route.js.map +2 -2
- package/dist/modules/workflows/api/definitions/route.js +3 -2
- package/dist/modules/workflows/api/definitions/route.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
- package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
- package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
- package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
- package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
- package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
- package/generated/entities/organization/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +11 -12
- package/src/bootstrap.ts +65 -7
- package/src/helpers/integration/crmFixtures.ts +21 -1
- package/src/modules/attachments/AGENTS.md +79 -0
- package/src/modules/attachments/api/library/route.ts +2 -2
- package/src/modules/attachments/api/route.ts +2 -0
- package/src/modules/attachments/components/AttachmentContentPreview.tsx +6 -6
- package/src/modules/attachments/lib/access.ts +36 -0
- package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +14 -2
- package/src/modules/audit_logs/data/entities.ts +1 -0
- package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
- package/src/modules/audit_logs/services/accessLogService.ts +15 -0
- package/src/modules/auth/api/admin/nav.ts +9 -0
- package/src/modules/auth/api/login.ts +13 -13
- package/src/modules/auth/commands/users.ts +32 -15
- package/src/modules/auth/data/entities.ts +13 -1
- package/src/modules/auth/i18n/de.json +0 -1
- package/src/modules/auth/i18n/en.json +0 -1
- package/src/modules/auth/i18n/es.json +0 -1
- package/src/modules/auth/i18n/pl.json +0 -1
- package/src/modules/auth/lib/backendChrome.tsx +37 -1
- package/src/modules/auth/lib/consentIntegrity.ts +6 -3
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -10
- package/src/modules/auth/migrations/Migration20260610120000.ts +53 -0
- package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
- package/src/modules/auth/services/authService.ts +24 -4
- package/src/modules/auth/services/rbacService.ts +11 -2
- package/src/modules/catalog/ai-tools/configuration-pack.ts +1 -1
- package/src/modules/catalog/ai-tools/prices-offers-pack.ts +1 -1
- package/src/modules/catalog/ai-tools/products-pack.ts +1 -1
- package/src/modules/catalog/ai-tools/variants-pack.ts +1 -1
- package/src/modules/communication_channels/data/entities.ts +2 -2
- package/src/modules/communication_channels/encryption.ts +1 -1
- package/src/modules/communication_channels/lib/adapter.ts +1 -1
- package/src/modules/communication_channels/lib/thread-matcher.ts +1 -1
- package/src/modules/communication_channels/lib/thread-token.ts +1 -1
- package/src/modules/currencies/api/currencies/route.ts +4 -3
- package/src/modules/customer_accounts/api/admin/roles.ts +2 -1
- package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
- package/src/modules/customer_accounts/events.ts +1 -1
- package/src/modules/customer_accounts/lib/resolveTenantContext.ts +2 -2
- package/src/modules/customers/acl.ts +1 -1
- package/src/modules/customers/ai-tools/companies-pack.ts +1 -1
- package/src/modules/customers/ai-tools/deals-pack.ts +1 -1
- package/src/modules/customers/ai-tools/people-pack.ts +1 -1
- package/src/modules/customers/api/companies/route.ts +4 -4
- package/src/modules/customers/api/deals/route.ts +51 -2
- package/src/modules/customers/api/deals/summary/route.ts +496 -0
- package/src/modules/customers/api/people/route.ts +4 -4
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
- package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
- package/src/modules/customers/cli.ts +15 -15
- package/src/modules/customers/commands/addresses.ts +5 -5
- package/src/modules/customers/commands/comments.ts +5 -5
- package/src/modules/customers/commands/deals.ts +2 -2
- package/src/modules/customers/commands/entity-roles.ts +2 -1
- package/src/modules/customers/commands/interactions.ts +8 -5
- package/src/modules/customers/commands/shared.ts +26 -4
- package/src/modules/customers/commands/tags.ts +3 -3
- package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
- package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
- package/src/modules/customers/components/detail/DealForm.tsx +121 -19
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
- package/src/modules/customers/components/detail/assignableStaff.ts +32 -8
- package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
- package/src/modules/customers/i18n/de.json +43 -0
- package/src/modules/customers/i18n/en.json +43 -0
- package/src/modules/customers/i18n/es.json +43 -0
- package/src/modules/customers/i18n/pl.json +43 -0
- package/src/modules/customers/lib/dealsMetrics.ts +159 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +1 -1
- package/src/modules/data_sync/api/run.ts +1 -1
- package/src/modules/directory/api/organization-branding/route.ts +238 -0
- package/src/modules/directory/api/organizations/route.ts +7 -0
- package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
- package/src/modules/directory/commands/organizations.ts +9 -1
- package/src/modules/directory/data/entities.ts +3 -0
- package/src/modules/directory/data/validators.ts +12 -0
- package/src/modules/directory/i18n/de.json +21 -0
- package/src/modules/directory/i18n/en.json +21 -0
- package/src/modules/directory/i18n/es.json +21 -0
- package/src/modules/directory/i18n/pl.json +21 -0
- package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
- package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
- package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
- package/src/modules/directory/utils/organizationScope.ts +85 -30
- package/src/modules/entities/api/definitions.batch.ts +11 -7
- package/src/modules/entities/api/entities.ts +11 -0
- package/src/modules/entities/api/records.ts +46 -25
- package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
- package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
- package/src/modules/entities/i18n/de.json +1 -0
- package/src/modules/entities/i18n/en.json +1 -0
- package/src/modules/entities/i18n/es.json +1 -0
- package/src/modules/entities/i18n/pl.json +1 -0
- package/src/modules/payment_gateways/api/transactions/route.ts +2 -5
- package/src/modules/progress/api/jobs/[id]/route.ts +6 -1
- package/src/modules/progress/api/jobs/route.ts +1 -1
- package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
- package/src/modules/query_index/data/entities.ts +1 -0
- package/src/modules/query_index/lib/engine.ts +11 -5
- package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
- package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
- package/src/modules/resources/api/resources.ts +2 -3
- package/src/modules/sales/api/documents/factory.ts +2 -2
- package/src/modules/sales/commands/documents.ts +7 -5
- package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
- package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
- package/src/modules/staff/AGENTS.md +1 -1
- package/src/modules/staff/api/team-members.ts +9 -2
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
- package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
- package/src/modules/staff/commands/team-members.ts +5 -2
- package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
- package/src/modules/sync_excel/api/import/route.ts +1 -1
- package/src/modules/workflows/api/definitions/route.ts +3 -2
- package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
- package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
- package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
- package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
- package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
- 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 {
|
|
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:
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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 {
|
|
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
|
|
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": [
|