From dcb4893b100cad1bbfa7588489d23371245bb732 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 7 Sep 2026 05:07:53 -0400 Subject: [PATCH] feat(storage): split storage permissions and rework manage-storage UI Deposits/withdrawals of stock (economy-mutating) are now superuser-only, while moving items around (transfer/place/collect) stays open to logistics-qualified users; seed roles updated to match. ManageStorageDialog reworked alongside its toolbar/button/view wrappers, and the structure page wires up the new permission split. --- .../logistics/structures/[id]/page.tsx | 17 +- .../logistics/structures/actions.ts | 108 +++- .../frontend/storage/GridToolbar.tsx | 28 +- .../frontend/storage/ManageStorageButton.tsx | 18 +- .../frontend/storage/ManageStorageDialog.tsx | 527 +++++++++--------- .../frontend/storage/StructureStorageView.tsx | 70 ++- src/tools/seed/seedRoles.ts | 2 +- 7 files changed, 457 insertions(+), 313 deletions(-) diff --git a/src/app/(frontend)/logistics/structures/[id]/page.tsx b/src/app/(frontend)/logistics/structures/[id]/page.tsx index a38ff23..b61a839 100644 --- a/src/app/(frontend)/logistics/structures/[id]/page.tsx +++ b/src/app/(frontend)/logistics/structures/[id]/page.tsx @@ -21,6 +21,7 @@ import { pluralize } from "@/lib/utils"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import { currencyConfigFromGameRules } from "@/lib/banking"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; import { ArrowLeftIcon, BoxIcon, @@ -183,6 +184,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag }; }); const canManage = await hasLogisticsQualification(payload, user); + const canManageStorage = await isSuperuser(payload, user); + const canTransferStorage = canManageStorage || canManage; const classificationsRes = await payload.find({ collection: "labor-classifications", @@ -307,7 +310,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag {structure.faction.name} )} - {user && } + {user && ( + + )} @@ -413,7 +422,11 @@ export default async function StructureDetailPage({ params }: StructureDetailPag currencyConfig={currencyConfig} /> - + >; +type AuthedUser = Awaited>["user"]; + +// SECURITY: creates/destroys stored items, overriding economy/production — superusers only +async function canManageStorage(payload: PayloadClient, user: AuthedUser): Promise { + return isSuperuser(payload, user); +} + +// SECURITY: conserves quantities (moves items around) — logistics personnel + superusers +async function canHandleStorage(payload: PayloadClient, user: AuthedUser): Promise { + return (await isSuperuser(payload, user)) || (await hasLogisticsQualification(payload, user)); +} + async function authenticate() { const payloadConfig = await config; const payload = await getPayload({ config: payloadConfig }); @@ -74,13 +88,30 @@ export async function addResource( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canManageStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required.", + error: "Only superusers may deposit resources into structures.", }; } + return await addResourceInternal(payload, user, structureId, resourceId, amount); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} +async function addResourceInternal( + payload: PayloadClient, + user: AuthedUser, + structureId: number, + resourceId: number, + amount: number, +): Promise { + try { if (amount <= 0) { return { success: false, error: "Amount must be greater than zero." }; } @@ -264,13 +295,30 @@ export async function removeResource( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canManageStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. Admin or Developer role required for withdrawal.", + error: "Only superusers may withdraw resources from structures.", }; } + return await removeResourceInternal(payload, user, structureId, resourceId, amount); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} +async function removeResourceInternal( + payload: PayloadClient, + user: AuthedUser, + structureId: number, + resourceId: number, + amount: number, +): Promise { + try { if (amount <= 0) { return { success: false, error: "Amount must be greater than zero." }; } @@ -373,10 +421,10 @@ export async function transferResource( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canHandleStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required for transfer.", + error: "Logistics personnel or superuser role required for transfers.", }; } @@ -464,17 +512,23 @@ export async function transferResource( }; } - // Remove from source - const removeResult = await removeResource(fromStructureId, resourceId, amount); + // Remove from source (unguarded internal — gate already applied above) + const removeResult = await removeResourceInternal( + payload, + user, + fromStructureId, + resourceId, + amount, + ); if (!removeResult.success) { return removeResult; } // Add to destination - const addResult = await addResource(toStructureId, resourceId, amount); + const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount); if (!addResult.success) { // Rollback: re-add to source - await addResource(fromStructureId, resourceId, amount); + await addResourceInternal(payload, user, fromStructureId, resourceId, amount); return { success: false, error: `Transfer failed at destination: ${addResult.error}` }; } @@ -524,10 +578,10 @@ export async function placeResourceOnGrid( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canManageStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. Admin or Developer role required for placement.", + error: "Only superusers may place resources on the storage grid.", }; } @@ -641,10 +695,10 @@ export async function moveGridItem( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canHandleStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required.", + error: "Logistics personnel or superuser role required.", }; } const structure = await getStructure(payload, structureId); @@ -730,10 +784,10 @@ export async function mergeGridStacks( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canHandleStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required.", + error: "Logistics personnel or superuser role required.", }; } const structure = await getStructure(payload, structureId); @@ -812,10 +866,10 @@ export async function rotateGridItem( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canHandleStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required.", + error: "Logistics personnel or superuser role required.", }; } const structure = await getStructure(payload, structureId); @@ -899,10 +953,10 @@ export async function removeGridItem( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canManageStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. Admin or Developer role required for removal.", + error: "Only superusers may remove items from the storage grid.", }; } const structure = await getStructure(payload, structureId); @@ -953,10 +1007,10 @@ export async function splitGridStack( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canHandleStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required.", + error: "Logistics personnel or superuser role required.", }; } @@ -1061,8 +1115,8 @@ export async function splitGridStack( export async function retrieveFromVoid(structureId: number): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { - return { success: false, error: "Insufficient permissions." }; + if (!(await canHandleStorage(payload, user))) { + return { success: false, error: "Logistics personnel or superuser role required." }; } const structure = await getStructure(payload, structureId); const gridW = structure.type.xSize ?? 0; @@ -1159,10 +1213,10 @@ export async function retrieveFromVoid(structureId: number): Promise { try { const { payload, user } = await authenticate(); - if (!(await hasPermission(payload, user, "structures:update"))) { + if (!(await canHandleStorage(payload, user))) { return { success: false, - error: "Insufficient permissions. User, Admin, or Developer role required.", + error: "Logistics personnel or superuser role required.", }; } const structure = await getStructure(payload, structureId); diff --git a/src/components/frontend/storage/GridToolbar.tsx b/src/components/frontend/storage/GridToolbar.tsx index 6c19806..1172871 100644 --- a/src/components/frontend/storage/GridToolbar.tsx +++ b/src/components/frontend/storage/GridToolbar.tsx @@ -24,6 +24,10 @@ interface GridToolbarProps { onRotate: (entryId: string) => Promise; onRemove: (entryId: string) => Promise; onSplit: (entryId: string, amount: number) => Promise; + /** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */ + canArrange?: boolean; + /** Removing grid items destroys stored stock — superuser only. */ + canMutate?: boolean; } export function GridToolbar({ @@ -37,6 +41,8 @@ export function GridToolbar({ onRotate, onRemove, onSplit, + canArrange = false, + canMutate = false, }: GridToolbarProps) { const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight); const res = selectedItem ? (selectedItem.resource as Resource) : null; @@ -50,10 +56,12 @@ export function GridToolbar({
- + {canArrange && ( + + )}
@@ -86,12 +94,12 @@ export function GridToolbar({
- {res.rotatable !== false && ( + {canArrange && res.rotatable !== false && ( )} - {res.stackable && selectedItem.amount > 1 && ( + {canArrange && res.stackable && selectedItem.amount > 1 && ( )} - + {canMutate && ( + + )}
)} diff --git a/src/components/frontend/storage/ManageStorageButton.tsx b/src/components/frontend/storage/ManageStorageButton.tsx index b2e8838..395d79a 100644 --- a/src/components/frontend/storage/ManageStorageButton.tsx +++ b/src/components/frontend/storage/ManageStorageButton.tsx @@ -7,18 +7,32 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage interface ManageStorageButtonProps { structureId: number; + canManage: boolean; + canTransfer: boolean; } -export function ManageStorageButton({ structureId }: ManageStorageButtonProps) { +export function ManageStorageButton({ + structureId, + canManage, + canTransfer, +}: ManageStorageButtonProps) { const [open, setOpen] = useState(false); + if (!canManage && !canTransfer) return null; + return ( <> - + ); } diff --git a/src/components/frontend/storage/ManageStorageDialog.tsx b/src/components/frontend/storage/ManageStorageDialog.tsx index 99179ae..c9623bf 100644 --- a/src/components/frontend/storage/ManageStorageDialog.tsx +++ b/src/components/frontend/storage/ManageStorageDialog.tsx @@ -46,9 +46,17 @@ interface ManageStorageDialogProps { structureId: number; open: boolean; onOpenChange: (open: boolean) => void; + canManage: boolean; + canTransfer: boolean; } -export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) { +export function ManageStorageDialog({ + structureId, + open, + onOpenChange, + canManage, + canTransfer, +}: ManageStorageDialogProps) { const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null); const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]); const [allResources, setAllResources] = useState([]); @@ -641,265 +649,288 @@ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageS )} - - - - - Deposit - - - - Withdraw - - - - Transfer - + + + {canManage && ( + + + Deposit + + )} + {canManage && ( + + + Withdraw + + )} + {canTransfer && ( + + + Transfer + + )} - -
- - { + setDepositResourceId(val); + setDepositAmount( + clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max), + ); + }} + > + + + + + {allResources + .filter((r) => isResourceAllowed(r.id)) + .map((resource) => { + const cap = getAllowedDepositCap(resource.id); + return ( + + {resource.name} + {cap !== undefined && ( + + {" "} + ({cap.toLocaleString()} max) + + )} + + ); + })} + + +
+
+ + + setDepositAmount(clampAmount(e.target.value, getDepositMax())) + } + /> + {getDepositLimitInfoFor(depositResourceId) !== undefined && ( + + Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "} + (limited by {getDepositLimitInfoFor(depositResourceId)!.reason}) + + )} +
+ {renderCapacityPreview("deposit")} + +
+ )} + + {canManage && ( + +
+ + -
-
- - setDepositAmount(clampAmount(e.target.value, getDepositMax()))} - /> - {getDepositLimitInfoFor(depositResourceId) !== undefined && ( - - Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "} - (limited by {getDepositLimitInfoFor(depositResourceId)!.reason}) - - )} -
- {renderCapacityPreview("deposit")} - -
+ + + +
+ + + setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax())) + } + /> + {getWithdrawMax() !== undefined && ( + + Max: {getWithdrawMax()!.toLocaleString()} stored + + )} +
+ {renderCapacityPreview("withdraw")} + + + )} - -
- - { + setTransferToId(val); + setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val))); + }} + > + + + + + {allStructures.map((s) => ( + + {s.name} - ); - })} - - -
-
- - - setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax())) - } - /> - {getWithdrawMax() !== undefined && ( - - Max: {getWithdrawMax()!.toLocaleString()} stored - - )} -
- {renderCapacityPreview("withdraw")} - -
- - -
- - -
-
- - +
+
+ + +
+
+ + + setTransferAmount(clampAmount(e.target.value, getTransferMax())) + } + /> + {getTransferMax() !== undefined && ( + + Max: {getTransferMax()!.toLocaleString()} stored + + )} + {transferToId && + transferResourceId && + (() => { + const dest = allStructures.find((s) => s.id === Number(transferToId)); + const destType = dest?.type; + if (!dest || !destType) return null; + const stored = findStoredResource(transferResourceId); + if (!stored) return null; + const resId = stored.resource.id; + if (isResourceProhibited(destType, resId)) { + return ( + + Destination prohibits this resource. + + ); + } + if (!isResourceWhitelisted(destType, resId)) { + return ( + + Destination only accepts items on its allowed storage list. This + resource is not permitted. + + ); + } + const cap = getResourceStorageCap(destType, resId); + const destStored = getStoredAmountOf(dest, resId); return ( - - {resource.name} ({entry.amount.toLocaleString()}{" "} - {pluralize(entry.amount, resource.unitOfMeasure)}) - - ); - })} - - -
-
- - - setTransferAmount(clampAmount(e.target.value, getTransferMax())) - } - /> - {getTransferMax() !== undefined && ( - - Max: {getTransferMax()!.toLocaleString()} stored - - )} - {transferToId && - transferResourceId && - (() => { - const dest = allStructures.find((s) => s.id === Number(transferToId)); - const destType = dest?.type; - if (!dest || !destType) return null; - const stored = findStoredResource(transferResourceId); - if (!stored) return null; - const resId = stored.resource.id; - if (isResourceProhibited(destType, resId)) { - return ( - - Destination prohibits this resource. + + {cap != null + ? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}` + : "Destination has no per-item storage limit"} ); - } - if (!isResourceWhitelisted(destType, resId)) { - return ( - - Destination only accepts items on its allowed storage list. This - resource is not permitted. - - ); - } - const cap = getResourceStorageCap(destType, resId); - const destStored = getStoredAmountOf(dest, resId); - return ( - - {cap != null - ? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}` - : "Destination has no per-item storage limit"} - - ); - })()} -
- {renderCapacityPreview("transfer")} - -
+ })()} + + {renderCapacityPreview("transfer")} + + + )}
)} diff --git a/src/components/frontend/storage/StructureStorageView.tsx b/src/components/frontend/storage/StructureStorageView.tsx index ac6d207..1c1c537 100644 --- a/src/components/frontend/storage/StructureStorageView.tsx +++ b/src/components/frontend/storage/StructureStorageView.tsx @@ -45,9 +45,17 @@ function getResourceIcon(type: Resource["type"]) { interface StructureStorageViewProps { structure: GameStructure & { type: Structure }; + /** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */ + canArrange?: boolean; + /** Removing grid items / withdrawing destroys stored stock — superuser only. */ + canMutate?: boolean; } -export function StructureStorageView({ structure }: StructureStorageViewProps) { +export function StructureStorageView({ + structure, + canArrange = false, + canMutate = false, +}: StructureStorageViewProps) { const router = useRouter(); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [selectedItem, setSelectedItem] = useState(null); @@ -80,64 +88,72 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { const handleMoveItem = useCallback( async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => { + if (!canArrange) return; const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated); if (result.success) refresh(); }, - [structure.id, refresh], + [structure.id, refresh, canArrange], ); const handleMergeStacks = useCallback( async (sourceId: string, targetId: string) => { + if (!canArrange) return; const result = await mergeGridStacks(structure.id, sourceId, targetId); if (result.success) refresh(); }, - [structure.id, refresh], + [structure.id, refresh, canArrange], ); const handleRotateItem = useCallback( async (entryId: string) => { + if (!canArrange) return; const result = await rotateGridItem(structure.id, entryId); if (result.success) refresh(); }, - [structure.id, refresh], + [structure.id, refresh, canArrange], ); const handleRemoveItem = useCallback( async (entryId: string) => { + if (!canMutate) return; const result = await removeGridItem(structure.id, entryId); if (result.success) { setSelectedItem(null); refresh(); } }, - [structure.id, refresh], + [structure.id, refresh, canMutate], ); const handleSplitStack = useCallback( async (entryId: string, amount: number) => { + if (!canArrange) return; const result = await splitGridStack(structure.id, entryId, amount); if (result.success) refresh(); }, - [structure.id, refresh], + [structure.id, refresh, canArrange], ); const handleAutoArrange = useCallback(async () => { + if (!canArrange) return; const result = await autoArrangeGrid(structure.id); if (result.success) refresh(); - }, [structure.id, refresh]); + }, [structure.id, refresh, canArrange]); const handleWithdrawFromVoid = useCallback( async (resourceId: number, amount: number) => { + if (!canMutate) return; const result = await removeResource(structure.id, resourceId, amount); if (result.success) refresh(); }, - [structure.id, refresh], + [structure.id, refresh, canMutate], ); const handleRetrieveFromVoid = useCallback(async () => { + if (!canArrange) return; const result = await retrieveFromVoid(structure.id); if (result.success) refresh(); - }, [structure.id, refresh]); + }, [structure.id, refresh, canArrange]); if (!hasGrid) { return ( @@ -179,6 +195,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { onRotate={handleRotateItem} onRemove={handleRemoveItem} onSplit={handleSplitStack} + canArrange={canArrange} + canMutate={canMutate} /> {viewMode === "grid" ? ( @@ -213,13 +231,15 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { {voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no longer fit in the grid. - + {canArrange && ( + + )} @@ -262,14 +282,16 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { {mass > 0 && `${mass.toLocaleString()} kg`} - + {canMutate && ( + + )} {index < voidStorageItems.length - 1 && } diff --git a/src/tools/seed/seedRoles.ts b/src/tools/seed/seedRoles.ts index b39c6f2..e355af5 100644 --- a/src/tools/seed/seedRoles.ts +++ b/src/tools/seed/seedRoles.ts @@ -53,7 +53,6 @@ const USER_PERMISSIONS: Permission[] = [ "shims:read", "shipments:create", "shipments:update", - "structures:update", "structures:delete", ]; @@ -87,6 +86,7 @@ const ADMIN_PERMISSIONS: Permission[] = [ "discord:staff", "discord:announce", "structures:create", + "structures:update", ...ALL_COLLECTION_READS, ...ALL_ADMIN_PAGE_MANAGE, ]),