From 5a554b57b077b747263cad3fff0f49bc22447813 Mon Sep 17 00:00:00 2001 From: Chinmay Chaudhari Date: Tue, 9 Dec 2025 00:32:52 +0530 Subject: [PATCH] Refactor frontend components and structure Signed-off-by: Chinmay Chaudhari --- frontend/app/page.tsx | 589 +++--------------- frontend/app/simulator/page.tsx | 462 +------------- .../{ => features/commit}/commit-analysis.tsx | 90 +-- .../{ => features/commit}/commit-compare.tsx | 6 +- .../{ => features/commit}/commit-list.tsx | 0 .../{ => features/json}/json-diff-stats.tsx | 0 .../json}/json-diff-visualization.tsx | 416 +++++++------ .../{ => features/json}/json-tree-view.tsx | 0 .../json}/json-tree-visualization.tsx | 2 +- .../features/json/tree-view-tab.tsx | 87 +++ .../repository}/repository-selector.tsx | 0 .../repository}/repository-status.tsx | 0 .../features/visualization/file-selector.tsx | 88 +++ .../visualization}/trust-graph.tsx | 0 .../visualization/visualization-tab.tsx | 97 +++ frontend/components/layout/header.tsx | 49 ++ .../{ => shared}/collapsible-card.tsx | 8 +- .../enhanced-view-mode-toggle.tsx | 0 .../{ => shared}/progress-indicator.tsx | 0 .../{ => shared}/quick-start-guide.tsx | 0 .../components/{ => shared}/status-card.tsx | 0 .../components/{ => shared}/story-modal.tsx | 2 +- .../{ => shared}/view-mode-toggle.tsx | 0 frontend/components/shared/welcome-screen.tsx | 24 + .../{ => shared}/welcome-section.tsx | 0 frontend/hooks/use-gittuf-explorer.ts | 284 +++++++++ frontend/hooks/use-gittuf-simulator.ts | 436 +++++++++++++ frontend/json-diff.ts | 96 --- frontend/json-utils.ts | 94 --- frontend/lib/json-diff.ts | 96 ++- frontend/lib/simulator-types.ts | 21 + frontend/lib/types.ts | 19 + frontend/mock-api.ts | 41 -- frontend/package-lock.json | 41 +- frontend/package.json | 1 + frontend/tsconfig.json | 24 +- frontend/types.ts | 16 - frontend/utils.ts | 6 - frontend/view-mode-utils.ts | 77 --- 39 files changed, 1618 insertions(+), 1554 deletions(-) rename frontend/components/{ => features/commit}/commit-analysis.tsx (91%) rename frontend/components/{ => features/commit}/commit-compare.tsx (99%) rename frontend/components/{ => features/commit}/commit-list.tsx (100%) rename frontend/components/{ => features/json}/json-diff-stats.tsx (100%) rename frontend/components/{ => features/json}/json-diff-visualization.tsx (64%) rename frontend/components/{ => features/json}/json-tree-view.tsx (100%) rename frontend/components/{ => features/json}/json-tree-visualization.tsx (99%) create mode 100644 frontend/components/features/json/tree-view-tab.tsx rename frontend/components/{ => features/repository}/repository-selector.tsx (100%) rename frontend/components/{ => features/repository}/repository-status.tsx (100%) create mode 100644 frontend/components/features/visualization/file-selector.tsx rename frontend/components/{ => features/visualization}/trust-graph.tsx (100%) create mode 100644 frontend/components/features/visualization/visualization-tab.tsx create mode 100644 frontend/components/layout/header.tsx rename frontend/components/{ => shared}/collapsible-card.tsx (91%) rename frontend/components/{ => shared}/enhanced-view-mode-toggle.tsx (100%) rename frontend/components/{ => shared}/progress-indicator.tsx (100%) rename frontend/components/{ => shared}/quick-start-guide.tsx (100%) rename frontend/components/{ => shared}/status-card.tsx (100%) rename frontend/components/{ => shared}/story-modal.tsx (99%) rename frontend/components/{ => shared}/view-mode-toggle.tsx (100%) create mode 100644 frontend/components/shared/welcome-screen.tsx rename frontend/components/{ => shared}/welcome-section.tsx (100%) create mode 100644 frontend/hooks/use-gittuf-explorer.ts create mode 100644 frontend/hooks/use-gittuf-simulator.ts delete mode 100644 frontend/json-diff.ts delete mode 100644 frontend/json-utils.ts delete mode 100644 frontend/mock-api.ts delete mode 100644 frontend/types.ts delete mode 100644 frontend/utils.ts delete mode 100644 frontend/view-mode-utils.ts diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c28c058..3898db6 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,324 +1,71 @@ "use client" -import type React from "react" -import { useState, useEffect } from "react" -import dynamic from "next/dynamic" -import { Loader2, Github, GitCommit, FileJson, GitCompare, BarChart3, Sparkles } from "lucide-react" +import { GitCommit, GitCompare, BarChart3, FileJson, Github, Sparkles } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import CommitList from "@/components/commit-list" -import CommitCompare from "@/components/commit-compare" -import CommitAnalysis from "@/components/commit-analysis" -import QuickStartGuide from "@/components/quick-start-guide" -import EnhancedViewModeToggle from "@/components/enhanced-view-mode-toggle" -import ProgressIndicator from "@/components/progress-indicator" -import { mockFetchCommits, mockFetchMetadata } from "@/lib/mock-api" -import type { Commit } from "@/lib/types" -import JsonTreeView from "@/components/json-tree-view" -import { getHiddenFieldsCount, type ViewMode } from "@/lib/view-mode-utils" +import CommitList from "@/components/features/commit/commit-list" +import CommitCompare from "@/components/features/commit/commit-compare" +import CommitAnalysis from "@/components/features/commit/commit-analysis" +import QuickStartGuide from "@/components/shared/quick-start-guide" -import RepositoryStatus from "@/components/repository-status" -import { RepositoryHandler, type RepositoryInfo } from "@/lib/repository-handler" -import RepositorySelector from "@/components/repository-selector" +import RepositoryStatus from "@/components/features/repository/repository-status" +import RepositorySelector from "@/components/features/repository/repository-selector" -// Dynamically import the JsonTreeVisualization component to avoid SSR issues with ReactFlow -const JsonTreeVisualization = dynamic(() => import("@/components/json-tree-visualization"), { - ssr: false, - loading: () => ( -
- - Loading visualization... -
- ), -}) +// New extracted components +import Header from "@/components/layout/header" +import WelcomeScreen from "@/components/shared/welcome-screen" +import FileSelector from "@/components/features/visualization/file-selector" +import VisualizationTab from "@/components/features/visualization/visualization-tab" +import TreeViewTab from "@/components/features/json/tree-view-tab" -// Dynamically import the JsonDiffVisualization component -const JsonDiffVisualization = dynamic(() => import("@/components/json-diff-visualization"), { - ssr: false, - loading: () => ( -
- - Loading diff visualization... -
- ), -}) +// Custom Hook +import { useGittufExplorer } from "@/hooks/use-gittuf-explorer" export default function Home() { - const [repoUrl, setRepoUrl] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [commits, setCommits] = useState([]) - const [selectedCommit, setSelectedCommit] = useState(null) - const [compareCommits, setCompareCommits] = useState<{ - base: Commit | null - compare: Commit | null - }>({ base: null, compare: null }) - const [jsonData, setJsonData] = useState(null) - const [compareData, setCompareData] = useState<{ - base: any | null - compare: any | null - }>({ base: null, compare: null }) - const [activeTab, setActiveTab] = useState("commits") - const [error, setError] = useState("") - const [selectedFile, setSelectedFile] = useState("root.json") - const [selectedCommits, setSelectedCommits] = useState([]) - const [globalViewMode, setGlobalViewMode] = useState("normal") - const [currentStep, setCurrentStep] = useState(0) - const [repositoryHandler] = useState(() => new RepositoryHandler()) - const [currentRepository, setCurrentRepository] = useState(null) - const [showRepositorySelector, setShowRepositorySelector] = useState(true) - - const steps = ["Repository", "Commits", "Visualization", "Analysis"] - - const handleTryDemo = async () => { - setRepoUrl("https://github.com/gittuf/gittuf") - setCurrentStep(1) - - setIsLoading(true) - setError("") - - try { - const commitsData = await mockFetchCommits("https://github.com/gittuf/gittuf") - setCommits(commitsData) - setSelectedCommit(null) - setCompareCommits({ base: null, compare: null }) - setJsonData(null) - setCompareData({ base: null, compare: null }) - setSelectedCommits([]) - setActiveTab("commits") - setCurrentStep(2) - } catch (err) { - setError("Failed to load demo data. Please try again.") - } finally { - setIsLoading(false) - } - } - - const handleRepositorySelect = async (repoInfo: RepositoryInfo) => { - setCurrentRepository(repoInfo) - setCurrentStep(1) - setIsLoading(true) - setError("") - - try { - await repositoryHandler.setRepository(repoInfo) - const commitsData = await repositoryHandler.fetchCommits() - setCommits(commitsData) - setSelectedCommit(null) - setCompareCommits({ base: null, compare: null }) - setJsonData(null) - setCompareData({ base: null, compare: null }) - setSelectedCommits([]) - setActiveTab("commits") - setCurrentStep(2) - setShowRepositorySelector(false) - } catch (err) { - setError(`Failed to connect to repository: ${err instanceof Error ? err.message : "Unknown error"}`) - setCurrentStep(0) - } finally { - setIsLoading(false) - } - } - - const handleRepositoryRefresh = async () => { - if (!currentRepository) return - - setIsLoading(true) - setError("") - - try { - const commitsData = await repositoryHandler.fetchCommits() - setCommits(commitsData) - } catch (err) { - setError(`Failed to refresh repository data: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setIsLoading(false) - } - } - - const handleRepoSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!repoUrl.trim()) { - setError("Please enter a GitHub repository URL") - return - } - - setCurrentStep(1) - setIsLoading(true) - setError("") - - try { - const commitsData = await mockFetchCommits(repoUrl) - setCommits(commitsData) - setSelectedCommit(null) - setCompareCommits({ base: null, compare: null }) - setJsonData(null) - setCompareData({ base: null, compare: null }) - setSelectedCommits([]) - setActiveTab("commits") - setCurrentStep(2) - } catch (err) { - setError("Failed to fetch repository data. Please check the URL and try again.") - setCurrentStep(0) - } finally { - setIsLoading(false) - } - } - - const handleCommitSelect = async (commit: Commit) => { - setSelectedCommit(commit) - setIsLoading(true) - setActiveTab("visualization") - setCurrentStep(3) - setError("") - - try { - // Use the mock API directly with proper fallback URL - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const metadata = await mockFetchMetadata(fallbackUrl, commit.hash, selectedFile) - setJsonData(metadata) - } catch (err) { - console.error("Failed to fetch metadata:", err) - setError( - `Failed to fetch ${selectedFile} for this commit: ${err instanceof Error ? err.message : "Unknown error"}`, - ) - } finally { - setIsLoading(false) - } - } - - const handleCompareSelect = async (base: Commit, compare: Commit) => { - setCompareCommits({ base, compare }) - setIsLoading(true) - setActiveTab("compare") - setCurrentStep(3) - setError("") - - try { - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const [baseData, compareData] = await Promise.all([ - mockFetchMetadata(fallbackUrl, base.hash, selectedFile), - mockFetchMetadata(fallbackUrl, compare.hash, selectedFile), - ]) - - setCompareData({ base: baseData, compare: compareData }) - } catch (err) { - console.error("Failed to fetch comparison data:", err) - setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setIsLoading(false) - } - } - - const handleFileChange = async (file: string) => { - setSelectedFile(file) - - if (selectedCommit && (activeTab === "visualization" || activeTab === "tree")) { - setIsLoading(true) - setError("") - try { - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const metadata = await mockFetchMetadata(fallbackUrl, selectedCommit.hash, file) - setJsonData(metadata) - } catch (err) { - console.error("Failed to fetch file data:", err) - setError(`Failed to fetch ${file} for this commit: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setIsLoading(false) - } - } - - if (compareCommits.base && compareCommits.compare && activeTab === "compare") { - setIsLoading(true) - setError("") - try { - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const [baseData, compareData] = await Promise.all([ - mockFetchMetadata(fallbackUrl, compareCommits.base.hash, file), - mockFetchMetadata(fallbackUrl, compareCommits.compare.hash, file), - ]) - - setCompareData({ base: baseData, compare: compareData }) - } catch (err) { - console.error("Failed to fetch file comparison data:", err) - setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setIsLoading(false) - } - } - } - - const handleCommitRangeSelect = (commits: Commit[]) => { - setSelectedCommits(commits) - setActiveTab("analysis") - setCurrentStep(4) - } - - useEffect(() => { - if (activeTab === "analysis" && selectedCommits.length > 0) { - const loadAnalysisData = async () => { - setIsLoading(true) - setError("") - - try { - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const dataPromises = selectedCommits.map((commit) => - mockFetchMetadata(fallbackUrl, commit.hash, selectedFile), - ) - const results = await Promise.all(dataPromises) - const commitsWithData = selectedCommits.map((commit, index) => ({ - ...commit, - data: results[index], - })) - - setSelectedCommits(commitsWithData) - } catch (err) { - console.error("Failed to load analysis data:", err) - setError("Failed to load analysis data for selected commits. Please try again.") - } finally { - setIsLoading(false) - } - } - - loadAnalysisData() - } - }, [activeTab, selectedCommits.length, selectedFile, currentRepository, repoUrl]) - - const hiddenCount = globalViewMode === "normal" && jsonData ? getHiddenFieldsCount(jsonData) : 0 + const { + repoUrl, + setRepoUrl, + isLoading, + commits, + selectedCommit, + compareCommits, + jsonData, + compareData, + activeTab, + setActiveTab, + error, + selectedFile, + selectedCommits, + globalViewMode, + setGlobalViewMode, + currentStep, + currentRepository, + showRepositorySelector, + setShowRepositorySelector, + steps, + handleTryDemo, + handleRepositorySelect, + handleRepositoryRefresh, + handleRepoSubmit, + handleCommitSelect, + handleCompareSelect, + handleFileChange, + handleCommitRangeSelect, + hiddenCount, + } = useGittufExplorer() return (
-
-
-
-
- -
-
-

