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 { 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",
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
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,
|
// 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",
|
||||||
|
|
|
||||||
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