diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 39f21b1..34a106c 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -9,7 +9,7 @@ import { headers as nextHeaders } from "next/headers"; import { SiteHeader } from "@/components/frontend/SiteHeader"; import { Metadata } from "next"; 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 { canAccessAdminPanel } from "@/utils/access-control/divisionAccess"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; @@ -102,7 +102,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { gameRulesRes, announcementsRes, ] = await Promise.all([ - hasIntelligenceQualification(payload, user).catch(() => false), + canViewIntelligence(payload, user).catch(() => false), hasLogisticsQualification(payload, user).catch(() => false), payload.find({ collection: "profiles", diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index 7715d61..78a5a9c 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -5,7 +5,7 @@ import { headers as nextHeaders } from "next/headers"; import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types"; import { resolveLevel } from "@/utils/xp/resolveLevel"; 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 { CLOSING_STATUSES } from "@/lib/tickets/ticketMeta"; import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary"; @@ -38,7 +38,7 @@ export default async function HomePage() { 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([ payload.find({ diff --git a/src/utils/access-control/canViewIntelligence.ts b/src/utils/access-control/canViewIntelligence.ts new file mode 100644 index 0000000..6551870 --- /dev/null +++ b/src/utils/access-control/canViewIntelligence.ts @@ -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 { + if (!user) return false; + + if (await hasAnyPermission(payload, user, ...INTELLIGENCE_READ_PERMISSIONS)) return true; + + return hasIntelligenceQualification(payload, user); +} diff --git a/src/utils/access-control/hasIntelligenceQualification.ts b/src/utils/access-control/hasIntelligenceQualification.ts index 36d92f4..6acc5d7 100644 --- a/src/utils/access-control/hasIntelligenceQualification.ts +++ b/src/utils/access-control/hasIntelligenceQualification.ts @@ -4,7 +4,8 @@ import { hasAnyPermission } from "@/utils/access-control/hasPermission"; // Membership signals only. Read permissions (missions:read, campaigns:read, // factions:read, technologies:read) must NOT appear here: the standard "user" // 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 = [ "intelligence:manage", "missions:create", diff --git a/tests/int/intelligence-visibility.int.spec.ts b/tests/int/intelligence-visibility.int.spec.ts new file mode 100644 index 0000000..4dd5d3d --- /dev/null +++ b/tests/int/intelligence-visibility.int.spec.ts @@ -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 = {}): Promise => { + 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 => { + 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 => { + 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); + }); +});