@open-mercato/core 0.6.8-develop.7096.1.97319f09f6 → 0.6.8-develop.7100.1.fbf66fca35

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 (61) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/users/route.js +13 -5
  3. package/dist/modules/auth/api/users/route.js.map +2 -2
  4. package/dist/modules/auth/lib/userIdFilter.js +16 -0
  5. package/dist/modules/auth/lib/userIdFilter.js.map +7 -0
  6. package/dist/modules/devices/acl.js +9 -1
  7. package/dist/modules/devices/acl.js.map +2 -2
  8. package/dist/modules/devices/backend/devices/[id]/page.js +4 -20
  9. package/dist/modules/devices/backend/devices/[id]/page.js.map +2 -2
  10. package/dist/modules/devices/backend/devices/create/page.js +17 -1
  11. package/dist/modules/devices/backend/devices/create/page.js.map +2 -2
  12. package/dist/modules/devices/backend/devices/page.js +13 -21
  13. package/dist/modules/devices/backend/devices/page.js.map +2 -2
  14. package/dist/modules/devices/backend/devices/useDeviceUserLabels.js +34 -0
  15. package/dist/modules/devices/backend/devices/useDeviceUserLabels.js.map +7 -0
  16. package/dist/modules/devices/backend/devices/userOptions.js +52 -0
  17. package/dist/modules/devices/backend/devices/userOptions.js.map +7 -0
  18. package/dist/modules/devices/setup.js +6 -2
  19. package/dist/modules/devices/setup.js.map +2 -2
  20. package/dist/modules/wms/api/warehouseSearch.js +21 -0
  21. package/dist/modules/wms/api/warehouseSearch.js.map +7 -0
  22. package/dist/modules/wms/api/warehouses/route.js +13 -9
  23. package/dist/modules/wms/api/warehouses/route.js.map +2 -2
  24. package/dist/modules/wms/commands/configuration.js +59 -5
  25. package/dist/modules/wms/commands/configuration.js.map +2 -2
  26. package/dist/modules/wms/commands/shared.js +4 -0
  27. package/dist/modules/wms/commands/shared.js.map +2 -2
  28. package/dist/modules/wms/components/backend/WarehouseEditDialog.js +158 -0
  29. package/dist/modules/wms/components/backend/WarehouseEditDialog.js.map +7 -0
  30. package/dist/modules/wms/components/backend/WmsConfigurationPage.js +29 -103
  31. package/dist/modules/wms/components/backend/WmsConfigurationPage.js.map +2 -2
  32. package/dist/modules/wms/components/backend/warehouseFormOptions.js +66 -0
  33. package/dist/modules/wms/components/backend/warehouseFormOptions.js.map +7 -0
  34. package/package.json +7 -7
  35. package/src/modules/auth/api/users/route.ts +18 -5
  36. package/src/modules/auth/lib/userIdFilter.ts +31 -0
  37. package/src/modules/devices/AGENTS.md +12 -1
  38. package/src/modules/devices/acl.ts +9 -1
  39. package/src/modules/devices/backend/devices/[id]/page.tsx +5 -19
  40. package/src/modules/devices/backend/devices/create/page.tsx +17 -1
  41. package/src/modules/devices/backend/devices/page.tsx +20 -24
  42. package/src/modules/devices/backend/devices/useDeviceUserLabels.ts +45 -0
  43. package/src/modules/devices/backend/devices/userOptions.ts +99 -0
  44. package/src/modules/devices/i18n/de.json +3 -2
  45. package/src/modules/devices/i18n/en.json +3 -2
  46. package/src/modules/devices/i18n/es.json +3 -2
  47. package/src/modules/devices/i18n/ko.json +3 -2
  48. package/src/modules/devices/i18n/pl.json +3 -2
  49. package/src/modules/devices/setup.ts +6 -2
  50. package/src/modules/wms/api/warehouseSearch.ts +23 -0
  51. package/src/modules/wms/api/warehouses/route.ts +13 -9
  52. package/src/modules/wms/commands/configuration.ts +73 -7
  53. package/src/modules/wms/commands/shared.ts +9 -0
  54. package/src/modules/wms/components/backend/WarehouseEditDialog.tsx +216 -0
  55. package/src/modules/wms/components/backend/WmsConfigurationPage.tsx +31 -139
  56. package/src/modules/wms/components/backend/warehouseFormOptions.ts +72 -0
  57. package/src/modules/wms/i18n/de.json +2 -0
  58. package/src/modules/wms/i18n/en.json +2 -0
  59. package/src/modules/wms/i18n/es.json +2 -0
  60. package/src/modules/wms/i18n/ko.json +2 -0
  61. package/src/modules/wms/i18n/pl.json +2 -0
@@ -26,6 +26,7 @@ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/
26
26
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
27
27
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
28
28
  import { parseBooleanFlag } from '@open-mercato/shared/lib/boolean'
