1
0
Fork 0

feat(campaigns): let members create and own campaigns

This commit is contained in:
Jason Fraley 2026-08-26 00:53:17 -04:00
parent d6b2ffa5d2
commit 0771424446
9 changed files with 367 additions and 24 deletions

View file

@ -42,9 +42,24 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
collection: "campaigns",
id: Number(id),
depth: 1,
select: {
id: true,
name: true,
summary: true,
status: true,
startDate: true,
endDate: true,
owner: true,
},
})
.catch(() => null);
const owner = campaign?.owner;
const ownerName =
owner && typeof owner === "object" && "displayName" in owner
? owner.displayName
: "Unknown";
if (!campaign) {
notFound();
}
@ -237,15 +252,17 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
<span className="font-mono">{count}</span>
</div>
))}
{campaign.startDate && (
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span>
<span>
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
</div>
)}
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span>
<span>
{startDate}
{endDate ? ` — ${endDate}` : " — Present"}
</span>
</div>
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Owner</span>
<span className="font-medium">{ownerName}</span>
</div>
</div>
</ItemContent>
</Item>

View file

@ -2,6 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload";
import { ShieldIcon } from "lucide-react";
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
import { CreateCampaignDialog } from "@/components/frontend/intelligence/CreateCampaignDialog";
import type { Media } from "@/payload-types";
import { Button } from "@/components/ui/button";
@ -17,6 +18,15 @@ export default async function CampaignsPage() {
sort: "-createdAt",
limit: 100,
depth: 1,
select: {
id: true,
name: true,
summary: true,
status: true,
startDate: true,
endDate: true,
owner: true,
},
});
const campaigns = campaignsRes.docs;
@ -60,12 +70,19 @@ export default async function CampaignsPage() {
: null;
const coverImageUrl = coverImage?.url ?? null;
const owner = (campaign as any).owner;
const ownerName =
owner && typeof owner === "object" && "displayName" in owner
? owner.displayName
: "Unknown";
return {
...campaign,
coverImageUrl,
missionCount,
avgPlayers: missionCount === 0 ? 0 : totalPlayers / missionCount,
avgDurationMinutes: missionCount === 0 ? 0 : totalDurationMinutes / missionCount,
ownerName,
};
}),
);
@ -85,12 +102,7 @@ export default async function CampaignsPage() {
</div>
<div className="flex flex-col gap-1 justify-end text-center max-w-3xs">
<p className="text-sm text-muted-foreground">
Are you a Zeus and want to start your own campaign?
</p>
<Button variant="outline" size="sm">
Create your own campaign!
</Button>
<CreateCampaignDialog />
</div>
</div>
{sorted.length > 0 ? (

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
import type { User } from "@/payload-types";
export const Campaigns: CollectionConfig = {
slug: "campaigns",
@ -8,10 +9,10 @@ export const Campaigns: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: requirePermission("campaigns:create"),
update: requirePermission("campaigns:update"),
delete: requirePermission("campaigns:delete"),
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user, // Any logged-in user can create (becomes owner)
read: ({ req }) => !!req.user, // Anyone logged in can read
update: requireCampaignOwnership("campaigns:update"),
delete: requireCampaignOwnership("campaigns:delete"),
},
fields: [
{
@ -67,5 +68,14 @@ export const Campaigns: CollectionConfig = {
"Background image for the campaign selector card on the campaigns page. Renders translucent with hover effects. Recommended aspect ratio: 16:9 or square.",
},
},
{
name: "owner",
type: "relationship",
relationTo: "users",
admin: {
position: "sidebar",
description: "User who created this campaign. Set automatically on create.",
},
},
],
};

View file

@ -51,6 +51,12 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
roundedDuration >= 60 ? `${durationHours}h ${durationMinutes}m` : `${roundedDuration}m`;
const playersLabel = Number.isInteger(avgPlayers) ? avgPlayers.toString() : avgPlayers.toFixed(1);
const owner = (campaign as any).owner;
const ownerName =
owner && typeof owner === "object" && "displayName" in owner
? owner.displayName
: "Unknown";
return (
<Link
href={`/intelligence/campaigns/${campaign.id}`}
@ -113,7 +119,13 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 text-xs text-white/80">
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0 text-white/60" />
<span className="sr-only">Avg duration</span>
<span className="truncate">{durationLabel}</span>
<span className="truncate">{durationLabel}</span>
</div>
<div className="flex min-w-0 flex-1 items-center justify-center gap-1.5 text-xs text-white/80">
<div className="text-right">
<div className="truncate">{ownerName}</div>
<span className="text-[10px] uppercase tracking-wider text-white/60">Owner</span>
</div>
</div>
</footer>
</article>

View file

@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { createCampaign } from "./create";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Shield, Loader2 } from "lucide-react";
export function CreateCampaignDialog() {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: "",
summary: "An Arma 3 campaign.",
status: "concept" as "concept" | "inProgress" | "completed",
description: "",
startDate: "",
endDate: "",
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const result = await createCampaign(formData);
setIsSubmitting(false);
if (result.success) {
setOpen(false);
setFormData({
name: "",
summary: "An Arma 3 campaign.",
status: "concept",
description: "",
startDate: "",
endDate: "",
});
window.location.href = "/intelligence/campaigns";
} else {
alert("Error creating campaign: " + (result.error || "Unknown error"));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Shield className="size-4" />
<span>Create campaign</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Campaign</DialogTitle>
<DialogDescription>
Create a new Arma 3 campaign to manage your operations.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Campaign Name <span className="text-red-500">*</span>
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter campaign name"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="summary" className="text-sm font-medium">
Summary <span className="text-red-500">*</span>
</label>
<Textarea
id="summary"
value={formData.summary}
onChange={(e) => setFormData({ ...formData, summary: e.target.value })}
placeholder="Brief description of the campaign"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="status" className="text-sm font-medium">
Status <span className="text-red-500">*</span>
</label>
<select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
required
>
<option value="concept">Concept</option>
<option value="inProgress">In Progress</option>
<option value="completed">Completed</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description
</label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional: Detailed description of the campaign"
/>
</div>
<div className="space-y-2">
<label htmlFor="startDate" className="text-sm font-medium">
Start Date
</label>
<Input
id="startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
max={formData.endDate}
/>
</div>
<div className="space-y-2">
<label htmlFor="endDate" className="text-sm font-medium">
End Date
</label>
<Input
id="endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
min={formData.startDate}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Campaign"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,72 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
interface CreateCampaignData {
name: string;
summary: string;
status: "concept" | "inProgress" | "completed";
startDate?: string;
endDate?: string;
description?: string;
}
async function authenticate() {
const payload = await getPayload({ config });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function createCampaign(data: CreateCampaignData) {
try {
const { payload, user } = await authenticate();
// Filter out undefined dates
const campaignData: Record<string, any> = {
name: data.name,
summary: data.summary,
status: data.status,
owner: user.id,
};
// Only add date fields if they have values
if (data.startDate) {
campaignData.startDate = data.startDate;
}
if (data.endDate) {
campaignData.endDate = data.endDate;
}
if (data.description) {
campaignData.description = data.description;
}
const campaign = await payload.create({
collection: "campaigns",
data: campaignData as any,
depth: 1,
});
return {
success: true,
data: campaign,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to create campaign",
};
}
}

View file

@ -142,6 +142,10 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "campaigns:delete", label: "Delete" },
],
},
{
group: "Campaigns",
permissions: [{ value: "campaigns:manage", label: "Manage Campaigns (owner bypass)" }],
},
{
group: "Factions",
permissions: [
@ -512,6 +516,7 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"campaigns:read",
"campaigns:update",
"campaigns:delete",
"campaigns:manage",
"factions:create",
"factions:read",
"factions:update",
@ -628,6 +633,7 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"game-rules:update",
"shims:read",
"shims:update",
"campaigns:manage",
"logistics:manage",
"banking:manage",
"tickets:staff",

View file

@ -14,6 +14,7 @@ const USER_PERMISSIONS: Permission[] = [
"missions:read",
"mission-attendances:read",
"campaigns:read",
"campaigns:manage",
"factions:read",
"technologies:read",
"assets:read",

View file

@ -103,11 +103,60 @@ export function requirePermission(permission: Permission) {
*
* @example
* access: {
* update: requireAnyPermission("tickets:update", "tickets:staff"),
* }
*/
* update: requireAnyPermission("tickets:update", "tickets:staff"),
* }
*/
export function requireAnyPermission(...permissions: Permission[]) {
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
return hasAnyPermission(req.payload, req.user, ...permissions);
};
}
/**
* Factory that creates a Payload collection access function requiring campaign ownership.
*
* A user can access a campaign if:
* 1. They own the campaign (owner field matches their user ID), OR
* 2. They have the "campaigns:manage" permission
*
* @example
* access: {
* update: requireCampaignOwnership("campaigns:update"),
* delete: requireCampaignOwnership("campaigns:delete"),
* }
*/
export function requireCampaignOwnership(permission: Permission) {
return async ({ req, id }: { req: PayloadRequest; id?: any }) => {
if (!req.user) return false;
// Check permission first (grants access to all campaigns for managers)
const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(req.payload, req.user);
if (su || userPerms.has(permission)) return true;
// If no user permissions for manage, check ownership
let campaign;
try {
campaign = await req.payload.findByID({
collection: "campaigns",
id: id,
depth: 1,
overrideAccess: true,
});
} catch (error) {
// If campaign lookup fails (e.g. invalid ID), deny access
return false;
}
if (!campaign) return false;
// User owns the campaign if owner field matches their ID
// campaign.owner can be number (ID) or User object
const campaignOwnerId = campaign.owner ? (typeof campaign.owner === "object" ? campaign.owner.id : campaign.owner) : null;
const userId = Number(req.user.id);
if (campaignOwnerId && campaignOwnerId === userId) return true;
// User does not own this campaign and lacks manage permission
return false;
};
}