Files
stack/apps/web/src/components/search/SearchResults.tsx
Jason Woltje 6e63508f97 fix(#M5-QA): address security findings from code review
Fixes 2 important-level security issues identified in M5 QA:

1. XSS Protection (SearchResults.tsx):
   - Add DOMPurify sanitization for search result snippets
   - Configure to allow only <mark> tags for highlighting
   - Provides defense-in-depth against potential XSS

2. Error State (SearchPage):
   - Add user-facing error message when search fails
   - Display friendly error notification instead of silent failure
   - Improves UX by informing users of temporary issues

Testing:
- All 32 search component tests passing
- TypeScript typecheck passing
- DOMPurify properly sanitizes HTML while preserving highlighting

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 16:50:38 -06:00

181 lines
5.2 KiB
TypeScript

"use client";
import Link from "next/link";
import DOMPurify from "dompurify";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { SearchFilters } from "./SearchFilters";
import type { SearchResult, SearchFiltersState, Tag } from "./types";
interface SearchResultsProps {
results: SearchResult[];
query: string;
totalResults: number;
isLoading: boolean;
selectedTags?: string[] | undefined;
selectedStatus?: string | undefined;
availableTags?: Tag[] | undefined;
onFilterChange: (filters: SearchFiltersState) => void;
}
/**
* Status indicator icons (PDA-friendly)
*/
const STATUS_ICONS: Record<string, string> = {
ACTIVE: "🟢",
SCHEDULED: "🔵",
PAUSED: "⏸️",
DORMANT: "💤",
ARCHIVED: "⚪",
};
/**
* Individual search result item with highlighted snippet
*/
function SearchResultItem({ result }: { result: SearchResult }): React.JSX.Element {
const statusIcon = STATUS_ICONS[result.status] ?? "⚪";
return (
<Link href={`/knowledge/${result.slug}`}>
<Card className="p-4 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="space-y-2">
{/* Title and status */}
<div className="flex items-start justify-between gap-2">
<h3 className="text-lg font-semibold text-gray-900 flex-1">{result.title}</h3>
<span className="text-xl" aria-label={result.status}>
{statusIcon}
</span>
</div>
{/* Highlighted snippet */}
{result.headline && (
<div
className="text-sm text-gray-600 line-clamp-2"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(result.headline, {
ALLOWED_TAGS: ["mark"],
ALLOWED_ATTR: [],
}),
}}
/>
)}
{/* Tags */}
{result.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{result.tags.map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="text-xs"
style={
tag.color
? {
backgroundColor: `${tag.color}20`,
color: tag.color,
}
: undefined
}
>
{tag.name}
</Badge>
))}
</div>
)}
{/* Metadata */}
<div className="text-xs text-gray-500">
Updated {new Date(result.updatedAt).toLocaleDateString()}
</div>
</div>
</Card>
</Link>
);
}
/**
* Loading state
*/
function LoadingState(): React.JSX.Element {
return (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-gray-200 border-t-blue-600" />
<p className="text-gray-600">Searching...</p>
</div>
);
}
/**
* No results state with PDA-friendly suggestions
*/
function NoResultsState({ query }: { query: string }): React.JSX.Element {
return (
<div className="flex flex-col items-center justify-center py-12 space-y-4 text-center">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-xl font-semibold text-gray-900">No results found</h3>
<p className="text-gray-600 max-w-md">We couldn't find any entries matching "{query}"</p>
<div className="mt-6 space-y-2 text-sm text-gray-600">
<p className="font-medium">Consider these options:</p>
<ul className="space-y-1">
<li>Try different keywords</li>
<li>Check your spelling</li>
<li>Use more general terms</li>
<li>Remove filters to broaden your search</li>
</ul>
</div>
</div>
);
}
/**
* Main search results component with filter sidebar
*/
export function SearchResults({
results,
query,
totalResults,
isLoading,
selectedTags = [],
selectedStatus,
availableTags = [],
onFilterChange,
}: SearchResultsProps): React.JSX.Element {
return (
<div className="flex min-h-screen">
{/* Main results area */}
<div className="flex-1 p-6">
{/* Results header */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Search Results</h2>
{!isLoading && (
<p className="text-gray-600">
{totalResults} {totalResults === 1 ? "result" : "results"} for "{query}"
</p>
)}
</div>
{/* Results content */}
{isLoading ? (
<LoadingState />
) : results.length === 0 ? (
<NoResultsState query={query} />
) : (
<div className="space-y-4">
{results.map((result) => (
<SearchResultItem key={result.id} result={result} />
))}
</div>
)}
</div>
{/* Filter sidebar */}
<SearchFilters
availableTags={availableTags}
selectedTags={selectedTags}
selectedStatus={selectedStatus as never}
onFilterChange={onFilterChange}
/>
</div>
);
}