feat: enhance RDAP component rendering with comprehensive data display

Major improvements to RDAP card components:
- Add tooltips to action buttons in AbstractCard for better UX
- Implement dedicated section components (EntitiesSection, LinksSection,
  NameserversSection, RemarksSection, SecureDNSSection, VCardDisplay)
- Add conditional rendering for optional fields across all card types
- Enhance Entity and Nameserver cards with full data display
- Add WHOIS server (port43) display to relevant cards
- Improve visual hierarchy with nested entity displays
- Fix autodetection to only run when in autodetect mode
- Add proper null/undefined checks throughout components
This commit is contained in:
2025-10-22 16:19:04 -05:00
parent 48eb1c630b
commit 3ff347b81f
17 changed files with 1393 additions and 206 deletions

View File

@@ -2,7 +2,7 @@ import type { FunctionComponent, ReactNode } from "react";
import React from "react"; import React from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons"; import { Link2Icon, CodeIcon, DownloadIcon, ClipboardIcon } from "@radix-ui/react-icons";
import { Card, Flex, Box, IconButton, Code, ScrollArea } from "@radix-ui/themes"; import { Card, Flex, Box, IconButton, Code, ScrollArea, Tooltip } from "@radix-ui/themes";
type AbstractCardProps = { type AbstractCardProps = {
children?: ReactNode; children?: ReactNode;
@@ -38,72 +38,85 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
</Flex> </Flex>
<Flex gap="2" align="center"> <Flex gap="2" align="center">
{url != undefined && ( {url != undefined && (
<IconButton variant="ghost" size="2" asChild> <Tooltip content="Open in new tab">
<a <IconButton variant="ghost" size="2" asChild>
href={url} <a
target="_blank" href={url}
rel="noreferrer" target="_blank"
aria-label="Open RDAP URL" rel="noreferrer"
> aria-label="Open RDAP URL"
<Link2Icon width="18" height="18" /> >
</a> <Link2Icon width="18" height="18" />
</IconButton> </a>
</IconButton>
</Tooltip>
)} )}
{data != undefined && ( {data != undefined && (
<> <>
<IconButton <Tooltip content="Copy JSON to clipboard">
variant="ghost" <IconButton
size="2" variant="ghost"
onClick={() => { size="2"
navigator.clipboard onClick={() => {
.writeText(JSON.stringify(data, null, 4)) navigator.clipboard
.then( .writeText(JSON.stringify(data, null, 4))
() => { .then(
// Successfully copied to clipboard () => {
}, // Successfully copied to clipboard
(err) => { },
if (err instanceof Error) (err) => {
console.error( if (err instanceof Error)
`Failed to copy to clipboard (${err.toString()}).` console.error(
); `Failed to copy to clipboard (${err.toString()}).`
else );
console.error( else
"Failed to copy to clipboard." console.error(
); "Failed to copy to clipboard."
);
}
);
}}
aria-label="Copy JSON to clipboard"
>
<ClipboardIcon width="18" height="18" />
</IconButton>
</Tooltip>
<Tooltip content="Download JSON">
<IconButton
variant="ghost"
size="2"
onClick={() => {
const file = new Blob(
[JSON.stringify(data, null, 4)],
{
type: "application/json",
} }
); );
}}
aria-label="Copy JSON to clipboard"
>
<ClipboardIcon width="18" height="18" />
</IconButton>
<IconButton
variant="ghost"
size="2"
onClick={() => {
const file = new Blob([JSON.stringify(data, null, 4)], {
type: "application/json",
});
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = URL.createObjectURL(file); anchor.href = URL.createObjectURL(file);
anchor.download = "response.json"; anchor.download = "response.json";
anchor.click(); anchor.click();
}} }}
aria-label="Download JSON" aria-label="Download JSON"
>
<DownloadIcon width="18" height="18" />
</IconButton>
</Tooltip>
<Tooltip
content={showRaw ? "Show formatted view" : "Show raw JSON"}
> >
<DownloadIcon width="18" height="18" /> <IconButton
</IconButton> variant="ghost"
<IconButton size="2"
variant="ghost" onClick={toggleRaw}
size="2" aria-label={
onClick={toggleRaw} showRaw ? "Show formatted view" : "Show raw JSON"
aria-label={ }
showRaw ? "Show formatted view" : "Show raw JSON" >
} <CodeIcon width="18" height="18" />
> </IconButton>
<CodeIcon width="18" height="18" /> </Tooltip>
</IconButton>
</> </>
)} )}
</Flex> </Flex>

View File

@@ -70,11 +70,14 @@ const Index: NextPage = () => {
setTarget(target); setTarget(target);
setTargetType(targetType); setTargetType(targetType);
const detectResult = await getType(target); // Only run autodetection when in autodetect mode (targetType is null)
if (detectResult.isOk) { if (targetType === null) {
setDetectedType(Maybe.just(detectResult.value)); const detectResult = await getType(target);
} else { if (detectResult.isOk) {
setDetectedType(Maybe.nothing()); setDetectedType(Maybe.just(detectResult.value));
} else {
setDetectedType(Maybe.nothing());
}
} }
}} }}
onSubmit={async function (props) { onSubmit={async function (props) {

View File

@@ -6,6 +6,9 @@ import Property from "@/components/Property";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import EntitiesSection from "@/rdap/components/EntitiesSection";
import LinksSection from "@/rdap/components/LinksSection";
import RemarksSection from "@/rdap/components/RemarksSection";
import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
export type AutnumCardProps = { export type AutnumCardProps = {
@@ -50,21 +53,51 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
<Property title="Type">{data.type}</Property> {data.type && <Property title="Type">{data.type}</Property>}
<Property title="Country">{data.country.toUpperCase()}</Property> {data.country && <Property title="Country">{data.country.toUpperCase()}</Property>}
<Property title="Events"> {data.status && data.status.length > 0 && (
<Events key={0} data={data.events} /> <DataList.Item>
</Property> <DataList.Label>Status</DataList.Label>
<DataList.Item align="center"> <DataList.Value>
<DataList.Label>Status</DataList.Label> <Flex gap="2" wrap="wrap">
<DataList.Value> {data.status.map((status, index) => (
<Flex gap="2" wrap="wrap"> <StatusBadge key={index} status={status} />
{data.status.map((status, index) => ( ))}
<StatusBadge key={index} status={status} /> </Flex>
))} </DataList.Value>
</Flex> </DataList.Item>
</DataList.Value> )}
</DataList.Item> {data.port43 && (
<DataList.Item>
<DataList.Label>WHOIS Server</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.port43}</Code>
<CopyButton value={data.port43} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.entities && data.entities.length > 0 && (
<Property title="Entities">
<EntitiesSection entities={data.entities} />
</Property>
)}
{data.events && data.events.length > 0 && (
<Property title="Events">
<Events data={data.events} />
</Property>
)}
{data.links && data.links.length > 0 && (
<Property title="Links">
<LinksSection links={data.links} />
</Property>
)}
{data.remarks && data.remarks.length > 0 && (
<Property title="Remarks">
<RemarksSection remarks={data.remarks} />
</Property>
)}
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -6,6 +6,11 @@ import Property from "@/components/Property";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import NameserversSection from "@/rdap/components/NameserversSection";
import SecureDNSSection from "@/rdap/components/SecureDNSSection";
import EntitiesSection from "@/rdap/components/EntitiesSection";
import LinksSection from "@/rdap/components/LinksSection";
import RemarksSection from "@/rdap/components/RemarksSection";
import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
export type DomainProps = { export type DomainProps = {
@@ -48,28 +53,75 @@ const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps)
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
<DataList.Item> {data.handle != undefined ? (
<DataList.Label>Handle</DataList.Label> <DataList.Item>
<DataList.Value> <DataList.Label>Handle</DataList.Label>
<Flex align="center" gap="2"> <DataList.Value>
<Code variant="ghost">{data.handle}</Code> <Flex align="center" gap="2">
<CopyButton value={data.handle} /> <Code variant="ghost">{data.handle}</Code>
</Flex> <CopyButton value={data.handle} />
</DataList.Value> </Flex>
</DataList.Item> </DataList.Value>
<Property title="Events"> </DataList.Item>
<Events key={0} data={data.events} /> ) : null}
</Property> {data.status && data.status.length > 0 && (
<DataList.Item align="center"> <DataList.Item>
<DataList.Label>Status</DataList.Label> <DataList.Label>Status</DataList.Label>
<DataList.Value> <DataList.Value>
<Flex gap="2" wrap="wrap"> <Flex gap="2" wrap="wrap">
{data.status.map((statusKey, index) => ( {data.status.map((statusKey, index) => (
<StatusBadge key={index} status={statusKey} /> <StatusBadge key={index} status={statusKey} />
))} ))}
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
)}
{data.port43 && (
<DataList.Item>
<DataList.Label>WHOIS Server</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.port43}</Code>
<CopyButton value={data.port43} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.nameservers && data.nameservers.length > 0 && (
<Property title="Nameservers">
<NameserversSection nameservers={data.nameservers} />
</Property>
)}
{data.secureDNS && (
<Property title="DNSSEC">
<SecureDNSSection secureDNS={data.secureDNS} />
</Property>
)}
{data.entities && data.entities.length > 0 && (
<Property title="Entities">
<EntitiesSection entities={data.entities} />
</Property>
)}
{data.events && data.events.length > 0 && (
<Property title="Events">
<Events data={data.events} />
</Property>
)}
{data.links && data.links.length > 0 && (
<Property title="Links">
<LinksSection links={data.links} />
</Property>
)}
{data.notices && data.notices.length > 0 && (
<Property title="Notices">
<RemarksSection remarks={data.notices} />
</Property>
)}
{data.remarks && data.remarks.length > 0 && (
<Property title="Remarks">
<RemarksSection remarks={data.remarks} />
</Property>
)}
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -0,0 +1,147 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Entity, RdapStatusType } from "@/rdap/schemas";
import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes";
import VCardDisplay from "@/rdap/components/VCardDisplay";
import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge";
export type EntitiesSectionProps = {
entities: Entity[];
};
const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities }) => {
if (!entities || entities.length === 0) return null;
return (
<Flex direction="column" gap="3">
{entities.map((entity, index) => {
return (
<Box
key={index}
p="3"
style={{
border: "1px solid var(--gray-a5)",
borderRadius: "var(--radius-3)",
backgroundColor: "var(--gray-a2)",
}}
>
<Flex direction="column" gap="3">
<DataList.Root
orientation={{ initial: "vertical", sm: "horizontal" }}
size="2"
>
{entity.handle && (
<DataList.Item>
<DataList.Label>Handle</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{entity.handle}</Code>
<CopyButton value={entity.handle} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{entity.roles && entity.roles.length > 0 && (
<DataList.Item>
<DataList.Label>Roles</DataList.Label>
<DataList.Value>
<Flex gap="2" wrap="wrap">
{entity.roles.map(
(role: string, roleIndex: number) => (
<Badge
key={roleIndex}
variant="soft"
size="1"
>
{role}
</Badge>
)
)}
</Flex>
</DataList.Value>
</DataList.Item>
)}
{entity.status && entity.status.length > 0 && (
<DataList.Item>
<DataList.Label>Status</DataList.Label>
<DataList.Value>
<Flex gap="2" wrap="wrap">
{entity.status.map(
(
status: RdapStatusType,
statusIndex: number
) => (
<StatusBadge
key={statusIndex}
status={status}
/>
)
)}
</Flex>
</DataList.Value>
</DataList.Item>
)}
</DataList.Root>
{entity.vcardArray && (
<Flex direction="column" gap="2">
<VCardDisplay vcardArray={entity.vcardArray} />
</Flex>
)}
{entity.publicIds && entity.publicIds.length > 0 && (
<Table.Root size="1" variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
Public ID Type
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>
Identifier
</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{entity.publicIds.map(
(
publicId: { type: string; identifier: string },
publicIdIndex: number
) => (
<Table.Row key={publicIdIndex}>
<Table.Cell>{publicId.type}</Table.Cell>
<Table.Cell>
<Flex align="center" gap="2">
<Code variant="ghost">
{publicId.identifier}
</Code>
<CopyButton
value={publicId.identifier}
/>
</Flex>
</Table.Cell>
</Table.Row>
)
)}
</Table.Body>
</Table.Root>
)}
{entity.port43 && (
<Flex align="center" gap="2">
<Text size="2" weight="medium">
WHOIS Server:
</Text>
<Code variant="ghost">{entity.port43}</Code>
<CopyButton value={entity.port43} />
</Flex>
)}
</Flex>
</Box>
);
})}
</Flex>
);
};
export default EntitiesSection;

View File

@@ -1,8 +1,15 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Entity } from "@/rdap/schemas"; import type { Entity, RdapStatusType } from "@/rdap/schemas";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import Property from "@/components/Property";
import VCardDisplay from "@/rdap/components/VCardDisplay";
import Events from "@/rdap/components/Events";
import LinksSection from "@/rdap/components/LinksSection";
import RemarksSection from "@/rdap/components/RemarksSection";
import EntitiesSection from "@/rdap/components/EntitiesSection";
import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes"; import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes";
export type EntityCardProps = { export type EntityCardProps = {
@@ -17,7 +24,9 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
url={url} url={url}
header={ header={
<Flex gap="2" align="center" wrap="wrap"> <Flex gap="2" align="center" wrap="wrap">
<Text size="5">{data.handle || data.roles.join(", ")}</Text> <Text size="5">
{data.handle || (data.roles && data.roles.join(", ")) || "Entity"}
</Text>
<Badge color="gray">ENTITY</Badge> <Badge color="gray">ENTITY</Badge>
</Flex> </Flex>
} }
@@ -34,35 +43,90 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
)} )}
<DataList.Item align="center"> {data.roles && data.roles.length > 0 && (
<DataList.Label>Roles</DataList.Label> <DataList.Item>
<DataList.Value> <DataList.Label>Roles</DataList.Label>
<Flex gap="2" wrap="wrap">
{data.roles.map((role, index) => (
<Badge key={index} color="gray" variant="soft" radius="full">
{role}
</Badge>
))}
</Flex>
</DataList.Value>
</DataList.Item>
{data.publicIds && data.publicIds.length > 0 && (
<DataList.Item align="center">
<DataList.Label>Public IDs</DataList.Label>
<DataList.Value> <DataList.Value>
<Flex direction="column" gap="2"> <Flex gap="2" wrap="wrap">
{data.publicIds.map((publicId, index) => ( {data.roles.map((role: string, index: number) => (
<Flex key={index} align="center" gap="2"> <Badge key={index} color="gray" variant="soft" radius="full">
<Code variant="ghost"> {role}
{publicId.identifier} ({publicId.type}) </Badge>
</Code>
<CopyButton value={publicId.identifier} />
</Flex>
))} ))}
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
)} )}
{data.status && data.status.length > 0 && (
<DataList.Item>
<DataList.Label>Status</DataList.Label>
<DataList.Value>
<Flex gap="2" wrap="wrap">
{data.status.map((status: RdapStatusType, index: number) => (
<StatusBadge key={index} status={status} />
))}
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.publicIds && data.publicIds.length > 0 && (
<DataList.Item>
<DataList.Label>Public IDs</DataList.Label>
<DataList.Value>
<Flex direction="column" gap="2">
{data.publicIds.map(
(
publicId: { type: string; identifier: string },
index: number
) => (
<Flex key={index} align="center" gap="2">
<Code variant="ghost">
{publicId.identifier} ({publicId.type})
</Code>
<CopyButton value={publicId.identifier} />
</Flex>
)
)}
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.port43 && (
<DataList.Item>
<DataList.Label>WHOIS Server</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.port43}</Code>
<CopyButton value={data.port43} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.vcardArray && (
<Property title="Contact Information">
<VCardDisplay vcardArray={data.vcardArray} />
</Property>
)}
{data.entities && data.entities.length > 0 && (
<Property title="Associated Entities">
<EntitiesSection entities={data.entities} />
</Property>
)}
{data.events && data.events.length > 0 && (
<Property title="Events">
<Events data={data.events} />
</Property>
)}
{data.links && data.links.length > 0 && (
<Property title="Links">
<LinksSection links={data.links} />
</Property>
)}
{data.remarks && data.remarks.length > 0 && (
<Property title="Remarks">
<RemarksSection remarks={data.remarks} />
</Property>
)}
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -32,7 +32,7 @@ const Events: FunctionComponent<EventsProps> = ({ data }) => {
{eventActor} {eventActor}
</Text> </Text>
) : ( ) : (
<Text size="2" style={{ color: "var(--gray-a6)" }}> <Text size="2" style={{ color: "var(--gray-a4)" }}>
</Text> </Text>
)} )}

View File

@@ -6,6 +6,9 @@ import Property from "@/components/Property";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import EntitiesSection from "@/rdap/components/EntitiesSection";
import LinksSection from "@/rdap/components/LinksSection";
import RemarksSection from "@/rdap/components/RemarksSection";
import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes"; import { Flex, Text, DataList, Badge, Code } from "@radix-ui/themes";
export type IPCardProps = { export type IPCardProps = {
@@ -57,7 +60,7 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
<Property title="Type">{data.type}</Property> {data.type && <Property title="Type">{data.type}</Property>}
{data.country && <Property title="Country">{data.country}</Property>} {data.country && <Property title="Country">{data.country}</Property>}
{data.parentHandle && ( {data.parentHandle && (
<DataList.Item> <DataList.Item>
@@ -70,19 +73,49 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
)} )}
<Property title="Events"> {data.status && data.status.length > 0 && (
<Events key={0} data={data.events} /> <DataList.Item>
</Property> <DataList.Label>Status</DataList.Label>
<DataList.Item align="center"> <DataList.Value>
<DataList.Label>Status</DataList.Label> <Flex gap="2" wrap="wrap">
<DataList.Value> {data.status.map((status, index) => (
<Flex gap="2" wrap="wrap"> <StatusBadge key={index} status={status} />
{data.status.map((status, index) => ( ))}
<StatusBadge key={index} status={status} /> </Flex>
))} </DataList.Value>
</Flex> </DataList.Item>
</DataList.Value> )}
</DataList.Item> {data.port43 && (
<DataList.Item>
<DataList.Label>WHOIS Server</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.port43}</Code>
<CopyButton value={data.port43} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.entities && data.entities.length > 0 && (
<Property title="Entities">
<EntitiesSection entities={data.entities} />
</Property>
)}
{data.events && data.events.length > 0 && (
<Property title="Events">
<Events data={data.events} />
</Property>
)}
{data.links && data.links.length > 0 && (
<Property title="Links">
<LinksSection links={data.links} />
</Property>
)}
{data.remarks && data.remarks.length > 0 && (
<Property title="Remarks">
<RemarksSection remarks={data.remarks} />
</Property>
)}
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -0,0 +1,69 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Link as RdapLink } from "@/rdap/schemas";
import { Table, Link, Text, Badge } from "@radix-ui/themes";
export type LinksSectionProps = {
links: RdapLink[];
};
const LinksSection: FunctionComponent<LinksSectionProps> = ({ links }) => {
if (!links || links.length === 0) return null;
return (
<Table.Root size="1" variant="surface" layout="auto">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>URL</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Relation</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Title</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Type</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{links.map((link, index) => (
<Table.Row key={index}>
<Table.Cell>
<Link href={link.href} target="_blank" rel="noreferrer" size="2">
{link.value || link.href}
</Link>
</Table.Cell>
<Table.Cell>
{link.rel ? (
<Badge variant="soft" size="1">
{link.rel}
</Badge>
) : (
<Text size="2" style={{ color: "var(--gray-a6)" }}>
</Text>
)}
</Table.Cell>
<Table.Cell>
{link.title ? (
<Text size="2">{link.title}</Text>
) : (
<Text size="2" style={{ color: "var(--gray-a6)" }}>
</Text>
)}
</Table.Cell>
<Table.Cell>
{link.type ? (
<Text size="2" color="gray">
{link.type}
</Text>
) : (
<Text size="2" style={{ color: "var(--gray-a6)" }}>
</Text>
)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
};
export default LinksSection;

View File

@@ -2,7 +2,13 @@ import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Nameserver } from "@/rdap/schemas"; import type { Nameserver } from "@/rdap/schemas";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import Property from "@/components/Property";
import Events from "@/rdap/components/Events";
import LinksSection from "@/rdap/components/LinksSection";
import RemarksSection from "@/rdap/components/RemarksSection";
import EntitiesSection from "@/rdap/components/EntitiesSection";
import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes"; import { Flex, DataList, Badge, Text, Code } from "@radix-ui/themes";
export type NameserverCardProps = { export type NameserverCardProps = {
@@ -26,8 +32,19 @@ const NameserverCard: FunctionComponent<NameserverCardProps> = ({
} }
> >
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2"> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
{data.unicodeName && data.unicodeName !== data.ldhName && (
<DataList.Item>
<DataList.Label>Unicode Name</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.unicodeName}</Code>
<CopyButton value={data.unicodeName} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
<DataList.Item> <DataList.Item>
<DataList.Label>LDH Name</DataList.Label> <DataList.Label>{data.unicodeName ? "LDH Name" : "Name"}</DataList.Label>
<DataList.Value> <DataList.Value>
<Flex align="center" gap="2"> <Flex align="center" gap="2">
<Code variant="ghost">{data.ldhName}</Code> <Code variant="ghost">{data.ldhName}</Code>
@@ -35,6 +52,90 @@ const NameserverCard: FunctionComponent<NameserverCardProps> = ({
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
{data.handle && (
<DataList.Item>
<DataList.Label>Handle</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.handle}</Code>
<CopyButton value={data.handle} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.ipAddresses?.v4 && data.ipAddresses.v4.length > 0 && (
<DataList.Item>
<DataList.Label>IPv4 Addresses</DataList.Label>
<DataList.Value>
<Flex direction="column" gap="1">
{data.ipAddresses.v4.map((ip, index) => (
<Flex key={index} align="center" gap="2">
<Code variant="ghost">{ip}</Code>
<CopyButton value={ip} />
</Flex>
))}
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.ipAddresses?.v6 && data.ipAddresses.v6.length > 0 && (
<DataList.Item>
<DataList.Label>IPv6 Addresses</DataList.Label>
<DataList.Value>
<Flex direction="column" gap="1">
{data.ipAddresses.v6.map((ip, index) => (
<Flex key={index} align="center" gap="2">
<Code variant="ghost">{ip}</Code>
<CopyButton value={ip} />
</Flex>
))}
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.status && data.status.length > 0 && (
<DataList.Item>
<DataList.Label>Status</DataList.Label>
<DataList.Value>
<Flex gap="2" wrap="wrap">
{data.status.map((status, index) => (
<StatusBadge key={index} status={status} />
))}
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.port43 && (
<DataList.Item>
<DataList.Label>WHOIS Server</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{data.port43}</Code>
<CopyButton value={data.port43} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
{data.entities && data.entities.length > 0 && (
<Property title="Entities">
<EntitiesSection entities={data.entities} />
</Property>
)}
{data.events && data.events.length > 0 && (
<Property title="Events">
<Events data={data.events} />
</Property>
)}
{data.links && data.links.length > 0 && (
<Property title="Links">
<LinksSection links={data.links} />
</Property>
)}
{data.remarks && data.remarks.length > 0 && (
<Property title="Remarks">
<RemarksSection remarks={data.remarks} />
</Property>
)}
</DataList.Root> </DataList.Root>
</AbstractCard> </AbstractCard>
); );

View File

@@ -0,0 +1,85 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Nameserver } from "@/rdap/schemas";
import { Table, Text, Code, Flex, Badge } from "@radix-ui/themes";
import CopyButton from "@/components/CopyButton";
export type NameserversSectionProps = {
nameservers: Nameserver[];
};
const NameserversSection: FunctionComponent<NameserversSectionProps> = ({ nameservers }) => {
if (!nameservers || nameservers.length === 0) return null;
return (
<Table.Root size="1" variant="surface" layout="auto">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>Nameserver</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>IPv4 Addresses</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>IPv6 Addresses</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{nameservers.map((ns, index) => (
<Table.Row key={index}>
<Table.Cell>
<Flex align="center" gap="2">
<Code variant="ghost">{ns.ldhName}</Code>
<CopyButton value={ns.ldhName} />
</Flex>
{ns.unicodeName && ns.unicodeName !== ns.ldhName && (
<Flex align="center" gap="2" mt="1">
<Badge variant="soft" size="1">
Unicode
</Badge>
<Code variant="ghost" size="1">
{ns.unicodeName}
</Code>
</Flex>
)}
</Table.Cell>
<Table.Cell>
{ns.ipAddresses?.v4 && ns.ipAddresses.v4.length > 0 ? (
<Flex direction="column" gap="1">
{ns.ipAddresses.v4.map((ip, ipIndex) => (
<Flex key={ipIndex} align="center" gap="2">
<Code variant="ghost" size="1">
{ip}
</Code>
<CopyButton value={ip} />
</Flex>
))}
</Flex>
) : (
<Text size="2" style={{ color: "var(--gray-a4)" }}>
</Text>
)}
</Table.Cell>
<Table.Cell>
{ns.ipAddresses?.v6 && ns.ipAddresses.v6.length > 0 ? (
<Flex direction="column" gap="1">
{ns.ipAddresses.v6.map((ip, ipIndex) => (
<Flex key={ipIndex} align="center" gap="2">
<Code variant="ghost" size="1">
{ip}
</Code>
<CopyButton value={ip} />
</Flex>
))}
</Flex>
) : (
<Text size="2" style={{ color: "var(--gray-a4)" }}>
</Text>
)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
};
export default NameserversSection;

View File

@@ -0,0 +1,61 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Remark, Notice } from "@/rdap/schemas";
import { Box, Text, Heading, Flex, Badge } from "@radix-ui/themes";
import LinksSection from "@/rdap/components/LinksSection";
export type RemarksSectionProps = {
remarks?: Remark[] | Notice[];
};
const RemarksSection: FunctionComponent<RemarksSectionProps> = ({ remarks }) => {
if (!remarks || remarks.length === 0) return null;
return (
<Flex direction="column" gap="3">
{remarks.map((remark, index) => (
<Box
key={index}
p="3"
style={{
borderLeft: "3px solid var(--accent-a5)",
backgroundColor: "var(--gray-a2)",
borderRadius: "var(--radius-3)",
}}
>
<Flex direction="column" gap="2">
{remark.title && (
<Flex align="center" gap="2">
<Heading size="3">{remark.title}</Heading>
{remark.type && (
<Badge variant="soft" size="1">
{remark.type}
</Badge>
)}
</Flex>
)}
{remark.description && remark.description.length > 0 && (
<Flex direction="column" gap="1">
{remark.description.map((desc, descIndex) => (
<Text key={descIndex} size="2" style={{ lineHeight: "1.6" }}>
{desc}
</Text>
))}
</Flex>
)}
{remark.links && remark.links.length > 0 && (
<Box mt="2">
<Text size="2" weight="medium" mb="1" style={{ display: "block" }}>
Related Links
</Text>
<LinksSection links={remark.links} />
</Box>
)}
</Flex>
</Box>
))}
</Flex>
);
};
export default RemarksSection;

View File

@@ -0,0 +1,152 @@
import CopyButton from "@/components/CopyButton";
import type { SecureDNS } from "@/rdap/schemas";
import { Badge, Box, Code, DataList, Flex, Table, Text } from "@radix-ui/themes";
import type { FunctionComponent } from "react";
export type SecureDNSSectionProps = {
secureDNS: SecureDNS;
};
const SecureDNSSection: FunctionComponent<SecureDNSSectionProps> = ({ secureDNS }) => {
const hasData =
secureDNS.zoneSigned !== undefined ||
secureDNS.delegationSigned !== undefined ||
secureDNS.maxSigLife !== undefined ||
(secureDNS.dsData && secureDNS.dsData.length > 0) ||
(secureDNS.keyData && secureDNS.keyData.length > 0);
if (!hasData) return null;
return (
<Box>
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
{secureDNS.zoneSigned !== undefined && (
<DataList.Item>
<DataList.Label>Zone Signed</DataList.Label>
<DataList.Value>
<Badge color={secureDNS.zoneSigned ? "green" : "gray"} variant="soft">
{secureDNS.zoneSigned ? "Yes" : "No"}
</Badge>
</DataList.Value>
</DataList.Item>
)}
{secureDNS.delegationSigned !== undefined && (
<DataList.Item>
<DataList.Label>Delegation Signed</DataList.Label>
<DataList.Value>
<Badge
color={secureDNS.delegationSigned ? "green" : "gray"}
variant="soft"
>
{secureDNS.delegationSigned ? "Yes" : "No"}
</Badge>
</DataList.Value>
</DataList.Item>
)}
{secureDNS.maxSigLife !== undefined && (
<DataList.Item>
<DataList.Label>Max Signature Life</DataList.Label>
<DataList.Value>
<Text>{secureDNS.maxSigLife} seconds</Text>
</DataList.Value>
</DataList.Item>
)}
</DataList.Root>
{secureDNS.dsData && secureDNS.dsData.length > 0 && (
<Table.Root size="1" variant="surface" mt="3">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>Key Tag</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Algorithm</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Digest Type</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Digest</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{secureDNS.dsData.map((ds, index) => (
<Table.Row key={index}>
<Table.Cell>
<Code variant="ghost">{ds.keyTag}</Code>
</Table.Cell>
<Table.Cell>
<Code variant="ghost">{ds.algorithm}</Code>
</Table.Cell>
<Table.Cell>
<Code variant="ghost">{ds.digestType}</Code>
</Table.Cell>
<Table.Cell>
<Flex align="center" gap="2">
<Code
variant="ghost"
style={{
maxWidth: "300px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{ds.digest}
</Code>
<CopyButton value={ds.digest} />
</Flex>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
{secureDNS.keyData && secureDNS.keyData.length > 0 && (
<Box mt="4">
<Text size="3" weight="medium" mb="2" style={{ display: "block" }}>
Key Data
</Text>
<Table.Root size="1" variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>Flags</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Protocol</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Algorithm</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Public Key</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{secureDNS.keyData.map((key, index) => (
<Table.Row key={index}>
<Table.Cell>
<Code variant="ghost">{key.flags}</Code>
</Table.Cell>
<Table.Cell>
<Code variant="ghost">{key.protocol}</Code>
</Table.Cell>
<Table.Cell>
<Code variant="ghost">{key.algorithm}</Code>
</Table.Cell>
<Table.Cell>
<Flex align="center" gap="2">
<Code
variant="ghost"
style={{
maxWidth: "300px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{key.publicKey}
</Code>
<CopyButton value={key.publicKey} />
</Flex>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
</Box>
);
};
export default SecureDNSSection;

View File

@@ -0,0 +1,239 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { VCardArray } from "@/rdap/schemas";
import { Table, Flex, Code, Link, Text } from "@radix-ui/themes";
import CopyButton from "@/components/CopyButton";
export type VCardDisplayProps = {
vcardArray: VCardArray;
};
type VCardProperty = [string, Record<string, string>, string, string];
type StructuredName = {
family: string;
given: string;
additional: string;
prefix: string;
suffix: string;
};
type Address = {
street: string;
locality: string;
region: string;
postal: string;
country: string;
};
type ParsedVCard = {
fn?: string;
name?: StructuredName;
org?: string;
emails?: string[];
phones?: string[];
addresses?: Address[];
urls?: string[];
title?: string;
role?: string;
};
/**
* Parses a vCard (jCard) array and extracts common contact properties.
* jCard format: ["vcard", [[prop_name, params, value_type, value], ...]]
*/
const parseVCard = (vcardArray: VCardArray): ParsedVCard => {
const [, properties] = vcardArray;
const result: ParsedVCard = {};
properties.forEach((prop: VCardProperty) => {
if (!Array.isArray(prop) || prop.length < 4) return;
const [name, , , value] = prop as VCardProperty;
const nameLower = name.toLowerCase();
switch (nameLower) {
case "fn": // Formatted name
result.fn = value;
break;
case "n": // Structured name [family, given, additional, prefix, suffix]
if (Array.isArray(value)) {
result.name = {
family: value[0],
given: value[1],
additional: value[2],
prefix: value[3],
suffix: value[4],
};
}
break;
case "org": // Organization
result.org = Array.isArray(value) ? value.join(" > ") : value;
break;
case "email":
if (!result.emails) result.emails = [];
result.emails.push(value);
break;
case "tel": // Telephone
if (!result.phones) result.phones = [];
result.phones.push(value);
break;
case "adr": // Address [PO, extended, street, locality, region, postal, country]
if (Array.isArray(value)) {
const address = {
street: value[2],
locality: value[3],
region: value[4],
postal: value[5],
country: value[6],
};
if (!result.addresses) result.addresses = [];
result.addresses.push(address);
}
break;
case "url":
if (!result.urls) result.urls = [];
result.urls.push(value);
break;
case "title":
result.title = value;
break;
case "role":
result.role = value;
break;
}
});
return result;
};
const VCardDisplay: FunctionComponent<VCardDisplayProps> = ({ vcardArray }) => {
const vcard = parseVCard(vcardArray);
return (
<Table.Root size="2" variant="surface">
<Table.Body>
{vcard.fn && (
<Table.Row>
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
<Table.Cell>
<Flex gap="2" align="center">
<Text>{vcard.fn}</Text>
<CopyButton value={vcard.fn} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.org && (
<Table.Row>
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
<Table.Cell>
<Flex align="center" gap="2">
<Code variant="ghost">{vcard.org}</Code>
<CopyButton value={vcard.org} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.title && (
<Table.Row>
<Table.RowHeaderCell>Title</Table.RowHeaderCell>
<Table.Cell>
<Text>{vcard.title}</Text>
</Table.Cell>
</Table.Row>
)}
{vcard.role && (
<Table.Row>
<Table.RowHeaderCell>Role</Table.RowHeaderCell>
<Table.Cell>
<Text>{vcard.role}</Text>
</Table.Cell>
</Table.Row>
)}
{vcard.emails && vcard.emails.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{vcard.emails.map((email: string, index: number) => (
<Flex key={index} align="center" gap="2">
<Link href={`mailto:${email}`}>
<Code variant="ghost">{email}</Code>
</Link>
<CopyButton value={email} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.phones && vcard.phones.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{vcard.phones.map((phone: string, index: number) => (
<Flex key={index} align="center" gap="2">
<Link href={`tel:${phone}`}>
<Code variant="ghost">{phone}</Code>
</Link>
<CopyButton value={phone} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.addresses && vcard.addresses.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="2">
{vcard.addresses.map((addr: Address, index: number) => (
<Text key={index} size="2">
{[
addr.street,
addr.locality,
addr.region,
addr.postal,
addr.country,
]
.filter(Boolean)
.join(", ")}
</Text>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.urls && vcard.urls.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>URL</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{vcard.urls.map((url: string, index: number) => (
<Flex key={index} align="center" gap="2">
<Link href={url} target="_blank" rel="noreferrer">
<Code variant="ghost">{url}</Code>
</Link>
<CopyButton value={url} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
);
};
export default VCardDisplay;

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getType } from "@/rdap/utils"; import { getType, validateInputForType } from "@/rdap/utils";
import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas"; import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas";
import { import {
AutonomousNumberSchema, AutonomousNumberSchema,
@@ -75,35 +75,56 @@ const useLookup = (warningHandler?: WarningHandler) => {
if (target == null || target.length == 0) if (target == null || target.length == 0)
return Result.err(new Error("A target must be given in order to execute a lookup.")); return Result.err(new Error("A target must be given in order to execute a lookup."));
const targetType = await getTypeEasy(target); let targetType: TargetType;
if (targetType.isErr) { if (currentType != null) {
return Result.err( // User has explicitly selected a type
new Error("Unable to determine type, unable to send query", { targetType = currentType;
cause: targetType.error,
}) // Validate the input matches the selected type
); const validation = await validateInputForType(target, currentType, getRegistry);
if (validation.isErr) {
// Show warning but proceed with user's selection
if (warningHandler != undefined) {
warningHandler({
message: `Warning: ${validation.error}. Proceeding with selected type "${currentType}".`,
});
}
}
} else {
// Autodetect mode
const detectedType = await getTypeEasy(target);
if (detectedType.isErr) {
return Result.err(
new Error("Unable to determine type, unable to send query", {
cause: detectedType.error,
})
);
}
targetType = detectedType.value;
} }
switch (targetType.value) { switch (targetType) {
// Block scoped case to allow url const reuse // Block scoped case to allow url const reuse
case "ip4": { case "ip4": {
await loadBootstrap("ip4"); await loadBootstrap("ip4");
const url = getRegistryURL(targetType.value, target); const url = getRegistryURL(targetType, target);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
if (result.isErr) return Result.err(result.error); if (result.isErr) return Result.err(result.error);
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "ip6": { case "ip6": {
await loadBootstrap("ip6"); await loadBootstrap("ip6");
const url = getRegistryURL(targetType.value, target); const url = getRegistryURL(targetType, target);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
if (result.isErr) return Result.err(result.error); if (result.isErr) return Result.err(result.error);
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "domain": { case "domain": {
await loadBootstrap("domain"); await loadBootstrap("domain");
const url = getRegistryURL(targetType.value, target); const url = getRegistryURL(targetType, target);
// HTTP // HTTP
if (url.startsWith("http://") && url != repeatableRef.current) { if (url.startsWith("http://") && url != repeatableRef.current) {
@@ -123,7 +144,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
} }
case "autnum": { case "autnum": {
await loadBootstrap("autnum"); await loadBootstrap("autnum");
const url = getRegistryURL(targetType.value, target); const url = getRegistryURL(targetType, target);
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema); const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema);
if (result.isErr) return Result.err(result.error); if (result.isErr) return Result.err(result.error);
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });

View File

@@ -1,9 +1,5 @@
import { z } from "zod"; import { z } from "zod";
// ============================================================================
// Enums
// ============================================================================
export const TargetTypeEnum = z.enum([ export const TargetTypeEnum = z.enum([
"autnum", "autnum",
"domain", "domain",
@@ -55,10 +51,6 @@ export const StatusEnum = z.enum([
"transfer period", "transfer period",
]); ]);
// ============================================================================
// Schemas
// ============================================================================
export const LinkSchema = z.object({ export const LinkSchema = z.object({
value: z.string().optional(), // de-facto optional value: z.string().optional(), // de-facto optional
rel: z.string().optional(), // de-facto optional rel: z.string().optional(), // de-facto optional
@@ -69,25 +61,6 @@ export const LinkSchema = z.object({
type: z.string().optional(), type: z.string().optional(),
}); });
export const EntitySchema = z.object({
objectClassName: z.literal("entity"),
handle: z.string().optional(),
roles: z.array(z.string()),
publicIds: z
.array(
z.object({
type: z.string(),
identifier: z.string(),
})
)
.optional(),
});
export const NameserverSchema = z.object({
objectClassName: z.literal("nameserver"),
ldhName: z.string(),
});
export const EventSchema = z.object({ export const EventSchema = z.object({
eventAction: z.string(), eventAction: z.string(),
eventActor: z.string().optional(), eventActor: z.string().optional(),
@@ -97,25 +70,116 @@ export const EventSchema = z.object({
export const NoticeSchema = z.object({ export const NoticeSchema = z.object({
description: z.string().array(), // de jure required description: z.string().array(), // de jure required
title: z.string().optional(), title: z.string().optional(),
type: z.string().optional(),
links: z.array(LinkSchema).optional(), links: z.array(LinkSchema).optional(),
}); });
export const RemarkSchema = z.object({
description: z.string().array(), // de jure required
title: z.string().optional(),
type: z.string().optional(),
links: z.array(LinkSchema).optional(),
});
// vCard 4.0 in jCard format (RFC 7095)
// Simplified schema - full vCard is complex, so we use a loose schema
// Format: ["vcard", [properties...]]
export const VCardArraySchema = z.array(z.any());
export const IPAddressesSchema = z.object({
v4: z.array(z.string()).optional(),
v6: z.array(z.string()).optional(),
});
export const DSDataSchema = z.object({
keyTag: z.number(),
algorithm: z.number(),
digest: z.string(),
digestType: z.number(),
});
export const KeyDataSchema = z.object({
flags: z.number(),
protocol: z.number(),
publicKey: z.string(),
algorithm: z.number(),
});
export const SecureDNSSchema = z.object({
zoneSigned: z.boolean().optional(),
delegationSigned: z.boolean().optional(),
maxSigLife: z.number().optional(),
dsData: z.array(DSDataSchema).optional(),
keyData: z.array(KeyDataSchema).optional(),
});
export const VariantSchema = z.object({
relation: z.array(z.string()).optional(),
idnTable: z.string().optional(),
variantNames: z
.array(
z.object({
ldhName: z.string(),
unicodeName: z.string().optional(),
})
)
.optional(),
});
export const NameserverSchema = z.object({
objectClassName: z.literal("nameserver"),
ldhName: z.string(),
unicodeName: z.string().optional(),
handle: z.string().optional(),
ipAddresses: IPAddressesSchema.optional(),
status: z.array(StatusEnum).optional(),
events: z.array(EventSchema).optional(),
links: z.array(LinkSchema).optional(),
remarks: z.array(RemarkSchema).optional(),
port43: z.string().optional(),
entities: z.lazy(() => z.array(EntitySchema)).optional(),
});
export const IpNetworkSchema = z.object({ export const IpNetworkSchema = z.object({
objectClassName: z.literal("ip network"), objectClassName: z.literal("ip network"),
handle: z.string(), handle: z.string(),
startAddress: z.string(), startAddress: z.string(),
endAddress: z.string(), endAddress: z.string(),
ipVersion: z.enum(["v4", "v6"]), ipVersion: z.enum(["v4", "v6"]),
name: z.string(), name: z.string().optional(),
type: z.string(), type: z.string().optional(),
country: z.string().optional(), country: z.string().optional(),
parentHandle: z.string().optional(), parentHandle: z.string().optional(),
status: z.array(StatusEnum), status: z.array(StatusEnum).optional(),
entities: z.array(EntitySchema).optional(), remarks: z.array(RemarkSchema).optional(),
remarks: z.any().optional(), links: z.array(LinkSchema).optional(),
links: z.any().optional(), port43: z.string().optional(),
port43: z.any().optional(), events: z.array(EventSchema).optional(),
events: z.array(EventSchema), // Required for circular reference
get entities() {
return z.array(EntitySchema).optional();
},
});
// Forward declaration for circular Entity reference
const BaseEntitySchema = z.object({
objectClassName: z.literal("entity"),
handle: z.string().optional(),
vcardArray: VCardArraySchema.optional(),
roles: z.array(z.string()).optional(),
publicIds: z
.array(
z.object({
type: z.string(),
identifier: z.string(),
})
)
.optional(),
status: z.array(StatusEnum).optional(),
events: z.array(EventSchema).optional(),
links: z.array(LinkSchema).optional(),
remarks: z.array(RemarkSchema).optional(),
port43: z.string().optional(),
}); });
export const AutonomousNumberSchema = z.object({ export const AutonomousNumberSchema = z.object({
@@ -128,24 +192,40 @@ export const AutonomousNumberSchema = z.object({
status: z.array(StatusEnum), status: z.array(StatusEnum),
country: z.string().length(2), country: z.string().length(2),
events: z.array(EventSchema), events: z.array(EventSchema),
entities: z.array(EntitySchema), get entities() {
roles: z.array(z.string()), return z.array(EntitySchema).optional();
links: z.array(LinkSchema), },
links: z.array(LinkSchema).optional(),
remarks: z.array(RemarkSchema).optional(),
port43: z.string().optional(),
});
// Full Entity schema with circular references
export const EntitySchema = BaseEntitySchema.extend({
networks: z.lazy(() => z.array(IpNetworkSchema)).optional(),
autnums: z.lazy(() => z.array(AutonomousNumberSchema)).optional(),
asEventActor: z.array(EventSchema).optional(),
get entities() {
return z.array(EntitySchema).optional();
},
}); });
export const DomainSchema = z.object({ export const DomainSchema = z.object({
objectClassName: z.literal("domain"), objectClassName: z.literal("domain"),
handle: z.string(), handle: z.string().optional(),
ldhName: z.string(), ldhName: z.string(),
unicodeName: z.string().optional(), unicodeName: z.string().optional(),
variants: z.array(VariantSchema).optional(),
links: z.array(LinkSchema).optional(), links: z.array(LinkSchema).optional(),
status: z.array(StatusEnum), status: z.array(StatusEnum).optional(),
entities: z.array(EntitySchema), entities: z.lazy(() => z.array(EntitySchema)).optional(),
events: z.array(EventSchema), events: z.array(EventSchema).optional(),
secureDNS: z.any(), // TODO: Complete schema secureDNS: SecureDNSSchema.optional(),
nameservers: z.array(NameserverSchema), nameservers: z.array(NameserverSchema).optional(),
rdapConformance: z.string().array(), // TODO: Complete rdapConformance: z.string().array().optional(),
notices: z.array(NoticeSchema), notices: z.array(NoticeSchema).optional(),
remarks: z.array(RemarkSchema).optional(),
port43: z.string().optional(),
network: IpNetworkSchema.optional(), network: IpNetworkSchema.optional(),
}); });
@@ -166,10 +246,6 @@ export const RegisterSchema = z.object({
version: z.string(), version: z.string(),
}); });
// ============================================================================
// TypeScript Types
// ============================================================================
// All precise target types that can be placed in the search bar. // All precise target types that can be placed in the search bar.
export type TargetType = z.infer<typeof TargetTypeEnum>; export type TargetType = z.infer<typeof TargetTypeEnum>;
@@ -185,6 +261,13 @@ export type Entity = z.infer<typeof EntitySchema>;
export type Nameserver = z.infer<typeof NameserverSchema>; export type Nameserver = z.infer<typeof NameserverSchema>;
export type Event = z.infer<typeof EventSchema>; export type Event = z.infer<typeof EventSchema>;
export type Notice = z.infer<typeof NoticeSchema>; export type Notice = z.infer<typeof NoticeSchema>;
export type Remark = z.infer<typeof RemarkSchema>;
export type VCardArray = z.infer<typeof VCardArraySchema>;
export type IPAddresses = z.infer<typeof IPAddressesSchema>;
export type DSData = z.infer<typeof DSDataSchema>;
export type KeyData = z.infer<typeof KeyDataSchema>;
export type SecureDNS = z.infer<typeof SecureDNSSchema>;
export type Variant = z.infer<typeof VariantSchema>;
export type IpNetwork = z.infer<typeof IpNetworkSchema>; export type IpNetwork = z.infer<typeof IpNetworkSchema>;
export type AutonomousNumber = z.infer<typeof AutonomousNumberSchema>; export type AutonomousNumber = z.infer<typeof AutonomousNumberSchema>;
export type Register = z.infer<typeof RegisterSchema>; export type Register = z.infer<typeof RegisterSchema>;

View File

@@ -176,3 +176,34 @@ export async function getType(
return Result.err(new Error("No patterns matched the input")); return Result.err(new Error("No patterns matched the input"));
} }
/**
* Validates if a given input matches a specific target type.
* Used to warn users when their explicit type selection doesn't match the input format.
*
* @param value - The input value to validate
* @param type - The expected target type
* @param getRegistry - Function to fetch registry data
* @returns A Result containing true if valid, or an error message if invalid
*/
export async function validateInputForType(
value: string,
type: TargetType,
getRegistry: (type: RootRegistryType) => Promise<Register>
): Promise<Result<true, string>> {
const validator = TypeValidators.get(type);
if (!validator) {
return Result.err(`Unknown type: ${type}`);
}
const result = await validator({ value, getRegistry });
if (result === true) {
return Result.ok(true);
} else if (result === false) {
return Result.err(`Input "${value}" does not match the format for type "${type}"`);
} else {
// result is an error message string
return Result.err(result);
}
}