feat(22-03): add tooltip primitive + inert UrlList component (D-09)

- npx shadcn add tooltip generates components/ui/tooltip.tsx (official
  registry, no npm dependency added)
- components/phishing/url-list.tsx renders extracted URLs as inert
  <code> text with copy-to-clipboard only — no <a>/href, no <Link>,
  no navigating onClick per D-09
This commit is contained in:
lorentz 2026-07-16 14:27:11 -04:00
parent 4b5e31c068
commit 87008a5da6
2 changed files with 107 additions and 0 deletions

View file

@ -0,0 +1,50 @@
'use client';
/* UrlList inert, copy-only rendering of URLs extracted from a reported
* phishing/spam email (REVIEW-03, D-09).
*
* D-09 is a deliberately STRICTER-than-sanitization posture: extracted URLs
* are attacker-controlled content and must never be rendered as anything
* clickable. There is no anchor tag with a navigation attribute, no
* `<Link>`, and no navigating `onClick` anywhere in this file the only
* affordance is copy-to-clipboard via an icon-only button. Do not "improve"
* this by adding a real link. */
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
interface UrlListProps {
urls: string[];
}
export function UrlList({ urls }: UrlListProps) {
if (urls.length === 0) {
return (
<p className="text-sm text-muted-foreground">No URLs found in this message.</p>
);
}
async function handleCopy(url: string) {
await navigator.clipboard.writeText(url);
toast.success('Copied');
}
return (
<ul className="flex flex-col gap-2">
{urls.map((url, index) => (
<li key={`${url}-${index}`} className="flex items-center gap-2">
<code className="font-mono text-xs truncate flex-1">{url}</code>
<Button
size="icon"
variant="ghost"
aria-label="Copy URL"
onClick={() => handleCopy(url)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
);
}

57
components/ui/tooltip.tsx Normal file
View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }