feat(campaigns): let members create and own campaigns
This commit is contained in:
parent
d6b2ffa5d2
commit
0771424446
9 changed files with 367 additions and 24 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
164
src/components/frontend/intelligence/CreateCampaignDialog.tsx
Normal file
164
src/components/frontend/intelligence/CreateCampaignDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/frontend/intelligence/create.ts
Normal file
72
src/components/frontend/intelligence/create.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const USER_PERMISSIONS: Permission[] = [
|
|||
"missions:read",
|
||||
"mission-attendances:read",
|
||||
"campaigns:read",
|
||||
"campaigns:manage",
|
||||
"factions:read",
|
||||
"technologies:read",
|
||||
"assets:read",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue