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:
parent
0b956a3beb
commit
56fb6ac26a
11 changed files with 135 additions and 53 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue