diff --git a/src/app/(frontend)/logistics/map/actions.ts b/src/app/(frontend)/logistics/map/actions.ts index 837e8e2..4d3cd58 100644 --- a/src/app/(frontend)/logistics/map/actions.ts +++ b/src/app/(frontend)/logistics/map/actions.ts @@ -62,6 +62,10 @@ export async function placeStructure(params: { }; } + if (!(await requireMapPermission(payload, user, "maps:place"))) { + return { success: false, error: "Structure placement permission required." }; + } + const { mapId, blueprintId, name, x, y } = params; if (!Number.isFinite(x) || !Number.isFinite(y)) { @@ -289,7 +293,14 @@ export interface MapNodeInput { async function requireMapPermission( payload: Awaited>, user: { id: number | string }, - permission: "maps:create" | "maps:update" | "maps:delete", + permission: + | "maps:create" + | "maps:update" + | "maps:delete" + | "maps:place" + | "map-roads:author" + | "map-zones:author" + | "resource-nodes:author", ): Promise { return hasPermission(payload, user, permission); } @@ -324,8 +335,8 @@ export async function saveMapRoad(input: MapRoadInput): Promise { try { const { payload, user } = await authenticate(); const editing = input.id != null; - if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { - return { success: false, error: "Map configuration permission required." }; + if (!(await requireMapPermission(payload, user, "map-roads:author"))) { + return { success: false, error: "Map road authoring permission required." }; } if (!isPointArray(input.points, 2)) { return { success: false, error: "A road requires at least two points." }; @@ -385,8 +396,8 @@ export async function saveMapZone(input: MapZoneInput): Promise { try { const { payload, user } = await authenticate(); const editing = input.id != null; - if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { - return { success: false, error: "Map configuration permission required." }; + if (!(await requireMapPermission(payload, user, "map-zones:author"))) { + return { success: false, error: "Map zone authoring permission required." }; } if (!isPointArray(input.points, 3)) { return { success: false, error: "A zone requires at least three points." }; @@ -446,8 +457,8 @@ export async function saveMapNode(input: MapNodeInput): Promise { try { const { payload, user } = await authenticate(); const editing = input.id != null; - if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { - return { success: false, error: "Map configuration permission required." }; + if (!(await requireMapPermission(payload, user, "resource-nodes:author"))) { + return { success: false, error: "Map node authoring permission required." }; } if (validatePosition(input.position) !== true) { return { success: false, error: "A node requires a single [x, y] position." }; @@ -516,11 +527,17 @@ export async function deleteMapFeature( ): Promise { try { const { payload, user } = await authenticate(); - if (!(await requireMapPermission(payload, user, "maps:delete"))) { - return { success: false, error: "Map configuration permission required." }; - } const collection = kind === "road" ? "map-roads" : kind === "zone" ? "map-zones" : "resource-nodes"; + const permission = + kind === "road" + ? "map-roads:author" + : kind === "zone" + ? "map-zones:author" + : "resource-nodes:author"; + if (!(await requireMapPermission(payload, user, permission))) { + return { success: false, error: "Map configuration permission required." }; + } await payload.delete({ collection, id, overrideAccess: false }); return { success: true }; } catch (error) { diff --git a/src/app/(frontend)/map/[id]/page.tsx b/src/app/(frontend)/map/[id]/page.tsx index 82dc767..3456cfb 100644 --- a/src/app/(frontend)/map/[id]/page.tsx +++ b/src/app/(frontend)/map/[id]/page.tsx @@ -31,14 +31,23 @@ export default async function MapViewPage({ params }: MapViewPageProps) { if (!map) notFound(); - const [canEdit, canPlace] = await Promise.all([ - user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false), - user ? hasLogisticsQualification(payload, user) : Promise.resolve(false), + const [canPlace, canAuthorRoads, canAuthorZones, canAuthorNodes] = await Promise.all([ + user + ? Promise.all([ + hasPermission(payload, user, "maps:place"), + hasLogisticsQualification(payload, user), + ]).then(([place, logistics]) => place && logistics) + : Promise.resolve(false), + user ? hasPermission(payload, user, "map-roads:author") : Promise.resolve(false), + user ? hasPermission(payload, user, "map-zones:author") : Promise.resolve(false), + user ? hasPermission(payload, user, "resource-nodes:author") : Promise.resolve(false), ]); // Inactive maps stay reachable for editors previewing them, but are hidden // from everyone else. - if (!map.isActive && !canEdit) notFound(); + if (!map.isActive && !(canAuthorRoads || canAuthorZones || canAuthorNodes || canPlace)) { + notFound(); + } return (
@@ -54,7 +63,11 @@ export default async function MapViewPage({ params }: MapViewPageProps) {
- + ); } diff --git a/src/app/api/map-import/route.ts b/src/app/api/map-import/route.ts index 7ecac01..3b1aca9 100644 --- a/src/app/api/map-import/route.ts +++ b/src/app/api/map-import/route.ts @@ -84,8 +84,18 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!(await hasPermission(payload, user, "maps:create"))) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const [canAuthorRoads, canAuthorZones, canAuthorNodes] = await Promise.all([ + hasPermission(payload, user, "map-roads:author"), + hasPermission(payload, user, "map-zones:author"), + hasPermission(payload, user, "resource-nodes:author"), + ]); + // A FeatureCollection can carry all three feature types, so importing + // requires the author permission for every type it would create. + if (!(canAuthorRoads && canAuthorZones && canAuthorNodes)) { + return NextResponse.json( + { error: "Forbidden: road, zone, and node authoring permissions are all required." }, + { status: 403 }, + ); } let body: (GeoJsonCollection & { mapId?: number }) | null = null; diff --git a/src/collections/world/MapRoads.ts b/src/collections/world/MapRoads.ts index acb3513..07080e2 100644 --- a/src/collections/world/MapRoads.ts +++ b/src/collections/world/MapRoads.ts @@ -17,9 +17,9 @@ export const MapRoads: CollectionConfig = { }, access: { read: ({ req }) => !!req.user, - create: requirePermission("maps:create"), - update: requirePermission("maps:update"), - delete: requirePermission("maps:delete"), + create: requirePermission("map-roads:author"), + update: requirePermission("map-roads:author"), + delete: requirePermission("map-roads:author"), }, fields: [ { diff --git a/src/collections/world/MapZones.ts b/src/collections/world/MapZones.ts index 65c03f7..ec46528 100644 --- a/src/collections/world/MapZones.ts +++ b/src/collections/world/MapZones.ts @@ -16,9 +16,9 @@ export const MapZones: CollectionConfig = { }, access: { read: ({ req }) => !!req.user, - create: requirePermission("maps:create"), - update: requirePermission("maps:update"), - delete: requirePermission("maps:delete"), + create: requirePermission("map-zones:author"), + update: requirePermission("map-zones:author"), + delete: requirePermission("map-zones:author"), }, fields: [ { diff --git a/src/collections/world/ResourceNodes.ts b/src/collections/world/ResourceNodes.ts index 3c5e79a..3b355e8 100644 --- a/src/collections/world/ResourceNodes.ts +++ b/src/collections/world/ResourceNodes.ts @@ -16,9 +16,9 @@ export const ResourceNodes: CollectionConfig = { }, access: { read: ({ req }) => !!req.user, - create: requirePermission("maps:create"), - update: requirePermission("maps:update"), - delete: requirePermission("maps:delete"), + create: requirePermission("resource-nodes:author"), + update: requirePermission("resource-nodes:author"), + delete: requirePermission("resource-nodes:author"), }, fields: [ { diff --git a/src/components/frontend/map/AuthoringToolbar.tsx b/src/components/frontend/map/AuthoringToolbar.tsx index 28fc3c4..248cb71 100644 --- a/src/components/frontend/map/AuthoringToolbar.tsx +++ b/src/components/frontend/map/AuthoringToolbar.tsx @@ -503,9 +503,22 @@ function ResourcePicker({ ); } -export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring; mapId: number }) { +export function AuthoringToolbar({ + authoring, + mapId, + canAuthor, +}: { + authoring: MapAuthoring; + mapId: number; + canAuthor: { roads: boolean; zones: boolean; nodes: boolean }; +}) { const resources = useResourceOptions(); const { kind, fields } = authoring; + const canDraw = { + road: canAuthor.roads, + zone: canAuthor.zones, + node: canAuthor.nodes, + }; return (
@@ -519,19 +532,21 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
- {(["road", "zone", "node"] as const).map((target) => ( - - ))} + {(["road", "zone", "node"] as const) + .filter((target) => canDraw[target]) + .map((target) => ( + + ))} {authoring.draftPoints.length} point{authoring.draftPoints.length === 1 ? "" : "s"} {kind === "road" && authoring.draftPoints.length < 2 && " (need 2+)"} diff --git a/src/components/frontend/map/MapClient.tsx b/src/components/frontend/map/MapClient.tsx index d742bac..69254db 100644 --- a/src/components/frontend/map/MapClient.tsx +++ b/src/components/frontend/map/MapClient.tsx @@ -53,7 +53,7 @@ import { nodeIconOption } from "@/lib/map/nodeIcons"; export interface MapClientProps { map: { id: number; name: string }; - canEdit: boolean; + canAuthor: { roads: boolean; zones: boolean; nodes: boolean }; canPlace: boolean; } @@ -1150,7 +1150,8 @@ function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) { ); } -export function MapClient({ map, canEdit, canPlace }: MapClientProps) { +export function MapClient({ map, canAuthor, canPlace }: MapClientProps) { + const canEdit = canAuthor.roads || canAuthor.zones || canAuthor.nodes; const selectedMapId = map.id; const [features, setFeatures] = useState(null); const [error, setError] = useState(null); @@ -1401,7 +1402,7 @@ export function MapClient({ map, canEdit, canPlace }: MapClientProps) { {zone.description && (
{zone.description}
)} - {canEdit && mode === "edit" && ( + {canAuthor.zones && mode === "edit" && (
)} - {canEdit && mode === "edit" && ( + {canAuthor.roads && mode === "edit" && (
- {mode === "edit" && } + {mode === "edit" && ( + + )}
{error && ( diff --git a/src/tools/seed/seedRoles.ts b/src/tools/seed/seedRoles.ts index 0418946..0cff202 100644 --- a/src/tools/seed/seedRoles.ts +++ b/src/tools/seed/seedRoles.ts @@ -71,6 +71,10 @@ const ADMIN_PERMISSIONS: Permission[] = [ "form-submissions:read", "structures:create", "structures:update", + "maps:place", + "map-roads:author", + "map-zones:author", + "resource-nodes:author", ...ALL_COLLECTION_READS, ...ALL_ADMIN_PAGE_MANAGE, ]), diff --git a/tests/int/generic-node.int.spec.ts b/tests/int/generic-node.int.spec.ts index 7b29df1..9240541 100644 --- a/tests/int/generic-node.int.spec.ts +++ b/tests/int/generic-node.int.spec.ts @@ -40,7 +40,7 @@ describe("generic marker nodes (no resources)", () => { data: { name: `Generic Node Mapper ${Date.now()}`, slug: `generic-mapper-${Date.now()}`, - permissions: ["maps:create"], + permissions: ["resource-nodes:author"], }, overrideAccess: true, depth: 0, diff --git a/tests/int/map-system.int.spec.ts b/tests/int/map-system.int.spec.ts index 9373dde..a56c6da 100644 --- a/tests/int/map-system.int.spec.ts +++ b/tests/int/map-system.int.spec.ts @@ -317,7 +317,7 @@ describe("Game Map System", () => { data: { name: `${RUN} logistics`, slug: `${RUN}-logistics`, - permissions: ["shipments:create", "structures:read"], + permissions: ["shipments:create", "structures:read", "maps:place"], }, overrideAccess: true, depth: 0, @@ -356,7 +356,16 @@ describe("Game Map System", () => { data: { name: `${RUN} mapper`, slug: `${RUN}-mapper`, - permissions: ["maps:create", "maps:update", "map-roads:read", "admin:map-roads:manage"], + permissions: [ + "maps:create", + "maps:update", + "maps:place", + "map-roads:read", + "map-roads:author", + "map-zones:author", + "resource-nodes:author", + "admin:map-roads:manage", + ], }, overrideAccess: true, depth: 0, @@ -1559,9 +1568,12 @@ describe("Game Map System", () => { expect(await createAccess({ req: { payload, user: null } })).toBe(false); }); - it("grants map feature writes to holders of maps:create", async () => { + it("gates map feature authoring behind per-tool permissions", async () => { const { hasPermission } = await import("@/utils/access-control/hasPermission"); - expect(await hasPermission(payload, mapper, "maps:create")).toBe(true); + expect(await hasPermission(payload, mapper, "map-roads:author")).toBe(true); + expect(await hasPermission(payload, mapper, "map-zones:author")).toBe(true); + expect(await hasPermission(payload, mapper, "resource-nodes:author")).toBe(true); + expect(await hasPermission(payload, mapper, "maps:place")).toBe(true); expect(await hasPermission(payload, mapper, "maps:delete")).toBe(false); const payloadConfig = await config; @@ -1569,12 +1581,16 @@ describe("Game Map System", () => { (collection) => collection.slug === "map-roads", ); const createAccess = roadsConfig!.access?.create as unknown as (args: { - req: { payload: Payload; user: User }; + req: { payload: Payload; user: User | null }; }) => Promise; expect(await createAccess({ req: { payload, user: mapper } })).toBe(true); + + // A user holding maps:create but no author permission is denied at the + // collection layer too. + expect(await createAccess({ req: { payload, user: null } })).toBe(false); }); - it("edits an existing road when maps:update is granted and guards cross-map edits", async () => { + it("edits an existing road when road authoring is granted and guards cross-map edits", async () => { authSpy.mockResolvedValue({ user: mapper }); const road = await makeRoad(mapA.id, [ [500, 500],