@open-mercato/core 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +3 -3
  3. package/agentic/standalone-guide.md +13 -0
  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/modules/auth/cli.js +1 -1
  9. package/dist/modules/auth/cli.js.map +2 -2
  10. package/dist/modules/auth/lib/backendChrome.js +4 -6
  11. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  12. package/dist/modules/auth/lib/setup-app.js +1 -0
  13. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  14. package/dist/modules/auth/services/rbacService.js +29 -44
  15. package/dist/modules/auth/services/rbacService.js.map +2 -2
  16. package/dist/modules/catalog/widgets/injection/product-seo/widget.js +1 -1
  17. package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
  18. package/dist/modules/communication_channels/lib/access-control.js +2 -2
  19. package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
  20. package/dist/modules/customer_accounts/api/invitations/accept.js +1 -2
  21. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  22. package/dist/modules/customer_accounts/api/login.js +4 -2
  23. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  24. package/dist/modules/customer_accounts/api/magic-link/verify.js +4 -2
  25. package/dist/modules/customer_accounts/api/magic-link/verify.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/portal/feature-check.js +5 -4
  27. package/dist/modules/customer_accounts/api/portal/feature-check.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/nav.js +6 -4
  29. package/dist/modules/customer_accounts/api/portal/nav.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/portal/profile.js +5 -1
  31. package/dist/modules/customer_accounts/api/portal/profile.js.map +2 -2
  32. package/dist/modules/customer_accounts/api/portal/sessions-refresh.js +3 -3
  33. package/dist/modules/customer_accounts/api/portal/sessions-refresh.js.map +2 -2
  34. package/dist/modules/customer_accounts/lib/customerAuth.js +7 -2
  35. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  36. package/dist/modules/customer_accounts/lib/customerAuthServer.js +2 -1
  37. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  38. package/dist/modules/customer_accounts/services/customerRbacService.js +12 -3
  39. package/dist/modules/customer_accounts/services/customerRbacService.js.map +2 -2
  40. package/dist/modules/customer_accounts/setup.js +6 -0
  41. package/dist/modules/customer_accounts/setup.js.map +2 -2
  42. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +1 -1
  43. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +2 -2
  44. package/dist/modules/customers/lib/visibilityFilter.js +6 -4
  45. package/dist/modules/customers/lib/visibilityFilter.js.map +2 -2
  46. package/dist/modules/dashboards/api/layout/[itemId]/route.js +6 -3
  47. package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
  48. package/dist/modules/dashboards/api/layout/route.js +22 -9
  49. package/dist/modules/dashboards/api/layout/route.js.map +2 -2
  50. package/dist/modules/dashboards/api/roles/widgets/route.js +9 -3
  51. package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
  52. package/dist/modules/dashboards/api/users/widgets/route.js +9 -3
  53. package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
  54. package/dist/modules/dashboards/api/widgets/catalog.js +6 -3
  55. package/dist/modules/dashboards/api/widgets/catalog.js.map +2 -2
  56. package/dist/modules/dashboards/lib/access.js +5 -3
  57. package/dist/modules/dashboards/lib/access.js.map +2 -2
  58. package/dist/modules/directory/api/organization-branding/route.js +10 -3
  59. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  60. package/dist/modules/directory/api/organizations/route.js +7 -0
  61. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  62. package/dist/modules/directory/backend/directory/branding/page.js +31 -8
  63. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  64. package/dist/modules/directory/commands/organizations.js +8 -1
  65. package/dist/modules/directory/commands/organizations.js.map +2 -2
  66. package/dist/modules/directory/data/entities.js +4 -0
  67. package/dist/modules/directory/data/entities.js.map +2 -2
  68. package/dist/modules/directory/data/validators.js +2 -0
  69. package/dist/modules/directory/data/validators.js.map +2 -2
  70. package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js +13 -0
  71. package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js.map +7 -0
  72. package/dist/modules/entities/lib/entityAcl.js +47 -15
  73. package/dist/modules/entities/lib/entityAcl.js.map +3 -3
  74. package/dist/modules/inbox_ops/ai-tools.js +5 -2
  75. package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
  76. package/dist/modules/messages/lib/routeHelpers.js +1 -3
  77. package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
  78. package/dist/modules/notifications/lib/notificationRecipients.js +6 -7
  79. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  80. package/dist/modules/query_index/di.js +3 -0
  81. package/dist/modules/query_index/di.js.map +2 -2
  82. package/dist/modules/query_index/lib/coverage.js +2 -2
  83. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  84. package/dist/modules/query_index/lib/indexer.js +83 -17
  85. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  86. package/dist/modules/query_index/lib/search-tokens.js +13 -7
  87. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  88. package/dist/modules/query_index/subscribers/coverage_refresh.js +4 -0
  89. package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
  90. package/dist/modules/query_index/subscribers/delete_one.js +53 -1
  91. package/dist/modules/query_index/subscribers/delete_one.js.map +3 -3
  92. package/dist/modules/query_index/subscribers/upsert_one.js +60 -0
  93. package/dist/modules/query_index/subscribers/upsert_one.js.map +3 -3
  94. package/dist/modules/sales/commands/documents.js +17 -0
  95. package/dist/modules/sales/commands/documents.js.map +2 -2
  96. package/dist/modules/staff/api/interceptors.js +5 -3
  97. package/dist/modules/staff/api/interceptors.js.map +2 -2
  98. package/dist/modules/sync_excel/api/upload/route.js +15 -2
  99. package/dist/modules/sync_excel/api/upload/route.js.map +2 -2
  100. package/dist/modules/workflows/lib/activity-executor.js +7 -7
  101. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  102. package/generated/entities/organization/index.ts +1 -0
  103. package/generated/entity-fields-registry.ts +1 -0
  104. package/package.json +7 -7
  105. package/src/modules/auth/AGENTS.md +5 -3
  106. package/src/modules/auth/cli.ts +1 -1
  107. package/src/modules/auth/lib/backendChrome.tsx +5 -11
  108. package/src/modules/auth/lib/setup-app.ts +1 -0
  109. package/src/modules/auth/services/rbacService.ts +41 -46
  110. package/src/modules/catalog/widgets/injection/product-seo/widget.ts +1 -1
  111. package/src/modules/communication_channels/lib/access-control.ts +3 -3
  112. package/src/modules/customer_accounts/AGENTS.md +3 -3
  113. package/src/modules/customer_accounts/api/invitations/accept.ts +1 -2
  114. package/src/modules/customer_accounts/api/login.ts +4 -2
  115. package/src/modules/customer_accounts/api/magic-link/verify.ts +4 -2
  116. package/src/modules/customer_accounts/api/portal/feature-check.ts +5 -7
  117. package/src/modules/customer_accounts/api/portal/nav.ts +5 -3
  118. package/src/modules/customer_accounts/api/portal/profile.ts +5 -1
  119. package/src/modules/customer_accounts/api/portal/sessions-refresh.ts +3 -3
  120. package/src/modules/customer_accounts/lib/customerAuth.ts +8 -2
  121. package/src/modules/customer_accounts/lib/customerAuthServer.ts +1 -0
  122. package/src/modules/customer_accounts/services/customerRbacService.ts +16 -3
  123. package/src/modules/customer_accounts/setup.ts +7 -0
  124. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +1 -3
  125. package/src/modules/customers/lib/visibilityFilter.ts +7 -5
  126. package/src/modules/dashboards/api/layout/[itemId]/route.ts +6 -3
  127. package/src/modules/dashboards/api/layout/route.ts +22 -9
  128. package/src/modules/dashboards/api/roles/widgets/route.ts +9 -3
  129. package/src/modules/dashboards/api/users/widgets/route.ts +9 -3
  130. package/src/modules/dashboards/api/widgets/catalog.ts +6 -3
  131. package/src/modules/dashboards/lib/access.ts +5 -3
  132. package/src/modules/directory/api/organization-branding/route.ts +12 -1
  133. package/src/modules/directory/api/organizations/route.ts +7 -0
  134. package/src/modules/directory/backend/directory/branding/page.tsx +34 -9
  135. package/src/modules/directory/commands/organizations.ts +9 -1
  136. package/src/modules/directory/data/entities.ts +3 -0
  137. package/src/modules/directory/data/validators.ts +2 -0
  138. package/src/modules/directory/i18n/de.json +3 -1
  139. package/src/modules/directory/i18n/en.json +3 -1
  140. package/src/modules/directory/i18n/es.json +3 -1
  141. package/src/modules/directory/i18n/pl.json +3 -1
  142. package/src/modules/directory/migrations/.snapshot-open-mercato.json +18 -1
  143. package/src/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.ts +13 -0
  144. package/src/modules/entities/lib/entityAcl.ts +47 -18
  145. package/src/modules/inbox_ops/ai-tools.ts +5 -3
  146. package/src/modules/messages/lib/routeHelpers.ts +4 -6
  147. package/src/modules/notifications/lib/notificationRecipients.ts +6 -8
  148. package/src/modules/query_index/di.ts +6 -0
  149. package/src/modules/query_index/lib/coverage.ts +9 -6
  150. package/src/modules/query_index/lib/indexer.ts +105 -19
  151. package/src/modules/query_index/lib/search-tokens.ts +19 -8
  152. package/src/modules/query_index/subscribers/coverage_refresh.ts +4 -0
  153. package/src/modules/query_index/subscribers/delete_one.ts +65 -1
  154. package/src/modules/query_index/subscribers/upsert_one.ts +68 -1
  155. package/src/modules/sales/commands/documents.ts +27 -0
  156. package/src/modules/sales/i18n/de.json +1 -0
  157. package/src/modules/sales/i18n/en.json +1 -0
  158. package/src/modules/sales/i18n/es.json +1 -0
  159. package/src/modules/sales/i18n/pl.json +1 -0
  160. package/src/modules/staff/api/interceptors.ts +6 -4
  161. package/src/modules/sync_excel/api/upload/route.ts +15 -2
  162. package/src/modules/workflows/lib/activity-executor.ts +12 -10
@@ -10,7 +10,7 @@ import {
10
10
  resolveWidgetAssignmentReadScope,
11
11
  resolveWidgetAssignmentTargetAccess,
12
12
  } from '@open-mercato/core/modules/dashboards/lib/widgetAssignmentScope'
13
- import { hasFeature } from '@open-mercato/shared/security/features'
13
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
14
14
  import {
15
15
  runCrudMutationGuardAfterSuccess,
16
16
  validateCrudMutationGuard,
@@ -61,7 +61,10 @@ export async function GET(req: Request) {
61
61
  const em = container.resolve('em') as any
62
62
  const rbac = container.resolve('rbacService') as any
63
63
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
64
- if (!acl.isSuperAdmin && !hasFeature(acl.features, FEATURE)) {
64
+ if (!authorizeFeatures([FEATURE], {
65
+ grantedFeatures: acl.features ?? [],
66
+ unrestricted: !!acl.isSuperAdmin,
67
+ })) {
65
68
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
66
69
  }
67
70
 
@@ -120,7 +123,10 @@ export async function PUT(req: Request) {
120
123
  const em = resolve('em') as any
121
124
  const rbac = resolve('rbacService') as any
122
125
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
123
- if (!acl.isSuperAdmin && !hasFeature(acl.features, FEATURE)) {
126
+ if (!authorizeFeatures([FEATURE], {
127
+ grantedFeatures: acl.features ?? [],
128
+ unrestricted: !!acl.isSuperAdmin,
129
+ })) {
124
130
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
125
131
  }
126
132
 
@@ -11,7 +11,7 @@ import {
11
11
  resolveWidgetAssignmentReadScope,
12
12
  resolveWidgetAssignmentTargetAccess,
13
13
  } from '@open-mercato/core/modules/dashboards/lib/widgetAssignmentScope'
14
- import { hasFeature } from '@open-mercato/shared/security/features'
14
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
15
15
  import {
16
16
  runCrudMutationGuardAfterSuccess,
17
17
  validateCrudMutationGuard,
@@ -44,7 +44,10 @@ export async function GET(req: Request) {
44
44
  const em = container.resolve('em') as any
45
45
  const rbac = container.resolve('rbacService') as any
46
46
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
47
- if (!acl.isSuperAdmin && !hasFeature(acl.features, FEATURE)) {
47
+ if (!authorizeFeatures([FEATURE], {
48
+ grantedFeatures: acl.features ?? [],
49
+ unrestricted: !!acl.isSuperAdmin,
50
+ })) {
48
51
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
49
52
  }
50
53
 
@@ -120,7 +123,10 @@ export async function PUT(req: Request) {
120
123
  const em = resolve('em') as any
121
124
  const rbac = resolve('rbacService') as any
122
125
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
123
- if (!acl.isSuperAdmin && !hasFeature(acl.features, FEATURE)) {
126
+ if (!authorizeFeatures([FEATURE], {
127
+ grantedFeatures: acl.features ?? [],
128
+ unrestricted: !!acl.isSuperAdmin,
129
+ })) {
124
130
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
125
131
  }
126
132
 
@@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { loadAllWidgets } from '@open-mercato/core/modules/dashboards/lib/widgets'
5
- import { hasFeature } from '@open-mercato/shared/security/features'
6
5
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
6
  import { dashboardsTag, dashboardsErrorSchema, dashboardWidgetCatalogSchema } from '../openapi'
8
7
 
@@ -16,8 +15,12 @@ export async function GET(req: Request) {
16
15
 
17
16
  const { resolve } = await createRequestContainer()
18
17
  const rbac = resolve('rbacService') as any
19
- const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
20
- if (!acl.isSuperAdmin && !hasFeature(acl.features, 'dashboards.admin.assign-widgets')) {
18
+ const canAssignWidgets = await rbac.userHasAllFeatures(
19
+ auth.sub,
20
+ ['dashboards.admin.assign-widgets'],
21
+ { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
22
+ )
23
+ if (!canAssignWidgets) {
21
24
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
22
25
  }
23
26
 
@@ -1,7 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { DashboardRoleWidgets, DashboardUserWidgets } from '../data/entities'
3
3
  import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
4
- import { hasAllFeatures as userHasAllFeatures } from '@open-mercato/shared/security/features'
4
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
5
5
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
6
 
7
7
  type LoadedWidget = {
@@ -97,8 +97,10 @@ export async function resolveAllowedWidgetIds(
97
97
 
98
98
  const filtered = widgets.filter((widget) => {
99
99
  if (!baseSet.has(widget.metadata.id)) return false
100
- if (ctx.isSuperAdmin) return true
101
- return userHasAllFeatures(ctx.features, widget.metadata.features ?? [])
100
+ return authorizeFeatures(widget.metadata.features ?? [], {
101
+ grantedFeatures: ctx.features,
102
+ unrestricted: ctx.isSuperAdmin,
103
+ })
102
104
  })
103
105
 
104
106
  return filtered.map((widget) => widget.metadata.id)
@@ -32,11 +32,13 @@ const brandingResponseSchema = z.object({
32
32
  organizationName: z.string(),
33
33
  tenantId: z.string().uuid(),
34
34
  logoUrl: z.string().nullable(),
35
+ logoPreserveAspectRatio: z.boolean(),
35
36
  updatedAt: z.string().nullable(),
36
37
  })
37
38
 
38
39
  const brandingUpdateSchema = z.object({
39
40
  logoUrl: organizationUpdateSchema.shape.logoUrl,
41
+ logoPreserveAspectRatio: organizationUpdateSchema.shape.logoPreserveAspectRatio,
40
42
  })
41
43
 
42
44
  const errorSchema = z.object({
@@ -132,6 +134,7 @@ function toResponsePayload(organization: Organization, tenantId: string) {
132
134
  organizationName: organization.name,
133
135
  tenantId,
134
136
  logoUrl: organization.logoUrl ?? null,
137
+ logoPreserveAspectRatio: !!organization.logoPreserveAspectRatio,
135
138
  updatedAt: toIsoOrNull(organization.updatedAt),
136
139
  }
137
140
  }
@@ -211,7 +214,12 @@ export async function PUT(req: Request) {
211
214
  operation: 'update',
212
215
  requestMethod: req.method,
213
216
  requestHeaders: req.headers,
214
- mutationPayload: { logoUrl: parsed.data.logoUrl ?? null },
217
+ mutationPayload: {
218
+ logoUrl: parsed.data.logoUrl ?? null,
219
+ ...(parsed.data.logoPreserveAspectRatio !== undefined
220
+ ? { logoPreserveAspectRatio: parsed.data.logoPreserveAspectRatio }
221
+ : {}),
222
+ },
215
223
  })
216
224
  if (guardResult && !guardResult.ok) {
217
225
  return NextResponse.json(guardResult.body, { status: guardResult.status })
@@ -232,6 +240,9 @@ export async function PUT(req: Request) {
232
240
  id: resolved.organizationId,
233
241
  tenantId: resolved.tenantId,
234
242
  logoUrl: parsed.data.logoUrl ?? null,
243
+ ...(parsed.data.logoPreserveAspectRatio !== undefined
244
+ ? { logoPreserveAspectRatio: parsed.data.logoPreserveAspectRatio }
245
+ : {}),
235
246
  },
236
247
  ctx,
237
248
  },
@@ -251,6 +251,7 @@ export async function GET(req: Request) {
251
251
  id: stringId(org.id),
252
252
  name: org.name,
253
253
  logoUrl: org.logoUrl ?? null,
254
+ logoPreserveAspectRatio: !!org.logoPreserveAspectRatio,
254
255
  parentId: org.parentId ?? null,
255
256
  tenantId: tenantId,
256
257
  isActive: !!org.isActive,
@@ -344,10 +345,12 @@ export async function GET(req: Request) {
344
345
  const slugByOrgId = new Map<string, string | null>()
345
346
  const updatedAtByOrgId = new Map<string, string | null>()
346
347
  const logoUrlByOrgId = new Map<string, string | null>()
348
+ const logoPreserveAspectRatioByOrgId = new Map<string, boolean>()
347
349
  for (const org of allOrgs) {
348
350
  slugByOrgId.set(String(org.id), org.slug ?? null)
349
351
  updatedAtByOrgId.set(String(org.id), org.updatedAt instanceof Date ? org.updatedAt.toISOString() : null)
350
352
  logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
353
+ logoPreserveAspectRatioByOrgId.set(String(org.id), !!org.logoPreserveAspectRatio)
351
354
  }
352
355
 
353
356
  const tenantIds = Array.from(byTenant.keys())
@@ -433,6 +436,7 @@ export async function GET(req: Request) {
433
436
  slug: slugByOrgId.get(recordId) ?? null,
434
437
  updatedAt: updatedAtByOrgId.get(recordId) ?? null,
435
438
  logoUrl: logoUrlByOrgId.get(recordId) ?? null,
439
+ logoPreserveAspectRatio: logoPreserveAspectRatioByOrgId.get(recordId) ?? false,
436
440
  tenantId: tid,
437
441
  tenantName: tenantNameMap[tid] ?? tid,
438
442
  parentId: node.parentId,
@@ -475,10 +479,12 @@ export async function GET(req: Request) {
475
479
  const hierarchy = computeHierarchyForOrganizations(orgs, tenantId)
476
480
  const slugByOrgId = new Map<string, string | null>()
477
481
  const logoUrlByOrgId = new Map<string, string | null>()
482
+ const logoPreserveAspectRatioByOrgId = new Map<string, boolean>()
478
483
  const updatedAtByOrgId = new Map<string, string | null>()
479
484
  for (const org of orgs) {
480
485
  slugByOrgId.set(String(org.id), org.slug ?? null)
481
486
  logoUrlByOrgId.set(String(org.id), org.logoUrl ?? null)
487
+ logoPreserveAspectRatioByOrgId.set(String(org.id), !!org.logoPreserveAspectRatio)
482
488
  updatedAtByOrgId.set(String(org.id), org.updatedAt instanceof Date ? org.updatedAt.toISOString() : null)
483
489
  }
484
490
 
@@ -543,6 +549,7 @@ export async function GET(req: Request) {
543
549
  name: node.name,
544
550
  slug: slugByOrgId.get(recordId) ?? null,
545
551
  logoUrl: logoUrlByOrgId.get(recordId) ?? null,
552
+ logoPreserveAspectRatio: logoPreserveAspectRatioByOrgId.get(recordId) ?? false,
546
553
  updatedAt: updatedAtByOrgId.get(recordId) ?? null,
547
554
  tenantId: node.tenantId,
548
555
  tenantName,
@@ -12,6 +12,7 @@ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimi
12
12
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
13
13
  import { Button } from '@open-mercato/ui/primitives/button'
14
14
  import { Input } from '@open-mercato/ui/primitives/input'
15
+ import { SwitchField } from '@open-mercato/ui/primitives/switch-field'
15
16
  import { useT } from '@open-mercato/shared/lib/i18n/context'
16
17
 
17
18
  type BrandingPayload = {
@@ -19,6 +20,7 @@ type BrandingPayload = {
19
20
  organizationName: string
20
21
  tenantId: string
21
22
  logoUrl: string | null
23
+ logoPreserveAspectRatio: boolean
22
24
  updatedAt: string | null
23
25
  }
24
26
 
@@ -38,6 +40,7 @@ export default function OrganizationBrandingPage() {
38
40
  const t = useT()
39
41
  const queryClient = useQueryClient()
40
42
  const [logoUrl, setLogoUrl] = React.useState('')
43
+ const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false)
41
44
  const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
42
45
  const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)
43
46
  const [saving, setSaving] = React.useState(false)
@@ -58,8 +61,9 @@ export default function OrganizationBrandingPage() {
58
61
 
59
62
  React.useEffect(() => {
60
63
  setLogoUrl(data?.logoUrl ?? '')
64
+ setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false)
61
65
  setSelectedFile(null)
62
- }, [data?.logoUrl])
66
+ }, [data?.logoPreserveAspectRatio, data?.logoUrl])
63
67
 
64
68
  React.useEffect(() => {
65
69
  if (!selectedFile || typeof URL === 'undefined') {
@@ -89,12 +93,16 @@ export default function OrganizationBrandingPage() {
89
93
  },
90
94
  { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },
91
95
  )
92
- return upload?.item.thumbnailUrl ?? upload?.item.url ?? null
96
+ return upload?.item.url ?? upload?.item.thumbnailUrl ?? null
93
97
  }, [selectedFile, t])
94
98
 
95
- const saveBranding = React.useCallback(async (nextLogoUrl?: string, options?: { skipUpload?: boolean }) => {
99
+ const saveBranding = React.useCallback(async (
100
+ nextLogoUrl?: string,
101
+ options?: { skipUpload?: boolean; logoPreserveAspectRatio?: boolean },
102
+ ) => {
96
103
  if (!data) return
97
104
  const shouldUpload = Boolean(selectedFile && !options?.skipUpload)
105
+ const resolvedLogoPreserveAspectRatio = options?.logoPreserveAspectRatio ?? logoPreserveAspectRatio
98
106
  setSaving(true)
99
107
  try {
100
108
  await runMutation({
@@ -108,7 +116,10 @@ export default function OrganizationBrandingPage() {
108
116
  {
109
117
  method: 'PUT',
110
118
  headers: { 'content-type': 'application/json' },
111
- body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),
119
+ body: JSON.stringify({
120
+ logoUrl: resolvedLogoUrl || null,
121
+ logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,
122
+ }),
112
123
  },
113
124
  { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },
114
125
  ),
@@ -123,6 +134,7 @@ export default function OrganizationBrandingPage() {
123
134
  mutationPayload: {
124
135
  organizationId: data.organizationId,
125
136
  logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,
137
+ logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,
126
138
  hasUpload: shouldUpload,
127
139
  },
128
140
  })
@@ -139,7 +151,7 @@ export default function OrganizationBrandingPage() {
139
151
  } finally {
140
152
  setSaving(false)
141
153
  }
142
- }, [data, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])
154
+ }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])
143
155
 
144
156
  const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
145
157
  event.preventDefault()
@@ -177,7 +189,7 @@ export default function OrganizationBrandingPage() {
177
189
  <img
178
190
  src={currentPreviewUrl}
179
191
  alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}
180
- className="h-full w-full object-contain"
192
+ className={`h-full w-full ${logoPreserveAspectRatio ? 'object-contain' : 'rounded-full object-cover'}`}
181
193
  />
182
194
  ) : (
183
195
  <ImagePlus className="size-10 text-muted-foreground" aria-hidden />
@@ -198,7 +210,7 @@ export default function OrganizationBrandingPage() {
198
210
  ref={fileInputRef}
199
211
  id="organization-logo-file"
200
212
  type="file"
201
- accept="image/png,image/jpeg,image/webp,image/svg+xml"
213
+ accept="image/png,image/jpeg,image/webp"
202
214
  onChange={(event) => {
203
215
  const file = event.currentTarget.files?.[0]
204
216
  if (!file) return
@@ -206,7 +218,7 @@ export default function OrganizationBrandingPage() {
206
218
  }}
207
219
  />
208
220
  <p className="text-xs text-muted-foreground">
209
- {t('directory.branding.file.hint', 'PNG, JPG, WebP, or SVG works best. Uploaded files are stored as organization attachments.')}
221
+ {t('directory.branding.file.hint', 'PNG, JPG, or WebP works best. Uploaded files are stored as organization attachments.')}
210
222
  </p>
211
223
  </div>
212
224
 
@@ -225,6 +237,18 @@ export default function OrganizationBrandingPage() {
225
237
  </p>
226
238
  </div>
227
239
 
240
+ <SwitchField
241
+ id="organization-logo-preserve-aspect-ratio"
242
+ label={t('directory.branding.aspectRatio.label', 'Keep the aspect ratio')}
243
+ description={t(
244
+ 'directory.branding.aspectRatio.description',
245
+ 'When disabled, the sidebar crops the logo into the standard icon shape.',
246
+ )}
247
+ checked={logoPreserveAspectRatio}
248
+ disabled={saving}
249
+ onCheckedChange={setLogoPreserveAspectRatio}
250
+ />
251
+
228
252
  <div className="flex flex-wrap items-center gap-2">
229
253
  <Button type="submit" disabled={saving}>
230
254
  {saving ? <Loader2 className="mr-2 size-4 animate-spin" aria-hidden /> : <Save className="mr-2 size-4" aria-hidden />}
@@ -238,7 +262,8 @@ export default function OrganizationBrandingPage() {
238
262
  setSelectedFile(null)
239
263
  if (fileInputRef.current) fileInputRef.current.value = ''
240
264
  setLogoUrl('')
241
- void saveBranding('', { skipUpload: true })
265
+ setLogoPreserveAspectRatio(false)
266
+ void saveBranding('', { skipUpload: true, logoPreserveAspectRatio: false })
242
267
  }}
243
268
  >
244
269
  <RotateCcw className="mr-2 size-4" aria-hidden />
@@ -85,6 +85,7 @@ type OrganizationUndoSnapshot = {
85
85
  name: string
86
86
  slug?: string | null
87
87
  logoUrl?: string | null
88
+ logoPreserveAspectRatio?: boolean
88
89
  isActive: boolean
89
90
  parentId: string | null
90
91
  childParents: ChildParentSnapshot[]
@@ -122,6 +123,7 @@ function serializeOrganization(entity: Organization, custom?: Record<string, unk
122
123
  name: entity.name,
123
124
  slug: entity.slug ?? null,
124
125
  logoUrl: entity.logoUrl ?? null,
126
+ logoPreserveAspectRatio: !!entity.logoPreserveAspectRatio,
125
127
  isActive: !!entity.isActive,
126
128
  parentId: entity.parentId ?? null,
127
129
  ancestorIds: Array.isArray(entity.ancestorIds) ? [...entity.ancestorIds] : [],
@@ -147,6 +149,7 @@ function captureOrganizationSnapshots(
147
149
  name: entity.name,
148
150
  slug: entity.slug ?? null,
149
151
  logoUrl: entity.logoUrl ?? null,
152
+ logoPreserveAspectRatio: !!entity.logoPreserveAspectRatio,
150
153
  isActive: !!entity.isActive,
151
154
  parentId: entity.parentId ?? null,
152
155
  childParents: (childParents ?? []).map((entry) => ({
@@ -307,6 +310,7 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
307
310
  name: parsed.name,
308
311
  slug,
309
312
  logoUrl: parsed.logoUrl ?? null,
313
+ logoPreserveAspectRatio: parsed.logoPreserveAspectRatio ?? false,
310
314
  isActive: parsed.isActive ?? true,
311
315
  parentId,
312
316
  },
@@ -434,6 +438,7 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
434
438
  existing.name = after.name
435
439
  if (after.slug !== undefined) existing.slug = after.slug ?? null
436
440
  if (after.logoUrl !== undefined) existing.logoUrl = after.logoUrl ?? null
441
+ if (after.logoPreserveAspectRatio !== undefined) existing.logoPreserveAspectRatio = !!after.logoPreserveAspectRatio
437
442
  existing.isActive = after.isActive
438
443
  existing.parentId = after.parentId
439
444
  await em.flush()
@@ -446,6 +451,7 @@ const createOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
446
451
  name: after.name,
447
452
  slug: after.slug ?? null,
448
453
  logoUrl: after.logoUrl ?? null,
454
+ logoPreserveAspectRatio: after.logoPreserveAspectRatio ?? false,
449
455
  tenant: em.getReference(Tenant, tenantId),
450
456
  isActive: after.isActive,
451
457
  parentId: after.parentId,
@@ -571,6 +577,7 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
571
577
  if (parsed.name !== undefined) entity.name = parsed.name
572
578
  if (resolvedSlug !== undefined) entity.slug = resolvedSlug
573
579
  if (parsed.logoUrl !== undefined) entity.logoUrl = parsed.logoUrl ?? null
580
+ if (parsed.logoPreserveAspectRatio !== undefined) entity.logoPreserveAspectRatio = parsed.logoPreserveAspectRatio
574
581
  if (parsed.isActive !== undefined) entity.isActive = parsed.isActive
575
582
  entity.parentId = parentId
576
583
  },
@@ -637,7 +644,7 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
637
644
  organizationId: String(result.id),
638
645
  })
639
646
  const after = serializeOrganization(result, custom)
640
- const changes = buildChanges(beforeRecord, after as Record<string, unknown>, ['name', 'slug', 'logoUrl', 'isActive', 'parentId'])
647
+ const changes = buildChanges(beforeRecord, after as Record<string, unknown>, ['name', 'slug', 'logoUrl', 'logoPreserveAspectRatio', 'isActive', 'parentId'])
641
648
  const customDiff = diffCustomFieldChanges(beforeRecord?.custom, custom)
642
649
  for (const [key, diff] of Object.entries(customDiff)) {
643
650
  changes[`cf_${key}`] = diff
@@ -675,6 +682,7 @@ const updateOrganizationCommand: CommandHandler<Record<string, unknown>, Organiz
675
682
  entity.name = before.name
676
683
  if (before.slug !== undefined) entity.slug = before.slug
677
684
  if (before.logoUrl !== undefined) entity.logoUrl = before.logoUrl ?? null
685
+ if (before.logoPreserveAspectRatio !== undefined) entity.logoPreserveAspectRatio = !!before.logoPreserveAspectRatio
678
686
  entity.isActive = before.isActive
679
687
  entity.parentId = before.parentId
680
688
  },
@@ -43,6 +43,9 @@ export class Organization {
43
43
  @Property({ name: 'logo_url', type: 'text', nullable: true })
44
44
  logoUrl?: string | null
45
45
 
46
+ @Property({ name: 'logo_preserve_aspect_ratio', type: 'boolean', default: false })
47
+ logoPreserveAspectRatio: boolean = false
48
+
46
49
  @Property({ name: 'is_active', type: 'boolean', default: true })
47
50
  isActive: boolean = true
48
51
 
@@ -28,6 +28,7 @@ export const organizationCreateSchema = z.object({
28
28
  name: z.string().min(1).max(200),
29
29
  slug: slugField,
30
30
  logoUrl: logoUrlField,
31
+ logoPreserveAspectRatio: z.boolean().optional(),
31
32
  isActive: z.boolean().optional(),
32
33
  parentId: z.string().uuid().nullable().optional(),
33
34
  childIds: z.array(z.string().uuid()).optional(),
@@ -39,6 +40,7 @@ export const organizationUpdateSchema = z.object({
39
40
  name: z.string().min(1).max(200).optional(),
40
41
  slug: slugField,
41
42
  logoUrl: logoUrlField,
43
+ logoPreserveAspectRatio: z.boolean().optional(),
42
44
  isActive: z.boolean().optional(),
43
45
  parentId: z.string().uuid().nullable().optional(),
44
46
  childIds: z.array(z.string().uuid()).optional(),
@@ -7,6 +7,8 @@
7
7
  "directory.audit.tenants.update": "Mandanten aktualisieren",
8
8
  "directory.branding.actions.reset": "Standardlogo verwenden",
9
9
  "directory.branding.actions.save": "Branding speichern",
10
+ "directory.branding.aspectRatio.description": "Wenn deaktiviert, schneidet die Seitenleiste das Logo auf die Standard-Iconform zu.",
11
+ "directory.branding.aspectRatio.label": "Seitenverhältnis beibehalten",
10
12
  "directory.branding.currentScope": "Aktuelle Organisation",
11
13
  "directory.branding.description": "Legen Sie das Logo fest, das in der Backend-Seitenleiste für die aktuell ausgewählte Organisation verwendet wird.",
12
14
  "directory.branding.errors.blocked": "Das Speichern des Brandings wurde blockiert.",
@@ -16,7 +18,7 @@
16
18
  "directory.branding.errors.organizationRequired": "Wählen Sie eine einzelne Organisation aus, bevor Sie das Seitenleisten-Branding ändern.",
17
19
  "directory.branding.errors.save": "Organisationsbranding konnte nicht aktualisiert werden",
18
20
  "directory.branding.errors.upload": "Logo konnte nicht hochgeladen werden",
19
- "directory.branding.file.hint": "PNG, JPG, WebP oder SVG funktioniert am besten. Hochgeladene Dateien werden als Organisationsanhänge gespeichert.",
21
+ "directory.branding.file.hint": "PNG, JPG oder WebP funktioniert am besten. Hochgeladene Dateien werden als Organisationsanhänge gespeichert.",
20
22
  "directory.branding.file.label": "Logo hochladen",
21
23
  "directory.branding.flash.saved": "Organisationsbranding aktualisiert",
22
24
  "directory.branding.loading": "Organisationsbranding wird geladen...",
@@ -7,6 +7,8 @@
7
7
  "directory.audit.tenants.update": "Update tenant",
8
8
  "directory.branding.actions.reset": "Use default logo",
9
9
  "directory.branding.actions.save": "Save branding",
10
+ "directory.branding.aspectRatio.description": "When disabled, the sidebar crops the logo into the standard icon shape.",
11
+ "directory.branding.aspectRatio.label": "Keep the aspect ratio",
10
12
  "directory.branding.currentScope": "Current organization",
11
13
  "directory.branding.description": "Set the logo used in the backend sidebar for the currently selected organization.",
12
14
  "directory.branding.errors.blocked": "Branding save was blocked.",
@@ -16,7 +18,7 @@
16
18
  "directory.branding.errors.organizationRequired": "Select a single organization before changing sidebar branding.",
17
19
  "directory.branding.errors.save": "Failed to update organization branding",
18
20
  "directory.branding.errors.upload": "Failed to upload logo",
19
- "directory.branding.file.hint": "PNG, JPG, WebP, or SVG works best. Uploaded files are stored as organization attachments.",
21
+ "directory.branding.file.hint": "PNG, JPG, or WebP works best. Uploaded files are stored as organization attachments.",
20
22
  "directory.branding.file.label": "Upload logo",
21
23
  "directory.branding.flash.saved": "Organization branding updated",
22
24
  "directory.branding.loading": "Loading organization branding...",
@@ -7,6 +7,8 @@
7
7
  "directory.audit.tenants.update": "Actualizar inquilino",
8
8
  "directory.branding.actions.reset": "Usar logo predeterminado",
9
9
  "directory.branding.actions.save": "Guardar marca",
10
+ "directory.branding.aspectRatio.description": "Cuando está desactivado, la barra lateral recorta el logo con la forma de icono estándar.",
11
+ "directory.branding.aspectRatio.label": "Mantener la relación de aspecto",
10
12
  "directory.branding.currentScope": "Organización actual",
11
13
  "directory.branding.description": "Define el logo usado en la barra lateral del backend para la organización seleccionada.",
12
14
  "directory.branding.errors.blocked": "Se bloqueó el guardado de la marca.",
@@ -16,7 +18,7 @@
16
18
  "directory.branding.errors.organizationRequired": "Selecciona una sola organización antes de cambiar la marca de la barra lateral.",
17
19
  "directory.branding.errors.save": "No se pudo actualizar la marca de la organización",
18
20
  "directory.branding.errors.upload": "No se pudo subir el logo",
19
- "directory.branding.file.hint": "PNG, JPG, WebP o SVG funcionan mejor. Los archivos subidos se guardan como adjuntos de la organización.",
21
+ "directory.branding.file.hint": "PNG, JPG o WebP funcionan mejor. Los archivos subidos se guardan como adjuntos de la organización.",
20
22
  "directory.branding.file.label": "Subir logo",
21
23
  "directory.branding.flash.saved": "Marca de la organización actualizada",
22
24
  "directory.branding.loading": "Cargando marca de la organización...",
@@ -7,6 +7,8 @@
7
7
  "directory.audit.tenants.update": "Aktualizuj najemcę",
8
8
  "directory.branding.actions.reset": "Użyj domyślnego logo",
9
9
  "directory.branding.actions.save": "Zapisz branding",
10
+ "directory.branding.aspectRatio.description": "Gdy opcja jest wyłączona, sidebar przycina logo do standardowego kształtu ikony.",
11
+ "directory.branding.aspectRatio.label": "Zachowaj proporcje",
10
12
  "directory.branding.currentScope": "Aktualna organizacja",
11
13
  "directory.branding.description": "Ustaw logo używane w sidebarze backendu dla aktualnie wybranej organizacji.",
12
14
  "directory.branding.errors.blocked": "Zapis brandingu został zablokowany.",
@@ -16,7 +18,7 @@
16
18
  "directory.branding.errors.organizationRequired": "Wybierz pojedynczą organizację przed zmianą brandingu sidebara.",
17
19
  "directory.branding.errors.save": "Nie udało się zaktualizować brandingu organizacji",
18
20
  "directory.branding.errors.upload": "Nie udało się przesłać logo",
19
- "directory.branding.file.hint": "Najlepiej sprawdzi się PNG, JPG, WebP albo SVG. Przesłane pliki są zapisywane jako załączniki organizacji.",
21
+ "directory.branding.file.hint": "Najlepiej sprawdzi się PNG, JPG albo WebP. Przesłane pliki są zapisywane jako załączniki organizacji.",
20
22
  "directory.branding.file.label": "Prześlij logo",
21
23
  "directory.branding.flash.saved": "Branding organizacji zaktualizowany",
22
24
  "directory.branding.loading": "Ładowanie brandingu organizacji...",
@@ -161,6 +161,23 @@
161
161
  "enumItems": [],
162
162
  "mappedType": "text"
163
163
  },
164
+ "logo_preserve_aspect_ratio": {
165
+ "name": "logo_preserve_aspect_ratio",
166
+ "type": "boolean",
167
+ "unsigned": false,
168
+ "autoincrement": false,
169
+ "primary": false,
170
+ "nullable": false,
171
+ "unique": false,
172
+ "length": null,
173
+ "precision": null,
174
+ "scale": null,
175
+ "default": "false",
176
+ "comment": null,
177
+ "collation": null,
178
+ "enumItems": [],
179
+ "mappedType": "boolean"
180
+ },
164
181
  "name": {
165
182
  "name": "name",
166
183
  "type": "text",
@@ -448,4 +465,4 @@
448
465
  ],
449
466
  "views": [],
450
467
  "nativeEnums": {}
451
- }
468
+ }
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260626145500DirectoryLogoPreserveAspectRatio extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "organizations" add "logo_preserve_aspect_ratio" boolean not null default false;`);
7
+ }
8
+
9
+ override down(): void | Promise<void> {
10
+ this.addSql(`alter table "organizations" drop column "logo_preserve_aspect_ratio";`);
11
+ }
12
+
13
+ }