@open-mercato/core 0.6.8-develop.7099.1.7ba2771d06 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/users/route.js +13 -5
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/lib/userIdFilter.js +16 -0
- package/dist/modules/auth/lib/userIdFilter.js.map +7 -0
- package/dist/modules/devices/acl.js +9 -1
- package/dist/modules/devices/acl.js.map +2 -2
- package/dist/modules/devices/backend/devices/[id]/page.js +4 -20
- package/dist/modules/devices/backend/devices/[id]/page.js.map +2 -2
- package/dist/modules/devices/backend/devices/create/page.js +17 -1
- package/dist/modules/devices/backend/devices/create/page.js.map +2 -2
- package/dist/modules/devices/backend/devices/page.js +13 -21
- package/dist/modules/devices/backend/devices/page.js.map +2 -2
- package/dist/modules/devices/backend/devices/useDeviceUserLabels.js +34 -0
- package/dist/modules/devices/backend/devices/useDeviceUserLabels.js.map +7 -0
- package/dist/modules/devices/backend/devices/userOptions.js +52 -0
- package/dist/modules/devices/backend/devices/userOptions.js.map +7 -0
- package/dist/modules/devices/setup.js +6 -2
- package/dist/modules/devices/setup.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/users/route.ts +18 -5
- package/src/modules/auth/lib/userIdFilter.ts +31 -0
- package/src/modules/devices/AGENTS.md +12 -1
- package/src/modules/devices/acl.ts +9 -1
- package/src/modules/devices/backend/devices/[id]/page.tsx +5 -19
- package/src/modules/devices/backend/devices/create/page.tsx +17 -1
- package/src/modules/devices/backend/devices/page.tsx +20 -24
- package/src/modules/devices/backend/devices/useDeviceUserLabels.ts +45 -0
- package/src/modules/devices/backend/devices/userOptions.ts +99 -0
- package/src/modules/devices/i18n/de.json +3 -2
- package/src/modules/devices/i18n/en.json +3 -2
- package/src/modules/devices/i18n/es.json +3 -2
- package/src/modules/devices/i18n/ko.json +3 -2
- package/src/modules/devices/i18n/pl.json +3 -2
- package/src/modules/devices/setup.ts +6 -2
|
@@ -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<
|
|
63
|
+
const [userOptions, setUserOptions] = React.useState<DeviceUserOption[]>([])
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
27
|
-
"devices.form.userIdHint": "
|
|
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
|
|
27
|
-
"devices.form.userIdHint": "
|
|
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": "
|
|
27
|
-
"devices.form.userIdHint": "
|
|
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": "사용자
|
|
27
|
-
"devices.form.userIdHint": "
|
|
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": "
|
|
27
|
-
"devices.form.userIdHint": "
|
|
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
|
-
|
|
6
|
-
admin: ['
|
|
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
|
}
|