From 9710bd98e9ead49464064ab6dac89872bc8c4570 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Sun, 17 May 2026 02:05:47 -0400 Subject: [PATCH 1/6] fix: gate org inventory UI and fetch on can_view_org_inventory (ISSUE-163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add canViewOrgInventory memo (view|edit|admin implies view access) - Skip getOrgInventory fetch when permissions loading or user lacks can_view_org_inventory — no 403 surfaced - Revert to personal mode silently after permissions resolve without view access - Use permissionsFetchedForOrgId ref to avoid premature revert before fetch completes - Remove misleading "no permission to add" info alert; showAddButton already gates the add action --- frontend/src/pages/Inventory.tsx | 48 ++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 2cacbee..4425ada 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -173,6 +173,7 @@ const InventoryPage = () => { const [orgPermissionsError, setOrgPermissionsError] = useState( null, ); + const permissionsFetchedForOrgId = useRef(null); const [filters, setFilters] = useState({ search: '', @@ -465,6 +466,14 @@ const InventoryPage = () => { [], ); + const canViewOrgInventory = useMemo( + () => + orgPermissions.includes(OrgPermission.CAN_VIEW_ORG_INVENTORY) || + orgPermissions.includes(OrgPermission.CAN_EDIT_ORG_INVENTORY) || + orgPermissions.includes(OrgPermission.CAN_ADMIN_ORG_INVENTORY), + [orgPermissions], + ); + const canManageOrgInventory = useMemo( () => orgPermissions.includes(OrgPermission.CAN_EDIT_ORG_INVENTORY) || @@ -588,6 +597,11 @@ const InventoryPage = () => { const offset = page * rowsPerPage; if (isOrgMode && selectedOrgId) { + if (orgPermissionsLoading || !canViewOrgInventory) { + setItems([]); + setTotalCount(0); + return; + } const data = await inventoryService.getOrgInventory(selectedOrgId, { gameId: GAME_ID, search: debouncedSearch || undefined, @@ -653,6 +667,8 @@ const InventoryPage = () => { user, isOrgMode, selectedOrgId, + orgPermissionsLoading, + canViewOrgInventory, filters.categoryId, filters.locationId, filters.sharedOnly, @@ -879,9 +895,11 @@ const InventoryPage = () => { if (viewMode !== 'org' || !user?.userId || !selectedOrgId) { setOrgPermissions([]); setOrgPermissionsError(null); + permissionsFetchedForOrgId.current = null; return; } let isMounted = true; + permissionsFetchedForOrgId.current = null; setOrgPermissionsLoading(true); permissionsService .getUserPermissions(user.userId, selectedOrgId) @@ -889,6 +907,7 @@ const InventoryPage = () => { if (isMounted) { setOrgPermissions(permissions); setOrgPermissionsError(null); + permissionsFetchedForOrgId.current = selectedOrgId; } }) .catch((err) => { @@ -908,6 +927,26 @@ const InventoryPage = () => { }; }, [viewMode, user?.userId, selectedOrgId]); + useEffect(() => { + if ( + viewMode === 'org' && + selectedOrgId !== null && + !orgPermissionsLoading && + !orgPermissionsError && + permissionsFetchedForOrgId.current === selectedOrgId && + !canViewOrgInventory + ) { + setViewMode('personal'); + setSelectedOrgId(null); + } + }, [ + viewMode, + selectedOrgId, + orgPermissionsLoading, + orgPermissionsError, + canViewOrgInventory, + ]); + useEffect(() => { if (user) { fetchInventory(); @@ -2171,15 +2210,6 @@ const InventoryPage = () => { autoFocusSearch disabled={inventoryBusy} /> - {viewMode === 'org' && - selectedOrgId && - !canManageOrgInventory && - !orgPermissionsLoading && ( - - You do not have permission to add items to this - organization. - - )} {orgPermissionsError && ( {orgPermissionsError} From 05c9d96a83fa54c64d674eadb329f5312a4ce753 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Sun, 17 May 2026 02:35:07 -0400 Subject: [PATCH 2/6] fix: clear loading state when org inventory fetch is skipped due to permissions --- frontend/src/pages/Inventory.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 4425ada..5007ee0 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -600,6 +600,8 @@ const InventoryPage = () => { if (orgPermissionsLoading || !canViewOrgInventory) { setItems([]); setTotalCount(0); + if (initialLoading) setInitialLoading(false); + setRefreshing(false); return; } const data = await inventoryService.getOrgInventory(selectedOrgId, { From 5d2725a773aa9ff257ca9298456cd4ea7ddf9a88 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Sun, 17 May 2026 02:38:44 -0400 Subject: [PATCH 3/6] fix: guard org inventory fetch against stale permissions from previous org selection --- frontend/src/pages/Inventory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 5007ee0..c63dfa5 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -597,7 +597,7 @@ const InventoryPage = () => { const offset = page * rowsPerPage; if (isOrgMode && selectedOrgId) { - if (orgPermissionsLoading || !canViewOrgInventory) { + if (orgPermissionsLoading || permissionsFetchedForOrgId.current !== selectedOrgId || !canViewOrgInventory) { setItems([]); setTotalCount(0); if (initialLoading) setInitialLoading(false); From 8bef8b6cdd4f9a072720913959f4e53223a8a7e7 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Sun, 17 May 2026 02:49:56 -0400 Subject: [PATCH 4/6] fix: pre-filter org dropdown to only show orgs with can_view_org_inventory --- frontend/src/pages/Inventory.tsx | 41 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index c63dfa5..00bc677 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -526,7 +526,26 @@ const InventoryPage = () => { id: entry.organization?.id ?? entry.organizationId, name: entry.organization?.name ?? `Org #${entry.organizationId}`, })); - setOrgOptions(mapped); + const viewableOrgs = ( + await Promise.all( + mapped.map(async (org) => { + try { + const perms = await permissionsService.getUserPermissions( + userId, + org.id, + ); + const canView = + perms.includes(OrgPermission.CAN_VIEW_ORG_INVENTORY) || + perms.includes(OrgPermission.CAN_EDIT_ORG_INVENTORY) || + perms.includes(OrgPermission.CAN_ADMIN_ORG_INVENTORY); + return canView ? org : null; + } catch { + return null; + } + }), + ) + ).filter((org): org is { id: number; name: string } => org !== null); + setOrgOptions(viewableOrgs); } catch (err) { console.error('Error loading organizations', err); } @@ -929,26 +948,6 @@ const InventoryPage = () => { }; }, [viewMode, user?.userId, selectedOrgId]); - useEffect(() => { - if ( - viewMode === 'org' && - selectedOrgId !== null && - !orgPermissionsLoading && - !orgPermissionsError && - permissionsFetchedForOrgId.current === selectedOrgId && - !canViewOrgInventory - ) { - setViewMode('personal'); - setSelectedOrgId(null); - } - }, [ - viewMode, - selectedOrgId, - orgPermissionsLoading, - orgPermissionsError, - canViewOrgInventory, - ]); - useEffect(() => { if (user) { fetchInventory(); From 2bb870ab775984584b4412f211e39b81aec2056b Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Sun, 17 May 2026 18:13:22 -0400 Subject: [PATCH 5/6] fix: reset to personal when no viewable orgs remain; update test for view-only org behavior --- frontend/src/pages/Inventory.editor-mode.test.tsx | 14 ++++++++------ frontend/src/pages/Inventory.tsx | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/Inventory.editor-mode.test.tsx b/frontend/src/pages/Inventory.editor-mode.test.tsx index c4b9a64..c7018ff 100644 --- a/frontend/src/pages/Inventory.editor-mode.test.tsx +++ b/frontend/src/pages/Inventory.editor-mode.test.tsx @@ -475,7 +475,7 @@ describe('Inventory editor mode inline controls', () => { ); }); - it('hides the org add flow when org permissions do not include edit/admin', async () => { + it('hides the add button for view-only org users but still shows org inventory', async () => { mockGetUserOrganizations.mockResolvedValue([ { id: 1, @@ -502,15 +502,17 @@ describe('Inventory editor mode inline controls', () => { fireEvent.click(await screen.findByText('Test Org')); await waitFor(() => - expect( - screen.getByText( - 'You do not have permission to add items to this organization.', - ), - ).toBeInTheDocument(), + expect(screen.getByText('Test Org')).toBeInTheDocument(), ); + expect( screen.queryByRole('button', { name: 'Add org item' }), ).not.toBeInTheDocument(); + expect( + screen.queryByText( + 'You do not have permission to add items to this organization.', + ), + ).not.toBeInTheDocument(); }); it('handles org add conflicts by loading the existing item and merging quantities', async () => { diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 00bc677..60fe905 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -264,7 +264,7 @@ const InventoryPage = () => { }, [density]); useEffect(() => { - if (orgOptions.length === 0 || selectedOrgId === null) return; + if (selectedOrgId === null) return; const isValidOrg = orgOptions.some((org) => org.id === selectedOrgId); if (!isValidOrg) { setSelectedOrgId(null); From 619e56adb51ebe21b8e08ed75173dfc1aab60916 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Sun, 17 May 2026 18:40:19 -0400 Subject: [PATCH 6/6] fix: separate view-filtered and all-orgs lists; gate validation on orgsLoaded - Add allOrgOptions (unfiltered) for the share dialog so members can share to any org they belong to, regardless of inventory view perms - orgOptions (view-filtered) remains the source for the View selector only - Add orgsLoaded ref; validation effect now only runs after fetchOrganizations completes, preventing premature reset of a valid sessionStorage org selection --- frontend/src/pages/Inventory.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 60fe905..b447822 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -131,6 +131,10 @@ const InventoryPage = () => { const [orgOptions, setOrgOptions] = useState<{ id: number; name: string }[]>( [], ); + const [allOrgOptions, setAllOrgOptions] = useState< + { id: number; name: string }[] + >([]); + const orgsLoaded = useRef(false); const [selectedOrgId, setSelectedOrgId] = useState(() => readStoredOrgId(), ); @@ -264,7 +268,7 @@ const InventoryPage = () => { }, [density]); useEffect(() => { - if (selectedOrgId === null) return; + if (!orgsLoaded.current || selectedOrgId === null) return; const isValidOrg = orgOptions.some((org) => org.id === selectedOrgId); if (!isValidOrg) { setSelectedOrgId(null); @@ -526,6 +530,7 @@ const InventoryPage = () => { id: entry.organization?.id ?? entry.organizationId, name: entry.organization?.name ?? `Org #${entry.organizationId}`, })); + setAllOrgOptions(mapped); const viewableOrgs = ( await Promise.all( mapped.map(async (org) => { @@ -546,6 +551,7 @@ const InventoryPage = () => { ) ).filter((org): org is { id: number; name: string } => org !== null); setOrgOptions(viewableOrgs); + orgsLoaded.current = true; } catch (err) { console.error('Error loading organizations', err); } @@ -1849,7 +1855,7 @@ const InventoryPage = () => { ) } > - {orgOptions.map((org) => ( + {allOrgOptions.map((org) => ( {org.name}