1
0
Fork 0

feat(missions): add redacted objective toggle

Mission objectives gain a redacted checkbox. Redacted objectives are
shown block-redacted to players; mission managers, authors, and Zeus
see the real text (via tooltip) and can toggle the flag from the
mission detail page. Toggles emit mission:objective-redacted-toggle
events. Also registers the mission:auto-complete event type used by
the upcoming mission-tick bin.
This commit is contained in:
Jason Fraley 2026-08-25 12:27:52 -04:00
parent c0d00fc113
commit 824b03ec49
7 changed files with 280 additions and 7 deletions

View file

@ -17,6 +17,8 @@ import {
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; 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 { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles"; import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics"; import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
@ -138,6 +140,28 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
canSetHeaders: false, 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([ const [attendance, userAttendance] = await Promise.all([
getMissionAttendance(payload, mission.id), getMissionAttendance(payload, mission.id),
currentUser currentUser
@ -289,7 +313,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
<MissionBriefingSection briefing={mission.briefing ?? null} /> <MissionBriefingSection briefing={mission.briefing ?? null} />
<MissionObjectives objectives={mission.missionObjectives ?? null} /> <MissionObjectives
objectives={objectives}
missionId={mission.id}
isEditable={canManage}
canReveal={canReveal}
/>
<MissionFactions factionsInvolved={mission.factionsInvolved ?? null} /> <MissionFactions factionsInvolved={mission.factionsInvolved ?? null} />

View file

@ -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<T = undefined> {
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<ActionResult> {
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 };
}
}

View file

@ -437,6 +437,16 @@ export const Missions: CollectionConfig = {
type: "textarea", type: "textarea",
label: "Success / Fail Conditions", 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,
},
], ],
}, },
{ {

View file

@ -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 { 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 { interface Objective {
title: string; title: string;
description?: string | null; description?: string | null;
missionCritical?: boolean | null; missionCritical?: boolean | null;
successOrFailConditions?: string | null; successOrFailConditions?: string | null;
redacted?: boolean | null;
} }
interface MissionObjectivesProps { interface MissionObjectivesProps {
objectives: Objective[] | null; 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 <span className={className}>{text ?? "—"}</span>;
}
const display = redactText(text) ?? "—";
if (!canReveal) {
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
}
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{text ?? "—"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function MissionObjectives({
objectives,
missionId,
isEditable = false,
canReveal = false,
}: MissionObjectivesProps) {
const router = useRouter();
const [togglingIndex, setTogglingIndex] = useState<number | null>(null);
const [toggleError, setToggleError] = useState<string | null>(null);
if (!objectives || objectives.length === 0) { if (!objectives || objectives.length === 0) {
return ( return (
<Item variant="outline"> <Item variant="outline">
@ -37,7 +98,7 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
Objectives Objectives
</h2> </h2>
</div> </div>
<div className="border border-border rounded-lg overflow-hidden"> <div className="border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-muted/50 border-b border-border"> <tr className="bg-muted/50 border-b border-border">
@ -53,14 +114,29 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground"> <th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Success / Fail Conditions Success / Fail Conditions
</th> </th>
{isEditable && (
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Actions
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{objectives.map((obj, index) => ( {objectives.map((obj, index) => (
<tr key={index} className="border-b border-border/50"> <tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">{obj.title}</td> <td className="px-4 py-2 font-medium">
<ObjectiveText
text={obj.title}
redacted={!!obj.redacted}
canReveal={canReveal}
/>
</td>
<td className="px-4 py-2 text-muted-foreground max-w-md wrap-break-word"> <td className="px-4 py-2 text-muted-foreground max-w-md wrap-break-word">
{obj.description ?? "—"} <ObjectiveText
text={obj.description}
redacted={!!obj.redacted}
canReveal={canReveal}
/>
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{obj.missionCritical ? ( {obj.missionCritical ? (
@ -70,13 +146,62 @@ export function MissionObjectives({ objectives }: MissionObjectivesProps) {
)} )}
</td> </td>
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{obj.successOrFailConditions ?? "—"} <ObjectiveText
text={obj.successOrFailConditions}
redacted={!!obj.redacted}
canReveal={canReveal}
/>
</td> </td>
{isEditable && (
<td className="px-4 py-2 text-center whitespace-nowrap">
<Button
variant="outline"
size="sm"
disabled={togglingIndex !== null}
onClick={async () => {
if (!missionId || togglingIndex !== null) return;
setToggleError(null);
setTogglingIndex(index);
try {
const res = await toggleObjectiveRedacted({
missionId,
objectiveIndex: index,
redacted: !obj.redacted,
});
if (!res.success) {
setToggleError(res.error ?? "Failed to update objective.");
return;
}
router.refresh();
} catch {
setToggleError("Something went wrong. Please try again.");
} finally {
setTogglingIndex(null);
}
}}
>
{togglingIndex === index ? (
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
) : obj.redacted ? (
<>
<EyeIcon className="size-3.5 mr-1" />
Show
</>
) : (
<>
<EyeOffIcon className="size-3.5 mr-1" />
Hide
</>
)}
</Button>
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{toggleError && <p className="text-sm text-destructive">{toggleError}</p>}
</div> </div>
); );
} }

6
src/lib/redaction.ts Normal file
View file

@ -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);
}

View file

@ -1924,6 +1924,10 @@ export interface Mission {
*/ */
missionCritical?: boolean | null; missionCritical?: boolean | null;
successOrFailConditions?: string | null; successOrFailConditions?: string | null;
/**
* If true, this objective will be hidden from players until revealed.
*/
redacted?: boolean | null;
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
@ -3576,6 +3580,7 @@ export interface MissionsSelect<T extends boolean = true> {
description?: T; description?: T;
missionCritical?: T; missionCritical?: T;
successOrFailConditions?: T; successOrFailConditions?: T;
redacted?: T;
id?: T; id?: T;
}; };
civilianPresence?: T; civilianPresence?: T;

View file

@ -85,6 +85,10 @@ export const EventTypes = {
// Attendance // Attendance
MissionAttendanceChange: "mission:attendance-change", MissionAttendanceChange: "mission:attendance-change",
// Missions
MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
// Helpdesk // Helpdesk
TicketCreate: "ticket:create", TicketCreate: "ticket:create",
TicketReply: "ticket:reply", TicketReply: "ticket:reply",