diff --git a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx
index 4ef687f..cb35db0 100644
--- a/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx
+++ b/src/app/(frontend)/intelligence/campaigns/[id]/page.tsx
@@ -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
{count}
))}
- {campaign.startDate && (
-
- Duration
-
- {startDate}
- {endDate ? ` — ${endDate}` : " — Present"}
-
-
- )}
+
+ Duration
+
+ {startDate}
+ {endDate ? ` — ${endDate}` : " — Present"}
+
+
+
+ Owner
+ {ownerName}
+
diff --git a/src/app/(frontend)/intelligence/campaigns/page.tsx b/src/app/(frontend)/intelligence/campaigns/page.tsx
index bf8e2de..a782355 100644
--- a/src/app/(frontend)/intelligence/campaigns/page.tsx
+++ b/src/app/(frontend)/intelligence/campaigns/page.tsx
@@ -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() {
-
- Are you a Zeus and want to start your own campaign?
-
-
+
{sorted.length > 0 ? (
diff --git a/src/collections/intelligence/Campaigns.ts b/src/collections/intelligence/Campaigns.ts
index 2d29091..5e8affe 100644
--- a/src/collections/intelligence/Campaigns.ts
+++ b/src/collections/intelligence/Campaigns.ts
@@ -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.",
+ },
+ },
],
};
diff --git a/src/components/frontend/intelligence/CampaignCard.tsx b/src/components/frontend/intelligence/CampaignCard.tsx
index 0ad8398..cab9171 100644
--- a/src/components/frontend/intelligence/CampaignCard.tsx
+++ b/src/components/frontend/intelligence/CampaignCard.tsx
@@ -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 (
Avg duration
- {durationLabel}
+ {durationLabel}
+
+
diff --git a/src/components/frontend/intelligence/CreateCampaignDialog.tsx b/src/components/frontend/intelligence/CreateCampaignDialog.tsx
new file mode 100644
index 0000000..59454a3
--- /dev/null
+++ b/src/components/frontend/intelligence/CreateCampaignDialog.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/frontend/intelligence/create.ts b/src/components/frontend/intelligence/create.ts
new file mode 100644
index 0000000..50762a2
--- /dev/null
+++ b/src/components/frontend/intelligence/create.ts
@@ -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 = {
+ 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",
+ };
+ }
+}
diff --git a/src/permissions/index.ts b/src/permissions/index.ts
index ced6e8d..0124a98 100644
--- a/src/permissions/index.ts
+++ b/src/permissions/index.ts
@@ -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",
diff --git a/src/tools/seed/seedRoles.ts b/src/tools/seed/seedRoles.ts
index e65139b..979000e 100644
--- a/src/tools/seed/seedRoles.ts
+++ b/src/tools/seed/seedRoles.ts
@@ -14,6 +14,7 @@ const USER_PERMISSIONS: Permission[] = [
"missions:read",
"mission-attendances:read",
"campaigns:read",
+ "campaigns:manage",
"factions:read",
"technologies:read",
"assets:read",
diff --git a/src/utils/access-control/hasPermission.ts b/src/utils/access-control/hasPermission.ts
index 4ff0a73..f4f9d21 100644
--- a/src/utils/access-control/hasPermission.ts
+++ b/src/utils/access-control/hasPermission.ts
@@ -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 => {
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;
+ };
+}