diff --git a/src/app/(frontend)/projects/[key]/board/page.tsx b/src/app/(frontend)/projects/[key]/board/page.tsx index 54ee879..0d5bf9d 100644 --- a/src/app/(frontend)/projects/[key]/board/page.tsx +++ b/src/app/(frontend)/projects/[key]/board/page.tsx @@ -3,6 +3,7 @@ import { headers as nextHeaders } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { getPayload } from "payload"; import { ProjectBoardView } from "@/components/frontend/projects/ProjectBoardView"; +import { hasPermission } from "@/utils/access-control/hasPermission"; export const metadata = { title: "Board — Polaris Task Force", @@ -10,10 +11,14 @@ export const metadata = { const KANBAN_STATUSES = [ { status: "open", label: "Open" }, + { status: "planning", label: "Planning" }, { status: "in_progress", label: "In Progress" }, { status: "in_review", label: "In Review" }, + { status: "waiting_on_reporter", label: "Waiting on Reporter" }, { status: "in_testing", label: "Testing" }, { status: "resolved", label: "Done" }, + { status: "canceled", label: "Canceled" }, + { status: "closed", label: "Closed" }, ] as const; export default async function BoardPage({ @@ -43,6 +48,7 @@ export default async function BoardPage({ } const project = projectResult.docs[0] as any; + const canManageTickets = await hasPermission(payload, user, "tickets:staff"); const [ticketResult, sprintResult, labelResult] = await Promise.all([ payload.find({ @@ -147,7 +153,7 @@ export default async function BoardPage({ project: l.project, }))} currentUserId={user.id as number} - isAdmin={true} + isAdmin={canManageTickets} /> ); diff --git a/src/app/(frontend)/projects/[key]/releases/page.tsx b/src/app/(frontend)/projects/[key]/releases/page.tsx index b987163..5a48c41 100644 --- a/src/app/(frontend)/projects/[key]/releases/page.tsx +++ b/src/app/(frontend)/projects/[key]/releases/page.tsx @@ -3,6 +3,8 @@ import { headers as nextHeaders } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { getPayload } from "payload"; import { ReleasesView } from "@/components/frontend/projects/ReleasesView"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { richTextToPlaintext } from "@/lib/projects/projectMeta"; export const metadata = { title: "Releases — Polaris Task Force", @@ -35,6 +37,8 @@ export default async function ReleasesPage({ } const project = projectResult.docs[0] as any; + const canManage = await hasPermission(payload, user, "releases:update"); + const canCreate = await hasPermission(payload, user, "releases:create"); const [releaseResult, ticketResult] = await Promise.all([ payload.find({ @@ -75,7 +79,7 @@ export default async function ReleasesPage({ status: r.status, targetVersion: r.targetVersion, releaseDate: r.releaseDate, - description: r.description, + description: richTextToPlaintext(r.description) || null, createdAt: r.createdAt, ticketsTotal, ticketsResolved, @@ -91,7 +95,7 @@ export default async function ReleasesPage({ key: project.key, }} releases={releases} - isAdmin={true} + isAdmin={canManage || canCreate} /> ); diff --git a/src/app/(frontend)/projects/[key]/sprints/page.tsx b/src/app/(frontend)/projects/[key]/sprints/page.tsx index 670da5c..0ad605b 100644 --- a/src/app/(frontend)/projects/[key]/sprints/page.tsx +++ b/src/app/(frontend)/projects/[key]/sprints/page.tsx @@ -3,6 +3,7 @@ import { headers as nextHeaders } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { getPayload } from "payload"; import { SprintsView } from "@/components/frontend/projects/SprintsView"; +import { hasPermission } from "@/utils/access-control/hasPermission"; export const metadata = { title: "Sprints — Polaris Task Force", @@ -35,6 +36,8 @@ export default async function SprintsPage({ } const project = projectResult.docs[0] as any; + const canManage = await hasPermission(payload, user, "sprints:update"); + const canCreate = await hasPermission(payload, user, "sprints:create"); const [sprintResult, ticketResult] = await Promise.all([ payload.find({ @@ -97,7 +100,7 @@ export default async function SprintsPage({ key: project.key, }} sprints={sprints} - isAdmin={true} + isAdmin={canManage || canCreate} /> ); diff --git a/src/app/(frontend)/projects/actions.ts b/src/app/(frontend)/projects/actions.ts index aed0719..64ada1e 100644 --- a/src/app/(frontend)/projects/actions.ts +++ b/src/app/(frontend)/projects/actions.ts @@ -23,6 +23,16 @@ async function authenticate() { return { payload, user }; } +async function requirePermission( + payload: Awaited>, + user: Parameters[1], + permission: Parameters[2], +) { + if (!(await hasPermission(payload, user, permission))) { + throw new Error("You don't have permission to manage project tracking."); + } +} + export async function createProject(input: { name: string; key: string; @@ -31,6 +41,7 @@ export async function createProject(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "projects:create"); const name = input.name.trim(); if (!name) throw new Error("Project name is required."); @@ -93,6 +104,7 @@ export async function createSprint(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "sprints:create"); const name = input.name.trim(); if (!name) throw new Error("Sprint name is required."); @@ -132,6 +144,7 @@ export async function startSprint(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "sprints:update"); const sprint = await payload.update({ collection: "sprints" as any, @@ -161,6 +174,7 @@ export async function completeSprint(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "sprints:update"); const sprint = await payload.update({ collection: "sprints" as any, @@ -190,6 +204,7 @@ export async function startRelease(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "releases:update"); const release = await payload.update({ collection: "releases" as any, @@ -219,6 +234,7 @@ export async function completeRelease(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "releases:update"); const release = await payload.update({ collection: "releases" as any, @@ -252,6 +268,7 @@ export async function createRelease(input: { }): Promise { try { const { payload, user } = await authenticate(); + await requirePermission(payload, user, "releases:create"); const name = input.name.trim(); if (!name) throw new Error("Release name is required."); diff --git a/src/app/(frontend)/projects/page.tsx b/src/app/(frontend)/projects/page.tsx index d84fc44..2b94955 100644 --- a/src/app/(frontend)/projects/page.tsx +++ b/src/app/(frontend)/projects/page.tsx @@ -3,6 +3,8 @@ import { headers as nextHeaders } from "next/headers"; import { redirect } from "next/navigation"; import { getPayload } from "payload"; import { ProjectsView } from "@/components/frontend/projects/ProjectsView"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { richTextToPlaintext } from "@/lib/projects/projectMeta"; export const metadata = { title: "Projects — Polaris Task Force", @@ -24,6 +26,7 @@ export default async function ProjectsPage() { limit: 100, overrideAccess: true, }); + const canCreate = await hasPermission(payload, user, "projects:create"); return (
@@ -40,12 +43,12 @@ export default async function ProjectsPage() { p.lead.payloadDisplayName || p.lead.username, } : null, - description: p.description, + description: richTextToPlaintext(p.description) || null, createdAt: p.createdAt, updatedAt: p.updatedAt, }))} currentUserId={user.id as number} - isAdmin={true} + isAdmin={canCreate} />
); diff --git a/src/app/(frontend)/projects/search/page.tsx b/src/app/(frontend)/projects/search/page.tsx index 2745f32..f2341a0 100644 --- a/src/app/(frontend)/projects/search/page.tsx +++ b/src/app/(frontend)/projects/search/page.tsx @@ -22,7 +22,7 @@ export default async function SearchPage() { collection: "tickets" as any, where: { project: { exists: true } }, depth: 2, - limit: 500, + pagination: false, overrideAccess: true, }), payload.find({ @@ -39,6 +39,7 @@ export default async function SearchPage() { status: t.status, priority: t.priority, type: t.type || "task", + category: t.category, storyPoints: t.storyPoints, project: t.project ? typeof t.project === "object" @@ -55,6 +56,7 @@ export default async function SearchPage() { ? { id: t.assignee.id, displayName: t.assignee.payloadDisplayName || t.assignee.username } : { id: t.assignee, displayName: `User #${t.assignee}` } : null, + href: `/helpdesk/${t.id}`, })); const labels = (labelResult.docs as any[]).map((l) => ({ diff --git a/src/collections/projects/Projects.ts b/src/collections/projects/Projects.ts index fcd02c0..54d89a0 100644 --- a/src/collections/projects/Projects.ts +++ b/src/collections/projects/Projects.ts @@ -11,7 +11,10 @@ export const Projects: CollectionConfig = { }, access: { read: ({ req }) => !!req.user, - create: ({ req }) => !!req.user, + create: async ({ req }) => { + if (!req.user) return false; + return await hasPermission(req.payload, req.user, "projects:create"); + }, update: async ({ req }) => { if (!req.user) return false; return await hasPermission(req.payload, req.user, "projects:update"); diff --git a/src/components/frontend/projects/ProjectSearchView.tsx b/src/components/frontend/projects/ProjectSearchView.tsx index f83e6cd..a7adae6 100644 --- a/src/components/frontend/projects/ProjectSearchView.tsx +++ b/src/components/frontend/projects/ProjectSearchView.tsx @@ -13,6 +13,13 @@ import { ItemTitle, } from "@/components/ui/item"; import { cn } from "@/lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; export interface SearchTicket { readonly id: number; @@ -20,10 +27,12 @@ export interface SearchTicket { readonly status: string; readonly priority: string; readonly type: string; + readonly category?: string; readonly storyPoints?: number | null; readonly project: { readonly id: number; readonly name: string; readonly key: string }; readonly labels: readonly { id: number; name: string; color: string }[]; readonly assignee: { id: number; displayName: string } | null; + readonly href: string; } export interface LabelOption { @@ -61,6 +70,8 @@ function labelFor(value: string): string { export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps) { const [search, setSearch] = useState(""); const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const [status, setStatus] = useState("all"); + const [type, setType] = useState("all"); function toggleLabel(labelId: number) { setSelectedLabelIds((current) => @@ -73,17 +84,19 @@ export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps const filteredTickets = useMemo(() => { return tickets.filter((ticket) => { const matchesSearch = !query - || ticket.title.toLowerCase().includes(query) - || ticket.project.name.toLowerCase().includes(query) - || ticket.project.key.toLowerCase().includes(query); + || `${ticket.id} ${ticket.title} ${ticket.status} ${ticket.priority} ${ticket.type} ${ticket.category ?? ""} ${ticket.project.name} ${ticket.project.key} ${ticket.labels.map((label) => label.name).join(" ")}` + .toLowerCase() + .includes(query); const matchesLabels = selectedLabelIds.length === 0 || ticket.labels.some((label) => selectedLabelIds.includes(label.id)); + const matchesStatus = status === "all" || ticket.status === status; + const matchesType = type === "all" || ticket.type === type; - return matchesSearch && matchesLabels; + return matchesSearch && matchesLabels && matchesStatus && matchesType; }); - }, [tickets, query, selectedLabelIds]); + }, [tickets, query, selectedLabelIds, status, type]); const groupedByProject = useMemo(() => { const groups = new Map(); @@ -122,6 +135,39 @@ export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps /> +
+ + +
+ {allLabels.length > 0 && (
@@ -177,7 +223,8 @@ export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps
{projectTickets.map((ticket) => ( - + +
{ticket.title} @@ -220,7 +267,8 @@ export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps
)} -
+
+ ))}
diff --git a/src/lib/projects/projectMeta.ts b/src/lib/projects/projectMeta.ts index 2659ac9..370cfc7 100644 --- a/src/lib/projects/projectMeta.ts +++ b/src/lib/projects/projectMeta.ts @@ -19,3 +19,17 @@ export const TYPE_STYLES: Readonly> = { improvement: "bg-amber-500/15 text-amber-400 border-amber-500/30", subtask: "bg-sky-500/15 text-sky-400 border-sky-500/30", }; + +/** Safely extracts readable text from Payload's Lexical rich-text JSON. */ +export function richTextToPlaintext(value: unknown): string { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return ""; + + const node = value as { text?: unknown; children?: unknown }; + if (typeof node.text === "string") return node.text; + if (!Array.isArray(node.children)) return ""; + return node.children + .map((child) => richTextToPlaintext(child)) + .filter(Boolean) + .join(" "); +}