diff --git a/src/app/(frontend)/intelligence/missions/[id]/page.tsx b/src/app/(frontend)/intelligence/missions/[id]/page.tsx index 66db88e..7f5fbb3 100644 --- a/src/app/(frontend)/intelligence/missions/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/missions/[id]/page.tsx @@ -17,6 +17,8 @@ import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { redactText } from "@/lib/redaction"; import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions"; import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles"; import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics"; @@ -138,6 +140,28 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro canSetHeaders: false, }); + const canManage = currentUser + ? await hasPermission(payload, currentUser, "missions:update") + : false; + + const authorOrZeusIds = [ + ...(mission.ownershipAndStatus.authors ?? []), + ...(mission.ownershipAndStatus.zeus ?? []), + ].map((u) => (typeof u === "object" ? u.id : u)); + const canReveal = + canManage || (currentUser ? authorOrZeusIds.includes(currentUser.id as number) : false); + + const objectives = (mission.missionObjectives ?? []).map((obj) => + obj.redacted && !canReveal + ? { + ...obj, + title: redactText(obj.title) ?? obj.title, + description: redactText(obj.description), + successOrFailConditions: redactText(obj.successOrFailConditions), + } + : obj, + ); + const [attendance, userAttendance] = await Promise.all([ getMissionAttendance(payload, mission.id), currentUser @@ -289,7 +313,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro - + diff --git a/src/app/(frontend)/intelligence/missions/objectives/actions.ts b/src/app/(frontend)/intelligence/missions/objectives/actions.ts new file mode 100644 index 0000000..869011b --- /dev/null +++ b/src/app/(frontend)/intelligence/missions/objectives/actions.ts @@ -0,0 +1,94 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { hasPermission } from "@/utils/access-control/hasPermission"; + +export interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); + + if (!user) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +export async function toggleObjectiveRedacted(input: { + missionId: number; + objectiveIndex: number; + redacted: boolean; +}): Promise { + try { + const { payload, user } = await authenticate(); + + // Check permissions - only admins/developers can toggle redacted flags + const hasAccess = await hasPermission(payload, user, "missions:update"); + if (!hasAccess) { + return { success: false, error: "You do not have permission to modify mission objectives." }; + } + + // Get current mission data + const mission = await payload.findByID({ + collection: "missions", + id: input.missionId, + depth: 0, + }); + + if (!mission) { + return { success: false, error: "Mission not found." }; + } + + // Ensure objectives array exists and has the requested index + const objectives = mission.missionObjectives || []; + if (input.objectiveIndex < 0 || input.objectiveIndex >= objectives.length) { + return { success: false, error: "Invalid objective index." }; + } + + // Update the redacted flag for the specific objective + const updatedObjectives = [...objectives]; + updatedObjectives[input.objectiveIndex] = { + ...updatedObjectives[input.objectiveIndex], + redacted: input.redacted, + }; + + // Update the mission with the new objectives array + await payload.update({ + collection: "missions", + id: input.missionId, + data: { + missionObjectives: updatedObjectives, + }, + overrideAccess: true, + }); + + // Emit an event for the objective update + await emitGameEvent(payload, { + type: "mission:objective-redacted-toggle", + message: `Objective redacted flag toggled for mission ${mission.name}`, + actor: user.id, + targetCollection: "missions", + targetId: input.missionId, + data: { + objectiveIndex: input.objectiveIndex, + redacted: input.redacted, + }, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return { success: false, error: message }; + } +} \ No newline at end of file diff --git a/src/collections/intelligence/Missions.ts b/src/collections/intelligence/Missions.ts index fe6d847..a1171df 100644 --- a/src/collections/intelligence/Missions.ts +++ b/src/collections/intelligence/Missions.ts @@ -437,6 +437,16 @@ export const Missions: CollectionConfig = { type: "textarea", label: "Success / Fail Conditions", }, + { + name: "redacted", + type: "checkbox", + label: "Redacted", + admin: { + description: + "If true, this objective will be hidden from players until revealed.", + }, + defaultValue: false, + }, ], }, { diff --git a/src/components/frontend/intelligence/MissionObjectives.tsx b/src/components/frontend/intelligence/MissionObjectives.tsx index 04f36a2..49906ff 100644 --- a/src/components/frontend/intelligence/MissionObjectives.tsx +++ b/src/components/frontend/intelligence/MissionObjectives.tsx @@ -1,18 +1,79 @@ -import { CheckIcon, CrosshairIcon, XIcon } from "lucide-react"; +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { CheckIcon, CrosshairIcon, EyeIcon, EyeOffIcon, Loader2Icon, XIcon } from "lucide-react"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { toggleObjectiveRedacted } from "@/app/(frontend)/intelligence/missions/objectives/actions"; +import { redactText } from "@/lib/redaction"; interface Objective { title: string; description?: string | null; missionCritical?: boolean | null; successOrFailConditions?: string | null; + redacted?: boolean | null; } interface MissionObjectivesProps { objectives: Objective[] | null; + missionId?: number; + isEditable?: boolean; + canReveal?: boolean; } -export function MissionObjectives({ objectives }: MissionObjectivesProps) { +function ObjectiveText({ + text, + redacted, + canReveal, + className, +}: { + text: string | null | undefined; + redacted: boolean; + canReveal: boolean; + className?: string; +}) { + if (!redacted) { + return {text ?? "—"}; + } + + const display = redactText(text) ?? "—"; + + if (!canReveal) { + return {display}; + } + + return ( + + + + {display} + + + {text ?? "—"} + + + + ); +} + +export function MissionObjectives({ + objectives, + missionId, + isEditable = false, + canReveal = false, +}: MissionObjectivesProps) { + const router = useRouter(); + const [togglingIndex, setTogglingIndex] = useState(null); + const [toggleError, setToggleError] = useState(null); + if (!objectives || objectives.length === 0) { return ( @@ -37,7 +98,7 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) { Objectives -
+
@@ -53,14 +114,29 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) { + {isEditable && ( + + )} {objectives.map((obj, index) => ( - + + {isEditable && ( + + )} ))}
Success / Fail Conditions + Actions +
{obj.title} + + - {obj.description ?? "—"} + {obj.missionCritical ? ( @@ -70,13 +146,62 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) { )} - {obj.successOrFailConditions ?? "—"} + + +
+ {toggleError &&

{toggleError}

}
); } diff --git a/src/lib/redaction.ts b/src/lib/redaction.ts new file mode 100644 index 0000000..c26fd0c --- /dev/null +++ b/src/lib/redaction.ts @@ -0,0 +1,6 @@ +const BLOCK = "█"; + +export function redactText(text: string | null | undefined): string | null { + if (!text) return null; + return text.replace(/\S/g, BLOCK); +} diff --git a/src/payload-types.ts b/src/payload-types.ts index cde3290..4bbd0cf 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1924,6 +1924,10 @@ export interface Mission { */ missionCritical?: boolean | null; successOrFailConditions?: string | null; + /** + * If true, this objective will be hidden from players until revealed. + */ + redacted?: boolean | null; id?: string | null; }[] | null; @@ -3576,6 +3580,7 @@ export interface MissionsSelect { description?: T; missionCritical?: T; successOrFailConditions?: T; + redacted?: T; id?: T; }; civilianPresence?: T; diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index dd3e6b9..2be6140 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -85,6 +85,10 @@ export const EventTypes = { // Attendance MissionAttendanceChange: "mission:attendance-change", + // Missions + MissionAutoComplete: "mission:auto-complete", + MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", + // Helpdesk TicketCreate: "ticket:create", TicketReply: "ticket:reply",