1
0
Fork 0

feat(map): gate feature authoring and structure placement behind dedicated permissions

Replace the maps:create/update/delete catch-all for map features: roads, zones, and nodes each take a per-collection author permission, structure placement takes maps:place (still co-gated with the logistics qualification), and GeoJSON import requires all three author permissions. The map view page keys edit UI per feature type; admin role seed grants the new values. Tests updated for the permission split.
This commit is contained in:
Jason Fraley 2026-09-30 14:50:14 -04:00
parent 0b956a3beb
commit 56fb6ac26a
11 changed files with 135 additions and 53 deletions

View file

@ -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; const { mapId, blueprintId, name, x, y } = params;
if (!Number.isFinite(x) || !Number.isFinite(y)) { if (!Number.isFinite(x) || !Number.isFinite(y)) {
@ -289,7 +293,14 @@ export interface MapNodeInput {
async function requireMapPermission( async function requireMapPermission(
payload: Awaited<ReturnType<typeof getPayload>>, payload: Awaited<ReturnType<typeof getPayload>>,
user: { id: number | string }, 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<boolean> { ): Promise<boolean> {
return hasPermission(payload, user, permission); return hasPermission(payload, user, permission);
} }
@ -324,8 +335,8 @@ export async function saveMapRoad(input: MapRoadInput): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const editing = input.id != null; const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { if (!(await requireMapPermission(payload, user, "map-roads:author"))) {
return { success: false, error: "Map configuration permission required." }; return { success: false, error: "Map road authoring permission required." };
} }
if (!isPointArray(input.points, 2)) { if (!isPointArray(input.points, 2)) {
return { success: false, error: "A road requires at least two points." }; return { success: false, error: "A road requires at least two points." };
@ -385,8 +396,8 @@ export async function saveMapZone(input: MapZoneInput): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const editing = input.id != null; const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { if (!(await requireMapPermission(payload, user, "map-zones:author"))) {
return { success: false, error: "Map configuration permission required." }; return { success: false, error: "Map zone authoring permission required." };
} }
if (!isPointArray(input.points, 3)) { if (!isPointArray(input.points, 3)) {
return { success: false, error: "A zone requires at least three points." }; return { success: false, error: "A zone requires at least three points." };
@ -446,8 +457,8 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const editing = input.id != null; const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { if (!(await requireMapPermission(payload, user, "resource-nodes:author"))) {
return { success: false, error: "Map configuration permission required." }; return { success: false, error: "Map node authoring permission required." };
} }
if (validatePosition(input.position) !== true) { if (validatePosition(input.position) !== true) {
return { success: false, error: "A node requires a single [x, y] position." }; return { success: false, error: "A node requires a single [x, y] position." };
@ -516,11 +527,17 @@ export async function deleteMapFeature(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await requireMapPermission(payload, user, "maps:delete"))) {
return { success: false, error: "Map configuration permission required." };
}
const collection = const collection =
kind === "road" ? "map-roads" : kind === "zone" ? "map-zones" : "resource-nodes"; 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 }); await payload.delete({ collection, id, overrideAccess: false });
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View file

@ -31,14 +31,23 @@ export default async function MapViewPage({ params }: MapViewPageProps) {
if (!map) notFound(); if (!map) notFound();
const [canEdit, canPlace] = await Promise.all([ const [canPlace, canAuthorRoads, canAuthorZones, canAuthorNodes] = await Promise.all([
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false), user
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false), ? 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 // Inactive maps stay reachable for editors previewing them, but are hidden
// from everyone else. // from everyone else.
if (!map.isActive && !canEdit) notFound(); if (!map.isActive && !(canAuthorRoads || canAuthorZones || canAuthorNodes || canPlace)) {
notFound();
}
return ( return (
<div className="relative flex min-h-[240px] flex-1 flex-col"> <div className="relative flex min-h-[240px] flex-1 flex-col">
@ -54,7 +63,11 @@ export default async function MapViewPage({ params }: MapViewPageProps) {
</span> </span>
</div> </div>
</div> </div>
<MapLoader map={{ id: map.id, name: map.name }} canEdit={canEdit} canPlace={canPlace} /> <MapLoader
map={{ id: map.id, name: map.name }}
canAuthor={{ roads: canAuthorRoads, zones: canAuthorZones, nodes: canAuthorNodes }}
canPlace={canPlace}
/>
</div> </div>
); );
} }

View file

@ -84,8 +84,18 @@ export async function POST(req: NextRequest) {
if (!user) { if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
if (!(await hasPermission(payload, user, "maps:create"))) { const [canAuthorRoads, canAuthorZones, canAuthorNodes] = await Promise.all([
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 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; let body: (GeoJsonCollection & { mapId?: number }) | null = null;

View file

@ -17,9 +17,9 @@ export const MapRoads: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: requirePermission("maps:create"), create: requirePermission("map-roads:author"),
update: requirePermission("maps:update"), update: requirePermission("map-roads:author"),
delete: requirePermission("maps:delete"), delete: requirePermission("map-roads:author"),
}, },
fields: [ fields: [
{ {

View file

@ -16,9 +16,9 @@ export const MapZones: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: requirePermission("maps:create"), create: requirePermission("map-zones:author"),
update: requirePermission("maps:update"), update: requirePermission("map-zones:author"),
delete: requirePermission("maps:delete"), delete: requirePermission("map-zones:author"),
}, },
fields: [ fields: [
{ {

View file

@ -16,9 +16,9 @@ export const ResourceNodes: CollectionConfig = {
}, },
access: { access: {
read: ({ req }) => !!req.user, read: ({ req }) => !!req.user,
create: requirePermission("maps:create"), create: requirePermission("resource-nodes:author"),
update: requirePermission("maps:update"), update: requirePermission("resource-nodes:author"),
delete: requirePermission("maps:delete"), delete: requirePermission("resource-nodes:author"),
}, },
fields: [ fields: [
{ {

View file

@ -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 resources = useResourceOptions();
const { kind, fields } = authoring; const { kind, fields } = authoring;
const canDraw = {
road: canAuthor.roads,
zone: canAuthor.zones,
node: canAuthor.nodes,
};
return ( return (
<div className="pointer-events-auto w-[640px] max-w-[calc(100vw-10rem)] overflow-hidden rounded-none border border-white/15 bg-black/80 backdrop-blur-sm"> <div className="pointer-events-auto w-[640px] max-w-[calc(100vw-10rem)] overflow-hidden rounded-none border border-white/15 bg-black/80 backdrop-blur-sm">
@ -519,7 +532,9 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
</div> </div>
<div className="flex flex-col gap-2 p-2"> <div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{(["road", "zone", "node"] as const).map((target) => ( {(["road", "zone", "node"] as const)
.filter((target) => canDraw[target])
.map((target) => (
<Button <Button
key={target} key={target}
size="sm" size="sm"

View file

@ -53,7 +53,7 @@ import { nodeIconOption } from "@/lib/map/nodeIcons";
export interface MapClientProps { export interface MapClientProps {
map: { id: number; name: string }; map: { id: number; name: string };
canEdit: boolean; canAuthor: { roads: boolean; zones: boolean; nodes: boolean };
canPlace: 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 selectedMapId = map.id;
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null); const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -1401,7 +1402,7 @@ export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
{zone.description && ( {zone.description && (
<div className="mt-1 text-xs text-white/70">{zone.description}</div> <div className="mt-1 text-xs text-white/70">{zone.description}</div>
)} )}
{canEdit && mode === "edit" && ( {canAuthor.zones && mode === "edit" && (
<div className="mt-2 flex gap-1.5"> <div className="mt-2 flex gap-1.5">
<Button <Button
size="sm" size="sm"
@ -1456,7 +1457,7 @@ export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
{road.description && ( {road.description && (
<div className="mt-1 text-xs text-white/70">{road.description}</div> <div className="mt-1 text-xs text-white/70">{road.description}</div>
)} )}
{canEdit && mode === "edit" && ( {canAuthor.roads && mode === "edit" && (
<div className="mt-2 flex gap-1.5"> <div className="mt-2 flex gap-1.5">
<Button <Button
size="sm" size="sm"
@ -1544,7 +1545,7 @@ export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
{node.richness != null && node.resources.length > 0 && ( {node.richness != null && node.resources.length > 0 && (
<div className="text-xs text-white/60">Richness {node.richness}</div> <div className="text-xs text-white/60">Richness {node.richness}</div>
)} )}
{canEdit && mode === "edit" && ( {canAuthor.nodes && mode === "edit" && (
<div className="mt-2 flex gap-1.5"> <div className="mt-2 flex gap-1.5">
<Button <Button
size="sm" size="sm"
@ -1719,7 +1720,13 @@ export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
</div> </div>
</div> </div>
{mode === "edit" && <AuthoringToolbar authoring={authoring} mapId={selectedMapId ?? 0} />} {mode === "edit" && (
<AuthoringToolbar
authoring={authoring}
mapId={selectedMapId ?? 0}
canAuthor={canAuthor}
/>
)}
</div> </div>
{error && ( {error && (

View file

@ -71,6 +71,10 @@ const ADMIN_PERMISSIONS: Permission[] = [
"form-submissions:read", "form-submissions:read",
"structures:create", "structures:create",
"structures:update", "structures:update",
"maps:place",
"map-roads:author",
"map-zones:author",
"resource-nodes:author",
...ALL_COLLECTION_READS, ...ALL_COLLECTION_READS,
...ALL_ADMIN_PAGE_MANAGE, ...ALL_ADMIN_PAGE_MANAGE,
]), ]),

View file

@ -40,7 +40,7 @@ describe("generic marker nodes (no resources)", () => {
data: { data: {
name: `Generic Node Mapper ${Date.now()}`, name: `Generic Node Mapper ${Date.now()}`,
slug: `generic-mapper-${Date.now()}`, slug: `generic-mapper-${Date.now()}`,
permissions: ["maps:create"], permissions: ["resource-nodes:author"],
}, },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,

View file

@ -317,7 +317,7 @@ describe("Game Map System", () => {
data: { data: {
name: `${RUN} logistics`, name: `${RUN} logistics`,
slug: `${RUN}-logistics`, slug: `${RUN}-logistics`,
permissions: ["shipments:create", "structures:read"], permissions: ["shipments:create", "structures:read", "maps:place"],
}, },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
@ -356,7 +356,16 @@ describe("Game Map System", () => {
data: { data: {
name: `${RUN} mapper`, name: `${RUN} mapper`,
slug: `${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, overrideAccess: true,
depth: 0, depth: 0,
@ -1559,9 +1568,12 @@ describe("Game Map System", () => {
expect(await createAccess({ req: { payload, user: null } })).toBe(false); 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"); 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); expect(await hasPermission(payload, mapper, "maps:delete")).toBe(false);
const payloadConfig = await config; const payloadConfig = await config;
@ -1569,12 +1581,16 @@ describe("Game Map System", () => {
(collection) => collection.slug === "map-roads", (collection) => collection.slug === "map-roads",
); );
const createAccess = roadsConfig!.access?.create as unknown as (args: { const createAccess = roadsConfig!.access?.create as unknown as (args: {
req: { payload: Payload; user: User }; req: { payload: Payload; user: User | null };
}) => Promise<boolean>; }) => Promise<boolean>;
expect(await createAccess({ req: { payload, user: mapper } })).toBe(true); 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 }); authSpy.mockResolvedValue({ user: mapper });
const road = await makeRoad(mapA.id, [ const road = await makeRoad(mapA.id, [
[500, 500], [500, 500],