diff --git a/app/(authenticated)/dashboard/page.tsx b/app/(authenticated)/dashboard/page.tsx index d443349..099d6ae 100644 --- a/app/(authenticated)/dashboard/page.tsx +++ b/app/(authenticated)/dashboard/page.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Container } from "@/components/Container"; import { FadeIn, SlideIn } from "@/components/animations"; @@ -26,6 +26,8 @@ interface Tool { icon: string; user_id?: string; status?: string; + packagename?: string; + version?: string; tool_categories?: Array<{ categories: { id: number; @@ -48,6 +50,14 @@ export default function DashboardPage() { const [viewMode, setViewMode] = useState<"all" | "my">("all"); const [isAdmin, setIsAdmin] = useState(false); const [authToken, setAuthToken] = useState(""); + const [triggeringUpdateForToolId, setTriggeringUpdateForToolId] = useState(null); + const [openMoreMenuForToolId, setOpenMoreMenuForToolId] = useState(null); + const moreMenuAnchorRef = useRef(null); + const [validationModal, setValidationModal] = useState<{ + packageName: string; + errors: string[]; + warnings: string[]; + } | null>(null); useEffect(() => { // Get auth token from sessionStorage (set by layout) @@ -85,6 +95,21 @@ export default function DashboardPage() { })(); }, []); + // Close the "More" dropdown on scroll or resize to avoid stale fixed positioning + useEffect(() => { + if (openMoreMenuForToolId === null) return; + const close = () => { + setOpenMoreMenuForToolId(null); + moreMenuAnchorRef.current = null; + }; + window.addEventListener("scroll", close, true); + window.addEventListener("resize", close); + return () => { + window.removeEventListener("scroll", close, true); + window.removeEventListener("resize", close); + }; + }, [openMoreMenuForToolId]); + // Sign out logic handled in Header component. const handleToolAction = async (toolId: string, action: "deprecate" | "delete") => { @@ -123,6 +148,44 @@ export default function DashboardPage() { } }; + const handleTriggerUpdate = async (toolId: string) => { + if (!user || !authToken) return; + + if (!confirm("Are you sure you want to trigger an update for this tool? This will run the update workflow.")) return; + + setTriggeringUpdateForToolId(toolId); + try { + const response = await fetch(`/api/tools/${toolId}/trigger-update`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + if (errorData.step === "validation" && errorData.details) { + const tool = tools.find((t) => t.id === toolId); + setValidationModal({ + packageName: tool?.packagename || toolId, + errors: errorData.details.errors || [], + warnings: errorData.details.warnings || [], + }); + } else { + throw new Error(errorData.error || "Failed to trigger update"); + } + return; + } + + alert("Update workflow triggered successfully!"); + } catch (error) { + console.error("Error triggering tool update:", error); + alert(`Failed to trigger update: ${error instanceof Error ? error.message : "Please try again."}`); + } finally { + setTriggeringUpdateForToolId(null); + } + }; + // Filter tools based on view mode const filteredTools = viewMode === "my" ? tools.filter((tool) => tool.user_id === user?.id) : tools.filter((tool) => tool.status !== TOOL_STATUSES.DELETED && tool.status !== TOOL_STATUSES.DEPRECATED); @@ -319,13 +382,14 @@ export default function DashboardPage() { )} ) : ( -
+
+ {viewMode === "my" && } {viewMode === "my" && } @@ -368,6 +432,15 @@ export default function DashboardPage() { )} + {viewMode === "my" && ( + + )} {viewMode === "my" && (
Tool CategoryVersionStatusDownloads Rating + {tool.version ? ( + v{tool.version} + ) : ( + -- + )} + {tool.status === TOOL_STATUSES.DEPRECATED ? ( @@ -390,28 +463,97 @@ export default function DashboardPage() { {analytics?.mau?.toLocaleString() || "--"} -
+
{viewMode === "my" ? ( <> View | - - | - +
+ + {openMoreMenuForToolId === tool.id && ( + <> +
{ setOpenMoreMenuForToolId(null); moreMenuAnchorRef.current = null; }} + onKeyDown={(e) => { if (e.key === "Escape") { setOpenMoreMenuForToolId(null); moreMenuAnchorRef.current = null; } }} + /> +
{ if (e.key === "Escape") { setOpenMoreMenuForToolId(null); moreMenuAnchorRef.current = null; } }} + > + + +
+ +
+ + )} +
) : ( <> @@ -438,6 +580,80 @@ export default function DashboardPage() {
+ + {/* Validation Error Modal */} + {validationModal && ( +
e.key === "Escape" && setValidationModal(null)} + > +
setValidationModal(null)} /> +
+
+
+
+ + + +
+
+

Package Validation Failed

+

+ {validationModal.packageName} +

+
+
+ +
+
+ {validationModal.errors.length > 0 && ( +
+

Errors ({validationModal.errors.length})

+
    + {validationModal.errors.map((err, i) => ( +
  • + + + + {err} +
  • + ))} +
+
+ )} + {validationModal.warnings.length > 0 && ( +
+

Warnings ({validationModal.warnings.length})

+
    + {validationModal.warnings.map((warn, i) => ( +
  • + + + + {warn} +
  • + ))} +
+
+ )} +
+
+ +
+
+
+ )} ); } diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts index 19aa7c7..4107a83 100644 --- a/app/api/dashboard/route.ts +++ b/app/api/dashboard/route.ts @@ -58,6 +58,8 @@ export async function GET(request: NextRequest) { icon, user_id, status, + packagename, + version, tool_analytics (downloads, rating, mau), tool_categories ( categories (id, name) diff --git a/app/api/tools/[id]/trigger-update/route.ts b/app/api/tools/[id]/trigger-update/route.ts new file mode 100644 index 0000000..60cb1d1 --- /dev/null +++ b/app/api/tools/[id]/trigger-update/route.ts @@ -0,0 +1,153 @@ +import { runUpdateToolWorkflow } from "@/lib/github-api"; +import { fetchNpmPackageInfo, ToolPackageJson, validatePackageJson } from "@/lib/tool-validation"; +import { createClient } from "@supabase/supabase-js"; +import { NextRequest, NextResponse } from "next/server"; + +// Create Supabase client with service role for server-side operations +function getSupabaseClient() { + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceKey) { + return null; + } + + return createClient(supabaseUrl, supabaseServiceKey); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const supabase = getSupabaseClient(); + + if (!supabase) { + return NextResponse.json({ error: "Database connection not configured" }, { status: 500 }); + } + + // Verify user is authenticated + const authHeader = request.headers.get("authorization"); + let userId: string | null = null; + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(token); + + if (!authError && user) { + userId = user.id; + } else { + return NextResponse.json({ error: "Unauthorized. Valid user token required." }, { status: 401 }); + } + } + + if (!userId) { + return NextResponse.json({ error: "Unauthorized. Please sign in." }, { status: 401 }); + } + + const { id: toolId } = await params; + + if (!toolId) { + return NextResponse.json({ error: "Tool ID is required" }, { status: 400 }); + } + + // Fetch the tool and verify ownership + const { data: tool, error: fetchError } = await supabase.from("tools").select("id, user_id, packagename, version").eq("id", toolId).single(); + + if (fetchError || !tool) { + return NextResponse.json({ error: "Tool not found" }, { status: 404 }); + } + + if (tool.user_id !== userId) { + return NextResponse.json({ error: "You do not have permission to trigger an update for this tool" }, { status: 403 }); + } + + const packageName = tool.packagename as string; + + if (!packageName) { + return NextResponse.json({ error: "Tool does not have a package name configured" }, { status: 400 }); + } + + // Fetch latest package info from npm + const npmResult = await fetchNpmPackageInfo(packageName); + + if (!npmResult.success) { + return NextResponse.json( + { + error: npmResult.error, + step: "npm_check", + }, + { status: 404 }, + ); + } + + // Validate package.json structure + const packageJson: ToolPackageJson = { + name: npmResult.data.name, + version: npmResult.data.version, + displayName: npmResult.data.displayName, + description: npmResult.data.description, + contributors: npmResult.data.contributors, + cspExceptions: npmResult.data.cspExceptions, + license: npmResult.data.license, + icon: npmResult.data.icon, + configurations: npmResult.data.configurations, + features: npmResult.data.features, + }; + + const validationResult = await validatePackageJson(packageJson); + + if (!validationResult.valid) { + return NextResponse.json( + { + error: "Package validation failed", + step: "validation", + details: { + errors: validationResult.errors, + warnings: validationResult.warnings, + }, + }, + { status: 400 }, + ); + } + + // Invoke the GitHub update workflow for this specific tool + const ghToken = process.env.GH_PAT_TOKEN; + if (!ghToken) { + return NextResponse.json({ error: "GitHub token not configured" }, { status: 500 }); + } + + const repoOwner = "PowerPlatformToolBox"; + const repoName = "tool-management"; + + const conclusion = await runUpdateToolWorkflow({ + owner: repoOwner, + repo: repoName, + token: ghToken, + inputs: { + tool_id: packageJson.name, + version: packageJson.version, + authors: (packageJson.contributors || []).map((c) => (typeof c === "string" ? c : c.name)).filter(Boolean).join(", "), + repository: packageJson.configurations?.repository || "", + website: packageJson.configurations?.website || "", + }, + ref: "main", + timeoutMs: 180000, + pollIntervalMs: 30000, + }); + + if (conclusion !== "success") { + console.warn(`[trigger-update] Update workflow failed for ${packageJson.name}@${packageJson.version} with conclusion: ${conclusion || "unknown"}`); + return NextResponse.json({ error: "Update workflow did not complete successfully" }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: `Update workflow completed successfully for ${packageJson.name}@${packageJson.version}`, + }); + } catch (error) { + console.error("[trigger-update] Error:", error); + const errorMessage = error instanceof Error ? error.message : "Internal server error"; + return NextResponse.json({ error: "Internal server error", details: errorMessage }, { status: 500 }); + } +}