1
0
Fork 0

fix(intel): show the intelligence section to anyone with read access

v0.2.33 tightened hasIntelligenceQualification to write permissions for wiki
moderation, but the same check also gated navigation: regular members lost the
Intelligence sidebar section, command palette entries, keyboard shortcuts,
dashboard widgets, and tour steps. Add canViewIntelligence (intel read
permissions, with the strict qualification as a fallback) and use it for those
visibility surfaces. Wiki moderation, the tech tree, and division admin pages
keep the strict qualification.
This commit is contained in:
Jason Fraley 2026-10-04 02:53:24 -04:00
parent 838c5035a4
commit ef7295c0d4
5 changed files with 206 additions and 5 deletions

View file

@ -9,7 +9,7 @@ import { headers as nextHeaders } from "next/headers";
import { SiteHeader } from "@/components/frontend/SiteHeader"; import { SiteHeader } from "@/components/frontend/SiteHeader";
import { Metadata } from "next"; import { Metadata } from "next";
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel"; import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { canViewIntelligence } from "@/utils/access-control/canViewIntelligence";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess"; import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
@ -102,7 +102,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
gameRulesRes, gameRulesRes,
announcementsRes, announcementsRes,
] = await Promise.all([ ] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false), canViewIntelligence(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false), hasLogisticsQualification(payload, user).catch(() => false),
payload.find({ payload.find({
collection: "profiles", collection: "profiles",

View file

@ -5,7 +5,7 @@ import { headers as nextHeaders } from "next/headers";
import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types"; import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { canViewIntelligence } from "@/utils/access-control/canViewIntelligence";
import { getCurrencyConfig } from "@/lib/banking"; import { getCurrencyConfig } from "@/lib/banking";
import { CLOSING_STATUSES } from "@/lib/tickets/ticketMeta"; import { CLOSING_STATUSES } from "@/lib/tickets/ticketMeta";
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary"; import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
@ -38,7 +38,7 @@ export default async function HomePage() {
const userId = user.id; const userId = user.id;
const hasIntelligence = await hasIntelligenceQualification(payload, user).catch(() => false); const hasIntelligence = await canViewIntelligence(payload, user).catch(() => false);
const [profileRes, experienceRes, assignmentRes] = await Promise.all([ const [profileRes, experienceRes, assignmentRes] = await Promise.all([
payload.find({ payload.find({

View file

@ -0,0 +1,35 @@
import type { Payload } from "payload";
import { hasAnyPermission } from "@/utils/access-control/hasPermission";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
// Visibility signals only. The standard "user" role grants all four read
// permissions, so every regular member passes. This gates NAVIGATION surfaces
// (sidebar section, command palette, keyboard shortcuts, dashboard widgets,
// tour visibility), never moderation: wiki moderation, tech tree editing, and
// intel admin pages stay on hasIntelligenceQualification (write permissions /
// qualification membership).
const INTELLIGENCE_READ_PERMISSIONS = [
"missions:read",
"campaigns:read",
"factions:read",
"technologies:read",
] as const;
/**
* Check whether a user can SEE the intelligence section of the app.
*
* True for anyone holding intel-domain read permissions (the standard member
* role included), so all regular players see Campaigns/Missions/Factions/Wiki
* in navigation. Intel-qualified members pass as a fallback even if their role
* setup somehow lacks the read grants. Guests get false.
*/
export async function canViewIntelligence(
payload: Payload,
user: { id: number | string; roles?: unknown } | null | undefined,
): Promise<boolean> {
if (!user) return false;
if (await hasAnyPermission(payload, user, ...INTELLIGENCE_READ_PERMISSIONS)) return true;
return hasIntelligenceQualification(payload, user);
}

View file

@ -4,7 +4,8 @@ import { hasAnyPermission } from "@/utils/access-control/hasPermission";
// Membership signals only. Read permissions (missions:read, campaigns:read, // Membership signals only. Read permissions (missions:read, campaigns:read,
// factions:read, technologies:read) must NOT appear here: the standard "user" // factions:read, technologies:read) must NOT appear here: the standard "user"
// role grants all four, so including them would make every regular player pass // role grants all four, so including them would make every regular player pass
// this qualification (and with it, wiki moderation and intel-only UI). // this qualification (and with it, wiki moderation and intel admin surfaces).
// Navigation visibility (sidebar, palette, dashboard) uses canViewIntelligence.
const INTELLIGENCE_PERMISSIONS = [ const INTELLIGENCE_PERMISSIONS = [
"intelligence:manage", "intelligence:manage",
"missions:create", "missions:create",

View file

@ -0,0 +1,165 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Role, User } from "@/payload-types";
import { canViewIntelligence } from "@/utils/access-control/canViewIntelligence";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
let payload: Payload;
const RUN = `intelvis-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
describe("Intelligence navigation visibility (canViewIntelligence)", () => {
const roleIds: number[] = [];
const userIds: number[] = [];
const createdQualificationIds: number[] = [];
let readerUser: User;
let outsiderUser: User;
let qualifiedUser: User;
let superUser: User;
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
const role = (await payload.create({
collection: "roles",
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
overrideAccess: true,
depth: 0,
})) as unknown as Role;
roleIds.push(role.id);
return role;
};
const makeUser = async (label: string, roleId: number): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roleDocs: [roleId],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const findOrCreateQualification = async (name: string): Promise<number> => {
const found = (await payload.find({
collection: "qualifications",
where: { name: { equals: name } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
if (found.docs.length > 0) return found.docs[0].id;
const created = (await payload.create({
collection: "qualifications",
data: { name },
overrideAccess: true,
depth: 0,
})) as unknown as { id: number };
createdQualificationIds.push(created.id);
return created.id;
};
const grantQualification = async (user: User, qualificationId: number) => {
const profile = (await payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
expect(profile.docs.length).toBeGreaterThan(0);
await payload.update({
collection: "profiles",
id: profile.docs[0].id,
data: { progression: { qualifications: [qualificationId] } },
overrideAccess: true,
depth: 0,
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
invalidatePermissionCache();
// Mirrors the standard "user" role: intel-domain read permissions, no writes.
const readerRole = await makeRole("reader", {
permissions: ["missions:read", "campaigns:read", "factions:read", "technologies:read"],
});
const outsiderRole = await makeRole("outsider", { permissions: ["tickets:read"] });
const bareRole = await makeRole("bare", { permissions: [] });
const superRole = await makeRole("super", { isSuperuser: true });
readerUser = await makeUser("reader", readerRole.id);
outsiderUser = await makeUser("outsider", outsiderRole.id);
qualifiedUser = await makeUser("qualified", bareRole.id);
superUser = await makeUser("super", superRole.id);
const intelligenceId = await findOrCreateQualification("Intelligence");
await grantQualification(qualifiedUser, intelligenceId);
}, TIMEOUT);
afterAll(async () => {
if (!payload) return;
for (const id of userIds) {
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const p of profiles?.docs ?? []) {
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
}
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
for (const id of createdQualificationIds) {
await payload
.delete({ collection: "qualifications", id, overrideAccess: true })
.catch(() => {});
}
});
it("member with intel read permissions sees the section but is not intel-qualified", async () => {
expect(await canViewIntelligence(payload, readerUser)).toBe(true);
// The strict gate must stay tight: read permissions alone never grant
// moderation (wiki moderator, tech tree editing, intel admin pages).
expect(await hasIntelligenceQualification(payload, readerUser)).toBe(false);
});
it("member without intel permissions sees neither surface", async () => {
expect(await canViewIntelligence(payload, outsiderUser)).toBe(false);
expect(await hasIntelligenceQualification(payload, outsiderUser)).toBe(false);
});
it("intel-qualified member keeps the section even without read grants", async () => {
expect(await hasIntelligenceQualification(payload, qualifiedUser)).toBe(true);
expect(await canViewIntelligence(payload, qualifiedUser)).toBe(true);
});
it("superuser sees the section", async () => {
expect(await canViewIntelligence(payload, superUser)).toBe(true);
});
it("guest (no user) sees nothing", async () => {
expect(await canViewIntelligence(payload, null)).toBe(false);
});
});