@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
|
@@ -11,6 +11,8 @@ import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
|
11
11
|
import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
12
12
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
13
13
|
import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
|
|
14
|
+
import { loadDeviceUserOptions } from "./userOptions.js";
|
|
15
|
+
import { useDeviceUserLabels } from "./useDeviceUserLabels.js";
|
|
14
16
|
function formatDate(value, t) {
|
|
15
17
|
if (!value) return t("devices.list.noValue");
|
|
16
18
|
try {
|
|
@@ -34,31 +36,19 @@ function DevicesAdminListPage() {
|
|
|
34
36
|
const { confirm, ConfirmDialogElement } = useConfirmDialog();
|
|
35
37
|
const [filterValues, setFilterValues] = React.useState({});
|
|
36
38
|
const [userOptions, setUserOptions] = React.useState([]);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
params.set("page", "1");
|
|
40
|
-
params.set("pageSize", "20");
|
|
41
|
-
if (query && query.trim().length > 0) params.set("search", query.trim());
|
|
42
|
-
const call = await apiCall(
|
|
43
|
-
`/api/auth/users?${params.toString()}`,
|
|
44
|
-
{ headers: { "x-om-forbidden-redirect": "0" } },
|
|
45
|
-
{ fallback: null }
|
|
46
|
-
).catch(() => null);
|
|
47
|
-
if (!call || !call.ok) return [];
|
|
48
|
-
const next = (call.result?.items ?? []).flatMap((item) => {
|
|
49
|
-
if (!item || typeof item.id !== "string" || !item.id.trim()) return [];
|
|
50
|
-
const name = typeof item.name === "string" && item.name.trim() ? item.name.trim() : null;
|
|
51
|
-
const email = typeof item.email === "string" && item.email.trim() ? item.email.trim() : null;
|
|
52
|
-
const label = name ?? email ?? item.id;
|
|
53
|
-
return [{ value: item.id, label, description: email && email !== label ? email : null }];
|
|
54
|
-
});
|
|
39
|
+
const mergeUserOptions = React.useCallback((next) => {
|
|
40
|
+
if (next.length === 0) return;
|
|
55
41
|
setUserOptions((prev) => {
|
|
56
42
|
const map = new Map(prev.map((opt) => [opt.value, opt]));
|
|
57
43
|
for (const opt of next) map.set(opt.value, opt);
|
|
58
44
|
return Array.from(map.values());
|
|
59
45
|
});
|
|
60
|
-
return next;
|
|
61
46
|
}, []);
|
|
47
|
+
const loadUserOptions = React.useCallback(async (query) => {
|
|
48
|
+
const next = await loadDeviceUserOptions(query);
|
|
49
|
+
mergeUserOptions(next);
|
|
50
|
+
return next;
|
|
51
|
+
}, [mergeUserOptions]);
|
|
62
52
|
React.useEffect(() => {
|
|
63
53
|
void loadUserOptions();
|
|
64
54
|
}, [loadUserOptions, scopeVersion]);
|
|
@@ -66,6 +56,8 @@ function DevicesAdminListPage() {
|
|
|
66
56
|
() => new Map(userOptions.map((opt) => [opt.value, opt.label])),
|
|
67
57
|
[userOptions]
|
|
68
58
|
);
|
|
59
|
+
const rowUserIds = React.useMemo(() => rows.map((row) => row.userId), [rows]);
|
|
60
|
+
const resolvedUserLabels = useDeviceUserLabels(rowUserIds);
|
|
69
61
|
const filters = React.useMemo(() => [
|
|
70
62
|
{
|
|
71
63
|
id: "platform",
|
|
@@ -163,7 +155,7 @@ function DevicesAdminListPage() {
|
|
|
163
155
|
header: t("devices.list.columns.user"),
|
|
164
156
|
cell: ({ row }) => {
|
|
165
157
|
const userId = row.original.userId;
|
|
166
|
-
const label = userLabelById.get(userId);
|
|
158
|
+
const label = resolvedUserLabels[userId] ?? userLabelById.get(userId);
|
|
167
159
|
return (
|
|
168
160
|
// Stop the click bubbling to the row, whose default action navigates to the device edit page.
|
|
169
161
|
/* @__PURE__ */ jsx(
|
|
@@ -198,7 +190,7 @@ function DevicesAdminListPage() {
|
|
|
198
190
|
header: t("devices.list.columns.lastSeen"),
|
|
199
191
|
cell: ({ row }) => formatDate(row.original.lastSeenAt, t)
|
|
200
192
|
}
|
|
201
|
-
], [t, userLabelById]);
|
|
193
|
+
], [t, userLabelById, resolvedUserLabels]);
|
|
202
194
|
return /* @__PURE__ */ jsxs(Page, { children: [
|
|
203
195
|
/* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
|
|
204
196
|
DataTable,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/devices/backend/devices/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\n\ntype Row = {\n id: string\n userId: string\n deviceId: string\n platform: string\n clientAppVersion: string | null\n osVersion: string | null\n pushProvider: string | null\n pushTokenUpdatedAt: string | null\n lastSeenAt: string | null\n createdAt: string | null\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page?: number\n pageSize?: number\n totalPages: number\n totalIsCapped?: boolean\n}\n\nfunction formatDate(value: string | null, t: (key: string) => string) {\n if (!value) return t('devices.list.noValue')\n try {\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return t('devices.list.noValue')\n return date.toLocaleString()\n } catch {\n return t('devices.list.noValue')\n }\n}\n\nexport default function DevicesAdminListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [totalIsCapped, setTotalIsCapped] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const [reloadToken, setReloadToken] = React.useState(0)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [userOptions, setUserOptions] = React.useState<{ value: string; label: string; description?: string | null }[]>([])\n\n // Devices admins may not hold auth.users.list; degrade gracefully (no options) instead of redirecting.\n const loadUserOptions = React.useCallback(async (query?: string) => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n const next = (call.result?.items ?? []).flatMap((item) => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name ?? email ?? item.id\n return [{ value: item.id, label, description: email && email !== label ? email : null }]\n })\n setUserOptions((prev) => {\n const map = new Map(prev.map((opt) => [opt.value, opt]))\n for (const opt of next) map.set(opt.value, opt)\n return Array.from(map.values())\n })\n return next\n }, [])\n\n React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])\n\n // Reuse the picker cache to label the User column; rows whose owner isn't cached still link by id.\n const userLabelById = React.useMemo(\n () => new Map(userOptions.map((opt) => [opt.value, opt.label])),\n [userOptions],\n )\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'platform',\n label: t('devices.list.columns.platform'),\n type: 'select',\n options: [\n { value: 'ios', label: 'iOS' },\n { value: 'android', label: 'Android' },\n { value: 'web', label: 'Web' },\n ],\n },\n {\n id: 'userId',\n label: t('devices.list.columns.user'),\n type: 'combobox',\n options: userOptions,\n loadOptions: loadUserOptions,\n },\n ], [t, userOptions, loadUserOptions])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '50')\n const platform = typeof filterValues.platform === 'string' ? filterValues.platform.trim() : ''\n const userId = typeof filterValues.userId === 'string' ? filterValues.userId.trim() : ''\n if (platform) params.set('platform', platform)\n if (userId) params.set('userId', userId)\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(`/api/devices/admin/devices?${params.toString()}`, undefined, { fallback })\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.loadFailed')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n setTotalIsCapped(payload?.totalIsCapped === true)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('devices.list.error.loadFailed')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [page, reloadToken, scopeVersion, filterValues, t])\n\n const handleDeactivate = React.useCallback(async (row: Row) => {\n const confirmed = await confirm({\n title: t('devices.list.confirmDeactivate'),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n // optimistic-lock-exempt: device deactivate is an idempotent soft-delete of a registry row, not a concurrent field edit\n const call = await apiCall<{ error?: string }>(\n `/api/devices/admin/devices/${encodeURIComponent(row.id)}`,\n { method: 'DELETE' },\n { fallback: null },\n )\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n return\n }\n flash(t('devices.list.success.deactivated'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n const message = error instanceof Error ? error.message : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n }\n }, [confirm, t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'deviceId',\n header: t('devices.list.columns.device'),\n cell: ({ row }) => <code className=\"text-xs\">{row.original.deviceId}</code>,\n },\n { accessorKey: 'platform', header: t('devices.list.columns.platform') },\n {\n accessorKey: 'userId',\n header: t('devices.list.columns.user'),\n cell: ({ row }) => {\n const userId = row.original.userId\n const label = userLabelById.get(userId)\n return (\n // Stop the click bubbling to the row, whose default action navigates to the device edit page.\n <Link\n href={`/backend/users/${encodeURIComponent(userId)}/edit`}\n className=\"text-primary hover:underline\"\n onClick={(e) => e.stopPropagation()}\n >\n {label ?? <code className=\"text-xs\">{userId}</code>}\n </Link>\n )\n },\n },\n {\n accessorKey: 'clientAppVersion',\n header: t('devices.list.columns.appVersion'),\n cell: ({ row }) => row.original.clientAppVersion || t('devices.list.noValue'),\n },\n {\n accessorKey: 'osVersion',\n header: t('devices.list.columns.osVersion'),\n cell: ({ row }) => row.original.osVersion || t('devices.list.noValue'),\n },\n {\n accessorKey: 'pushProvider',\n header: t('devices.list.columns.pushProvider'),\n cell: ({ row }) => row.original.pushProvider || t('devices.list.noValue'),\n },\n {\n accessorKey: 'lastSeenAt',\n header: t('devices.list.columns.lastSeen'),\n cell: ({ row }) => formatDate(row.original.lastSeenAt, t),\n },\n ], [t, userLabelById])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('devices.list.title')}\n actions={(\n <Button asChild>\n <Link href=\"/backend/devices/create\">{t('devices.list.actions.register')}</Link>\n </Button>\n )}\n columns={columns}\n data={rows}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}\n onFiltersClear={() => { setFilterValues({}); setPage(1) }}\n perspective={{ tableId: 'devices.list' }}\n rowActions={(row) => (\n <RowActions items={[\n { id: 'edit', label: t('devices.list.actions.edit'), href: `/backend/devices/${row.id}` },\n { id: 'deactivate', label: t('devices.list.actions.deactivate'), destructive: true, onSelect: () => { void handleDeactivate(row) } },\n ]} />\n )}\n pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { loadDeviceUserOptions, type DeviceUserOption } from './userOptions'\nimport { useDeviceUserLabels } from './useDeviceUserLabels'\n\ntype Row = {\n id: string\n userId: string\n deviceId: string\n platform: string\n clientAppVersion: string | null\n osVersion: string | null\n pushProvider: string | null\n pushTokenUpdatedAt: string | null\n lastSeenAt: string | null\n createdAt: string | null\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page?: number\n pageSize?: number\n totalPages: number\n totalIsCapped?: boolean\n}\n\nfunction formatDate(value: string | null, t: (key: string) => string) {\n if (!value) return t('devices.list.noValue')\n try {\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return t('devices.list.noValue')\n return date.toLocaleString()\n } catch {\n return t('devices.list.noValue')\n }\n}\n\nexport default function DevicesAdminListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [totalIsCapped, setTotalIsCapped] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const [reloadToken, setReloadToken] = React.useState(0)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [userOptions, setUserOptions] = React.useState<DeviceUserOption[]>([])\n\n const mergeUserOptions = React.useCallback((next: DeviceUserOption[]) => {\n if (next.length === 0) return\n setUserOptions((prev) => {\n const map = new Map(prev.map((opt) => [opt.value, opt]))\n for (const opt of next) map.set(opt.value, opt)\n return Array.from(map.values())\n })\n }, [])\n\n // Devices admins may not hold auth.users.list; the helper degrades to no options instead of\n // redirecting the whole page to /login.\n const loadUserOptions = React.useCallback(async (query?: string) => {\n const next = await loadDeviceUserOptions(query)\n mergeUserOptions(next)\n return next\n }, [mergeUserOptions])\n\n React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])\n\n const userLabelById = React.useMemo(\n () => new Map(userOptions.map((opt) => [opt.value, opt.label])),\n [userOptions],\n )\n\n // The picker only ever caches the users it happened to prefetch, so resolve the owners of the rows\n // actually on this page. Without it most rows render a bare UUID.\n const rowUserIds = React.useMemo(() => rows.map((row) => row.userId), [rows])\n const resolvedUserLabels = useDeviceUserLabels(rowUserIds)\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'platform',\n label: t('devices.list.columns.platform'),\n type: 'select',\n options: [\n { value: 'ios', label: 'iOS' },\n { value: 'android', label: 'Android' },\n { value: 'web', label: 'Web' },\n ],\n },\n {\n id: 'userId',\n label: t('devices.list.columns.user'),\n type: 'combobox',\n options: userOptions,\n loadOptions: loadUserOptions,\n },\n ], [t, userOptions, loadUserOptions])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '50')\n const platform = typeof filterValues.platform === 'string' ? filterValues.platform.trim() : ''\n const userId = typeof filterValues.userId === 'string' ? filterValues.userId.trim() : ''\n if (platform) params.set('platform', platform)\n if (userId) params.set('userId', userId)\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(`/api/devices/admin/devices?${params.toString()}`, undefined, { fallback })\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.loadFailed')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n setTotalIsCapped(payload?.totalIsCapped === true)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('devices.list.error.loadFailed')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [page, reloadToken, scopeVersion, filterValues, t])\n\n const handleDeactivate = React.useCallback(async (row: Row) => {\n const confirmed = await confirm({\n title: t('devices.list.confirmDeactivate'),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n // optimistic-lock-exempt: device deactivate is an idempotent soft-delete of a registry row, not a concurrent field edit\n const call = await apiCall<{ error?: string }>(\n `/api/devices/admin/devices/${encodeURIComponent(row.id)}`,\n { method: 'DELETE' },\n { fallback: null },\n )\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n return\n }\n flash(t('devices.list.success.deactivated'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n const message = error instanceof Error ? error.message : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n }\n }, [confirm, t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'deviceId',\n header: t('devices.list.columns.device'),\n cell: ({ row }) => <code className=\"text-xs\">{row.original.deviceId}</code>,\n },\n { accessorKey: 'platform', header: t('devices.list.columns.platform') },\n {\n accessorKey: 'userId',\n header: t('devices.list.columns.user'),\n cell: ({ row }) => {\n const userId = row.original.userId\n const label = resolvedUserLabels[userId] ?? userLabelById.get(userId)\n return (\n // Stop the click bubbling to the row, whose default action navigates to the device edit page.\n <Link\n href={`/backend/users/${encodeURIComponent(userId)}/edit`}\n className=\"text-primary hover:underline\"\n onClick={(e) => e.stopPropagation()}\n >\n {label ?? <code className=\"text-xs\">{userId}</code>}\n </Link>\n )\n },\n },\n {\n accessorKey: 'clientAppVersion',\n header: t('devices.list.columns.appVersion'),\n cell: ({ row }) => row.original.clientAppVersion || t('devices.list.noValue'),\n },\n {\n accessorKey: 'osVersion',\n header: t('devices.list.columns.osVersion'),\n cell: ({ row }) => row.original.osVersion || t('devices.list.noValue'),\n },\n {\n accessorKey: 'pushProvider',\n header: t('devices.list.columns.pushProvider'),\n cell: ({ row }) => row.original.pushProvider || t('devices.list.noValue'),\n },\n {\n accessorKey: 'lastSeenAt',\n header: t('devices.list.columns.lastSeen'),\n cell: ({ row }) => formatDate(row.original.lastSeenAt, t),\n },\n ], [t, userLabelById, resolvedUserLabels])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('devices.list.title')}\n actions={(\n <Button asChild>\n <Link href=\"/backend/devices/create\">{t('devices.list.actions.register')}</Link>\n </Button>\n )}\n columns={columns}\n data={rows}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}\n onFiltersClear={() => { setFilterValues({}); setPage(1) }}\n perspective={{ tableId: 'devices.list' }}\n rowActions={(row) => (\n <RowActions items={[\n { id: 'edit', label: t('devices.list.actions.edit'), href: `/backend/devices/${row.id}` },\n { id: 'deactivate', label: t('devices.list.actions.deactivate'), destructive: true, onSelect: () => { void handleDeactivate(row) } },\n ]} />\n )}\n pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { resolveDeviceUserOptions } from "./userOptions.js";
|
|
4
|
+
function useDeviceUserLabels(userIds) {
|
|
5
|
+
const [labels, setLabels] = React.useState({});
|
|
6
|
+
const resolvedIdsRef = React.useRef(/* @__PURE__ */ new Set());
|
|
7
|
+
const idsKey = React.useMemo(() => {
|
|
8
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
9
|
+
for (const userId of userIds) {
|
|
10
|
+
if (typeof userId === "string" && userId.trim()) normalized.add(userId.trim());
|
|
11
|
+
}
|
|
12
|
+
return Array.from(normalized).sort((left, right) => left < right ? -1 : left > right ? 1 : 0).join(",");
|
|
13
|
+
}, [userIds]);
|
|
14
|
+
React.useEffect(() => {
|
|
15
|
+
if (!idsKey) return;
|
|
16
|
+
const unresolved = idsKey.split(",").filter((userId) => !resolvedIdsRef.current.has(userId));
|
|
17
|
+
if (unresolved.length === 0) return;
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
void resolveDeviceUserOptions(unresolved, controller.signal).then(({ options, resolvedIds }) => {
|
|
20
|
+
if (controller.signal.aborted) return;
|
|
21
|
+
for (const userId of resolvedIds) resolvedIdsRef.current.add(userId);
|
|
22
|
+
const next = {};
|
|
23
|
+
for (const option of options) next[option.value] = option.label;
|
|
24
|
+
if (Object.keys(next).length) setLabels((current) => ({ ...current, ...next }));
|
|
25
|
+
}).catch(() => {
|
|
26
|
+
});
|
|
27
|
+
return () => controller.abort();
|
|
28
|
+
}, [idsKey]);
|
|
29
|
+
return labels;
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
useDeviceUserLabels
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=useDeviceUserLabels.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/devices/backend/devices/useDeviceUserLabels.ts"],
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { resolveDeviceUserOptions } from './userOptions'\n\n// Resolves owner ids that are on screen into display labels. Modelled on\n// warranty_claims/backend/components/useUserDisplayNames, but every failure degrades to an empty\n// map instead of throwing: a devices admin without `auth.users.list` must still see the page.\nexport function useDeviceUserLabels(userIds: readonly (string | null | undefined)[]): Record<string, string> {\n const [labels, setLabels] = React.useState<Record<string, string>>({})\n const resolvedIdsRef = React.useRef<Set<string>>(new Set())\n\n const idsKey = React.useMemo(() => {\n const normalized = new Set<string>()\n for (const userId of userIds) {\n if (typeof userId === 'string' && userId.trim()) normalized.add(userId.trim())\n }\n return Array.from(normalized)\n .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))\n .join(',')\n }, [userIds])\n\n React.useEffect(() => {\n if (!idsKey) return\n const unresolved = idsKey.split(',').filter((userId) => !resolvedIdsRef.current.has(userId))\n if (unresolved.length === 0) return\n\n const controller = new AbortController()\n void resolveDeviceUserOptions(unresolved, controller.signal)\n .then(({ options, resolvedIds }) => {\n if (controller.signal.aborted) return\n // Only ids the server actually answered for are remembered. Marking an id whose request\n // failed would keep its row showing a bare UUID for the life of the component, even though\n // the next attempt would have worked.\n for (const userId of resolvedIds) resolvedIdsRef.current.add(userId)\n const next: Record<string, string> = {}\n for (const option of options) next[option.value] = option.label\n if (Object.keys(next).length) setLabels((current) => ({ ...current, ...next }))\n })\n .catch(() => {})\n return () => controller.abort()\n }, [idsKey])\n\n return labels\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,gCAAgC;AAKlC,SAAS,oBAAoB,SAAyE;AAC3G,QAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,SAAiC,CAAC,CAAC;AACrE,QAAM,iBAAiB,MAAM,OAAoB,oBAAI,IAAI,CAAC;AAE1D,QAAM,SAAS,MAAM,QAAQ,MAAM;AACjC,UAAM,aAAa,oBAAI,IAAY;AACnC,eAAW,UAAU,SAAS;AAC5B,UAAI,OAAO,WAAW,YAAY,OAAO,KAAK,EAAG,YAAW,IAAI,OAAO,KAAK,CAAC;AAAA,IAC/E;AACA,WAAO,MAAM,KAAK,UAAU,EACzB,KAAK,CAAC,MAAM,UAAW,OAAO,QAAQ,KAAK,OAAO,QAAQ,IAAI,CAAE,EAChE,KAAK,GAAG;AAAA,EACb,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,OAAQ;AACb,UAAM,aAAa,OAAO,MAAM,GAAG,EAAE,OAAO,CAAC,WAAW,CAAC,eAAe,QAAQ,IAAI,MAAM,CAAC;AAC3F,QAAI,WAAW,WAAW,EAAG;AAE7B,UAAM,aAAa,IAAI,gBAAgB;AACvC,SAAK,yBAAyB,YAAY,WAAW,MAAM,EACxD,KAAK,CAAC,EAAE,SAAS,YAAY,MAAM;AAClC,UAAI,WAAW,OAAO,QAAS;AAI/B,iBAAW,UAAU,YAAa,gBAAe,QAAQ,IAAI,MAAM;AACnE,YAAM,OAA+B,CAAC;AACtC,iBAAW,UAAU,QAAS,MAAK,OAAO,KAAK,IAAI,OAAO;AAC1D,UAAI,OAAO,KAAK,IAAI,EAAE,OAAQ,WAAU,CAAC,aAAa,EAAE,GAAG,SAAS,GAAG,KAAK,EAAE;AAAA,IAChF,CAAC,EACA,MAAM,MAAM;AAAA,IAAC,CAAC;AACjB,WAAO,MAAM,WAAW,MAAM;AAAA,EAChC,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
const SEARCH_PAGE_SIZE = 20;
|
|
4
|
+
const MAX_IDS_PER_LOOKUP = MAX_USER_LOOKUP_IDS;
|
|
5
|
+
function toOption(item, style) {
|
|
6
|
+
if (!item || typeof item.id !== "string" || !item.id.trim()) return [];
|
|
7
|
+
const id = item.id.trim();
|
|
8
|
+
const name = typeof item.name === "string" && item.name.trim() ? item.name.trim() : null;
|
|
9
|
+
const email = typeof item.email === "string" && item.email.trim() ? item.email.trim() : null;
|
|
10
|
+
const label = style === "search" && name && email ? `${name} \u2014 ${email}` : name ?? email ?? id;
|
|
11
|
+
return [{ value: id, label }];
|
|
12
|
+
}
|
|
13
|
+
async function fetchUsers(params, style, signal) {
|
|
14
|
+
const call = await apiCall(
|
|
15
|
+
`/api/auth/users?${params.toString()}`,
|
|
16
|
+
{ headers: { "x-om-forbidden-redirect": "0" }, signal },
|
|
17
|
+
{ fallback: null }
|
|
18
|
+
).catch(() => null);
|
|
19
|
+
if (!call || !call.ok) return null;
|
|
20
|
+
return (call.result?.items ?? []).flatMap((item) => toOption(item, style));
|
|
21
|
+
}
|
|
22
|
+
async function loadDeviceUserOptions(query) {
|
|
23
|
+
const params = new URLSearchParams();
|
|
24
|
+
params.set("page", "1");
|
|
25
|
+
params.set("pageSize", String(SEARCH_PAGE_SIZE));
|
|
26
|
+
const trimmed = query?.trim();
|
|
27
|
+
if (trimmed) params.set("search", trimmed);
|
|
28
|
+
return await fetchUsers(params, "search") ?? [];
|
|
29
|
+
}
|
|
30
|
+
async function resolveDeviceUserOptions(ids, signal) {
|
|
31
|
+
const unique = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)));
|
|
32
|
+
if (unique.length === 0) return { options: [], resolvedIds: [] };
|
|
33
|
+
const options = [];
|
|
34
|
+
const resolvedIds = [];
|
|
35
|
+
for (let offset = 0; offset < unique.length; offset += MAX_IDS_PER_LOOKUP) {
|
|
36
|
+
const batch = unique.slice(offset, offset + MAX_IDS_PER_LOOKUP);
|
|
37
|
+
const params = new URLSearchParams();
|
|
38
|
+
params.set("page", "1");
|
|
39
|
+
params.set("pageSize", String(batch.length));
|
|
40
|
+
params.set("ids", batch.join(","));
|
|
41
|
+
const batchOptions = await fetchUsers(params, "compact", signal);
|
|
42
|
+
if (batchOptions === null) continue;
|
|
43
|
+
options.push(...batchOptions);
|
|
44
|
+
resolvedIds.push(...batch);
|
|
45
|
+
}
|
|
46
|
+
return { options, resolvedIds };
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
loadDeviceUserOptions,
|
|
50
|
+
resolveDeviceUserOptions
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=userOptions.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/devices/backend/devices/userOptions.ts"],
|
|
4
|
+
"sourcesContent": ["import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { MAX_USER_LOOKUP_IDS } from '@open-mercato/core/modules/auth/lib/userIdFilter'\n\nexport type DeviceUserOption = {\n value: string\n label: string\n}\n\ntype AuthUserItem = {\n id?: unknown\n name?: unknown\n email?: unknown\n}\n\nconst SEARCH_PAGE_SIZE = 20\n// Imported rather than restated: `parseIdsParam` slices past the route's cap without complaining,\n// so a client batch larger than the server accepts loses the overflow ids silently.\nconst MAX_IDS_PER_LOOKUP = MAX_USER_LOOKUP_IDS\n\n/**\n * Two label styles on purpose. A picker suggestion has to disambiguate two people with the same\n * display name, and neither `CrudForm`'s combobox nor `FilterBar` renders anything but the label \u2014\n * so the email has to live in the label there. A resolved column label is read next to a device,\n * where the email is noise.\n */\ntype LabelStyle = 'search' | 'compact'\n\nfunction toOption(item: AuthUserItem | null | undefined, style: LabelStyle): DeviceUserOption[] {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const id = item.id.trim()\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = style === 'search' && name && email ? `${name} \u2014 ${email}` : name ?? email ?? id\n return [{ value: id, label }]\n}\n\n// `devices.admin` declares `dependsOn: ['auth.users.list']`, but dependsOn only diagnoses \u2014 a\n// hand-built role can still reach these screens without it until the ACL editor or\n// `sync-role-acls` fixes it. `x-om-forbidden-redirect: 0` keeps that 403 from bouncing the whole\n// page to /login. `null` means the call itself failed, which callers must not\n// confuse with a successful call that matched nobody \u2014 a caller caching \"already resolved\" would\n// otherwise remember a transient network error forever.\nasync function fetchUsers(\n params: URLSearchParams,\n style: LabelStyle,\n signal?: AbortSignal,\n): Promise<DeviceUserOption[] | null> {\n const call = await apiCall<{ items?: AuthUserItem[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' }, signal },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return null\n return (call.result?.items ?? []).flatMap((item) => toOption(item, style))\n}\n\nexport async function loadDeviceUserOptions(query?: string): Promise<DeviceUserOption[]> {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', String(SEARCH_PAGE_SIZE))\n const trimmed = query?.trim()\n if (trimmed) params.set('search', trimmed)\n // A picker has nothing to cache, so a failed lookup is just an empty suggestion list.\n return (await fetchUsers(params, 'search')) ?? []\n}\n\n/**\n * The outcome of a batch lookup. `resolvedIds` lists the ids the server actually answered for \u2014\n * an id that came back without a row (deleted user) still counts as resolved, an id whose request\n * failed does not. Callers cache on `resolvedIds`, so a transient failure is retried rather than\n * remembered as a permanent blank.\n */\nexport type DeviceUserLookup = {\n options: DeviceUserOption[]\n resolvedIds: string[]\n}\n\n// Batch id \u2192 label resolution for rows already on screen, so a device whose owner never appeared in\n// a search result still renders a name instead of a bare UUID.\nexport async function resolveDeviceUserOptions(ids: string[], signal?: AbortSignal): Promise<DeviceUserLookup> {\n const unique = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)))\n if (unique.length === 0) return { options: [], resolvedIds: [] }\n const options: DeviceUserOption[] = []\n const resolvedIds: string[] = []\n for (let offset = 0; offset < unique.length; offset += MAX_IDS_PER_LOOKUP) {\n const batch = unique.slice(offset, offset + MAX_IDS_PER_LOOKUP)\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', String(batch.length))\n // URLSearchParams encodes on toString(); pre-encoding here would double-escape the commas.\n params.set('ids', batch.join(','))\n const batchOptions = await fetchUsers(params, 'compact', signal)\n // One failed batch must not cost the batches that did answer.\n if (batchOptions === null) continue\n options.push(...batchOptions)\n resolvedIds.push(...batch)\n }\n return { options, resolvedIds }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAAe;AACxB,SAAS,2BAA2B;AAapC,MAAM,mBAAmB;AAGzB,MAAM,qBAAqB;AAU3B,SAAS,SAAS,MAAuC,OAAuC;AAC9F,MAAI,CAAC,QAAQ,OAAO,KAAK,OAAO,YAAY,CAAC,KAAK,GAAG,KAAK,EAAG,QAAO,CAAC;AACrE,QAAM,KAAK,KAAK,GAAG,KAAK;AACxB,QAAM,OAAO,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,KAAK,IAAI;AACpF,QAAM,QAAQ,OAAO,KAAK,UAAU,YAAY,KAAK,MAAM,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI;AACxF,QAAM,QAAQ,UAAU,YAAY,QAAQ,QAAQ,GAAG,IAAI,WAAM,KAAK,KAAK,QAAQ,SAAS;AAC5F,SAAO,CAAC,EAAE,OAAO,IAAI,MAAM,CAAC;AAC9B;AAQA,eAAe,WACb,QACA,OACA,QACoC;AACpC,QAAM,OAAO,MAAM;AAAA,IACjB,mBAAmB,OAAO,SAAS,CAAC;AAAA,IACpC,EAAE,SAAS,EAAE,2BAA2B,IAAI,GAAG,OAAO;AAAA,IACtD,EAAE,UAAU,KAAK;AAAA,EACnB,EAAE,MAAM,MAAM,IAAI;AAClB,MAAI,CAAC,QAAQ,CAAC,KAAK,GAAI,QAAO;AAC9B,UAAQ,KAAK,QAAQ,SAAS,CAAC,GAAG,QAAQ,CAAC,SAAS,SAAS,MAAM,KAAK,CAAC;AAC3E;AAEA,eAAsB,sBAAsB,OAA6C;AACvF,QAAM,SAAS,IAAI,gBAAgB;AACnC,SAAO,IAAI,QAAQ,GAAG;AACtB,SAAO,IAAI,YAAY,OAAO,gBAAgB,CAAC;AAC/C,QAAM,UAAU,OAAO,KAAK;AAC5B,MAAI,QAAS,QAAO,IAAI,UAAU,OAAO;AAEzC,SAAQ,MAAM,WAAW,QAAQ,QAAQ,KAAM,CAAC;AAClD;AAeA,eAAsB,yBAAyB,KAAe,QAAiD;AAC7G,QAAM,SAAS,MAAM,KAAK,IAAI,IAAI,IAAI,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC,EAAE,OAAO,OAAO,CAAC,CAAC;AAC7E,MAAI,OAAO,WAAW,EAAG,QAAO,EAAE,SAAS,CAAC,GAAG,aAAa,CAAC,EAAE;AAC/D,QAAM,UAA8B,CAAC;AACrC,QAAM,cAAwB,CAAC;AAC/B,WAAS,SAAS,GAAG,SAAS,OAAO,QAAQ,UAAU,oBAAoB;AACzE,UAAM,QAAQ,OAAO,MAAM,QAAQ,SAAS,kBAAkB;AAC9D,UAAM,SAAS,IAAI,gBAAgB;AACnC,WAAO,IAAI,QAAQ,GAAG;AACtB,WAAO,IAAI,YAAY,OAAO,MAAM,MAAM,CAAC;AAE3C,WAAO,IAAI,OAAO,MAAM,KAAK,GAAG,CAAC;AACjC,UAAM,eAAe,MAAM,WAAW,QAAQ,WAAW,MAAM;AAE/D,QAAI,iBAAiB,KAAM;AAC3B,YAAQ,KAAK,GAAG,YAAY;AAC5B,gBAAY,KAAK,GAAG,KAAK;AAAA,EAC3B;AACA,SAAO,EAAE,SAAS,YAAY;AAChC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const setup = {
|
|
2
2
|
defaultRoleFeatures: {
|
|
3
|
-
|
|
4
|
-
admin: [
|
|
3
|
+
// `auth.users.list` is granted alongside `devices.admin` rather than left to the auth module's
|
|
4
|
+
// own `admin: ['auth.*']`, so the dependency declared in `acl.ts` holds even where that grant
|
|
5
|
+
// was narrowed. Without it the owner picker has nothing to offer and the register form cannot
|
|
6
|
+
// be completed. Existing tenants pick this up via `yarn mercato auth sync-role-acls`.
|
|
7
|
+
superadmin: ["devices.*", "auth.users.list"],
|
|
8
|
+
admin: ["devices.*", "auth.users.list"],
|
|
5
9
|
employee: ["devices.view", "devices.manage"]
|
|
6
10
|
}
|
|
7
11
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/devices/setup.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['devices.*'],\n admin: ['devices.*'],\n employee: ['devices.view', 'devices.manage'],\n },\n}\n\nexport default setup\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,
|
|
4
|
+
"sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n // `auth.users.list` is granted alongside `devices.admin` rather than left to the auth module's\n // own `admin: ['auth.*']`, so the dependency declared in `acl.ts` holds even where that grant\n // was narrowed. Without it the owner picker has nothing to offer and the register form cannot\n // be completed. Existing tenants pick this up via `yarn mercato auth sync-role-acls`.\n superadmin: ['devices.*', 'auth.users.list'],\n admin: ['devices.*', 'auth.users.list'],\n employee: ['devices.view', 'devices.manage'],\n },\n}\n\nexport default setup\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA,IAKnB,YAAY,CAAC,aAAa,iBAAiB;AAAA,IAC3C,OAAO,CAAC,aAAa,iBAAiB;AAAA,IACtC,UAAU,CAAC,gBAAgB,gBAAgB;AAAA,EAC7C;AACF;AAEA,IAAO,gBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7100.1.fbf66fca35",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7100.1.fbf66fca35",
|
|
256
|
+
"@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35",
|
|
257
|
+
"@open-mercato/ui": "0.6.8-develop.7100.1.fbf66fca35",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
263
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
264
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7100.1.fbf66fca35",
|
|
263
|
+
"@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35",
|
|
264
|
+
"@open-mercato/ui": "0.6.8-develop.7100.1.fbf66fca35",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
67
|
-
React.
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
{
|
|
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',
|