1
0
Fork 0

feat(rbac): migrate server actions and service layers to RBAC

Replace hasRoles calls with hasPermission in all server actions and page
components. Update qualification checks (logistics, intelligence) to use
permission-based checks instead of role name matching. Update staff lookup
in tickets/staff.ts to query roles collection. Use enlistmentDate field
on profile page instead of createdAt.
This commit is contained in:
Jason Fraley 2026-08-19 19:47:57 -04:00
parent 653caa8064
commit eef1b11bb1
17 changed files with 181 additions and 97 deletions

View file

@ -144,26 +144,34 @@ export async function requestDiscordUsernameChange(input: {
return { success: false, error: "That Discord username is already in use." }; return { success: false, error: "That Discord username is already in use." };
} }
const staffRes = await payload.find({ const staffRoles = await payload.find({
collection: "users", collection: "roles",
where: { where: { slug: { in: ["admin", "developer"] } },
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }], limit: 2,
},
limit: 100,
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
}); });
const staffIds = [...new Set(staffRes.docs.map((doc) => doc.id as number))].filter( const staffRoleIds = staffRoles.docs.map((d) => d.id);
(id) => id !== userId, if (staffRoleIds.length > 0) {
); const staffRes = await payload.find({
for (const staffId of staffIds) { collection: "users",
await notifyUser(payload, { where: { roleDocs: { in: staffRoleIds } },
userId: staffId, limit: 100,
type: "account:discord-request", depth: 0,
title: `Discord username change requested by ${user.username}`, overrideAccess: true,
message: `wants to change their Discord username to "${trimmed}".`,
link: `/admin/collections/users/${userId}`,
}); });
const staffIds = [...new Set(staffRes.docs.map((doc) => doc.id as number))].filter(
(id) => id !== userId,
);
for (const staffId of staffIds) {
await notifyUser(payload, {
userId: staffId,
type: "account:discord-request",
title: `Discord username change requested by ${user.username}`,
message: `wants to change their Discord username to "${trimmed}".`,
link: `/admin/collections/users/${userId}`,
});
}
} }
await notifyUser(payload, { await notifyUser(payload, {

View file

@ -4,7 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import { TicketDetail } from "@/components/frontend/helpdesk/TicketDetail"; import { TicketDetail } from "@/components/frontend/helpdesk/TicketDetail";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react"; import { ArrowLeftIcon, LifeBuoyIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -18,14 +18,16 @@ interface TicketPageProps {
export default async function TicketPage({ params }: TicketPageProps) { export default async function TicketPage({ params }: TicketPageProps) {
const { id } = await params; const { id } = await params;
const headers = await nextHeaders();
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ const { user } = await payload.auth({
headers: await nextHeaders(), headers,
canSetHeaders: false, canSetHeaders: false,
}); });
if (!user) { if (!user) {
redirect("/login"); const pathname = headers.get("x-invoke-path") || `/helpdesk/${id}`;
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
} }
const ticket = await payload const ticket = await payload
@ -40,8 +42,8 @@ export default async function TicketPage({ params }: TicketPageProps) {
notFound(); notFound();
} }
const typedTicket = ticket as unknown as Ticket; const typedTicket = ticket as unknown as Ticket;
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
const assigneeOptions = isStaff const assigneeOptions = isStaff
? ( ? (
await payload.find({ await payload.find({

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
@ -144,7 +144,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
const userId = user.id as number; const userId = user.id as number;
const ticket = await getTicketOrThrow(payload, ticketId); const ticket = await getTicketOrThrow(payload, ticketId);
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff && reporterId !== userId) { if (!isStaff && reporterId !== userId) {
throw new Error("You don't have access to this ticket."); throw new Error("You don't have access to this ticket.");
@ -241,7 +241,7 @@ export async function updateTicketStatus(
const ticket = await getTicketOrThrow(payload, ticketId); const ticket = await getTicketOrThrow(payload, ticketId);
if (status === ticket.status) return { success: true }; if (status === ticket.status) return { success: true };
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff) { if (!isStaff) {
const reporterCancel = const reporterCancel =
@ -299,7 +299,7 @@ export async function assignTicket(
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;
if (!hasRoles(["admin", "developer"], user)) { if (!(await hasPermission(payload, user, "tickets:staff"))) {
throw new Error("Only staff can assign tickets."); throw new Error("Only staff can assign tickets.");
} }

View file

@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import { TicketsView } from "@/components/frontend/helpdesk/TicketsView"; import { TicketsView } from "@/components/frontend/helpdesk/TicketsView";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { LifeBuoyIcon } from "lucide-react"; import { LifeBuoyIcon } from "lucide-react";
export const metadata = { export const metadata = {
@ -12,14 +12,16 @@ export const metadata = {
}; };
export default async function HelpdeskPage() { export default async function HelpdeskPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ const { user } = await payload.auth({
headers: await nextHeaders(), headers,
canSetHeaders: false, canSetHeaders: false,
}); });
if (!user) { if (!user) {
redirect("/login"); const pathname = headers.get("x-invoke-path") || "/helpdesk";
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
} }
const ticketsRes = await payload.find({ const ticketsRes = await payload.find({
@ -28,8 +30,8 @@ export default async function HelpdeskPage() {
limit: 200, limit: 200,
depth: 2, depth: 2,
}); });
const tickets = ticketsRes.docs as unknown as Ticket[]; const tickets = ticketsRes.docs as unknown as Ticket[];
const isStaff = hasRoles(["admin", "developer"], user); const isStaff = await hasPermission(payload, user, "tickets:staff");
return ( return (
<div className="flex flex-col gap-6 p-5"> <div className="flex flex-col gap-6 p-5">

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Asset, Loadout, LockerStorage, User } from "@/payload-types"; import type { Asset, Loadout, LockerStorage, User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
@ -24,6 +24,7 @@ import {
skinAppliesTo, skinAppliesTo,
toLockerGridItems, toLockerGridItems,
} from "@/lib/locker"; } from "@/lib/locker";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -45,8 +46,9 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function isLockerManager(user: User): boolean { async function isLockerManager(payload: Awaited<ReturnType<typeof getPayload>>, user: User): Promise<boolean> {
return hasRoles(["admin", "developer"], user); if (await hasPermission(payload, user, "locker-storages:update")) return true;
return hasLogisticsQualification(payload, user);
} }
async function getLockerWithItems( async function getLockerWithItems(
@ -114,7 +116,7 @@ export async function addItemToLocker(
): Promise<ActionResult<LockerStorage>> { ): Promise<ActionResult<LockerStorage>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!isLockerManager(user)) { if (!(await hasPermission(payload, user, "locker-storages:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required to add items.", error: "Insufficient permissions. Admin or Developer role required to add items.",
@ -759,7 +761,7 @@ async function getOwnedLoadout(
? (loadout.ownerUser as { id: number }).id ? (loadout.ownerUser as { id: number }).id
: (loadout.ownerUser as number); : (loadout.ownerUser as number);
if (ownerId !== user.id && !isLockerManager(user)) return null; if (ownerId !== user.id && !(await hasPermission(payload, user, "locker-storages:update"))) return null;
return loadout; return loadout;
} }

View file

@ -5,7 +5,7 @@ import type { Asset, Loadout, LockerStorage } from "@/payload-types";
import { LockKeyholeIcon } from "lucide-react"; import { LockKeyholeIcon } from "lucide-react";
import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker"; import { ensureLockerStorage, getLockerGridDimensions } from "@/lib/locker";
import { LockerView } from "@/components/frontend/locker/LockerView"; import { LockerView } from "@/components/frontend/locker/LockerView";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
export const metadata = { export const metadata = {
title: "Locker — Polaris Task Force", title: "Locker — Polaris Task Force",
@ -34,9 +34,9 @@ export default async function LockerPage() {
limit: 100, limit: 100,
depth: 1, depth: 1,
}); });
const loadouts = loadoutsRes.docs as unknown as Loadout[]; const loadouts = loadoutsRes.docs as unknown as Loadout[];
const isManager = user ? hasRoles(["admin", "developer"], user) : false; const isManager = user ? (await hasPermission(payload, user, "locker-storages:update")) : false;
let assetsCatalog: Asset[] = []; let assetsCatalog: Asset[] = [];
if (isManager) { if (isManager) {

View file

@ -4,7 +4,7 @@ import { headers as nextHeaders } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { BankAccount, LedgerEntry } from "@/payload-types"; import type { BankAccount, LedgerEntry } from "@/payload-types";
import { AccountDetail } from "@/components/frontend/banking/AccountDetail"; import { AccountDetail } from "@/components/frontend/banking/AccountDetail";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export const metadata = { export const metadata = {
@ -35,12 +35,12 @@ export default async function AccountPage({ params }: AccountPageProps) {
notFound(); notFound();
} }
const typedAccount = account as unknown as BankAccount; const typedAccount = account as unknown as BankAccount;
const isManager = user const isManager = user
? hasRoles(["admin", "developer"], user) || ? (await hasPermission(payload, user, "banking:manage")) ||
(await hasLogisticsQualification(payload, user).catch(() => false)) (await hasLogisticsQualification(payload, user).catch(() => false))
: false; : false;
const ownerId = const ownerId =
typeof typedAccount.ownerUser === "object" typeof typedAccount.ownerUser === "object"

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
@ -42,7 +42,7 @@ async function isBankingManager(
payload: Awaited<ReturnType<typeof getPayload>>, payload: Awaited<ReturnType<typeof getPayload>>,
user: User, user: User,
): Promise<boolean> { ): Promise<boolean> {
if (hasRoles(["admin", "developer"], user)) return true; if (await hasPermission(payload, user, "banking:manage")) return true;
return hasLogisticsQualification(payload, user); return hasLogisticsQualification(payload, user);
} }
@ -188,7 +188,7 @@ async function moveFunds(
): Promise<ActionResult<number>> { ): Promise<ActionResult<number>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "bank-accounts:create"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
if (!amount || amount <= 0) { if (!amount || amount <= 0) {

View file

@ -4,7 +4,7 @@ import { headers as nextHeaders } from "next/headers";
import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types"; import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types";
import { LandmarkIcon } from "lucide-react"; import { LandmarkIcon } from "lucide-react";
import { BankingOverview } from "@/components/frontend/banking/BankingOverview"; import { BankingOverview } from "@/components/frontend/banking/BankingOverview";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
export const metadata = { export const metadata = {
@ -34,20 +34,20 @@ export default async function BankingPage() {
}); });
const recentTransactions = transactionsRes.docs as unknown as BankTransaction[]; const recentTransactions = transactionsRes.docs as unknown as BankTransaction[];
const factionsRes = await payload.find({ const factionsRes = await payload.find({
collection: "factions", collection: "factions",
limit: 100, limit: 100,
depth: 0, depth: 0,
}); });
const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({ const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({
id: f.id, id: f.id,
name: f.name, name: f.name,
})); }));
const isManager = user const isManager = user
? hasRoles(["admin", "developer"], user) || ? (await hasPermission(payload, user, "banking:manage")) ||
(await hasLogisticsQualification(payload, user).catch(() => false)) (await hasLogisticsQualification(payload, user).catch(() => false))
: false; : false;
const currentUser = user const currentUser = user
? { ? {

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types"; import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
@ -44,8 +44,8 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function isMarketManager(user: User): boolean { async function isMarketManager(payload: Awaited<ReturnType<typeof getPayload>>, user: User): Promise<boolean> {
return hasRoles(["admin", "developer"], user); return await hasPermission(payload, user, "market-listings:update");
} }
function sellerIdOf(listing: MarketListing): number | null { function sellerIdOf(listing: MarketListing): number | null {
@ -467,7 +467,7 @@ export async function cancelMarketListing(listingId: number): Promise<ActionResu
return { success: false, error: "Only active listings can be cancelled." }; return { success: false, error: "Only active listings can be cancelled." };
} }
const sellerId = sellerIdOf(listing); const sellerId = sellerIdOf(listing);
if (sellerId !== userId && !isMarketManager(user)) { if (sellerId !== userId && !(await isMarketManager(payload, user))) {
return { success: false, error: "You can only cancel your own listings." }; return { success: false, error: "You can only cancel your own listings." };
} }
if (listing.isAutoGenerated) { if (listing.isAutoGenerated) {

View file

@ -4,7 +4,7 @@ import { headers as nextHeaders } from "next/headers";
import type { LockerStorage, MarketListing, MarketNegotiation } from "@/payload-types"; import type { LockerStorage, MarketListing, MarketNegotiation } from "@/payload-types";
import { StoreIcon } from "lucide-react"; import { StoreIcon } from "lucide-react";
import { MarketView } from "@/components/frontend/market/MarketView"; import { MarketView } from "@/components/frontend/market/MarketView";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { ensureLockerStorage } from "@/lib/locker"; import { ensureLockerStorage } from "@/lib/locker";
import { getMainCurrencyName } from "@/lib/banking"; import { getMainCurrencyName } from "@/lib/banking";
@ -76,7 +76,7 @@ export default async function MarketPage() {
return false; return false;
}); });
const isManager = user ? hasRoles(["admin", "developer"], user) : false; const isManager = user ? (await hasPermission(payload, user, "logistics:manage")) : false;
const currencyLabel = await getMainCurrencyName(payload); const currencyLabel = await getMainCurrencyName(payload);
return ( return (

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types"; import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { calculateDistance } from "@/lib/distance"; import { calculateDistance } from "@/lib/distance";
@ -42,7 +42,7 @@ export async function createShipment(params: {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "shipments:create"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
@ -380,7 +380,7 @@ export async function cancelShipment(shipmentId: number): Promise<ActionResult>
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "shipments:update"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
@ -501,7 +501,7 @@ export async function toggleAutoReturn(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "shipments:update"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }

View file

@ -3,7 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Resource, Structure } from "@/payload-types"; import { GameStructure, Resource, Structure } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
@ -74,10 +74,10 @@ export async function addResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for deposit.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
@ -264,7 +264,7 @@ export async function removeResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.", error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
@ -373,7 +373,7 @@ export async function transferResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.", error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
@ -524,7 +524,7 @@ export async function placeResourceOnGrid(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for placement.", error: "Insufficient permissions. Admin or Developer role required for placement.",
@ -641,7 +641,7 @@ export async function moveGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -730,7 +730,7 @@ export async function mergeGridStacks(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -812,7 +812,7 @@ export async function rotateGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -899,7 +899,7 @@ export async function removeGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. Admin or Developer role required for removal.", error: "Insufficient permissions. Admin or Developer role required for removal.",
@ -953,7 +953,7 @@ export async function splitGridStack(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
@ -1061,7 +1061,7 @@ export async function splitGridStack(
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> { export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Insufficient permissions." };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -1159,7 +1159,7 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> { export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",

View file

@ -107,7 +107,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const totalKills = infantryKills + vehicleKills + armorKills + airKills; const totalKills = infantryKills + vehicleKills + armorKills + airKills;
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—"; const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
const enlistDate = new Date(profile.createdAt); const enlistDate = new Date(profile.dossier.enlistmentDate);
const enlistDateStr = enlistDate.toLocaleDateString("en-US", { const enlistDateStr = enlistDate.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",

View file

@ -8,11 +8,19 @@ type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export async function getStaffUserIds(payload: PayloadType): Promise<number[]> { export async function getStaffUserIds(payload: PayloadType): Promise<number[]> {
try { try {
const adminRole = await payload.find({
collection: "roles",
where: { slug: { in: ["admin", "developer"] } },
limit: 2,
depth: 0,
overrideAccess: true,
});
const roleIds = adminRole.docs.map((d) => d.id);
if (roleIds.length === 0) return [];
const res = await payload.find({ const res = await payload.find({
collection: "users", collection: "users",
where: { where: { roleDocs: { in: roleIds } },
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
},
limit: 50, limit: 50,
depth: 0, depth: 0,
select: { username: true }, select: { username: true },

View file

@ -1,4 +1,26 @@
import type { Payload } from "payload"; import type { Payload } from "payload";
import { hasAnyPermission } from "@/utils/access-control/hasPermission";
const INTELLIGENCE_PERMISSIONS = [
"intelligence:manage",
"missions:read",
"missions:create",
"missions:update",
"missions:delete",
"missions:read-sensitive",
"campaigns:read",
"campaigns:create",
"campaigns:update",
"campaigns:delete",
"factions:read",
"factions:create",
"factions:update",
"factions:delete",
"technologies:read",
"technologies:create",
"technologies:update",
"technologies:delete",
] as const;
export async function hasIntelligenceQualification( export async function hasIntelligenceQualification(
payload: Payload, payload: Payload,
@ -6,10 +28,7 @@ export async function hasIntelligenceQualification(
): Promise<boolean> { ): Promise<boolean> {
if (!user) return false; if (!user) return false;
const roles = user.roles as string[] | undefined; if (await hasAnyPermission(payload, user, ...INTELLIGENCE_PERMISSIONS)) return true;
if (roles?.includes("developer") || roles?.includes("admin")) {
return true;
}
const profile = (await payload.find({ const profile = (await payload.find({
collection: "profiles", collection: "profiles",

View file

@ -1,9 +1,55 @@
import type { Payload } from "payload"; import type { Payload } from "payload";
import { hasAnyPermission } from "@/utils/access-control/hasPermission";
const LOGISTICS_PERMISSIONS = [
"logistics:manage",
"assets:read",
"assets:create",
"assets:update",
"assets:delete",
"resources:read",
"resources:create",
"resources:update",
"resources:delete",
"vehicles:read",
"vehicles:create",
"vehicles:update",
"vehicles:delete",
"structures:read",
"structures:create",
"structures:update",
"structures:delete",
"shipments:read",
"shipments:create",
"shipments:update",
"shipments:delete",
"bank-accounts:read",
"bank-accounts:create",
"bank-accounts:update",
"bank-accounts:delete",
"banking:manage",
"market-listings:read",
"market-listings:create",
"market-listings:update",
"market-listings:delete",
"game-structures:read",
"game-structures:create",
"game-structures:update",
"game-structures:delete",
"game-vehicles:read",
"game-vehicles:create",
"game-vehicles:update",
"game-vehicles:delete",
"game-npcs:read",
"game-npcs:create",
"game-npcs:update",
"game-npcs:delete",
] as const;
/** /**
* Checks if a user has the Logistics qualification. * Checks if a user has the Logistics qualification.
* Queries the user's profile for qualifications matching "Logistics" (case-insensitive). * Queries the user's profile for qualifications matching "Logistics" (case-insensitive).
* Developers and admins always pass. * Users with ANY logistics-domain RBAC permission always pass.
*/ */
export async function hasLogisticsQualification( export async function hasLogisticsQualification(
payload: Payload, payload: Payload,
@ -11,10 +57,7 @@ export async function hasLogisticsQualification(
): Promise<boolean> { ): Promise<boolean> {
if (!user) return false; if (!user) return false;
const roles = user.roles as string[] | undefined; if (await hasAnyPermission(payload, user, ...LOGISTICS_PERMISSIONS)) return true;
if (roles?.includes("developer") || roles?.includes("admin")) {
return true;
}
const profileRes = await payload.find({ const profileRes = await payload.find({
collection: "profiles", collection: "profiles",