29
+ import { MAX_USER_LOOKUP_IDS, resolveUserIdFilter } from '@open-mercato/core/modules/auth/lib/userIdFilter'
29
30
  import { findEntityIdsBySearchTokensCompat, type SearchTokenDatabase } from '@open-mercato/shared/lib/search/tokenLookup'
30
31
  import { normalizeDisplayNameInput } from '@open-mercato/core/modules/auth/lib/displayName'
31
32
  import {
@@ -38,6 +39,7 @@ const logger = createLogger('auth').child({ component: 'users' })
38
39
 
39
40
  const querySchema = z.object({
40
41
  id: z.string().uuid().optional(),
42
+ ids: z.string().optional().describe('Comma-separated user identifiers, at most 100'),
41
43
  page: z.coerce.number().min(1).default(1),
42
44
  pageSize: z.coerce.number().min(1).max(100).default(50),
43
45
  search: z.string().optional(),
@@ -204,10 +206,17 @@ export async function GET(req: Request) {
204
206
  if (!auth) return NextResponse.json({ items: [], total: 0, totalPages: 1 })
205
207
  const url = new URL(req.url)
206
208
  const rawRoleIds = url.searchParams.getAll('roleId').filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
209
+ // Accept both the repeated (`?ids=a&ids=b`) and comma-joined (`?ids=a,b`) spellings.
210
+ const rawIds = url.searchParams.getAll('ids').join(',') || undefined
211
+ const userIdFilter = resolveUserIdFilter(rawIds, url.searchParams.get('id'))
207
212
  const parsed = querySchema.safeParse({
208
213
  id: url.searchParams.get('id') || undefined,
214
+ ids: rawIds,
209
215
  page: url.searchParams.get('page') || undefined,
210
- pageSize: url.searchParams.get('pageSize') || undefined,
216
+ // A caller resolving a batch of ids wants all of them; without this the default page of 50
217
+ // would silently truncate a 100-id lookup.
218
+ pageSize: url.searchParams.get('pageSize')
219
+ || (rawIds && userIdFilter.kind === 'ids' ? String(Math.min(userIdFilter.ids.length, MAX_USER_LOOKUP_IDS)) : undefined),
211
220
  search: url.searchParams.get('search') || undefined,
212
221
  name: url.searchParams.get('name') || undefined,
213
222
  organizationId: url.searchParams.get('organizationId') || undefined,
@@ -228,6 +237,9 @@ export async function GET(req: Request) {
228
237
  logger.error('Failed to resolve rbac', { err })
229
238
  }
230
239
  const { id, page, pageSize, search, name, organizationId, scopeToActiveOrganization, roleIds } = parsed.data
240
+ if (userIdFilter.kind === 'none') {
241
+ return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
242
+ }
231
243
  const filters: any[] = [{ deletedAt: null }]
232
244
  const actorTenantId = auth.tenantId ? String(auth.tenantId) : null
233
245
  let effectiveTenantId: string | null = null
@@ -293,7 +305,8 @@ export async function GET(req: Request) {
293
305
  }
294
306
  filters.push(displayNameFilters.length > 1 ? { $or: displayNameFilters } : displayNameFilters[0])
295
307
  }
296
- let idFilter: Set<string> | null = id ? new Set([id]) : null
308
+ // `?id=` and `?ids=` are already intersected by resolveUserIdFilter.
309
+ let idFilter: Set<string> | null = userIdFilter.kind === 'ids' ? new Set(userIdFilter.ids) : null
297
310
  if (Array.isArray(roleIds) && roleIds.length > 0) {
298
311
  const uniqueRoleIds = Array.from(new Set(roleIds))
299
312
  const linksForRoles = await em.find(
@@ -387,10 +400,10 @@ export async function GET(req: Request) {
387
400
 
388
401
  filters.push(searchFilters.length > 1 ? { $or: searchFilters } : searchFilters[0])
389
402
  }
403
+ // `?id=` has no separate path: resolveUserIdFilter folds it into `idFilter`, and a `kind: 'none'`
404
+ // outcome already returned above, so `idFilter` is null only when neither param was supplied.
390
405
  if (idFilter && idFilter.size) {
391
406
  filters.push({ id: { $in: Array.from(idFilter) as any } })
392
- } else if (id) {
393
- filters.push({ id })
394
407
  }
395
408
  const where = filters.length > 1 ? { $and: filters } : filters[0]
396
409
  const [rows, count] = await em.findAndCount(User, where, { limit: pageSize, offset: (page - 1) * pageSize })
@@ -713,7 +726,7 @@ export const openApi: OpenApiRouteDoc = {
713
726
  GET: {
714
727
  summary: 'List users',
715
728
  description:
716
- 'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters. Pass scopeToActiveOrganization=1 to restrict results to the caller\'s active organization (used by recipient/assignee pickers so suggestions stay within the org that owns the resulting record).',
729
+ 'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters. Pass scopeToActiveOrganization=1 to restrict results to the caller\'s active organization (used by recipient/assignee pickers so suggestions stay within the org that owns the resulting record). Pass ids=<uuid>,<uuid> (max 100) to resolve a known set of users in one request, for example to label a list of foreign keys; it intersects with id and roleId, and a supplied ids value that contains no valid identifier matches nothing.',
717
730
  query: querySchema,
718
731
  responses: [
719
732
  { status: 200, description: 'User collection', schema: userListResponseSchema },
@@ -0,0 +1,31 @@
1
+ import { isIdsParamProvided, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'
2
+
3
+ // Batch id lookup shares the list's page cap, so `?ids=` can never pull more rows than `?pageSize=`.
4
+ export const MAX_USER_LOOKUP_IDS = 100
5
+
6
+ export type UserIdFilter =
7
+ /** Neither `?id=` nor `?ids=` was supplied — the list is filtered by the other params only. */
8
+ | { kind: 'unfiltered' }
9
+ /** Restrict the list to these ids. */
10
+ | { kind: 'ids'; ids: string[] }
11
+ /**
12
+ * `?ids=` was supplied but nothing usable survived — either no value was a UUID, or the
13
+ * intersection with `?id=` is empty. Match nothing rather than dropping the filter and returning
14
+ * the full first page, which would turn a malformed request into a record-count side channel
15
+ * (the same rule `mergeIdFilter` enforces for CRUD list routes, #4143 Finding 3).
16
+ */
17
+ | { kind: 'none' }
18
+
19
+ export function resolveUserIdFilter(
20
+ rawIds: unknown,
21
+ id?: string | null,
22
+ maxIds: number = MAX_USER_LOOKUP_IDS,
23
+ ): UserIdFilter {
24
+ const single = typeof id === 'string' && id.trim() ? id.trim() : null
25
+ if (!isIdsParamProvided(rawIds)) {
26
+ return single ? { kind: 'ids', ids: [single] } : { kind: 'unfiltered' }
27
+ }
28
+ const parsed = parseIdsParam(rawIds, maxIds)
29
+ const ids = single ? parsed.filter((value) => value === single) : parsed
30
+ return ids.length ? { kind: 'ids', ids } : { kind: 'none' }
31
+ }
@@ -97,9 +97,20 @@ write busts both caches (see cache-tag note above).
97
97
  ## ACL
98
98
 
99
99
  `devices.view`, `devices.manage` (self-serve), `devices.admin` (cross-user). Defaults in `setup.ts`:
100
- `superadmin`/`admin` get `devices.*`; `employee` gets `view` + `manage`. Run
100
+ `superadmin`/`admin` get `devices.*` **plus `auth.users.list`**; `employee` gets `view` + `manage`. Run
101
101
  `yarn mercato auth sync-role-acls` after changing `acl.ts`/`setup.ts` to backfill existing tenants.
102
102
 
103
+ `devices.admin` declares `dependsOn: ['auth.users.list']`: the admin screens name owners by person,
104
+ and the register form's owner picker rejects values that do not resolve to a directory entry, so a
105
+ role holding `devices.admin` without the dependency cannot complete that form. `dependsOn` only
106
+ surfaces the gap in the ACL editor — it does not grant. Run the sync command above after deploying
107
+ an ACL change here.
108
+
109
+ The picker is `allowCustomValues: false`. That rejects free text and an id belonging to nobody; it
110
+ does **not** reject a raw id that is already a known option, because `ComboboxInput` matches on
111
+ `option.value` and `CrudForm` keeps the unfiltered first page as suggestions for the form's
112
+ lifetime. Either way the submitted value is a real user in the caller's scope.
113
+
103
114
  ## Validation Commands
104
115
 
105
116
  ```bash
@@ -1,7 +1,15 @@
1
1
  export const features = [
2
2
  { id: 'devices.view', title: 'View own devices', module: 'devices' },
3
3
  { id: 'devices.manage', title: 'Manage own devices', module: 'devices' },
4
- { id: 'devices.admin', title: 'Manage devices across users', module: 'devices' },
4
+ {
5
+ id: 'devices.admin',
6
+ title: 'Manage devices across users',
7
+ module: 'devices',
8
+ // Managing devices across users means naming the owner, and the admin screens name them by
9
+ // person rather than by UUID: the register form's owner picker and the owner column on both
10
+ // list and detail resolve through `GET /api/auth/users`, which `auth.users.list` gates.
11
+ dependsOn: ['auth.users.list'],
12
+ },
5
13
  ]
6
14
 
7
15
  export default features
@@ -9,6 +9,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
10
  import { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
11
11
  import { useT } from '@open-mercato/shared/lib/i18n/context'
12
+ import { useDeviceUserLabels } from '../useDeviceUserLabels'
12
13
 
13
14
  type DeviceDetail = {
14
15
  id: string
@@ -35,7 +36,6 @@ export default function DeviceAdminEditPage({ params }: { params?: { id?: string
35
36
  const [isLoading, setIsLoading] = React.useState(true)
36
37
  const [error, setError] = React.useState<string | null>(null)
37
38
  const [notFound, setNotFound] = React.useState(false)
38
- const [userLabel, setUserLabel] = React.useState<string | null>(null)
39
39
 
40
40
  React.useEffect(() => {
41
41
  let cancelled = false
@@ -63,24 +63,10 @@ export default function DeviceAdminEditPage({ params }: { params?: { id?: string
63
63
  }, [id, t])
64
64
 
65
65
  // Resolve the owner's display name for a link to their profile. Devices admins may not hold
66
- // auth.users.list, so fall back to the raw id (rendered without a link) instead of redirecting.
67
- React.useEffect(() => {
68
- const userId = device?.userId
69
- if (!userId) return
70
- let cancelled = false
71
- void (async () => {
72
- const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(
73
- `/api/auth/users?id=${encodeURIComponent(userId)}`,
74
- { headers: { 'x-om-forbidden-redirect': '0' } },
75
- { fallback: null },
76
- ).catch(() => null)
77
- if (cancelled || !call || !call.ok) return
78
- const found = call.result?.items?.find((u) => u.id === userId)
79
- const label = found?.name?.trim() || found?.email?.trim() || null
80
- if (label) setUserLabel(label)
81
- })()
82
- return () => { cancelled = true }
83
- }, [device?.userId])
66
+ // auth.users.list, so this falls back to the raw id (rendered without a link) instead of redirecting.
67
+ const ownerIds = React.useMemo(() => [device?.userId], [device?.userId])
68
+ const userLabels = useDeviceUserLabels(ownerIds)
69
+ const userLabel = device?.userId ? userLabels[device.userId] ?? null : null
84
70
 
85
71
  const fields = React.useMemo<CrudField[]>(() => [
86
72
  { id: 'clientAppVersion', label: t('devices.form.appVersion'), type: 'text' },
@@ -6,6 +6,7 @@ import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/b
6
6
  import { createCrud } from '@open-mercato/ui/backend/utils/crud'
7
7
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { loadDeviceUserOptions } from '../userOptions'
9
10
 
10
11
  type FormValues = {
11
12
  userId: string
@@ -27,7 +28,22 @@ export default function DeviceAdminCreatePage() {
27
28
  const t = useT()
28
29
 
29
30
  const fields = React.useMemo<CrudField[]>(() => [
30
- { id: 'userId', label: t('devices.form.userId'), type: 'text', required: true, description: t('devices.form.userIdHint') },
31
+ {
32
+ id: 'userId',
33
+ label: t('devices.form.userId'),
34
+ type: 'combobox',
35
+ required: true,
36
+ description: t('devices.form.userIdHint'),
37
+ placeholder: t('devices.form.userIdPlaceholder'),
38
+ loadOptions: loadDeviceUserOptions,
39
+ // The owner must resolve to a real directory entry: `ComboboxInput` reverts anything that is
40
+ // not a known option on blur, so free text and an id belonging to nobody never reach submit.
41
+ // (A raw id that IS a known option still resolves — `findOptionForInput` matches on value, and
42
+ // `CrudForm` keeps the unfiltered first page as suggestions — which is a real user either way.)
43
+ // `devices.admin` declares `dependsOn: ['auth.users.list']` in `acl.ts`, so a role that can
44
+ // reach this form can also search the directory that fills the picker.
45
+ allowCustomValues: false,
46
+ },
31
47
  { id: 'deviceId', label: t('devices.form.deviceId'), type: 'text', required: true },
32
48
  {
33
49
  id: 'platform',
@@ -12,6 +12,8 @@ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/u
12
12
  import { useT } from '@open-mercato/shared/lib/i18n/context'
13
13
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
14
14
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
15
+ import { loadDeviceUserOptions, type DeviceUserOption } from './userOptions'
16
+ import { useDeviceUserLabels } from './useDeviceUserLabels'
15
17
 
16
18
  type Row = {
17
19
  id: string
@@ -58,43 +60,37 @@ export default function DevicesAdminListPage() {
58
60
  const t = useT()
59
61
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
60
62
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
61
- const [userOptions, setUserOptions] = React.useState<{ value: string; label: string; description?: string | null }[]>([])
63
+ const [userOptions, setUserOptions] = React.useState<DeviceUserOption[]>([])
62
64
 
63
- // Devices admins may not hold auth.users.list; degrade gracefully (no options) instead of redirecting.
64
- const loadUserOptions = React.useCallback(async (query?: string) => {
65
- const params = new URLSearchParams()
66
- params.set('page', '1')
67
- params.set('pageSize', '20')
68
- if (query && query.trim().length > 0) params.set('search', query.trim())
69
- const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(
70
- `/api/auth/users?${params.toString()}`,
71
- { headers: { 'x-om-forbidden-redirect': '0' } },
72
- { fallback: null },
73
- ).catch(() => null)
74
- if (!call || !call.ok) return []
75
- const next = (call.result?.items ?? []).flatMap((item) => {
76
- if (!item || typeof item.id !== 'string' || !item.id.trim()) return []
77
- const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null
78
- const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null
79
- const label = name ?? email ?? item.id
80
- return [{ value: item.id, label, description: email && email !== label ? email : null }]
81
- })
65
+ const mergeUserOptions = React.useCallback((next: DeviceUserOption[]) => {
66
+ if (next.length === 0) return
82
67
  setUserOptions((prev) => {
83
68
  const map = new Map(prev.map((opt) => [opt.value, opt]))
84
69
  for (const opt of next) map.set(opt.value, opt)
85
70
  return Array.from(map.values())
86
71
  })
87
- return next
88
72
  }, [])
89
73
 
74
+ // Devices admins may not hold auth.users.list; the helper degrades to no options instead of
75
+ // redirecting the whole page to /login.
76
+ const loadUserOptions = React.useCallback(async (query?: string) => {
77
+ const next = await loadDeviceUserOptions(query)
78
+ mergeUserOptions(next)
79
+ return next
80
+ }, [mergeUserOptions])
81
+
90
82
  React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])
91
83
 
92
- // Reuse the picker cache to label the User column; rows whose owner isn't cached still link by id.
93
84
  const userLabelById = React.useMemo(
94
85
  () => new Map(userOptions.map((opt) => [opt.value, opt.label])),
95
86
  [userOptions],
96
87
  )
97
88
 
89
+ // The picker only ever caches the users it happened to prefetch, so resolve the owners of the rows
90
+ // actually on this page. Without it most rows render a bare UUID.
91
+ const rowUserIds = React.useMemo(() => rows.map((row) => row.userId), [rows])
92
+ const resolvedUserLabels = useDeviceUserLabels(rowUserIds)
93
+
98
94
  const filters = React.useMemo<FilterDef[]>(() => [
99
95
  {
100
96
  id: 'platform',
@@ -194,7 +190,7 @@ export default function DevicesAdminListPage() {
194
190
  header: t('devices.list.columns.user'),
195
191
  cell: ({ row }) => {
196
192
  const userId = row.original.userId
197
- const label = userLabelById.get(userId)
193
+ const label = resolvedUserLabels[userId] ?? userLabelById.get(userId)
198
194
  return (
199
195
  // Stop the click bubbling to the row, whose default action navigates to the device edit page.
200
196
  <Link
@@ -227,7 +223,7 @@ export default function DevicesAdminListPage() {
227
223
  header: t('devices.list.columns.lastSeen'),
228
224
  cell: ({ row }) => formatDate(row.original.lastSeenAt, t),
229
225
  },
230
- ], [t, userLabelById])
226
+ ], [t, userLabelById, resolvedUserLabels])
231
227
 
232
228
  return (
233
229
  <Page>
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { resolveDeviceUserOptions } from './userOptions'
5
+
6
+ // Resolves owner ids that are on screen into display labels. Modelled on
7
+ // warranty_claims/backend/components/useUserDisplayNames, but every failure degrades to an empty
8
+ // map instead of throwing: a devices admin without `auth.users.list` must still see the page.
9
+ export function useDeviceUserLabels(userIds: readonly (string | null | undefined)[]): Record<string, string> {
10
+ const [labels, setLabels] = React.useState<Record<string, string>>({})
11
+ const resolvedIdsRef = React.useRef<Set<string>>(new Set())
12
+
13
+ const idsKey = React.useMemo(() => {
14
+ const normalized = new Set<string>()
15
+ for (const userId of userIds) {
16
+ if (typeof userId === 'string' && userId.trim()) normalized.add(userId.trim())
17
+ }
18
+ return Array.from(normalized)
19
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
20
+ .join(',')
21
+ }, [userIds])
22
+
23
+ React.useEffect(() => {
24
+ if (!idsKey) return
25
+ const unresolved = idsKey.split(',').filter((userId) => !resolvedIdsRef.current.has(userId))
26
+ if (unresolved.length === 0) return
27
+
28
+ const controller = new AbortController()
29
+ void resolveDeviceUserOptions(unresolved, controller.signal)
30
+ .then(({ options, resolvedIds }) => {
31
+ if (controller.signal.aborted) return
32
+ // Only ids the server actually answered for are remembered. Marking an id whose request
33
+ // failed would keep its row showing a bare UUID for the life of the component, even though
34
+ // the next attempt would have worked.
35
+ for (const userId of resolvedIds) resolvedIdsRef.current.add(userId)
36
+ const next: Record<string, string> = {}
37
+ for (const option of options) next[option.value] = option.label
38
+ if (Object.keys(next).length) setLabels((current) => ({ ...current, ...next }))
39
+ })
40
+ .catch(() => {})
41
+ return () => controller.abort()
42
+ }, [idsKey])
43
+
44
+ return labels
45
+ }
@@ -0,0 +1,99 @@
1
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
2
+ import { MAX_USER_LOOKUP_IDS } from '@open-mercato/core/modules/auth/lib/userIdFilter'
3
+
4
+ export type DeviceUserOption = {
5
+ value: string
6
+ label: string
7
+ }
8
+
9
+ type AuthUserItem = {
10
+ id?: unknown
11
+ name?: unknown
12
+ email?: unknown
13
+ }
14
+
15
+ const SEARCH_PAGE_SIZE = 20
16
+ // Imported rather than restated: `parseIdsParam` slices past the route's cap without complaining,
17
+ // so a client batch larger than the server accepts loses the overflow ids silently.
18
+ const MAX_IDS_PER_LOOKUP = MAX_USER_LOOKUP_IDS
19
+
20
+ /**
21
+ * Two label styles on purpose. A picker suggestion has to disambiguate two people with the same
22
+ * display name, and neither `CrudForm`'s combobox nor `FilterBar` renders anything but the label —
23
+ * so the email has to live in the label there. A resolved column label is read next to a device,
24
+ * where the email is noise.
25
+ */
26
+ type LabelStyle = 'search' | 'compact'
27
+
28
+ function toOption(item: AuthUserItem | null | undefined, style: LabelStyle): DeviceUserOption[] {
29
+ if (!item || typeof item.id !== 'string' || !item.id.trim()) return []
30
+ const id = item.id.trim()
31
+ const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null
32
+ const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null
33
+ const label = style === 'search' && name && email ? `${name} — ${email}` : name ?? email ?? id
34
+ return [{ value: id, label }]
35
+ }
36
+
37
+ // `devices.admin` declares `dependsOn: ['auth.users.list']`, but dependsOn only diagnoses — a
38
+ // hand-built role can still reach these screens without it until the ACL editor or
39
+ // `sync-role-acls` fixes it. `x-om-forbidden-redirect: 0` keeps that 403 from bouncing the whole
40
+ // page to /login. `null` means the call itself failed, which callers must not
41
+ // confuse with a successful call that matched nobody — a caller caching "already resolved" would
42
+ // otherwise remember a transient network error forever.
43
+ async function fetchUsers(
44
+ params: URLSearchParams,
45
+ style: LabelStyle,
46
+ signal?: AbortSignal,
47
+ ): Promise<DeviceUserOption[] | null> {
48
+ const call = await apiCall<{ items?: AuthUserItem[] }>(
49
+ `/api/auth/users?${params.toString()}`,
50
+ { headers: { 'x-om-forbidden-redirect': '0' }, signal },
51
+ { fallback: null },
52
+ ).catch(() => null)
53
+ if (!call || !call.ok) return null
54
+ return (call.result?.items ?? []).flatMap((item) => toOption(item, style))
55
+ }
56
+
57
+ export async function loadDeviceUserOptions(query?: string): Promise<DeviceUserOption[]> {
58
+ const params = new URLSearchParams()
59
+ params.set('page', '1')
60
+ params.set('pageSize', String(SEARCH_PAGE_SIZE))
61
+ const trimmed = query?.trim()
62
+ if (trimmed) params.set('search', trimmed)
63
+ // A picker has nothing to cache, so a failed lookup is just an empty suggestion list.
64
+ return (await fetchUsers(params, 'search')) ?? []
65
+ }
66
+
67
+ /**
68
+ * The outcome of a batch lookup. `resolvedIds` lists the ids the server actually answered for —
69
+ * an id that came back without a row (deleted user) still counts as resolved, an id whose request
70
+ * failed does not. Callers cache on `resolvedIds`, so a transient failure is retried rather than
71
+ * remembered as a permanent blank.
72
+ */
73
+ export type DeviceUserLookup = {
74
+ options: DeviceUserOption[]
75
+ resolvedIds: string[]
76
+ }
77
+
78
+ // Batch id → label resolution for rows already on screen, so a device whose owner never appeared in
79
+ // a search result still renders a name instead of a bare UUID.
80
+ export async function resolveDeviceUserOptions(ids: string[], signal?: AbortSignal): Promise<DeviceUserLookup> {
81
+ const unique = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)))
82
+ if (unique.length === 0) return { options: [], resolvedIds: [] }
83
+ const options: DeviceUserOption[] = []
84
+ const resolvedIds: string[] = []
85
+ for (let offset = 0; offset < unique.length; offset += MAX_IDS_PER_LOOKUP) {
86
+ const batch = unique.slice(offset, offset + MAX_IDS_PER_LOOKUP)
87
+ const params = new URLSearchParams()
88
+ params.set('page', '1')
89
+ params.set('pageSize', String(batch.length))
90
+ // URLSearchParams encodes on toString(); pre-encoding here would double-escape the commas.
91
+ params.set('ids', batch.join(','))
92
+ const batchOptions = await fetchUsers(params, 'compact', signal)
93
+ // One failed batch must not cost the batches that did answer.
94
+ if (batchOptions === null) continue
95
+ options.push(...batchOptions)
96
+ resolvedIds.push(...batch)
97
+ }
98
+ return { options, resolvedIds }
99
+ }
@@ -23,8 +23,9 @@
23
23
  "devices.form.pushTokenHint": "Optional. Sicher gespeichert und nie wieder angezeigt.",
24
24
  "devices.form.success.created": "Gerät registriert",
25
25
  "devices.form.success.updated": "Gerät aktualisiert",
26
- "devices.form.userId": "Benutzer-ID",
27
- "devices.form.userIdHint": "UUID des Benutzers, dem dieses Gerät gehört.",
26
+ "devices.form.userId": "Benutzer",
27
+ "devices.form.userIdHint": "Namen oder E-Mail eingeben und den Besitzer aus der Liste auswählen.",
28
+ "devices.form.userIdPlaceholder": "Benutzer suchen",
28
29
  "devices.list.actions.deactivate": "Deaktivieren",
29
30
  "devices.list.actions.edit": "Bearbeiten",
30
31
  "devices.list.actions.register": "Gerät registrieren",
@@ -23,8 +23,9 @@
23
23
  "devices.form.pushTokenHint": "Optional. Stored securely and never shown again.",
24
24
  "devices.form.success.created": "Device registered",
25
25
  "devices.form.success.updated": "Device updated",
26
- "devices.form.userId": "User ID",
27
- "devices.form.userIdHint": "UUID of the user who owns this device.",
26
+ "devices.form.userId": "User",
27
+ "devices.form.userIdHint": "Start typing a name or email, then pick the owner from the list.",
28
+ "devices.form.userIdPlaceholder": "Search users",
28
29
  "devices.list.actions.deactivate": "Deactivate",
29
30
  "devices.list.actions.edit": "Edit",
30
31
  "devices.list.actions.register": "Register device",
@@ -23,8 +23,9 @@
23
23
  "devices.form.pushTokenHint": "Opcional. Se almacena de forma segura y no se vuelve a mostrar.",
24
24
  "devices.form.success.created": "Dispositivo registrado",
25
25
  "devices.form.success.updated": "Dispositivo actualizado",
26
- "devices.form.userId": "ID de usuario",
27
- "devices.form.userIdHint": "UUID del usuario propietario de este dispositivo.",
26
+ "devices.form.userId": "Usuario",
27
+ "devices.form.userIdHint": "Empieza a escribir un nombre o correo electrónico y elige el propietario de la lista.",
28
+ "devices.form.userIdPlaceholder": "Buscar usuarios",
28
29
  "devices.list.actions.deactivate": "Desactivar",
29
30
  "devices.list.actions.edit": "Editar",
30
31
  "devices.list.actions.register": "Registrar dispositivo",
@@ -23,8 +23,9 @@
23
23
  "devices.form.pushTokenHint": "선택 사항입니다. 안전하게 저장되며 다시 표시되지 않습니다.",
24
24
  "devices.form.success.created": "기기가 등록되었습니다",
25
25
  "devices.form.success.updated": "기기가 수정되었습니다",
26
- "devices.form.userId": "사용자 ID",
27
- "devices.form.userIdHint": " 기기를 소유한 사용자의 UUID입니다.",
26
+ "devices.form.userId": "사용자",
27
+ "devices.form.userIdHint": "이름 또는 이메일을 입력한 다음 목록에서 소유자를 선택하세요.",
28
+ "devices.form.userIdPlaceholder": "사용자 검색",
28
29
  "devices.list.actions.deactivate": "비활성화",
29
30
  "devices.list.actions.edit": "수정",
30
31
  "devices.list.actions.register": "기기 등록",
@@ -23,8 +23,9 @@
23
23
  "devices.form.pushTokenHint": "Opcjonalne. Token jest przechowywany bezpiecznie i nigdy więcej niewyświetlany.",
24
24
  "devices.form.success.created": "Urządzenie zarejestrowane",
25
25
  "devices.form.success.updated": "Urządzenie zaktualizowane",
26
- "devices.form.userId": "ID użytkownika",
27
- "devices.form.userIdHint": "UUID użytkownika, do którego należy to urządzenie.",
26
+ "devices.form.userId": "Użytkownik",
27
+ "devices.form.userIdHint": "Zacznij wpisywać nazwę lub e-mail, a następnie wybierz właściciela z listy.",
28
+ "devices.form.userIdPlaceholder": "Szukaj użytkowników",
28
29
  "devices.list.actions.deactivate": "Dezaktywuj",
29
30
  "devices.list.actions.edit": "Edytuj",
30
31
  "devices.list.actions.register": "Zarejestruj urządzenie",
@@ -2,8 +2,12 @@ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
2
 
3
3
  export const setup: ModuleSetupConfig = {
4
4
  defaultRoleFeatures: {
5
- superadmin: ['devices.*'],
6
- admin: ['devices.*'],
5
+ // `auth.users.list` is granted alongside `devices.admin` rather than left to the auth module's
6
+ // own `admin: ['auth.*']`, so the dependency declared in `acl.ts` holds even where that grant
7
+ // was narrowed. Without it the owner picker has nothing to offer and the register form cannot
8
+ // be completed. Existing tenants pick this up via `yarn mercato auth sync-role-acls`.
9
+ superadmin: ['devices.*', 'auth.users.list'],
10
+ admin: ['devices.*', 'auth.users.list'],
7
11
  employee: ['devices.view', 'devices.manage'],
8
12
  },
9
13
  }
@@ -0,0 +1,23 @@
1
+ import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
2
+ import { locales } from '@open-mercato/shared/lib/i18n/config'
3
+ import { matchCountryCodes } from '@open-mercato/shared/lib/location/countries'
4
+
5
+ /**
6
+ * Warehouse list search matches name/code/city plus both stored country values:
7
+ * ISO codes (`PL`) and legacy free-text (`Poland`). Localized labels shown in
8
+ * the table (`Poland` / `Polska`) must resolve back to the stored ISO code.
9
+ */
10
+ export function buildWarehouseListSearchOr(term: string): Array<Record<string, unknown>> {
11
+ const like = `%${escapeLikePattern(term)}%`
12
+ const orFilters: Array<Record<string, unknown>> = [
13
+ { name: { $ilike: like } },
14
+ { code: { $ilike: like } },
15
+ { city: { $ilike: like } },
16
+ { country: { $ilike: like } },
17
+ ]
18
+ const matchedCountryCodes = matchCountryCodes(term, { locales })
19
+ if (matchedCountryCodes.length > 0) {
20
+ orFilters.push({ country: { $in: matchedCountryCodes } })
21
+ }
22
+ return orFilters
23
+ }
@@ -2,9 +2,9 @@ import { z } from 'zod'
2
2
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
3
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
4
  import { parseScopedCommandInput, resolveCrudRecordId } from '@open-mercato/shared/lib/api/scoped'
5
- import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
6
5
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
7
6
  import { E } from '#generated/entities.ids.generated'
7
+ import { buildWarehouseListSearchOr } from '../warehouseSearch'
8
8
  import { Warehouse } from '../../data/entities'
9
9
  import { warehouseCreateSchema, warehouseUpdateSchema } from '../../data/validators'
10
10
  import { createPagedListResponseSchema, createWmsCrudOpenApi, defaultOkResponseSchema } from '../openapi'
@@ -77,6 +77,9 @@ const crud = makeCrudRoute({
77
77
  F.created_at,
78
78
  F.updated_at,
79
79
  ],
80
+ decorateCustomFields: {
81
+ entityIds: E.wms.warehouse,
82
+ },
80
83
  sortFieldMap: {
81
84
  name: F.name,
82
85
  code: F.code,
@@ -94,13 +97,7 @@ const crud = makeCrudRoute({
94
97
  if (isActive !== null) filters[F.is_active] = { $eq: isActive }
95
98
  const term = query.search?.trim()
96
99
  if (term) {
97
- const like = `%${escapeLikePattern(term)}%`
98
- filters.$or = [
99
- { [F.name]: { $ilike: like } },
100
- { [F.code]: { $ilike: like } },
101
- { [F.city]: { $ilike: like } },
102
- { [F.country]: { $ilike: like } },
103
- ]
100
+ filters.$or = buildWarehouseListSearchOr(term)
104
101
  }
105
102
  return filters
106
103
  },
@@ -113,7 +110,10 @@ const crud = makeCrudRoute({
113
110
  const { translate } = await resolveTranslations()
114
111
  return parseScopedCommandInput(warehouseCreateSchema, raw ?? {}, ctx, translate)
115
112
  },
116
- response: ({ result }) => ({ id: result?.warehouseId ?? null }),
113
+ response: ({ result }) => ({
114
+ id: result?.warehouseId ?? null,
115
+ updatedAt: result?.updatedAt ?? null,
116
+ }),
117
117
  status: 201,
118
118
  },
119
119
  update: {
@@ -166,6 +166,10 @@ export const openApi = createWmsCrudOpenApi({
166
166
  listResponseSchema: createPagedListResponseSchema(warehouseListItemSchema),
167
167
  create: {
168
168
  schema: warehouseCreateSchema,
169
+ responseSchema: z.object({
170
+ id: z.string().uuid().nullable(),
171
+ updatedAt: z.string().nullable(),
172
+ }),
169
173
  description: 'Creates a warehouse for inventory operations.',
170
174
  },
171
175
  update: {