- gittuf Security Explorer -

-

Interactive tool to understand Git repository security metadata

-
-
- {currentRepository && ( - - )} -
- - {commits.length > 0 && } -
+
setShowRepositorySelector(!showRepositorySelector)} + hasCommits={commits.length > 0} + currentStep={currentStep} + steps={steps} + /> @@ -342,71 +89,14 @@ export default function Home() { {commits.length > 0 && ( <> -
-
-
-
- - Security Files: -
- - - - - - -
-

Root Security Policy

-

- Contains trust anchors, keys, and role definitions that form the foundation of repository - security. -

-
-
-
-
- - - - - - - -
-

Target Security Rules

-

- Contains specific security policies and rules that control who can modify different parts of - the repository. -

-
-
-
-
-
-
- - {(activeTab === "visualization" || activeTab === "tree") && jsonData && ( - - )} -
+ @@ -471,131 +161,27 @@ export default function Home() { - {selectedCommit && ( -
- - -
-
-

- - Interactive Graph View: {selectedFile} -

-
- - {selectedCommit.hash.substring(0, 8)} - -

{selectedCommit.message}

-
-
- - {new Date(selectedCommit.date).toLocaleDateString()} by {selectedCommit.author} - -
-
-
-
- )} - -
- {isLoading ? ( -
- - Loading security metadata visualization... -
- ) : error ? ( -
- -

Error Loading Visualization

-

{error}

- -
- ) : jsonData && selectedCommit ? ( - - ) : ( -
- -

Ready to Visualize!

-

- Select a commit from the "Browse Commits" tab to see an interactive graph of the security - metadata -

-
- )} -
+ selectedCommit && handleCommitSelect(selectedCommit)} + />
- {selectedCommit && ( -
- - -
-
-

- - Structured Tree View: {selectedFile} -

-
- - {selectedCommit.hash.substring(0, 8)} - -

{selectedCommit.message}

-
-
- - {new Date(selectedCommit.date).toLocaleDateString()} by {selectedCommit.author} - -
-
-
-
- )} - -
- {isLoading ? ( -
- - Loading tree structure... -
- ) : error ? ( -
- -

Error Loading Tree View

-

{error}

- -
- ) : jsonData && selectedCommit ? ( - - ) : ( -
- -

Tree View Ready!

-

- Select a commit to explore the security metadata in a familiar tree structure - perfect for - beginners! -

-
- )} -
+ selectedCommit && handleCommitSelect(selectedCommit)} + />
@@ -621,20 +207,7 @@ export default function Home() { )} - {commits.length === 0 && !isLoading && ( -
- -

Ready to Explore Security Metadata

-

- Enter a GitHub repository URL above or try our interactive demo to start learning about gittuf security - policies -

- -
- )} + {commits.length === 0 && !isLoading && }
) diff --git a/frontend/app/simulator/page.tsx b/frontend/app/simulator/page.tsx index 3fdfee4..9d18bca 100644 --- a/frontend/app/simulator/page.tsx +++ b/frontend/app/simulator/page.tsx @@ -1,6 +1,5 @@ "use client" -import { useState, useEffect, useCallback, useMemo } from "react" import { motion, AnimatePresence } from "framer-motion" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -39,436 +38,43 @@ import { ShieldPlus, } from "lucide-react" -import { StoryModal } from "@/components/story-modal" -import { StatusCard } from "@/components/status-card" -import { TrustGraph } from "@/components/trust-graph" -import type { SimulatorResponse, ApprovalRequirement, EligibleSigner } from "@/lib/simulator-types" - -// Import fixtures -import fixtureAllowed from "@/fixtures/fixture-allowed.json" -import fixtureBlocked from "@/fixtures/fixture-blocked.json" - -// Types -interface CustomPerson { - id: string - display_name: string - keyid: string - key_type: "ssh" | "gpg" | "sigstore" - has_signed: boolean -} - -interface CustomRole { - id: string - display_name: string - threshold: number - file_globs: string[] - assigned_people: string[] -} - -interface CustomConfig { - people: CustomPerson[] - roles: CustomRole[] -} - -// Default configuration -const DEFAULT_CONFIG: CustomConfig = { - people: [ - { - id: "alice", - display_name: "Alice Johnson", - keyid: "ssh-rsa-abc123", - key_type: "ssh", - has_signed: false, - }, - { - id: "bob", - display_name: "Bob Smith", - keyid: "gpg-def456", - key_type: "gpg", - has_signed: false, - }, - { - id: "charlie", - display_name: "Charlie Brown", - keyid: "sigstore-ghi789", - key_type: "sigstore", - has_signed: false, - }, - ], - roles: [ - { - id: "maintainer", - display_name: "Maintainer", - threshold: 2, - file_globs: ["src/**", "docs/**"], - assigned_people: ["alice", "bob", "charlie"], - }, - { - id: "reviewer", - display_name: "Reviewer", - threshold: 1, - file_globs: ["tests/**"], - assigned_people: ["alice", "bob"], - }, - ], -} +import { StoryModal } from "@/components/shared/story-modal" +import { StatusCard } from "@/components/shared/status-card" +import { TrustGraph } from "@/components/features/visualization/trust-graph" +import { useGittufSimulator } from "@/hooks/use-gittuf-simulator" export default function SimulatorPage() { - // Core UI State - const [darkMode, setDarkMode] = useState(false) - const [showStory, setShowStory] = useState(false) - const [showSimulator, setShowSimulator] = useState(false) - const [isProcessing, setIsProcessing] = useState(false) - - // Simulator State - const [currentFixture, setCurrentFixture] = useState<"blocked" | "allowed" | "custom">("blocked") - const [whatIfMode, setWhatIfMode] = useState(false) - const [simulatedSigners, setSimulatedSigners] = useState>(new Set()) - - // UI Layout State - const [expandedGraph, setExpandedGraph] = useState(false) - const [showControls, setShowControls] = useState(true) - const [showDetails, setShowDetails] = useState(false) - - // Custom Config State - const [showCustomConfig, setShowCustomConfig] = useState(false) - const [customConfig, setCustomConfig] = useState(DEFAULT_CONFIG) - - // Form States (isolated to prevent re-renders) - const [newPersonForm, setNewPersonForm] = useState({ - id: "", - display_name: "", - keyid: "", - key_type: "ssh" as const, - has_signed: false, - }) - - const [newRoleForm, setNewRoleForm] = useState({ - id: "", - display_name: "", - threshold: 1, - file_globs: ["src/**"], - assigned_people: [] as string[], - }) - - const [editingPerson, setEditingPerson] = useState(null) - const [editingRole, setEditingRole] = useState(null) - - // Generate custom fixture from config - const customFixture = useMemo((): SimulatorResponse => { - const approval_requirements: ApprovalRequirement[] = customConfig.roles.map((role) => { - const eligible_signers: EligibleSigner[] = role.assigned_people - .map((personId) => { - const person = customConfig.people.find((p) => p.id === personId) - return person - ? { - id: person.id, - display_name: person.display_name, - keyid: person.keyid, - key_type: person.key_type, - } - : null - }) - .filter(Boolean) as EligibleSigner[] - - const satisfiers = eligible_signers - .filter((signer) => { - const person = customConfig.people.find((p) => p.id === signer.id) - return person?.has_signed - }) - .map((signer) => ({ - who: signer.id, - keyid: signer.keyid, - signature_valid: true, - signature_time: new Date().toISOString(), - signature_verification_reason: `Valid ${signer.key_type.toUpperCase()} signature`, - })) - - return { - role: role.id, - role_metadata_version: 1, - threshold: role.threshold, - file_globs: role.file_globs, - eligible_signers, - satisfied: satisfiers.length, - satisfiers, - } - }) - - const allRequirementsMet = approval_requirements.every((req) => req.satisfied >= req.threshold) - - const visualization_hint = { - nodes: [ - ...customConfig.roles.map((role) => ({ - id: role.id, - type: "role" as const, - label: `${role.display_name} (${ - approval_requirements.find((req) => req.role === role.id)?.satisfied || 0 - }/${role.threshold})`, - meta: { - satisfied: (approval_requirements.find((req) => req.role === role.id)?.satisfied || 0) >= role.threshold, - threshold: role.threshold, - current: approval_requirements.find((req) => req.role === role.id)?.satisfied || 0, - }, - })), - ...customConfig.people.map((person) => ({ - id: person.id, - type: "person" as const, - label: person.display_name, - meta: { - signed: person.has_signed, - keyType: person.key_type, - }, - })), - ], - edges: customConfig.roles.flatMap((role) => - role.assigned_people.map((personId) => { - const person = customConfig.people.find((p) => p.id === personId) - return { - from: personId, - to: role.id, - label: person?.has_signed ? "Approved" : "Eligible", - satisfied: person?.has_signed || false, - } - }), - ), - } - - return { - result: allRequirementsMet ? "allowed" : "blocked", - reasons: allRequirementsMet - ? ["All approval requirements satisfied"] - : approval_requirements - .filter((req) => req.satisfied < req.threshold) - .map((req) => `Missing ${req.threshold - req.satisfied} ${req.role} approval(s)`), - approval_requirements, - signature_verification: approval_requirements.flatMap((req) => - req.satisfiers.map((satisfier, index) => ({ - signature_id: `sig-${req.role}-${index + 1}`, - keyid: satisfier.keyid, - sig_ok: satisfier.signature_valid, - verified_at: satisfier.signature_time, - reason: satisfier.signature_verification_reason, - })), - ), - attestation_matches: [ - { - attestation_id: "att-custom-001", - rsl_index: 42, - maps_to_proposal: true, - from_revision_ok: true, - target_tree_hash_match: true, - signature_valid: true, - }, - ], - visualization_hint, - } - }, [customConfig]) - - // Get current fixture - const getCurrentFixture = useCallback((): SimulatorResponse => { - switch (currentFixture) { - case "allowed": - return fixtureAllowed as SimulatorResponse - case "custom": - return customFixture - default: - return fixtureBlocked as SimulatorResponse - } - }, [currentFixture, customFixture]) - - const fixture = getCurrentFixture() - - // Calculate what-if result - const displayResult = useMemo((): SimulatorResponse => { - if (!whatIfMode || simulatedSigners.size === 0) return fixture - - const whatIfResult = JSON.parse(JSON.stringify(fixture)) as SimulatorResponse - - whatIfResult.approval_requirements = whatIfResult.approval_requirements.map((req) => { - const additionalSatisfiers = req.eligible_signers - .filter((signer) => simulatedSigners.has(signer.id) && !req.satisfiers.some((s) => s.who === signer.id)) - .map((signer) => ({ - who: signer.id, - keyid: signer.keyid, - signature_valid: true, - signature_time: new Date().toISOString(), - signature_verification_reason: "Simulated signature", - })) - - return { - ...req, - satisfied: req.satisfied + additionalSatisfiers.length, - satisfiers: [...req.satisfiers, ...additionalSatisfiers], - } - }) - - const allRequirementsMet = whatIfResult.approval_requirements.every((req) => req.satisfied >= req.threshold) - whatIfResult.result = allRequirementsMet ? "allowed" : "blocked" - - if (allRequirementsMet && fixture.result === "blocked") { - whatIfResult.reasons = ["All approval requirements satisfied (with simulated signatures)"] - } - - return whatIfResult - }, [whatIfMode, simulatedSigners, fixture]) - - // Event Handlers - const handleRunSimulation = useCallback(async () => { - setIsProcessing(true) - setShowSimulator(true) - await new Promise((resolve) => setTimeout(resolve, 1000)) - setIsProcessing(false) - }, []) - - const handleSimulatedSignerToggle = useCallback((signerId: string, checked: boolean) => { - setSimulatedSigners((prev) => { - const newSet = new Set(prev) - if (checked) { - newSet.add(signerId) - } else { - newSet.delete(signerId) - } - return newSet - }) - }, []) - - const handleExportJson = useCallback(() => { - const dataStr = JSON.stringify(displayResult, null, 2) - const dataBlob = new Blob([dataStr], { type: "application/json" }) - const url = URL.createObjectURL(dataBlob) - const link = document.createElement("a") - link.href = url - link.download = `gittuf-simulation-${Date.now()}.json` - link.click() - URL.revokeObjectURL(url) - }, [displayResult]) - - // Custom Config Handlers - const addPerson = useCallback(() => { - if (!newPersonForm.id || !newPersonForm.display_name) return - - const newPerson = { - ...newPersonForm, - keyid: newPersonForm.keyid || `${newPersonForm.key_type}-${Date.now()}`, - } - - setCustomConfig((prev) => ({ - ...prev, - people: [...prev.people, newPerson], - })) - - setNewPersonForm({ - id: "", - display_name: "", - keyid: "", - key_type: "ssh", - has_signed: false, - }) - }, [newPersonForm]) - - const addRole = useCallback(() => { - if (!newRoleForm.id || !newRoleForm.display_name) return - - setCustomConfig((prev) => ({ - ...prev, - roles: [...prev.roles, { ...newRoleForm }], - })) - - setNewRoleForm({ - id: "", - display_name: "", - threshold: 1, - file_globs: ["src/**"], - assigned_people: [], - }) - }, [newRoleForm]) - - const deletePerson = useCallback((id: string) => { - setCustomConfig((prev) => ({ - ...prev, - people: prev.people.filter((p) => p.id !== id), - roles: prev.roles.map((role) => ({ - ...role, - assigned_people: role.assigned_people.filter((pid) => pid !== id), - })), - })) - }, []) - - const deleteRole = useCallback((id: string) => { - setCustomConfig((prev) => ({ - ...prev, - roles: prev.roles.filter((r) => r.id !== id), - })) - }, []) - - const updatePerson = useCallback((person: CustomPerson) => { - setCustomConfig((prev) => ({ - ...prev, - people: prev.people.map((p) => (p.id === person.id ? person : p)), - })) - setEditingPerson(null) - }, []) - - const updateRole = useCallback((role: CustomRole) => { - setCustomConfig((prev) => ({ - ...prev, - roles: prev.roles.map((r) => (r.id === role.id ? role : r)), - })) - setEditingRole(null) - }, []) - - const togglePersonSigned = useCallback((personId: string) => { - setCustomConfig((prev) => ({ - ...prev, - people: prev.people.map((p) => (p.id === personId ? { ...p, has_signed: !p.has_signed } : p)), - })) - }, []) - - // Keyboard shortcuts with proper error handling - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Safety checks - if (!e || !e.key || typeof e.key !== "string") return - if (e.ctrlKey || e.metaKey) return - - try { - const key = e.key.toLowerCase() - - switch (key) { - case "r": - e.preventDefault() - handleRunSimulation() - break - case "w": - e.preventDefault() - setWhatIfMode(!whatIfMode) - break - case "s": - e.preventDefault() - setShowStory(true) - break - case "e": - e.preventDefault() - handleExportJson() - break - case "f": - e.preventDefault() - setExpandedGraph(!expandedGraph) - break - case "c": - e.preventDefault() - setShowCustomConfig(!showCustomConfig) - break - } - } catch (error) { - console.warn("Keyboard shortcut error:", error) - } - } + const { + darkMode, setDarkMode, + showStory, setShowStory, + showSimulator, setShowSimulator, + isProcessing, + currentFixture, setCurrentFixture, + whatIfMode, setWhatIfMode, + simulatedSigners, + expandedGraph, setExpandedGraph, + showControls, setShowControls, + showDetails, setShowDetails, + showCustomConfig, setShowCustomConfig, + customConfig, + newPersonForm, setNewPersonForm, + newRoleForm, setNewRoleForm, + editingPerson, setEditingPerson, + editingRole, setEditingRole, + fixture, + displayResult, + handleRunSimulation, + handleSimulatedSignerToggle, + handleExportJson, + addPerson, + addRole, + deletePerson, + deleteRole, + updatePerson, + updateRole, + togglePersonSigned + } = useGittufSimulator() - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleRunSimulation, handleExportJson, whatIfMode, expandedGraph, showCustomConfig]) return (
{ + const analyzeSecurityEvents = (diff: DiffResult | DiffEntry | null, commit: Commit): SecurityEvent[] => { const events: SecurityEvent[] = [] - const traverseChanges = (obj: any, path = "") => { + // If diff is null or singular DiffEntry (root change), we might need to handle it. + // Assuming structure is mostly Record for traversal. + + const traverseChanges = (obj: Record | undefined, path = "") => { if (!obj) return - Object.entries(obj).forEach(([key, value]: [string, any]) => { + Object.entries(obj).forEach(([key, value]) => { const currentPath = path ? `${path}.${key}` : key const pathLower = currentPath.toLowerCase() @@ -119,8 +110,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com // Expiration changes if (pathLower.includes("expires")) { if (value.status === "changed") { - const oldDate = new Date(value.oldValue) - const newDate = new Date(value.value) + const oldDate = new Date(String(value.oldValue)) + const newDate = new Date(String(value.value)) const extended = newDate > oldDate event = { @@ -141,7 +132,7 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com // Threshold changes else if (pathLower.includes("threshold")) { - if (value.status === "changed") { + if (value.status === "changed" && typeof value.value === 'number' && typeof value.oldValue === 'number') { const increased = value.value > value.oldValue event = { commit: commit.hash.substring(0, 8), @@ -230,15 +221,21 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com }) } - traverseChanges(diff) + if (diff && !('status' in diff)) { + traverseChanges(diff as DiffResult) + } else if (diff && 'status' in diff && (diff as DiffEntry).children) { + // If root is a DiffEntry with children + traverseChanges((diff as DiffEntry).children) + } + return events } - const calculateSecurityScore = (data: any, diff: any): number => { + const calculateSecurityScore = (data: JsonObject, diff: DiffResult | DiffEntry | null): number => { let score = 50 // Base score // Positive factors - if (data.expires) { + if (data.expires && typeof data.expires === 'string') { const expiryDate = new Date(data.expires) const now = new Date() const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) @@ -249,9 +246,9 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com } // Threshold analysis - if (data.roles) { - Object.values(data.roles).forEach((role: any) => { - if (role.threshold) { + if (data.roles && typeof data.roles === 'object') { + Object.values(data.roles as JsonObject).forEach((role: any) => { + if (role?.threshold) { if (role.threshold >= 2) score += 15 else if (role.threshold === 1) score += 5 } @@ -259,13 +256,13 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com } // Rules analysis - if (data.rules) { + if (data.rules && typeof data.rules === 'object') { const ruleCount = Object.keys(data.rules).length score += Math.min(ruleCount * 5, 25) // Max 25 points for rules } // Principal diversity - if (data.principals) { + if (data.principals && typeof data.principals === 'object') { const principalCount = Object.keys(data.principals).length score += Math.min(principalCount * 3, 15) // Max 15 points for principals } @@ -282,8 +279,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com const lastCommit = commits[commits.length - 1] // Principal count trend - const firstPrincipals = firstCommit.data?.principals ? Object.keys(firstCommit.data.principals).length : 0 - const lastPrincipals = lastCommit.data?.principals ? Object.keys(lastCommit.data.principals).length : 0 + const firstPrincipals = firstCommit.data?.principals && typeof firstCommit.data.principals === 'object' && !Array.isArray(firstCommit.data.principals) ? Object.keys(firstCommit.data.principals).length : 0 + const lastPrincipals = lastCommit.data?.principals && typeof lastCommit.data.principals === 'object' && !Array.isArray(lastCommit.data.principals) ? Object.keys(lastCommit.data.principals).length : 0 trends.push({ metric: "Security Principals", @@ -294,8 +291,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com }) // Rules count trend - const firstRules = firstCommit.data?.rules ? Object.keys(firstCommit.data.rules).length : 0 - const lastRules = lastCommit.data?.rules ? Object.keys(lastCommit.data.rules).length : 0 + const firstRules = firstCommit.data?.rules && typeof firstCommit.data.rules === 'object' && !Array.isArray(firstCommit.data.rules) ? Object.keys(firstCommit.data.rules).length : 0 + const lastRules = lastCommit.data?.rules && typeof lastCommit.data.rules === 'object' && !Array.isArray(lastCommit.data.rules) ? Object.keys(lastCommit.data.rules).length : 0 trends.push({ metric: "Security Rules", @@ -306,7 +303,7 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com }) // Expiration health - if (lastCommit.data?.expires) { + if (lastCommit.data?.expires && typeof lastCommit.data.expires === 'string') { const expiryDate = new Date(lastCommit.data.expires) const now = new Date() const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) @@ -729,19 +726,26 @@ function SecurityRecommendations({ overallScore: number isLoading: boolean }) { - const getRecommendations = () => { - const recommendations = [] + interface Recommendation { + priority: "critical" | "high" | "medium" | "low" + title: string + description: string + action: string + } + + const getRecommendations = (): Recommendation[] => { + const recommendations: Recommendation[] = [] // Check expiration const latestCommit = commits[commits.length - 1] - if (latestCommit?.data?.expires) { + if (latestCommit?.data?.expires && typeof latestCommit.data.expires === 'string') { const expiryDate = new Date(latestCommit.data.expires) const now = new Date() const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) if (daysUntilExpiry < 30) { recommendations.push({ - priority: "high" as const, + priority: "high", title: "Renew Security Metadata", description: `Security metadata expires in ${daysUntilExpiry} days. Plan renewal soon.`, action: "Update expiration date and refresh security keys", @@ -750,11 +754,11 @@ function SecurityRecommendations({ } // Check thresholds - if (latestCommit?.data?.roles) { + if (latestCommit?.data?.roles && typeof latestCommit.data.roles === 'object') { Object.entries(latestCommit.data.roles).forEach(([role, config]: [string, any]) => { - if (config.threshold === 1) { + if (config && typeof config === 'object' && config.threshold === 1) { recommendations.push({ - priority: "medium" as const, + priority: "medium", title: "Increase Security Threshold", description: `Role "${role}" only requires 1 signature. Consider increasing for better security.`, action: "Increase threshold to 2 or more signatures", @@ -767,7 +771,7 @@ function SecurityRecommendations({ securityTrends.forEach((trend) => { if (trend.trend === "declining") { recommendations.push({ - priority: "medium" as const, + priority: "medium", title: `Address Declining ${trend.metric}`, description: `${trend.metric} has decreased from ${trend.previous} to ${trend.current}.`, action: "Review and restore security measures", @@ -778,7 +782,7 @@ function SecurityRecommendations({ // Overall score recommendations if (overallScore < 60) { recommendations.push({ - priority: "high" as const, + priority: "high", title: "Improve Overall Security", description: "Security score is below recommended levels.", action: "Review all security policies and implement missing protections", @@ -789,7 +793,7 @@ function SecurityRecommendations({ const criticalEvents = securityEvents.filter((e) => e.severity === "critical") if (criticalEvents.length > 0) { recommendations.push({ - priority: "critical" as const, + priority: "critical", title: "Address Critical Security Events", description: `${criticalEvents.length} critical security events detected.`, action: "Review and remediate all critical security changes", diff --git a/frontend/components/commit-compare.tsx b/frontend/components/features/commit/commit-compare.tsx similarity index 99% rename from frontend/components/commit-compare.tsx rename to frontend/components/features/commit/commit-compare.tsx index 657565b..cb4ccdd 100644 --- a/frontend/components/commit-compare.tsx +++ b/frontend/components/features/commit/commit-compare.tsx @@ -4,12 +4,12 @@ import { Loader2, GitCompare, AlertTriangle, Minus, Plus, Edit3 } from "lucide-r import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import JsonDiffVisualization from "./json-diff-visualization" -import JsonDiffStats from "./json-diff-stats" +import JsonDiffVisualization from "@/components/features/json/json-diff-visualization" +import JsonDiffStats from "@/components/features/json/json-diff-stats" import { Button } from "@/components/ui/button" import type { Commit } from "@/lib/types" import { useState } from "react" -import JsonTreeView from "./json-tree-view" +import JsonTreeView from "@/components/features/json/json-tree-view" import { compareJsonObjects, countChanges } from "@/lib/json-diff" import type { ViewMode } from "@/lib/view-mode-utils" import { motion } from "framer-motion" diff --git a/frontend/components/commit-list.tsx b/frontend/components/features/commit/commit-list.tsx similarity index 100% rename from frontend/components/commit-list.tsx rename to frontend/components/features/commit/commit-list.tsx diff --git a/frontend/components/json-diff-stats.tsx b/frontend/components/features/json/json-diff-stats.tsx similarity index 100% rename from frontend/components/json-diff-stats.tsx rename to frontend/components/features/json/json-diff-stats.tsx diff --git a/frontend/components/json-diff-visualization.tsx b/frontend/components/features/json/json-diff-visualization.tsx similarity index 64% rename from frontend/components/json-diff-visualization.tsx rename to frontend/components/features/json/json-diff-visualization.tsx index 6a9c250..78db715 100644 --- a/frontend/components/json-diff-visualization.tsx +++ b/frontend/components/features/json/json-diff-visualization.tsx @@ -18,11 +18,13 @@ import ReactFlow, { import "reactflow/dist/style.css" import dagre from "dagre" import { motion } from "framer-motion" -import { CollapsibleCard } from "./collapsible-card" -import { compareJsonObjects } from "@/lib/json-diff" +import { CollapsibleCard } from "@/components/shared/collapsible-card" +import { compareJsonObjects, type DiffEntry, type DiffResult } from "@/lib/json-diff" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Badge } from "@/components/ui/badge" import { formatJsonValue, getNodeTypeDescription } from "@/lib/json-utils" +import type { JsonValue, JsonObject } from "@/lib/types" +import type { ViewMode } from "@/lib/view-mode-utils" // Node dimensions for layout const NODE_WIDTH = 220 @@ -37,8 +39,20 @@ const AnimatedNode = ({ children }: { children: React.ReactNode }) => { ) } +interface DiffNodeData { + label?: string + value?: JsonValue + oldValue?: JsonValue + newValue?: JsonValue + path?: string + isExpanded?: boolean + onToggle?: () => void + metadata?: Record + diffDetails?: string +} + // Node tooltip wrapper -const DiffNodeTooltip = ({ children, data, type }: { children: React.ReactNode; data: any; type: string }) => { +const DiffNodeTooltip = ({ children, data, type }: { children: React.ReactNode; data: DiffNodeData; type: string }) => { return ( @@ -134,127 +148,122 @@ const DiffNodeTooltip = ({ children, data, type }: { children: React.ReactNode; } // Node types -function DiffRootNode({ data, isConnectable }: any) { +function DiffRootNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) { return ( -
- - -
- {typeof data.value === "object" && data.value !== null - ? `Object with ${Object.keys(data.value).length} properties` - : data.value === null - ? "null" - : data.value === undefined - ? "undefined" - : String(data.value)} -
-
-
+ + +
) } -function DiffAddedNode({ data, isConnectable }: any) { +function DiffAddedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) { return ( -
- - - -
- {typeof data.value === "object" && data.value !== null - ? `Object with ${Object.keys(data.value).length} properties` - : data.value === null - ? "null" - : data.value === undefined - ? "undefined" - : String(data.value)} -
-
-
+ + +
+ {formatJsonValue(data.value)} +
+ +
) } -function DiffRemovedNode({ data, isConnectable }: any) { +function DiffRemovedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) { return ( -
- - - -
- {typeof data.value === "object" && data.value !== null - ? `Object with ${Object.keys(data.value).length} properties` - : data.value === null - ? "null" - : data.value === undefined - ? "undefined" - : String(data.value)} +
+ +
+
{data.label}
+
+ {formatJsonValue(data.value)}
- +
+
) } -function DiffChangedNode({ data, isConnectable }: any) { +function DiffChangedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) { return ( -
- - - -
-
{String(data.oldValue)}
-
{String(data.newValue)}
+
+ +
+
{data.label}
+
+
+
Old
+
{formatJsonValue(data.oldValue)}
+
+
+
New
+
{formatJsonValue(data.newValue)}
+
- +
+
) } -function DiffUnchangedNode({ data, isConnectable }: any) { +function DiffUnchangedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) { return ( @@ -262,7 +271,7 @@ function DiffUnchangedNode({ data, isConnectable }: any) {
@@ -326,20 +335,26 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = "TB") => } // Main component +export interface JsonDiffVisualizationProps { + baseData: JsonObject | null + compareData: JsonObject | null + className?: string + viewMode?: ViewMode +} + export default function JsonDiffVisualization({ baseData, compareData, -}: { - baseData: any - compareData: any -}) { + className, + viewMode, +}: JsonDiffVisualizationProps) { const [expandedNodes, setExpandedNodes] = useState>({}) const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [showUnchanged, setShowUnchanged] = useState(true) const [error, setError] = useState(null) - const onConnect = useCallback((params: any) => setEdges((eds) => addEdge(params, eds)), [setEdges]) + const onConnect = useCallback((params: Edge | any) => setEdges((eds) => addEdge(params, eds)), [setEdges]) const toggleNodeExpansion = useCallback((nodeId: string) => { setExpandedNodes((prev) => ({ @@ -370,7 +385,7 @@ export default function JsonDiffVisualization({ path: "$", metadata: { type: typeof compareData, - schemaVersion: compareData?.schemaVersion || "N/A", + schemaVersion: (compareData as any)?.schemaVersion || "N/A", }, }, }) @@ -378,11 +393,95 @@ export default function JsonDiffVisualization({ // Compare the two JSON objects const diff = compareJsonObjects(baseData, compareData) - // Process diff recursively - const processDiff = (parentId: string, diffObj: any, path = "$", level = 1) => { - if (!diffObj) return + if (!diff) { + setNodes(getLayoutedElements(newNodes, newEdges, "TB").nodes) + setEdges(newEdges) + return + } - Object.entries(diffObj).forEach(([key, value]: [string, any]) => { + // Helper function to process added objects recursively + const processAddedObject = (parentId: string, obj: JsonObject | any[], path: string, level: number) => { + Object.entries(obj).forEach(([childKey, childValue]) => { + const childId = `node-${nodeId++}` + const childPath = `${path}.${childKey}` + + newNodes.push({ + id: childId, + type: "diffAdded", + position: { x: 0, y: level * 100 }, + data: { + label: childKey, + value: childValue as JsonValue, + isExpanded: false, + path: childPath, + metadata: { + type: + typeof childValue === "object" + ? Array.isArray(childValue) + ? "array" + : "object" + : typeof childValue, + }, + }, + }) + + newEdges.push({ + id: `edge-${parentId}-${childId}`, + source: parentId, + target: childId, + animated: false, + style: { stroke: "#22c55e" }, + }) + + if (typeof childValue === "object" && childValue !== null) { + processAddedObject(childId, childValue, childPath, level + 1) + } + }) + } + + // Helper function to process removed objects recursively + const processRemovedObject = (parentId: string, obj: JsonObject | any[], path: string, level: number) => { + Object.entries(obj).forEach(([childKey, childValue]) => { + const childId = `node-${nodeId++}` + const childPath = `${path}.${childKey}` + + newNodes.push({ + id: childId, + type: "diffRemoved", + position: { x: 0, y: level * 100 }, + data: { + label: childKey, + value: childValue as JsonValue, + isExpanded: false, + path: childPath, + metadata: { + type: + typeof childValue === "object" + ? Array.isArray(childValue) + ? "array" + : "object" + : typeof childValue, + }, + }, + }) + + newEdges.push({ + id: `edge-${parentId}-${childId}`, + source: parentId, + target: childId, + animated: false, + style: { stroke: "#ef4444" }, + }) + + if (typeof childValue === "object" && childValue !== null) { + processRemovedObject(childId, childValue, childPath, level + 1) + } + }) + } + + // Process diff recursively + const processDiff = (parentId: string, diffObj: DiffResult, path = "$", level = 1) => { + Object.entries(diffObj).forEach(([key, value]) => { const currentId = `node-${nodeId++}` const currentPath = path === "$" ? `${path}.${key}` : `${path}.${key}` const isExpanded = expandedNodes[currentId] !== false @@ -420,46 +519,7 @@ export default function JsonDiffVisualization({ if (isExpanded && typeof value.value === "object" && value.value !== null) { // For added objects, create nodes for their properties - const addedObj = value.value - const processAddedObject = (parentId: string, obj: any, path: string, level: number) => { - Object.entries(obj).forEach(([childKey, childValue]: [string, any]) => { - const childId = `node-${nodeId++}` - const childPath = `${path}.${childKey}` - - newNodes.push({ - id: childId, - type: "diffAdded", - position: { x: 0, y: level * 100 }, - data: { - label: childKey, - value: childValue, - isExpanded: false, - path: childPath, - metadata: { - type: - typeof childValue === "object" - ? Array.isArray(childValue) - ? "array" - : "object" - : typeof childValue, - }, - }, - }) - - newEdges.push({ - id: `edge-${parentId}-${childId}`, - source: parentId, - target: childId, - animated: false, - style: { stroke: "#22c55e" }, - }) - - if (typeof childValue === "object" && childValue !== null) { - processAddedObject(childId, childValue, childPath, level + 1) - } - }) - } - + const addedObj = value.value as JsonObject | any[] processAddedObject(currentId, addedObj, currentPath, level + 1) } } else if (value.status === "removed") { @@ -495,46 +555,7 @@ export default function JsonDiffVisualization({ if (isExpanded && typeof value.value === "object" && value.value !== null) { // For removed objects, create nodes for their properties - const removedObj = value.value - const processRemovedObject = (parentId: string, obj: any, path: string, level: number) => { - Object.entries(obj).forEach(([childKey, childValue]: [string, any]) => { - const childId = `node-${nodeId++}` - const childPath = `${path}.${childKey}` - - newNodes.push({ - id: childId, - type: "diffRemoved", - position: { x: 0, y: level * 100 }, - data: { - label: childKey, - value: childValue, - isExpanded: false, - path: childPath, - metadata: { - type: - typeof childValue === "object" - ? Array.isArray(childValue) - ? "array" - : "object" - : typeof childValue, - }, - }, - }) - - newEdges.push({ - id: `edge-${parentId}-${childId}`, - source: parentId, - target: childId, - animated: false, - style: { stroke: "#ef4444" }, - }) - - if (typeof childValue === "object" && childValue !== null) { - processRemovedObject(childId, childValue, childPath, level + 1) - } - }) - } - + const removedObj = value.value as JsonObject | any[] processRemovedObject(currentId, removedObj, currentPath, level + 1) } } else if (value.status === "changed") { @@ -609,9 +630,18 @@ export default function JsonDiffVisualization({ processDiff(currentId, value.children, currentPath, level + 1) } } else if (value.children) { - // This is a nested object with changes inside - const nodeType = - value.status === "added" ? "diffAdded" : value.status === "removed" ? "diffRemoved" : "diffUnchanged" + // This is a nested object with changes inside, but the node itself is "unchanged" + // (checked above). If we are here, it means showUnchanged is false (otherwise caught above), + // OR the status was not added/removed/changed. + // Since we previously checked added/removed/changed, status is strictly "unchanged". + + // We usually want to show nodes that contain changes even if "showUnchanged" is false? + // If showUnchanged is false, the previous block skipped it. + // So this block executes for unchanged nodes with children. + // But if we want to hide unchanged nodes, we shouldn't render this node? + // However, if children have changes, we probably MUST render this node to maintain the tree. + + const nodeType = "diffUnchanged" newNodes.push({ id: currentId, @@ -635,13 +665,13 @@ export default function JsonDiffVisualization({ }, }) - const edgeColor = value.status === "added" ? "#22c55e" : value.status === "removed" ? "#ef4444" : "#94a3b8" + const edgeColor = "#94a3b8" newEdges.push({ id: `edge-${parentId}-${currentId}`, source: parentId, target: currentId, - animated: value.status !== "unchanged", + animated: false, style: { stroke: edgeColor }, }) @@ -652,8 +682,20 @@ export default function JsonDiffVisualization({ }) } - // Start processing from root - processDiff(rootId, diff, "$") + // Check if diff is a single entry (e.g., entire object added/removed) or a Result Record + if ('status' in diff && typeof (diff as DiffEntry).status === 'string') { + const diffEntry = diff as DiffEntry; + // If the whole thing is added, we iterate its value if it's an object + if (diffEntry.status === "added" && typeof diffEntry.value === "object" && diffEntry.value !== null) { + processAddedObject(rootId, diffEntry.value as JsonObject | any[], "$", 1); + } else if (diffEntry.status === "removed" && typeof diffEntry.value === "object" && diffEntry.value !== null) { + processRemovedObject(rootId, diffEntry.value as JsonObject | any[], "$", 1); + } + // Handle other cases if necessary (e.g. root changed type) + } else { + // Start processing from root as a DiffResult + processDiff(rootId, diff as DiffResult, "$") + } // Apply layout const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( diff --git a/frontend/components/json-tree-view.tsx b/frontend/components/features/json/json-tree-view.tsx similarity index 100% rename from frontend/components/json-tree-view.tsx rename to frontend/components/features/json/json-tree-view.tsx diff --git a/frontend/components/json-tree-visualization.tsx b/frontend/components/features/json/json-tree-visualization.tsx similarity index 99% rename from frontend/components/json-tree-visualization.tsx rename to frontend/components/features/json/json-tree-visualization.tsx index 932e557..7ec73ed 100644 --- a/frontend/components/json-tree-visualization.tsx +++ b/frontend/components/features/json/json-tree-visualization.tsx @@ -17,7 +17,7 @@ import ReactFlow, { } from "reactflow" import "reactflow/dist/style.css" import dagre from "dagre" -import { CollapsibleCard } from "./collapsible-card" +import { CollapsibleCard } from "@/components/shared/collapsible-card" import { motion } from "framer-motion" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Badge } from "@/components/ui/badge" diff --git a/frontend/components/features/json/tree-view-tab.tsx b/frontend/components/features/json/tree-view-tab.tsx new file mode 100644 index 0000000..9076658 --- /dev/null +++ b/frontend/components/features/json/tree-view-tab.tsx @@ -0,0 +1,87 @@ +"use client" + +import { FileJson, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import JsonTreeView from "@/components/features/json/json-tree-view" +import type { Commit } from "@/lib/types" +import type { ViewMode } from "@/lib/view-mode-utils" + +interface TreeViewTabProps { + selectedCommit: Commit | null + selectedFile: string + isLoading: boolean + error: string + jsonData: any + viewMode: ViewMode + onRetry: () => void +} + +export default function TreeViewTab({ + selectedCommit, + selectedFile, + isLoading, + error, + jsonData, + viewMode, + onRetry, +}: TreeViewTabProps) { + return ( + <> + {selectedCommit && ( +
+ + +
+
+

+ + Structured Tree View: {selectedFile} +

+
+ + {selectedCommit.hash.substring(0, 8)} + +

{selectedCommit.message}

+
+
+ + {new Date(selectedCommit.date).toLocaleDateString()} by {selectedCommit.author} + +
+
+
+
+ )} + +
+ {isLoading ? ( +
+ + Loading tree structure... +
+ ) : error ? ( +
+ +

Error Loading Tree View

+

{error}

+ +
+ ) : jsonData && selectedCommit ? ( + + ) : ( +
+ +

Tree View Ready!

+

+ Select a commit to explore the security metadata in a familiar tree structure - perfect for beginners! +

+
+ )} +
+ + ) +} diff --git a/frontend/components/repository-selector.tsx b/frontend/components/features/repository/repository-selector.tsx similarity index 100% rename from frontend/components/repository-selector.tsx rename to frontend/components/features/repository/repository-selector.tsx diff --git a/frontend/components/repository-status.tsx b/frontend/components/features/repository/repository-status.tsx similarity index 100% rename from frontend/components/repository-status.tsx rename to frontend/components/features/repository/repository-status.tsx diff --git a/frontend/components/features/visualization/file-selector.tsx b/frontend/components/features/visualization/file-selector.tsx new file mode 100644 index 0000000..7326255 --- /dev/null +++ b/frontend/components/features/visualization/file-selector.tsx @@ -0,0 +1,88 @@ +"use client" + +import { FileJson } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import EnhancedViewModeToggle from "@/components/shared/enhanced-view-mode-toggle" +import type { ViewMode } from "@/lib/view-mode-utils" + +interface FileSelectorProps { + selectedFile: string + onFileChange: (file: string) => void + viewMode: ViewMode + onViewModeChange: (mode: ViewMode) => void + hiddenCount: number + showViewToggle: boolean +} + +export default function FileSelector({ + selectedFile, + onFileChange, + viewMode, + onViewModeChange, + hiddenCount, + showViewToggle, +}: FileSelectorProps) { + return ( +
+
+
+
+ + Security Files: +
+ + + + + + +
+

Root Security Policy

+

+ Contains trust anchors, keys, and role definitions that form the foundation of repository security. +

+
+
+
+
+ + + + + + + +
+

Target Security Rules

+

+ Contains specific security policies and rules that control who can modify different parts of the + repository. +

+
+
+
+
+
+
+ + {showViewToggle && ( + + )} +
+ ) +} diff --git a/frontend/components/trust-graph.tsx b/frontend/components/features/visualization/trust-graph.tsx similarity index 100% rename from frontend/components/trust-graph.tsx rename to frontend/components/features/visualization/trust-graph.tsx diff --git a/frontend/components/features/visualization/visualization-tab.tsx b/frontend/components/features/visualization/visualization-tab.tsx new file mode 100644 index 0000000..a12e848 --- /dev/null +++ b/frontend/components/features/visualization/visualization-tab.tsx @@ -0,0 +1,97 @@ +"use client" + +import { FileJson, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import type { Commit } from "@/lib/types" +import type { ViewMode } from "@/lib/view-mode-utils" +import dynamic from "next/dynamic" + +const JsonTreeVisualization = dynamic(() => import("@/components/features/json/json-tree-visualization"), { + ssr: false, + loading: () => ( +
+ + Loading visualization... +
+ ), +}) + +interface VisualizationTabProps { + selectedCommit: Commit | null + selectedFile: string + isLoading: boolean + error: string + jsonData: any + viewMode: ViewMode + onRetry: () => void +} + +export default function VisualizationTab({ + selectedCommit, + selectedFile, + isLoading, + error, + jsonData, + viewMode, + onRetry, +}: VisualizationTabProps) { + return ( + <> + {selectedCommit && ( +
+ + +
+
+

+ + Interactive Graph View: {selectedFile} +

+
+ + {selectedCommit.hash.substring(0, 8)} + +

{selectedCommit.message}

+
+
+ + {new Date(selectedCommit.date).toLocaleDateString()} by {selectedCommit.author} + +
+
+
+
+ )} + +
+ {isLoading ? ( +
+ + Loading security metadata visualization... +
+ ) : error ? ( +
+ +

Error Loading Visualization

+

{error}

+ +
+ ) : jsonData && selectedCommit ? ( + + ) : ( +
+ +

Ready to Visualize!

+

+ Select a commit from the "Browse Commits" tab to see an interactive graph of the security metadata +

+
+ )} +
+ + ) +} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx new file mode 100644 index 0000000..8da638c --- /dev/null +++ b/frontend/components/layout/header.tsx @@ -0,0 +1,49 @@ +"use client" + +import { Github } from "lucide-react" +import { Button } from "@/components/ui/button" +import ProgressIndicator from "@/components/shared/progress-indicator" +import type { RepositoryInfo } from "@/lib/repository-handler" + +interface HeaderProps { + currentRepository: RepositoryInfo | null + showRepositorySelector: boolean + onToggleSelector: () => void + hasCommits: boolean + currentStep: number + steps: string[] +} + +export default function Header({ + currentRepository, + showRepositorySelector, + onToggleSelector, + hasCommits, + currentStep, + steps, +}: HeaderProps) { + return ( +
+
+
+
+ +
+
+

+ gittuf Security Explorer +

+

Interactive tool to understand Git repository security metadata

+
+
+ {currentRepository && ( + + )} +
+ + {hasCommits && } +
+ ) +} diff --git a/frontend/components/collapsible-card.tsx b/frontend/components/shared/collapsible-card.tsx similarity index 91% rename from frontend/components/collapsible-card.tsx rename to frontend/components/shared/collapsible-card.tsx index 9d2ab2e..208923e 100644 --- a/frontend/components/collapsible-card.tsx +++ b/frontend/components/shared/collapsible-card.tsx @@ -14,6 +14,8 @@ interface CollapsibleCardProps { isExpanded?: boolean badgeText?: string badgeColor?: string + className?: string + headerClassName?: string } export const CollapsibleCard: React.FC = ({ @@ -25,6 +27,8 @@ export const CollapsibleCard: React.FC = ({ isExpanded, badgeText, badgeColor, + className = "", + headerClassName = "", }) => { const colorMap: Record = { "border-blue-500": "text-blue-600", @@ -43,9 +47,9 @@ export const CollapsibleCard: React.FC = ({ -
+
{title}
{badgeText && ( diff --git a/frontend/components/enhanced-view-mode-toggle.tsx b/frontend/components/shared/enhanced-view-mode-toggle.tsx similarity index 100% rename from frontend/components/enhanced-view-mode-toggle.tsx rename to frontend/components/shared/enhanced-view-mode-toggle.tsx diff --git a/frontend/components/progress-indicator.tsx b/frontend/components/shared/progress-indicator.tsx similarity index 100% rename from frontend/components/progress-indicator.tsx rename to frontend/components/shared/progress-indicator.tsx diff --git a/frontend/components/quick-start-guide.tsx b/frontend/components/shared/quick-start-guide.tsx similarity index 100% rename from frontend/components/quick-start-guide.tsx rename to frontend/components/shared/quick-start-guide.tsx diff --git a/frontend/components/status-card.tsx b/frontend/components/shared/status-card.tsx similarity index 100% rename from frontend/components/status-card.tsx rename to frontend/components/shared/status-card.tsx diff --git a/frontend/components/story-modal.tsx b/frontend/components/shared/story-modal.tsx similarity index 99% rename from frontend/components/story-modal.tsx rename to frontend/components/shared/story-modal.tsx index c473de1..1df3425 100644 --- a/frontend/components/story-modal.tsx +++ b/frontend/components/shared/story-modal.tsx @@ -19,7 +19,7 @@ import { Key, FileText, } from "lucide-react" -import { TrustGraph } from "./trust-graph" +import { TrustGraph } from "@/components/features/visualization/trust-graph" import type { SimulatorResponse } from "@/lib/simulator-types" interface StoryModalProps { diff --git a/frontend/components/view-mode-toggle.tsx b/frontend/components/shared/view-mode-toggle.tsx similarity index 100% rename from frontend/components/view-mode-toggle.tsx rename to frontend/components/shared/view-mode-toggle.tsx diff --git a/frontend/components/shared/welcome-screen.tsx b/frontend/components/shared/welcome-screen.tsx new file mode 100644 index 0000000..5688740 --- /dev/null +++ b/frontend/components/shared/welcome-screen.tsx @@ -0,0 +1,24 @@ +"use client" + +import { Github, Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface WelcomeScreenProps { + onTryDemo: () => void +} + +export default function WelcomeScreen({ onTryDemo }: WelcomeScreenProps) { + return ( +
+ +

Ready to Explore Security Metadata

+

+ Enter a GitHub repository URL above or try our interactive demo to start learning about gittuf security policies +

+ +
+ ) +} diff --git a/frontend/components/welcome-section.tsx b/frontend/components/shared/welcome-section.tsx similarity index 100% rename from frontend/components/welcome-section.tsx rename to frontend/components/shared/welcome-section.tsx diff --git a/frontend/hooks/use-gittuf-explorer.ts b/frontend/hooks/use-gittuf-explorer.ts new file mode 100644 index 0000000..cdf37c8 --- /dev/null +++ b/frontend/hooks/use-gittuf-explorer.ts @@ -0,0 +1,284 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { mockFetchCommits, mockFetchMetadata } from "@/lib/mock-api" +import type { Commit } from "@/lib/types" +import { getHiddenFieldsCount, type ViewMode } from "@/lib/view-mode-utils" +import { RepositoryHandler, type RepositoryInfo } from "@/lib/repository-handler" + +export function useGittufExplorer() { + const [repoUrl, setRepoUrl] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [commits, setCommits] = useState([]) + const [selectedCommit, setSelectedCommit] = useState(null) + const [compareCommits, setCompareCommits] = useState<{ + base: Commit | null + compare: Commit | null + }>({ base: null, compare: null }) + const [jsonData, setJsonData] = useState(null) + const [compareData, setCompareData] = useState<{ + base: any | null + compare: any | null + }>({ base: null, compare: null }) + const [activeTab, setActiveTab] = useState("commits") + const [error, setError] = useState("") + const [selectedFile, setSelectedFile] = useState("root.json") + const [selectedCommits, setSelectedCommits] = useState([]) + const [globalViewMode, setGlobalViewMode] = useState("normal") + const [currentStep, setCurrentStep] = useState(0) + const [repositoryHandler] = useState(() => new RepositoryHandler()) + const [currentRepository, setCurrentRepository] = useState(null) + const [showRepositorySelector, setShowRepositorySelector] = useState(true) + + const steps = ["Repository", "Commits", "Visualization", "Analysis"] + + const handleTryDemo = async () => { + setRepoUrl("https://github.com/gittuf/gittuf") + setCurrentStep(1) + + setIsLoading(true) + setError("") + + try { + const commitsData = await mockFetchCommits("https://github.com/gittuf/gittuf") + setCommits(commitsData) + setSelectedCommit(null) + setCompareCommits({ base: null, compare: null }) + setJsonData(null) + setCompareData({ base: null, compare: null }) + setSelectedCommits([]) + setActiveTab("commits") + setCurrentStep(2) + } catch (err) { + setError("Failed to load demo data. Please try again.") + } finally { + setIsLoading(false) + } + } + + const handleRepositorySelect = async (repoInfo: RepositoryInfo) => { + setCurrentRepository(repoInfo) + setCurrentStep(1) + setIsLoading(true) + setError("") + + try { + await repositoryHandler.setRepository(repoInfo) + const commitsData = await repositoryHandler.fetchCommits() + setCommits(commitsData) + setSelectedCommit(null) + setCompareCommits({ base: null, compare: null }) + setJsonData(null) + setCompareData({ base: null, compare: null }) + setSelectedCommits([]) + setActiveTab("commits") + setCurrentStep(2) + setShowRepositorySelector(false) + } catch (err) { + setError(`Failed to connect to repository: ${err instanceof Error ? err.message : "Unknown error"}`) + setCurrentStep(0) + } finally { + setIsLoading(false) + } + } + + const handleRepositoryRefresh = async () => { + if (!currentRepository) return + + setIsLoading(true) + setError("") + + try { + const commitsData = await repositoryHandler.fetchCommits() + setCommits(commitsData) + } catch (err) { + setError(`Failed to refresh repository data: ${err instanceof Error ? err.message : "Unknown error"}`) + } finally { + setIsLoading(false) + } + } + + const handleRepoSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!repoUrl.trim()) { + setError("Please enter a GitHub repository URL") + return + } + + setCurrentStep(1) + setIsLoading(true) + setError("") + + try { + const commitsData = await mockFetchCommits(repoUrl) + setCommits(commitsData) + setSelectedCommit(null) + setCompareCommits({ base: null, compare: null }) + setJsonData(null) + setCompareData({ base: null, compare: null }) + setSelectedCommits([]) + setActiveTab("commits") + setCurrentStep(2) + } catch (err) { + setError("Failed to fetch repository data. Please check the URL and try again.") + setCurrentStep(0) + } finally { + setIsLoading(false) + } + } + + const handleCommitSelect = async (commit: Commit) => { + setSelectedCommit(commit) + setIsLoading(true) + setActiveTab("visualization") + setCurrentStep(3) + setError("") + + try { + // Use the mock API directly with proper fallback URL + const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" + const metadata = await mockFetchMetadata(fallbackUrl, commit.hash, selectedFile) + setJsonData(metadata) + } catch (err) { + console.error("Failed to fetch metadata:", err) + setError( + `Failed to fetch ${selectedFile} for this commit: ${err instanceof Error ? err.message : "Unknown error"}`, + ) + } finally { + setIsLoading(false) + } + } + + const handleCompareSelect = async (base: Commit, compare: Commit) => { + setCompareCommits({ base, compare }) + setIsLoading(true) + setActiveTab("compare") + setCurrentStep(3) + setError("") + + try { + const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" + const [baseData, compareData] = await Promise.all([ + mockFetchMetadata(fallbackUrl, base.hash, selectedFile), + mockFetchMetadata(fallbackUrl, compare.hash, selectedFile), + ]) + + setCompareData({ base: baseData, compare: compareData }) + } catch (err) { + console.error("Failed to fetch comparison data:", err) + setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`) + } finally { + setIsLoading(false) + } + } + + const handleFileChange = async (file: string) => { + setSelectedFile(file) + + if (selectedCommit && (activeTab === "visualization" || activeTab === "tree")) { + setIsLoading(true) + setError("") + try { + const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" + const metadata = await mockFetchMetadata(fallbackUrl, selectedCommit.hash, file) + setJsonData(metadata) + } catch (err) { + console.error("Failed to fetch file data:", err) + setError(`Failed to fetch ${file} for this commit: ${err instanceof Error ? err.message : "Unknown error"}`) + } finally { + setIsLoading(false) + } + } + + if (compareCommits.base && compareCommits.compare && activeTab === "compare") { + setIsLoading(true) + setError("") + try { + const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" + const [baseData, compareData] = await Promise.all([ + mockFetchMetadata(fallbackUrl, compareCommits.base.hash, file), + mockFetchMetadata(fallbackUrl, compareCommits.compare.hash, file), + ]) + + setCompareData({ base: baseData, compare: compareData }) + } catch (err) { + console.error("Failed to fetch file comparison data:", err) + setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`) + } finally { + setIsLoading(false) + } + } + } + + const handleCommitRangeSelect = (commits: Commit[]) => { + setSelectedCommits(commits) + setActiveTab("analysis") + setCurrentStep(4) + } + + useEffect(() => { + if (activeTab === "analysis" && selectedCommits.length > 0) { + const loadAnalysisData = async () => { + setIsLoading(true) + setError("") + + try { + const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" + const dataPromises = selectedCommits.map((commit) => + mockFetchMetadata(fallbackUrl, commit.hash, selectedFile), + ) + const results = await Promise.all(dataPromises) + const commitsWithData = selectedCommits.map((commit, index) => ({ + ...commit, + data: results[index], + })) + + setSelectedCommits(commitsWithData) + } catch (err) { + console.error("Failed to load analysis data:", err) + setError("Failed to load analysis data for selected commits. Please try again.") + } finally { + setIsLoading(false) + } + } + + loadAnalysisData() + } + }, [activeTab, selectedCommits.length, selectedFile, currentRepository, repoUrl]) + + const hiddenCount = globalViewMode === "normal" && jsonData ? getHiddenFieldsCount(jsonData) : 0 + + return { + repoUrl, + setRepoUrl, + isLoading, + commits, + selectedCommit, + compareCommits, + jsonData, + compareData, + activeTab, + setActiveTab, + error, + selectedFile, + selectedCommits, + globalViewMode, + setGlobalViewMode, + currentStep, + currentRepository, + showRepositorySelector, + setShowRepositorySelector, + steps, + handleTryDemo, + handleRepositorySelect, + handleRepositoryRefresh, + handleRepoSubmit, + handleCommitSelect, + handleCompareSelect, + handleFileChange, + handleCommitRangeSelect, + hiddenCount, + } +} diff --git a/frontend/hooks/use-gittuf-simulator.ts b/frontend/hooks/use-gittuf-simulator.ts new file mode 100644 index 0000000..731d9e5 --- /dev/null +++ b/frontend/hooks/use-gittuf-simulator.ts @@ -0,0 +1,436 @@ +import { useState, useCallback, useMemo, useEffect } from "react" +import type { SimulatorResponse, ApprovalRequirement, EligibleSigner, CustomConfig, CustomPerson, CustomRole } from "@/lib/simulator-types" +import fixtureAllowed from "@/fixtures/fixture-allowed.json" +import fixtureBlocked from "@/fixtures/fixture-blocked.json" + +const DEFAULT_CONFIG: CustomConfig = { + people: [ + { + id: "alice", + display_name: "Alice Johnson", + keyid: "ssh-rsa-abc123", + key_type: "ssh", + has_signed: false, + }, + { + id: "bob", + display_name: "Bob Smith", + keyid: "gpg-def456", + key_type: "gpg", + has_signed: false, + }, + { + id: "charlie", + display_name: "Charlie Brown", + keyid: "sigstore-ghi789", + key_type: "sigstore", + has_signed: false, + }, + ], + roles: [ + { + id: "maintainer", + display_name: "Maintainer", + threshold: 2, + file_globs: ["src/**", "docs/**"], + assigned_people: ["alice", "bob", "charlie"], + }, + { + id: "reviewer", + display_name: "Reviewer", + threshold: 1, + file_globs: ["tests/**"], + assigned_people: ["alice", "bob"], + }, + ], +} + +export function useGittufSimulator() { + // Core UI State + const [darkMode, setDarkMode] = useState(false) + const [showStory, setShowStory] = useState(false) + const [showSimulator, setShowSimulator] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + + // Simulator State + const [currentFixture, setCurrentFixture] = useState<"blocked" | "allowed" | "custom">("blocked") + const [whatIfMode, setWhatIfMode] = useState(false) + const [simulatedSigners, setSimulatedSigners] = useState>(new Set()) + + // UI Layout State + const [expandedGraph, setExpandedGraph] = useState(false) + const [showControls, setShowControls] = useState(true) + const [showDetails, setShowDetails] = useState(false) + + // Custom Config State + const [showCustomConfig, setShowCustomConfig] = useState(false) + const [customConfig, setCustomConfig] = useState(DEFAULT_CONFIG) + + // Form States + const [newPersonForm, setNewPersonForm] = useState({ + id: "", + display_name: "", + keyid: "", + key_type: "ssh" as const, + has_signed: false, + }) + + const [newRoleForm, setNewRoleForm] = useState({ + id: "", + display_name: "", + threshold: 1, + file_globs: ["src/**"], + assigned_people: [] as string[], + }) + + const [editingPerson, setEditingPerson] = useState(null) + const [editingRole, setEditingRole] = useState(null) + + // Generate custom fixture from config + const customFixture = useMemo((): SimulatorResponse => { + const approval_requirements: ApprovalRequirement[] = customConfig.roles.map((role) => { + const eligible_signers: EligibleSigner[] = role.assigned_people + .map((personId) => { + const person = customConfig.people.find((p) => p.id === personId) + return person + ? { + id: person.id, + display_name: person.display_name, + keyid: person.keyid, + key_type: person.key_type, + } + : null + }) + .filter(Boolean) as EligibleSigner[] + + const satisfiers = eligible_signers + .filter((signer) => { + const person = customConfig.people.find((p) => p.id === signer.id) + return person?.has_signed + }) + .map((signer) => ({ + who: signer.id, + keyid: signer.keyid, + signature_valid: true, + signature_time: new Date().toISOString(), + signature_verification_reason: `Valid ${signer.key_type.toUpperCase()} signature`, + })) + + return { + role: role.id, + role_metadata_version: 1, + threshold: role.threshold, + file_globs: role.file_globs, + eligible_signers, + satisfied: satisfiers.length, + satisfiers, + } + }) + + const allRequirementsMet = approval_requirements.every((req) => req.satisfied >= req.threshold) + + const visualization_hint = { + nodes: [ + ...customConfig.roles.map((role) => ({ + id: role.id, + type: "role" as const, + label: `${role.display_name} (${ + approval_requirements.find((req) => req.role === role.id)?.satisfied || 0 + }/${role.threshold})`, + meta: { + satisfied: (approval_requirements.find((req) => req.role === role.id)?.satisfied || 0) >= role.threshold, + threshold: role.threshold, + current: approval_requirements.find((req) => req.role === role.id)?.satisfied || 0, + }, + })), + ...customConfig.people.map((person) => ({ + id: person.id, + type: "person" as const, + label: person.display_name, + meta: { + signed: person.has_signed, + keyType: person.key_type, + }, + })), + ], + edges: customConfig.roles.flatMap((role) => + role.assigned_people.map((personId) => { + const person = customConfig.people.find((p) => p.id === personId) + return { + from: personId, + to: role.id, + label: person?.has_signed ? "Approved" : "Eligible", + satisfied: person?.has_signed || false, + } + }), + ), + } + + return { + result: allRequirementsMet ? "allowed" : "blocked", + reasons: allRequirementsMet + ? ["All approval requirements satisfied"] + : approval_requirements + .filter((req) => req.satisfied < req.threshold) + .map((req) => `Missing ${req.threshold - req.satisfied} ${req.role} approval(s)`), + approval_requirements, + signature_verification: approval_requirements.flatMap((req) => + req.satisfiers.map((satisfier, index) => ({ + signature_id: `sig-${req.role}-${index + 1}`, + keyid: satisfier.keyid, + sig_ok: satisfier.signature_valid, + verified_at: satisfier.signature_time, + reason: satisfier.signature_verification_reason, + })), + ), + attestation_matches: [ + { + attestation_id: "att-custom-001", + rsl_index: 42, + maps_to_proposal: true, + from_revision_ok: true, + target_tree_hash_match: true, + signature_valid: true, + }, + ], + visualization_hint, + } + }, [customConfig]) + + // Get current fixture + const getCurrentFixture = useCallback((): SimulatorResponse => { + switch (currentFixture) { + case "allowed": + return fixtureAllowed as SimulatorResponse + case "custom": + return customFixture + default: + return fixtureBlocked as SimulatorResponse + } + }, [currentFixture, customFixture]) + + const fixture = getCurrentFixture() + + // Calculate what-if result + const displayResult = useMemo((): SimulatorResponse => { + if (!whatIfMode || simulatedSigners.size === 0) return fixture + + const whatIfResult = JSON.parse(JSON.stringify(fixture)) as SimulatorResponse + + whatIfResult.approval_requirements = whatIfResult.approval_requirements.map((req) => { + const additionalSatisfiers = req.eligible_signers + .filter((signer) => simulatedSigners.has(signer.id) && !req.satisfiers.some((s) => s.who === signer.id)) + .map((signer) => ({ + who: signer.id, + keyid: signer.keyid, + signature_valid: true, + signature_time: new Date().toISOString(), + signature_verification_reason: "Simulated signature", + })) + + return { + ...req, + satisfied: req.satisfied + additionalSatisfiers.length, + satisfiers: [...req.satisfiers, ...additionalSatisfiers], + } + }) + + const allRequirementsMet = whatIfResult.approval_requirements.every((req) => req.satisfied >= req.threshold) + whatIfResult.result = allRequirementsMet ? "allowed" : "blocked" + + if (allRequirementsMet && fixture.result === "blocked") { + whatIfResult.reasons = ["All approval requirements satisfied (with simulated signatures)"] + } + + return whatIfResult + }, [whatIfMode, simulatedSigners, fixture]) + + // Event Handlers + const handleRunSimulation = useCallback(async () => { + setIsProcessing(true) + setShowSimulator(true) + await new Promise((resolve) => setTimeout(resolve, 1000)) + setIsProcessing(false) + }, []) + + const handleSimulatedSignerToggle = useCallback((signerId: string, checked: boolean) => { + setSimulatedSigners((prev) => { + const newSet = new Set(prev) + if (checked) { + newSet.add(signerId) + } else { + newSet.delete(signerId) + } + return newSet + }) + }, []) + + const handleExportJson = useCallback(() => { + const dataStr = JSON.stringify(displayResult, null, 2) + const dataBlob = new Blob([dataStr], { type: "application/json" }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement("a") + link.href = url + link.download = `gittuf-simulation-${Date.now()}.json` + link.click() + URL.revokeObjectURL(url) + }, [displayResult]) + + const addPerson = useCallback(() => { + if (!newPersonForm.id || !newPersonForm.display_name) return + + const newPerson = { + ...newPersonForm, + keyid: newPersonForm.keyid || `${newPersonForm.key_type}-${Date.now()}`, + } + + setCustomConfig((prev) => ({ + ...prev, + people: [...prev.people, newPerson], + })) + + setNewPersonForm({ + id: "", + display_name: "", + keyid: "", + key_type: "ssh", + has_signed: false, + }) + }, [newPersonForm]) + + const addRole = useCallback(() => { + if (!newRoleForm.id || !newRoleForm.display_name) return + + setCustomConfig((prev) => ({ + ...prev, + roles: [...prev.roles, { ...newRoleForm }], + })) + + setNewRoleForm({ + id: "", + display_name: "", + threshold: 1, + file_globs: ["src/**"], + assigned_people: [], + }) + }, [newRoleForm]) + + const deletePerson = useCallback((id: string) => { + setCustomConfig((prev) => ({ + ...prev, + people: prev.people.filter((p) => p.id !== id), + roles: prev.roles.map((role) => ({ + ...role, + assigned_people: role.assigned_people.filter((pid) => pid !== id), + })), + })) + }, []) + + const deleteRole = useCallback((id: string) => { + setCustomConfig((prev) => ({ + ...prev, + roles: prev.roles.filter((r) => r.id !== id), + })) + }, []) + + const updatePerson = useCallback((person: CustomPerson) => { + setCustomConfig((prev) => ({ + ...prev, + people: prev.people.map((p) => (p.id === person.id ? person : p)), + })) + setEditingPerson(null) + }, []) + + const updateRole = useCallback((role: CustomRole) => { + setCustomConfig((prev) => ({ + ...prev, + roles: prev.roles.map((r) => (r.id === role.id ? role : r)), + })) + setEditingRole(null) + }, []) + + const togglePersonSigned = useCallback((personId: string) => { + setCustomConfig((prev) => ({ + ...prev, + people: prev.people.map((p) => (p.id === personId ? { ...p, has_signed: !p.has_signed } : p)), + })) + }, []) + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!e || !e.key || typeof e.key !== "string") return + if (e.ctrlKey || e.metaKey) return + + try { + const key = e.key.toLowerCase() + + switch (key) { + case "r": + e.preventDefault() + handleRunSimulation() + break + case "w": + e.preventDefault() + setWhatIfMode(!whatIfMode) + break + case "s": + e.preventDefault() + setShowStory(true) + break + case "e": + e.preventDefault() + handleExportJson() + break + case "f": + e.preventDefault() + setExpandedGraph(!expandedGraph) + break + case "c": + e.preventDefault() + setShowCustomConfig(!showCustomConfig) + break + } + } catch (error) { + console.warn("Keyboard shortcut error:", error) + } + } + + // Only attach listener if no modal is open (simplified check) + // In a real app we might want more robust context + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleRunSimulation, handleExportJson, whatIfMode, expandedGraph, showCustomConfig, setShowStory, setWhatIfMode, setExpandedGraph, setShowCustomConfig]) + + return { + darkMode, setDarkMode, + showStory, setShowStory, + showSimulator, setShowSimulator, + isProcessing, + currentFixture, setCurrentFixture, + whatIfMode, setWhatIfMode, + simulatedSigners, + expandedGraph, setExpandedGraph, + showControls, setShowControls, + showDetails, setShowDetails, + showCustomConfig, setShowCustomConfig, + customConfig, + newPersonForm, setNewPersonForm, + newRoleForm, setNewRoleForm, + editingPerson, setEditingPerson, + editingRole, setEditingRole, + fixture, + displayResult, + handleRunSimulation, + handleSimulatedSignerToggle, + handleExportJson, + addPerson, + addRole, + deletePerson, + deleteRole, + updatePerson, + updateRole, + togglePersonSigned, + customFixture // Exporting just in case, though handled internally + } +} diff --git a/frontend/json-diff.ts b/frontend/json-diff.ts deleted file mode 100644 index 61a6b78..0000000 --- a/frontend/json-diff.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Function to compare two JSON objects and identify differences -export function compareJsonObjects(oldObj: any, newObj: any) { - try { - const result: Record = {} - - // Handle null or undefined objects - if (!oldObj && !newObj) return null - if (!oldObj) return { status: "added", value: newObj } - if (!newObj) return { status: "removed", value: oldObj } - - // Get all keys from both objects - const oldKeys = typeof oldObj === "object" && oldObj !== null ? Object.keys(oldObj) : [] - const newKeys = typeof newObj === "object" && newObj !== null ? Object.keys(newObj) : [] - const allKeys = new Set([...oldKeys, ...newKeys]) - - allKeys.forEach((key) => { - const oldValue = oldObj?.[key] - const newValue = newObj?.[key] - - // Key exists in both objects - if (key in oldObj && key in newObj) { - // Both values are objects - recursively compare - if (typeof oldValue === "object" && oldValue !== null && typeof newValue === "object" && newValue !== null) { - const childDiff = compareJsonObjects(oldValue, newValue) - - // If there are differences in the child objects - if (childDiff && Object.keys(childDiff).length > 0) { - result[key] = { - status: "unchanged", - value: newValue, - children: childDiff, - } - } else { - result[key] = { status: "unchanged", value: newValue } - } - } - // Values are different - else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { - result[key] = { - status: "changed", - oldValue: oldValue, - value: newValue, - } - } - // Values are the same - else { - result[key] = { status: "unchanged", value: newValue } - } - } - // Key only exists in the new object - else if (key in newObj) { - result[key] = { status: "added", value: newValue } - } - // Key only exists in the old object - else { - result[key] = { status: "removed", value: oldValue } - } - }) - - return result - } catch (error) { - console.error("Error comparing JSON objects:", error) - throw new Error("Failed to compare JSON objects. Please check the data format.") - } -} - -// Function to count the number of changes in a diff object -export function countChanges(diff: Record) { - let added = 0 - let removed = 0 - let changed = 0 - let unchanged = 0 - - const countRecursive = (obj: Record) => { - if (!obj) return - - Object.values(obj).forEach((value: any) => { - if (value.status === "added") { - added++ - } else if (value.status === "removed") { - removed++ - } else if (value.status === "changed") { - changed++ - } else if (value.status === "unchanged") { - unchanged++ - } - - if (value.children) { - countRecursive(value.children) - } - }) - } - - countRecursive(diff) - return { added, removed, changed, unchanged } -} diff --git a/frontend/json-utils.ts b/frontend/json-utils.ts deleted file mode 100644 index 4a4334c..0000000 --- a/frontend/json-utils.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Format JSON value for display in tooltips - */ -export function formatJsonValue(value: any): string { - if (value === undefined) return "undefined" - if (value === null) return "null" - - if (typeof value === "object") { - try { - return JSON.stringify(value, null, 2) - } catch (error) { - return "[Complex Object]" - } - } - - return String(value) -} - -/** - * Get a human-readable description of a node type - */ -export function getNodeTypeDescription(type: string): string { - switch (type) { - case "rootNode": - return "Root Object" - case "jsonNode": - return "Object" - case "arrayNode": - return "Array" - case "valueNode": - return "Value" - case "diffRoot": - return "Root Object" - case "diffAdded": - return "Added" - case "diffRemoved": - return "Removed" - case "diffChanged": - return "Changed" - case "diffUnchanged": - return "Unchanged" - default: - return type - } -} - -/** - * Get a description of the security implications of a node - */ -export function getSecurityImplication(path: string, value: any): string | null { - // Check for security-related paths - if (path.includes("principals") || path.includes("keyval")) { - return "Contains security principal information" - } - - if (path.includes("threshold")) { - return `Requires ${value} signature(s) for validation` - } - - if (path.includes("expires")) { - const expiryDate = new Date(value) - const now = new Date() - if (expiryDate < now) { - return "EXPIRED! This metadata has passed its expiration date" - } - - // Calculate days until expiry - const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) - if (daysUntilExpiry < 30) { - return `Expiring soon! Only ${daysUntilExpiry} days remaining` - } - - return `Valid for ${daysUntilExpiry} more days` - } - - if (path.includes("trusted") && value === true) { - return "Trusted security component" - } - - if (path.includes("rules") || path.includes("pattern") || path.includes("action")) { - return "Security policy rule component" - } - - return null -} - -/** - * Determine if a node contains sensitive security information - */ -export function isSensitiveNode(path: string): boolean { - const sensitivePatterns = ["private", "secret", "password", "token", "key", "keyval.private", "auth"] - - return sensitivePatterns.some((pattern) => path.toLowerCase().includes(pattern)) -} diff --git a/frontend/lib/json-diff.ts b/frontend/lib/json-diff.ts index 61a6b78..b69d714 100644 --- a/frontend/lib/json-diff.ts +++ b/frontend/lib/json-diff.ts @@ -1,37 +1,69 @@ +import { JsonValue, JsonObject } from "./types" + +export interface DiffEntry { + status: "added" | "removed" | "changed" | "unchanged" + value?: JsonValue + oldValue?: JsonValue + children?: Record +} + +export type DiffResult = Record + // Function to compare two JSON objects and identify differences -export function compareJsonObjects(oldObj: any, newObj: any) { +export function compareJsonObjects( + oldObj: JsonObject | null | undefined, + newObj: JsonObject | null | undefined, +): DiffResult | DiffEntry | null { try { - const result: Record = {} + const result: DiffResult = {} // Handle null or undefined objects if (!oldObj && !newObj) return null - if (!oldObj) return { status: "added", value: newObj } - if (!newObj) return { status: "removed", value: oldObj } + if (!oldObj) return { status: "added", value: newObj as JsonValue } + if (!newObj) return { status: "removed", value: oldObj as JsonValue } // Get all keys from both objects - const oldKeys = typeof oldObj === "object" && oldObj !== null ? Object.keys(oldObj) : [] - const newKeys = typeof newObj === "object" && newObj !== null ? Object.keys(newObj) : [] + const oldKeys = oldObj ? Object.keys(oldObj) : [] + const newKeys = newObj ? Object.keys(newObj) : [] const allKeys = new Set([...oldKeys, ...newKeys]) allKeys.forEach((key) => { - const oldValue = oldObj?.[key] - const newValue = newObj?.[key] + const oldValue = oldObj ? oldObj[key] : undefined + const newValue = newObj ? newObj[key] : undefined // Key exists in both objects - if (key in oldObj && key in newObj) { + if (oldObj && key in oldObj && newObj && key in newObj) { // Both values are objects - recursively compare - if (typeof oldValue === "object" && oldValue !== null && typeof newValue === "object" && newValue !== null) { - const childDiff = compareJsonObjects(oldValue, newValue) + if ( + typeof oldValue === "object" && + oldValue !== null && + !Array.isArray(oldValue) && + typeof newValue === "object" && + newValue !== null && + !Array.isArray(newValue) + ) { + const childDiff = compareJsonObjects(oldValue as JsonObject, newValue as JsonObject) // If there are differences in the child objects - if (childDiff && Object.keys(childDiff).length > 0) { - result[key] = { - status: "unchanged", - value: newValue, - children: childDiff, + if (childDiff) { + // If childDiff is a Record, use it as children + // If it's a single DiffEntry (from null check shortcut), what do we do? + // The recursive call with two objects should return a DiffResult (Record) + // unless one was null, but we checked type===object && !== null. + // So childDiff should be DiffResult here. + + const hasChanges = Object.keys(childDiff).length > 0 + if (hasChanges) { + result[key] = { + status: "unchanged", + value: newValue, + children: childDiff as Record, + } + } else { + result[key] = { status: "unchanged", value: newValue } } } else { - result[key] = { status: "unchanged", value: newValue } + result[key] = { status: "unchanged", value: newValue } } } // Values are different @@ -48,7 +80,7 @@ export function compareJsonObjects(oldObj: any, newObj: any) { } } // Key only exists in the new object - else if (key in newObj) { + else if (newObj && key in newObj) { result[key] = { status: "added", value: newValue } } // Key only exists in the old object @@ -65,16 +97,36 @@ export function compareJsonObjects(oldObj: any, newObj: any) { } // Function to count the number of changes in a diff object -export function countChanges(diff: Record) { +export function countChanges(diff: DiffResult | DiffEntry | null) { let added = 0 let removed = 0 let changed = 0 let unchanged = 0 - const countRecursive = (obj: Record) => { + if (!diff) return { added, removed, changed, unchanged } + + // Handle edge case where diff is a single DiffEntry + if ('status' in diff && typeof diff.status === 'string') { + const entry = diff as DiffEntry; + if (entry.status === 'added') added++; + else if (entry.status === 'removed') removed++; + else if (entry.status === 'changed') changed++; + else if (entry.status === 'unchanged') unchanged++; + + if (entry.children) { + const childrenCounts = countChanges(entry.children); + added += childrenCounts.added; + removed += childrenCounts.removed; + changed += childrenCounts.changed; + unchanged += childrenCounts.unchanged; + } + return { added, removed, changed, unchanged }; + } + + const countRecursive = (obj: Record) => { if (!obj) return - Object.values(obj).forEach((value: any) => { + Object.values(obj).forEach((value: DiffEntry) => { if (value.status === "added") { added++ } else if (value.status === "removed") { @@ -91,6 +143,6 @@ export function countChanges(diff: Record) { }) } - countRecursive(diff) + countRecursive(diff as Record) return { added, removed, changed, unchanged } } diff --git a/frontend/lib/simulator-types.ts b/frontend/lib/simulator-types.ts index aab6503..7edab03 100644 --- a/frontend/lib/simulator-types.ts +++ b/frontend/lib/simulator-types.ts @@ -76,3 +76,24 @@ export interface ProposedChange { commit?: string pr_json?: string } + +export interface CustomPerson { + id: string + display_name: string + keyid: string + key_type: "ssh" | "gpg" | "sigstore" + has_signed: boolean +} + +export interface CustomRole { + id: string + display_name: string + threshold: number + file_globs: string[] + assigned_people: string[] +} + +export interface CustomConfig { + people: CustomPerson[] + roles: CustomRole[] +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 8e4a189..cce7b2e 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -3,6 +3,7 @@ export interface Commit { message: string author: string date: string + data?: JsonObject } export interface MetadataRequest { @@ -14,3 +15,21 @@ export interface MetadataRequest { export interface CommitsRequest { url: string } + +export type JsonValue = string | number | boolean | null | JsonObject | JsonArray +export type JsonArray = JsonValue[] +export interface JsonObject { + [key: string]: JsonValue +} + +export interface SecurityEvent { + commit: string + date: string + author: string + message: string + type: "security_enhancement" | "security_degradation" | "policy_change" | "principal_change" | "expiration_change" + severity: "critical" | "high" | "medium" | "low" + description: string + details: string + impact: string +} diff --git a/frontend/mock-api.ts b/frontend/mock-api.ts deleted file mode 100644 index f71da38..0000000 --- a/frontend/mock-api.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Commit } from "./types" - -export async function mockFetchCommits(url: string): Promise { - const response = await fetch("http://localhost:5000/commits", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ url }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to fetch commits"); - } - - const data = await response.json(); - return data; -} - -export async function mockFetchMetadata( - url: string, - commit: string, - file: string -): Promise { - const response = await fetch("http://localhost:5000/metadata", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ url, commit, file }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to fetch metadata"); - } - - const data = await response.json(); - return data; -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e082e97..dcb9c16 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "my-v0-project", "version": "0.1.0", "dependencies": { - "@emotion/is-prop-valid": "*", + "@emotion/is-prop-valid": "latest", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", @@ -38,26 +38,26 @@ "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "autoprefixer": "^10.4.22", - "chart.js": "*", + "chart.js": "latest", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", "cytoscape": "^3.33.1", - "dagre": "*", + "dagre": "latest", "date-fns": "4.1.0", "embla-carousel-react": "8.6.0", - "framer-motion": "*", + "framer-motion": "latest", "input-otp": "1.4.2", "lucide-react": "^0.556.0", "next": "16.0.7", "next-themes": "^0.4.4", "react": "^19", - "react-chartjs-2": "*", + "react-chartjs-2": "latest", "react-day-picker": "9.12.0", "react-dom": "^19", "react-hook-form": "^7.68.0", "react-resizable-panels": "^3.0.6", - "reactflow": "*", + "reactflow": "latest", "recharts": "3.5.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -67,6 +67,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.17", + "@types/dagre": "^0.7.53", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", @@ -109,7 +110,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -3164,6 +3164,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.53", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", + "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3186,7 +3193,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3197,7 +3203,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3285,7 +3290,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3325,7 +3329,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3501,7 +3504,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3644,8 +3646,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -3778,7 +3779,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -4262,7 +4262,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4283,7 +4282,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4324,7 +4322,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4337,7 +4334,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4361,7 +4357,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4511,8 +4506,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4647,8 +4641,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", diff --git a/frontend/package.json b/frontend/package.json index 0425a05..093032c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.17", + "@types/dagre": "^0.7.53", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4b2dc7b..48d6d82 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "target": "ES6", "skipLibCheck": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,9 +23,19 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/frontend/types.ts b/frontend/types.ts deleted file mode 100644 index 8e4a189..0000000 --- a/frontend/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface Commit { - hash: string - message: string - author: string - date: string -} - -export interface MetadataRequest { - url: string - commit: string - file: string -} - -export interface CommitsRequest { - url: string -} diff --git a/frontend/utils.ts b/frontend/utils.ts deleted file mode 100644 index bd0c391..0000000 --- a/frontend/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/frontend/view-mode-utils.ts b/frontend/view-mode-utils.ts deleted file mode 100644 index 13b6191..0000000 --- a/frontend/view-mode-utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Utility functions for determining what to show in Normal vs Advanced mode - -export type ViewMode = "normal" | "advanced" - -export interface NodeImportance { - level: "critical" | "important" | "normal" | "hidden" - reason?: string -} - -// Define which fields are important for normal mode -export const getNodeImportance = (key: string, value: any, level: number, parentKey?: string): NodeImportance => { - const keyLower = key.toLowerCase() - - // Always show root level containers - if (level === 0) return { level: "critical", reason: "Root level" } - - // CRITICAL - Always show these security essentials - if (keyLower.includes("expire")) return { level: "critical", reason: "Security expiration" } - if (keyLower === "trusted") return { level: "critical", reason: "Trust status" } - if (keyLower === "threshold") return { level: "critical", reason: "Security threshold" } - - // IMPORTANT - Key security containers and policies - if (keyLower === "principals") return { level: "important", reason: "Security principals" } - if (keyLower === "roles") return { level: "important", reason: "Access control roles" } - if (keyLower === "rules") return { level: "important", reason: "Security policies" } - if (keyLower === "githubapps") return { level: "important", reason: "GitHub integrations" } - if (keyLower === "requirements") return { level: "important", reason: "Policy requirements" } - if (keyLower === "authorizedprincipals") return { level: "important", reason: "Authorized users" } - if (keyLower === "principalids") return { level: "important", reason: "Principal references" } - - // IMPORTANT - Policy definition fields - if (keyLower === "pattern") return { level: "important", reason: "Rule pattern" } - if (keyLower === "action") return { level: "important", reason: "Rule action" } - - // IMPORTANT - Identity fields - if (keyLower === "identity") return { level: "important", reason: "User identity" } - if (keyLower === "issuer") return { level: "important", reason: "Identity provider" } - if (keyLower === "keytype") return { level: "important", reason: "Key algorithm" } - - // HIDDEN - Technical implementation details that clutter the view - if (keyLower === "type") return { level: "hidden", reason: "Technical metadata type" } - if (keyLower === "schemaversion") return { level: "hidden", reason: "Technical schema version" } - if (keyLower === "keyid_hash_algorithms") return { level: "hidden", reason: "Technical key details" } - if (keyLower === "keyid") return { level: "hidden", reason: "Technical key identifier" } - if (keyLower === "scheme") return { level: "hidden", reason: "Technical signature scheme" } - if (keyLower === "public") return { level: "hidden", reason: "Raw key material" } - if (keyLower === "keyval") return { level: "hidden", reason: "Raw key data" } - - // NORMAL - Everything else - return { level: "normal", reason: "Standard field" } -} - -export const shouldShowInNormalMode = (key: string, value: any, level: number, parentKey?: string): boolean => { - const importance = getNodeImportance(key, value, level, parentKey) - return importance.level === "critical" || importance.level === "important" -} - -export const getHiddenFieldsCount = (data: any, level = 0, parentKey?: string): number => { - if (!data || typeof data !== "object") return 0 - - let hiddenCount = 0 - const keys = Array.isArray(data) ? data.map((_, i) => i.toString()) : Object.keys(data) - - for (const key of keys) { - const importance = getNodeImportance(key, data[key], level, parentKey) - if (importance.level === "hidden" || importance.level === "normal") { - hiddenCount++ - } - - // Recursively count hidden fields in nested objects - if (typeof data[key] === "object" && data[key] !== null) { - hiddenCount += getHiddenFieldsCount(data[key], level + 1, key) - } - } - - return hiddenCount -}