diff --git a/src/app/(frontend)/helpdesk/page.tsx b/src/app/(frontend)/helpdesk/page.tsx
index 733c8a7..f204db2 100644
--- a/src/app/(frontend)/helpdesk/page.tsx
+++ b/src/app/(frontend)/helpdesk/page.tsx
@@ -12,6 +12,22 @@ export const metadata = {
title: "Helpdesk: Polaris Task Force",
};
+function HelpdeskDisabled() {
+ return (
+
+
+
+
+
Helpdesk
+
+
+ The Helpdesk is currently disabled by a unit admin.
+
+
+
+ );
+}
+
export default async function HelpdeskPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
@@ -25,6 +41,11 @@ export default async function HelpdeskPage() {
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
}
+ const { helpdeskEnabled } = await payload.findGlobal({ slug: "game-rules" });
+ if (!helpdeskEnabled) {
+ return ;
+ }
+
const ticketsRes = await payload.find({
collection: "tickets",
sort: "-updatedAt",
diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx
index 1673980..4eff9d9 100644
--- a/src/app/(frontend)/layout.tsx
+++ b/src/app/(frontend)/layout.tsx
@@ -80,46 +80,57 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable;
let isAdmin = false;
+ let statisticsEnabled = true;
+ let helpdeskEnabled = true;
if (user) {
- const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
- await Promise.all([
- hasIntelligenceQualification(payload, user).catch(() => false),
- hasLogisticsQualification(payload, user).catch(() => false),
- payload.find({
- collection: "profiles",
- where: { user: { equals: user.id } },
- select: {
- rank: true,
- progression: {
- awards: true,
- scoreboard: true,
- xp: true,
- minigames: true,
- },
+ const [
+ intelligenceQual,
+ logisticsQual,
+ profileRes,
+ experienceRes,
+ personalAccountRes,
+ gameRulesRes,
+ ] = await Promise.all([
+ hasIntelligenceQualification(payload, user).catch(() => false),
+ hasLogisticsQualification(payload, user).catch(() => false),
+ payload.find({
+ collection: "profiles",
+ where: { user: { equals: user.id } },
+ select: {
+ rank: true,
+ progression: {
+ awards: true,
+ scoreboard: true,
+ xp: true,
+ minigames: true,
},
- }),
- payload.find({
- collection: "experience",
- sort: "requiredPoints",
- limit: 100,
- select: {
- name: true,
- description: true,
- requiredPoints: true,
- },
- }),
- payload.find({
- collection: "bank-accounts",
- where: {
- and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
- },
- limit: 1,
- }),
- ]);
+ },
+ }),
+ payload.find({
+ collection: "experience",
+ sort: "requiredPoints",
+ limit: 100,
+ select: {
+ name: true,
+ description: true,
+ requiredPoints: true,
+ },
+ }),
+ payload.find({
+ collection: "bank-accounts",
+ where: {
+ and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
+ },
+ limit: 1,
+ }),
+ payload.findGlobal({ slug: "game-rules" }),
+ ]);
isIntelligence = intelligenceQual;
isLogistics = logisticsQual;
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
+ statisticsEnabled = gameRulesRes.statisticsEnabled ?? true;
+ helpdeskEnabled = gameRulesRes.helpdeskEnabled ?? true;
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs);
@@ -150,6 +161,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
+ statisticsEnabled={statisticsEnabled}
+ helpdeskEnabled={helpdeskEnabled}
showCallsign={user.preferences?.display?.showCallsign ?? false}
canImpersonate={await isSuperuser(payload, user)}
preferences={user.preferences}
diff --git a/src/app/(frontend)/personnel/statistics/page.tsx b/src/app/(frontend)/personnel/statistics/page.tsx
index daecf13..88a8932 100644
--- a/src/app/(frontend)/personnel/statistics/page.tsx
+++ b/src/app/(frontend)/personnel/statistics/page.tsx
@@ -32,8 +32,28 @@ function npcIdFromListing(listing: MarketListing): number | null {
return null;
}
+function StatisticsDisabled() {
+ return (
+
+
+
+
+
Statistics
+
+
+ Personnel statistics are currently disabled by a unit admin.
+
+
+
+ );
+}
+
export default async function StatisticsPage() {
const payload = await getPayload({ config });
+ const { statisticsEnabled } = await payload.findGlobal({ slug: "game-rules" });
+ if (!statisticsEnabled) {
+ return ;
+ }
const [npcsRes, listingsRes] = await Promise.all([
payload.find({
collection: "game-npcs",
diff --git a/src/collections/game/GameRules.ts b/src/collections/game/GameRules.ts
index 70dba61..b377f18 100644
--- a/src/collections/game/GameRules.ts
+++ b/src/collections/game/GameRules.ts
@@ -226,5 +226,31 @@ export const GameRules: GlobalConfig = {
},
],
},
+ {
+ name: "zonePressureEnabled",
+ type: "checkbox",
+ defaultValue: true,
+ admin: {
+ description:
+ "When off, the Zone Pressure feature is hidden from the Operations feed (the overview card and the AAR section).",
+ },
+ },
+ {
+ name: "statisticsEnabled",
+ type: "checkbox",
+ defaultValue: true,
+ admin: {
+ description:
+ "When off, the Personnel > Statistics page and its navigation entry are hidden.",
+ },
+ },
+ {
+ name: "helpdeskEnabled",
+ type: "checkbox",
+ defaultValue: true,
+ admin: {
+ description: "When off, the Helpdesk page and its navigation entry are hidden.",
+ },
+ },
],
};
diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx
index 88f6399..1010fa6 100644
--- a/src/components/frontend/blocks/AppSidebar.tsx
+++ b/src/components/frontend/blocks/AppSidebar.tsx
@@ -72,6 +72,8 @@ export function AppSidebar({
adminUrl,
hasIntelligence = true,
hasLogistics = true,
+ statisticsEnabled = true,
+ helpdeskEnabled = true,
showCallsign = false,
canImpersonate = false,
preferences,
@@ -86,6 +88,8 @@ export function AppSidebar({
adminUrl?: string;
hasIntelligence?: boolean;
hasLogistics?: boolean;
+ statisticsEnabled?: boolean;
+ helpdeskEnabled?: boolean;
showCallsign?: boolean;
canImpersonate?: boolean;
preferences?: NonNullable;
@@ -119,6 +123,19 @@ export function AppSidebar({
}
}, [isMiniMode, preferences]);
+ // Game-flags: hide the Helpdesk group and the Personnel -> Statistics entry
+ // when their feature is toggled off (data preserved, front-end only). An
+ // empty filtered list makes NavCore skip the group entirely, label included.
+ const filteredNavLogistics = data.navLogistics.map((section) =>
+ section.title === "Personnel"
+ ? {
+ ...section,
+ items: section.items.filter((item) => item.title === "Contacts" || statisticsEnabled),
+ }
+ : section,
+ );
+ const filteredNavHelpdesk = helpdeskEnabled ? data.navHelpdesk : [];
+
return (
@@ -155,10 +172,10 @@ export function AppSidebar({
)}
{hasLogistics && (
-
+
)}
-
+
{/**/}
diff --git a/src/components/frontend/operations/AarView.tsx b/src/components/frontend/operations/AarView.tsx
index 6dab612..e800688 100644
--- a/src/components/frontend/operations/AarView.tsx
+++ b/src/components/frontend/operations/AarView.tsx
@@ -114,24 +114,28 @@ export function AarView({ aar }: { aar: AarResult }) {
)}
-
- {aar.pressure.length === 0 ? (
-
- ) : (
-
- {aar.pressure.map((p, i) => (
- -
- Zone {p.zoneId}
- = 0 ? "text-emerald-400" : "text-rose-400"}`}
- >
- {p.delta >= 0 ? `+${p.delta}` : p.delta}
-
-
- ))}
-
- )}
-
+ {aar.canSeePressure ? (
+
+ {aar.pressure.length === 0 ? (
+
+ ) : (
+
+ {aar.pressure.map((p, i) => (
+ -
+ Zone {p.zoneId}
+ = 0 ? "text-emerald-400" : "text-rose-400"}`}
+ >
+ {p.delta >= 0 ? `+${p.delta}` : p.delta}
+
+
+ ))}
+
+ )}
+
+ ) : (
+
+ )}
{aar.reservations.length === 0 ? (
diff --git a/src/lib/operations/surface.ts b/src/lib/operations/surface.ts
index 9f0b61a..9faad29 100644
--- a/src/lib/operations/surface.ts
+++ b/src/lib/operations/surface.ts
@@ -88,12 +88,18 @@ export async function getSurfaceCapabilities(
payload: PayloadType,
user: SurfaceUser,
): Promise {
- const [reservations, readiness, pressure] = await Promise.all([
+ const [reservations, readiness, pressure, rules] = await Promise.all([
hasPermission(payload, user, "operation-reservations:read"),
hasPermission(payload, user, "operation-readiness:read"),
hasPermission(payload, user, "zone-pressure:read"),
+ payload.findGlobal({ slug: "game-rules" }),
]);
- return { canSeeReservations: reservations, canSeeReadiness: readiness, canSeePressure: pressure };
+ const zonePressureEnabled = rules.zonePressureEnabled ?? true;
+ return {
+ canSeeReservations: reservations,
+ canSeeReadiness: readiness,
+ canSeePressure: pressure && zonePressureEnabled,
+ };
}
function relationId(value: unknown): number | null {
diff --git a/src/lib/operations/surfaceQuery.ts b/src/lib/operations/surfaceQuery.ts
index 65b395a..18a4de0 100644
--- a/src/lib/operations/surfaceQuery.ts
+++ b/src/lib/operations/surfaceQuery.ts
@@ -41,6 +41,7 @@ export interface AarResult {
effects: OperationEffectRow[];
counts: AarCounts;
pressure: { zoneId: number; factionId: number | null; delta: number; createdAt: string }[];
+ canSeePressure: boolean;
reservations: { reservationKey: string; status: string; expiresAt: string | null }[];
}
@@ -195,11 +196,16 @@ export async function getOperationAar(
else if (status === "reversed") counts.reversed += 1;
}
- const canSeePressure = await hasPermission(payload, user, "zone-pressure:read");
- const canSeeReservations = await hasPermission(payload, user, "operation-reservations:read");
+ const [rules, canSeePressure, canSeeReservations] = await Promise.all([
+ payload.findGlobal({ slug: "game-rules" }),
+ hasPermission(payload, user, "zone-pressure:read"),
+ hasPermission(payload, user, "operation-reservations:read"),
+ ]);
+ const zonePressureEnabled = rules.zonePressureEnabled ?? true;
+ const canSeePressureFlag = canSeePressure && zonePressureEnabled;
const pressure: AarResult["pressure"] = [];
- if (canSeePressure) {
+ if (canSeePressureFlag) {
const rows = await payload.find({
collection: "zone-pressure",
where: { operationId: { equals: operationId } },
@@ -254,6 +260,7 @@ export async function getOperationAar(
effects: effectsFound.docs.map(toEffectRow),
counts,
pressure,
+ canSeePressure: canSeePressureFlag,
reservations,
};
}
diff --git a/src/migrations/20260921_150000_add_feature_flags.ts b/src/migrations/20260921_150000_add_feature_flags.ts
new file mode 100644
index 0000000..9b07c9d
--- /dev/null
+++ b/src/migrations/20260921_150000_add_feature_flags.ts
@@ -0,0 +1,15 @@
+import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db, payload, req }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "game_rules" ADD COLUMN "zone_pressure_enabled" boolean DEFAULT true;
+ ALTER TABLE "game_rules" ADD COLUMN "statistics_enabled" boolean DEFAULT true;
+ ALTER TABLE "game_rules" ADD COLUMN "helpdesk_enabled" boolean DEFAULT true;`)
+}
+
+export async function down({ db, payload, req }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "game_rules" DROP COLUMN "zone_pressure_enabled";
+ ALTER TABLE "game_rules" DROP COLUMN "statistics_enabled";
+ ALTER TABLE "game_rules" DROP COLUMN "helpdesk_enabled";`)
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index b0af3aa..cbd81e5 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -82,6 +82,7 @@ import * as migration_20260921_123000_add_map_intel_fields from './20260921_1230
import * as migration_20260921_130500_weather_freeform from './20260921_130500_weather_freeform';
import * as migration_20260921_134000_add_node_icons from './20260921_134000_add_node_icons';
import * as migration_20260921_140000_add_currency_symbol_image from './20260921_140000_add_currency_symbol_image';
+import * as migration_20260921_150000_add_feature_flags from './20260921_150000_add_feature_flags';
export const migrations = [
{
@@ -504,4 +505,9 @@ export const migrations = [
down: migration_20260921_140000_add_currency_symbol_image.down,
name: '20260921_140000_add_currency_symbol_image',
},
+ {
+ up: migration_20260921_150000_add_feature_flags.up,
+ down: migration_20260921_150000_add_feature_flags.down,
+ name: '20260921_150000_add_feature_flags',
+ },
];