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:
parent
838c5035a4
commit
ef7295c0d4
5 changed files with 206 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
35
src/utils/access-control/canViewIntelligence.ts
Normal file
35
src/utils/access-control/canViewIntelligence.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
165
tests/int/intelligence-visibility.int.spec.ts
Normal file
165
tests/int/intelligence-visibility.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue