Merge branch 'develop' into feat/openprovider-provider

This commit is contained in:
Maël Gangloff
2025-11-17 11:01:50 +01:00
121 changed files with 10460 additions and 1014 deletions

View File

@@ -1,4 +1,4 @@
import {Button, ConfigProvider, FloatButton, Layout, Space, theme, Tooltip, Typography} from 'antd'
import {Button, ConfigProvider, Drawer, Flex, Layout, theme, Typography} from 'antd'
import {Link, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom'
import TextPage from './pages/TextPage'
import DomainSearchPage from './pages/search/DomainSearchPage'
@@ -7,7 +7,8 @@ import TldPage from './pages/infrastructure/TldPage'
import StatisticsPage from './pages/StatisticsPage'
import WatchlistPage from './pages/tracking/WatchlistPage'
import UserPage from './pages/UserPage'
import React, {useCallback, useEffect, useMemo, useState} from 'react'
import type {PropsWithChildren} from 'react'
import React, { useCallback, useEffect, useMemo, useState} from 'react'
import {getUser} from './utils/api'
import LoginPage, {AuthenticatedContext} from './pages/LoginPage'
import ConnectorPage from './pages/tracking/ConnectorPage'
@@ -15,21 +16,56 @@ import NotFoundPage from './pages/NotFoundPage'
import useBreakpoint from './hooks/useBreakpoint'
import {Sider} from './components/Sider'
import {jt, t} from 'ttag'
import {BugOutlined, InfoCircleOutlined, MergeOutlined} from '@ant-design/icons'
import {MenuOutlined} from '@ant-design/icons'
import TrackedDomainPage from './pages/tracking/TrackedDomainPage'
import IcannRegistrarPage from "./pages/infrastructure/IcannRegistrarPage"
const PROJECT_LINK = 'https://github.com/maelgangloff/domain-watchdog'
const LICENSE_LINK = 'https://www.gnu.org/licenses/agpl-3.0.txt'
const ProjectLink = <Typography.Link target='_blank' href={PROJECT_LINK}>Domain Watchdog</Typography.Link>
const LicenseLink = <Typography.Link target='_blank' href={LICENSE_LINK}>AGPL-3.0-or-later</Typography.Link>
const ProjectLink = <Typography.Link key="projectLink" target='_blank' href={PROJECT_LINK}>Domain Watchdog</Typography.Link>
const LicenseLink = <Typography.Link key="licenceLink" target='_blank' href={LICENSE_LINK}>AGPL-3.0-or-later</Typography.Link>
function SiderWrapper(props: PropsWithChildren<{sidebarCollapsed: boolean, setSidebarCollapsed: (collapsed: boolean) => void}>): React.ReactElement {
const {sidebarCollapsed, setSidebarCollapsed, children} = props
const sm = useBreakpoint('sm')
const location = useLocation()
useEffect(() => {
if (sm) {
setSidebarCollapsed(false)
}
}, [location])
if (sm) {
return <Drawer
placement="left"
open={sidebarCollapsed}
onClose={() => setSidebarCollapsed(false)}
closeIcon={null}
styles={{body: {padding: 0, height: '100%', background: '#001529'}}}
width='200px'>
{children}
</Drawer>
} else {
return <Layout.Sider
collapsible
breakpoint='sm'
width={220}
trigger={null}
collapsed={sidebarCollapsed && sm}
{...(sm ? {collapsedWidth: 0} : {})}>
{children}
</Layout.Sider>
}
}
export default function App(): React.ReactElement {
const navigate = useNavigate()
const location = useLocation()
const sm = useBreakpoint('sm')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const authenticated = useCallback((authenticated: boolean) => {
@@ -75,15 +111,20 @@ export default function App(): React.ReactElement {
>
<AuthenticatedContext.Provider value={contextValue}>
<Layout hasSider style={{minHeight: '100vh'}}>
{/* Ant will use a break-off tab to toggle the collapse of the sider when collapseWidth = 0 */}
<Layout.Sider collapsible breakpoint='sm' width={220} {...(sm ? {collapsedWidth: 0} : {})}>
<SiderWrapper sidebarCollapsed={sidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed}>
<Sider isAuthenticated={isAuthenticated}/>
</Layout.Sider>
</SiderWrapper>
<Layout>
<Layout.Header style={{padding: 0}}/>
<Layout.Header style={{padding: 0}}>
{sm &&
<Button type="text" style={{marginLeft: 8}} onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
<MenuOutlined />
</Button>
}
</Layout.Header>
<Layout.Content style={sm ? {margin: '24px 0'} : {margin: '24px 16px 0'}}>
<div style={{
padding: 24,
padding: sm ? 8 : 24,
minHeight: 360
}}
>
@@ -116,41 +157,40 @@ export default function App(): React.ReactElement {
</div>
</Layout.Content>
<Layout.Footer style={{textAlign: 'center'}}>
<Space size='middle' wrap align='center'>
<Flex gap='middle' wrap justify='center'>
<Link to='/tos'><Button type='text'>{t`TOS`}</Button></Link>
<Link to='/privacy'><Button type='text'>{t`Privacy Policy`}</Button></Link>
<Link to='/faq'><Button type='text'>{t`FAQ`}</Button></Link>
<Typography.Link
target='_blank'
href='https://github.com/maelgangloff/domain-watchdog/wiki'
href='https://domainwatchdog.eu'
>
<Button
type='text'
>{t`Documentation`}
<Button type='text'>
{t`Documentation`}
</Button>
</Typography.Link>
</Space>
<Typography.Link
target='_blank'
href={PROJECT_LINK}
>
<Button type='text'>
{t`Source code`}
</Button>
</Typography.Link>
<Typography.Link
target='_blank'
href={PROJECT_LINK + '/issues'}
>
<Button type='text'>
{t`Submit an issue`}
</Button>
</Typography.Link>
</Flex>
<Typography.Paragraph style={{marginTop: '1em'}}>
{jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`}
</Typography.Paragraph>
</Layout.Footer>
</Layout>
<FloatButton.Group
trigger='hover'
style={{
position: 'fixed',
insetInlineEnd: (100 - 40) / 2,
bottom: 100 - 40 / 2
}}
icon={<InfoCircleOutlined/>}
>
<Tooltip title={t`Official git repository`} placement='left'>
<FloatButton icon={<MergeOutlined/>} target='_blank' href={PROJECT_LINK}/>
</Tooltip>
<Tooltip title={t`Submit an issue`} placement='left'>
<FloatButton icon={<BugOutlined/>} target='_blank' href={PROJECT_LINK + '/issues'}/>
</Tooltip>
</FloatButton.Group>
</Layout>
</AuthenticatedContext.Provider>
</ConfigProvider>

View File

@@ -1,6 +1,6 @@
import {Button, Form, Input, message, Space} from 'antd'
import {Button, Flex, Form, Input, message} from 'antd'
import {t} from 'ttag'
import React, {useContext, useEffect} from 'react'
import React, {useContext, useEffect, useState} from 'react'
import {getUser, login} from '../utils/api'
import {AuthenticatedContext} from '../pages/LoginPage'
import {useNavigate} from 'react-router-dom'
@@ -16,6 +16,7 @@ export function LoginForm({ssoLogin}: { ssoLogin?: boolean }) {
const navigate = useNavigate()
const [messageApi, contextHolder] = message.useMessage()
const {setIsAuthenticated} = useContext(AuthenticatedContext)
const [loading, setLoading] = useState(false)
useEffect(() => {
getUser().then(() => {
@@ -25,12 +26,15 @@ export function LoginForm({ssoLogin}: { ssoLogin?: boolean }) {
}, [])
const onFinish = (data: FieldType) => {
setLoading(true)
login(data.username, data.password).then(() => {
setIsAuthenticated(true)
navigate('/home')
}).catch((e) => {
setIsAuthenticated(false)
showErrorAPI(e, messageApi)
setLoading(false)
})
}
return (
@@ -43,6 +47,7 @@ export function LoginForm({ssoLogin}: { ssoLogin?: boolean }) {
style={{maxWidth: 600}}
onFinish={onFinish}
autoComplete='off'
disabled={loading}
>
<Form.Item
label={t`Email address`}
@@ -60,18 +65,15 @@ export function LoginForm({ssoLogin}: { ssoLogin?: boolean }) {
<Input.Password/>
</Form.Item>
<Space>
<Form.Item wrapperCol={{offset: 8, span: 16}}>
<Flex wrap justify="center" gap="middle">
<Button type='primary' htmlType='submit'>
{t`Submit`}
</Button>
</Form.Item>
{ssoLogin && <Form.Item wrapperCol={{offset: 8, span: 16}}>
{ssoLogin &&
<Button type='dashed' htmlType='button' href='/login/oauth'>
{t`Log in with SSO`}
</Button>
</Form.Item>}
</Space>
</Button>}
</Flex>
</Form>
</>
)

View File

@@ -22,16 +22,16 @@ export function ConnectorsList({connectors, onDelete}: { connectors: ConnectorEl
<>
<Divider/>
{connectors.map(connector => {
const createdAt = <Typography.Text strong>
const createdAt = <Typography.Text strong key={"createdAt"}>
{new Date(connector.createdAt).toLocaleString()}
</Typography.Text>
const {watchlistCount} = connector
const connectorName = Object.keys(ConnectorProvider).find(p => ConnectorProvider[p as keyof typeof ConnectorProvider] === connector.provider)
return <>
{contextHolder}
<Card
hoverable title={<Space>
return <Card
hoverable
key={connector.id}
title={<Space>
{t`Connector ${connectorName}`}<Typography.Text code>{connector.id}</Typography.Text>
</Space>}
size='small'
@@ -45,6 +45,7 @@ export function ConnectorsList({connectors, onDelete}: { connectors: ConnectorEl
><DeleteFilled style={{color: token.colorError}}/>
</Popconfirm>}
>
{contextHolder}
<Typography.Paragraph>{jt`Creation date: ${createdAt}`}</Typography.Paragraph>
<Typography.Paragraph>{t`Used in: ${watchlistCount} Watchlist`}</Typography.Paragraph>
<Card.Meta description={
@@ -58,7 +59,6 @@ The creation date corresponds to the date on which you consented to the creation
</>
}/>
</Card>
</>
}
)}
</>

View File

@@ -42,7 +42,6 @@ export function CreateWatchlistButton({onUpdateWatchlist, connectors}: {
paddingBottom: 80
}
}}
extra={<Button onClick={onClose}>{t`Cancel`}</Button>}
>
<WatchlistForm
form={form}

View File

@@ -22,11 +22,13 @@ import {
} from '@ant-design/icons'
import {DomainToTag} from '../../../utils/functions/DomainToTag'
import {isDomainLocked} from "../../../utils/functions/isDomainLocked"
import useBreakpoint from "../../../hooks/useBreakpoint"
export function TrackedDomainTable() {
const REDEMPTION_NOTICE = (
<Tooltip
title={t`At least one domain name is in redemption period and will potentially be deleted soon`}
key="redeptionNotice"
>
<Tag color={eppStatusCodeToColor('redemption period')}>redemption period</Tag>
</Tooltip>
@@ -35,6 +37,7 @@ export function TrackedDomainTable() {
const PENDING_DELETE_NOTICE = (
<Tooltip
title={t`At least one domain name is pending deletion and will soon become available for registration again`}
key="pendingDeleteNotice"
>
<Tag color={eppStatusCodeToColor('pending delete')}>pending delete</Tag>
</Tooltip>
@@ -53,6 +56,7 @@ export function TrackedDomainTable() {
const [dataTable, setDataTable] = useState<TableRow[]>([])
const [total, setTotal] = useState<number>()
const [specialNotice, setSpecialNotice] = useState<ReactElement[]>([])
const sm = useBreakpoint('sm')
const rdapStatusCodeDetailTranslated = rdapStatusCodeDetailTranslation()
@@ -220,6 +224,7 @@ export function TrackedDomainTable() {
text: <Tooltip
placement='bottomLeft'
title={rdapStatusCodeDetailTranslated[s as keyof typeof rdapStatusCodeDetailTranslated] || undefined}
key={s}
>
<Tag color={eppStatusCodeToColor(s)}>{s}</Tag>
</Tooltip>,
@@ -268,7 +273,8 @@ export function TrackedDomainTable() {
fetchData({page, itemsPerPage})
}
}}
scroll={{y: '50vh'}}
scroll={sm ? {} : {y: '50vh'}}
size={sm ? 'small' : 'large'}
/>
</Skeleton>
}

View File

@@ -1,4 +1,4 @@
import {Button, Drawer, Form, Typography} from 'antd'
import {Drawer, Form, Typography} from 'antd'
import {t} from 'ttag'
import {WatchlistForm} from './WatchlistForm'
import React, {useState} from 'react'
@@ -53,7 +53,6 @@ export function UpdateWatchlistButton({watchlist, onUpdateWatchlist, connectors}
paddingBottom: 80
}
}}
extra={<Button onClick={onClose}>{t`Cancel`}</Button>}
>
<WatchlistForm
form={form}

View File

@@ -0,0 +1,101 @@
import React, {useEffect, useState} from "react"
import type {ModalProps} from "antd"
import {Tag, Tooltip} from "antd"
import {Flex, Modal, Select, Typography} from "antd"
import type {Domain, Watchlist} from "../../../utils/api"
import {getWatchlists} from "../../../utils/api"
import {t} from 'ttag'
import {DomainToTag} from "../../../utils/functions/DomainToTag"
import {EllipsisOutlined} from '@ant-design/icons'
const MAX_DOMAIN_TAGS = 25
function WatchlistOption({watchlist}: {watchlist: Watchlist}) {
let domains = watchlist.domains
let rest: Domain[]|undefined = undefined
if (domains.length > MAX_DOMAIN_TAGS) {
rest = domains.slice(MAX_DOMAIN_TAGS)
domains = domains.slice(0, MAX_DOMAIN_TAGS)
}
return <Flex vertical>
<Typography.Text strong>{watchlist.name}</Typography.Text>
<Flex wrap gap='4px'>
{domains.map(d => <DomainToTag link={false} domain={d} key={d.ldhName} />)}
{rest
&& <Tooltip title={rest.map(d => <DomainToTag link={false} domain={d} key={d.ldhName} />)}>
<Tag icon={<EllipsisOutlined/>} color='processing'>
{t`${rest.length} more`}
</Tag>
</Tooltip>
}
</Flex>
</Flex>
}
interface WatchlistSelectionModalProps {
onFinish: (watchlist: Watchlist) => Promise<void>|void
description?: string
open?: boolean
modalProps?: Partial<ModalProps>
}
export default function WatchlistSelectionModal(props: WatchlistSelectionModalProps) {
const [watchlists, setWatchlists] = useState<Watchlist[] | undefined>()
const [selectedWatchlist, setSelectedWatchlist] = useState<Watchlist | undefined>()
const [validationLoading, setValidationLoading] = useState(false)
useEffect(() => {
if (props.open && !watchlists) {
getWatchlists().then(list => setWatchlists(list["hydra:member"]))
}
}, [props.open])
const onFinish = () => {
const promise = props.onFinish(selectedWatchlist as Watchlist)
if (promise) {
setValidationLoading(true)
promise.finally(() => {
setSelectedWatchlist(undefined)
setValidationLoading(false)
})
} else {
setSelectedWatchlist(undefined)
}
}
return <Modal
open={props.open}
onOk={onFinish}
okButtonProps={{
disabled: !selectedWatchlist,
loading: validationLoading,
}}
{...props.modalProps ?? {}}
>
<Flex vertical>
<Typography.Paragraph>
{
props.description
|| t`Select one of your available Watchlists`
}
</Typography.Paragraph>
<Select
placeholder={t`Watchlist`}
style={{width: '100%'}}
onChange={(_, option) => setSelectedWatchlist(option as Watchlist)}
options={watchlists}
value={selectedWatchlist?.token}
fieldNames={{
label: 'name',
value: 'token',
}}
loading={!watchlists}
status={selectedWatchlist ? '' : 'error'}
optionRender={(watchlist) => <WatchlistOption watchlist={watchlist.data}/>}
/>
</Flex>
</Modal>
}

View File

@@ -10,4 +10,8 @@
body {
margin: 0;
font-family: "Noto Color Emoji", sans-serif;
@media (prefers-color-scheme: dark) {
background: #000000;
}
}

View File

@@ -6,6 +6,7 @@ import {LoginForm} from '../components/LoginForm'
import type { InstanceConfig} from '../utils/api'
import {getConfiguration} from '../utils/api'
import {RegisterForm} from '../components/RegisterForm'
import useBreakpoint from "../hooks/useBreakpoint"
export const AuthenticatedContext = createContext<
{
@@ -22,6 +23,7 @@ export const AuthenticatedContext = createContext<
export default function LoginPage() {
const [wantRegister, setWantRegister] = useState<boolean>(false)
const [configuration, setConfiguration] = useState<InstanceConfig>()
const md = useBreakpoint('md')
const toggleWantRegister = () => {
setWantRegister(!wantRegister)
@@ -31,24 +33,32 @@ export default function LoginPage() {
getConfiguration().then(setConfiguration)
}, [])
const grid = [
<Card.Grid key="form" style={{width: md ? '100%' : '50%', textAlign: 'center'}} hoverable={false}>
{wantRegister ? <RegisterForm/> : <LoginForm ssoLogin={configuration?.ssoLogin}/>}
{
configuration?.registerEnabled &&
<Button
type='link'
block
style={{marginTop: '1em'}}
onClick={toggleWantRegister}
>{wantRegister ? t`Log in` : t`Create an account`}
</Button>
}
</Card.Grid>,
<Card.Grid key="ads" style={{width: md ? '100%' : '50%'}} hoverable={false}>
<TextPage resource='ads.md'/>
</Card.Grid>
]
if (md) {
grid.reverse()
}
return (
<Card title={wantRegister ? t`Register` : t`Log in`} style={{width: '100%'}}>
<Card.Grid style={{width: '50%', textAlign: 'center'}} hoverable={false}>
{wantRegister ? <RegisterForm/> : <LoginForm ssoLogin={configuration?.ssoLogin}/>}
{
configuration?.registerEnabled &&
<Button
type='link'
block
style={{marginTop: '1em'}}
onClick={toggleWantRegister}
>{wantRegister ? t`Log in` : t`Create an account`}
</Button>
}
</Card.Grid>
<Card.Grid style={{width: '50%'}} hoverable={false}>
<TextPage resource='ads.md'/>
</Card.Grid>
{grid}
</Card>
)
}

View File

@@ -1,7 +1,7 @@
import React, {useEffect, useState} from 'react'
import type { Statistics} from '../utils/api'
import {getStatistics} from '../utils/api'
import {Card, Col, Divider, Row, Statistic, Tooltip} from 'antd'
import {Card, Col, Divider, Flex, Row, Statistic, Tooltip} from 'antd'
import {t} from 'ttag'
import {
AimOutlined,
@@ -18,10 +18,8 @@ export default function StatisticsPage() {
getStatistics().then(setStats)
}, [])
const totalDomainPurchase = (stats?.domainPurchased ?? 0) + (stats?.domainPurchaseFailed ?? 0)
const successRate = stats !== undefined
? (totalDomainPurchase === 0 ? undefined : stats.domainPurchased / totalDomainPurchase)
? (stats?.domainPurchased === 0 ? undefined : ((stats?.domainPurchased ?? 0) - (stats?.domainPurchaseFailed ?? 0)) / stats?.domainPurchased)
: undefined
return (
@@ -82,7 +80,7 @@ export default function StatisticsPage() {
loading={stats === undefined}
title={t`Purchased domain names`}
prefix={<FieldTimeOutlined/>}
value={stats?.domainPurchased}
value={(stats?.domainPurchased??0) - (stats?.domainPurchaseFailed??0)}
valueStyle={{color: 'green'}}
/>
</Card>
@@ -95,7 +93,7 @@ export default function StatisticsPage() {
<Statistic
loading={stats === undefined}
title={t`Success rate`}
value={successRate === undefined ? '-' : successRate * 100}
value={successRate === undefined ? '-' : (successRate * 100).toFixed(2)}
suffix='%'
valueStyle={{color: successRate === undefined ? 'grey' : successRate >= 0.5 ? 'darkgreen' : 'orange'}}
/>
@@ -104,20 +102,19 @@ export default function StatisticsPage() {
</Col>
</Row>
<Divider/>
<Row gutter={16} justify='center' align='middle'>
<Flex gap={16} wrap justify='center' align='middle'>
{stats?.domainCount
.sort((a, b) => b.domain - a.domain)
.map(({domain, tld}) => <Col key={tld} span={4}>
<Card bordered={false}>
.map(({domain, tld}) =>
<Card key={tld} bordered={false}>
<Statistic
loading={stats === undefined}
title={tld ? tld : t`TLD`}
value={domain}
valueStyle={{color: 'darkorange'}}
/>
</Card>
</Col>)}
</Row>
</Card>)}
</Flex>
</>
)
}

View File

@@ -5,6 +5,7 @@ import {t} from 'ttag'
import type {ColumnType} from 'antd/es/table'
import {CheckCircleOutlined, SettingOutlined, CloseCircleOutlined} from "@ant-design/icons"
import {getIcannAccreditations} from "../../utils/api/icann-accreditations"
import useBreakpoint from "../../hooks/useBreakpoint"
const {Text, Paragraph} = Typography
@@ -19,6 +20,7 @@ function RegistrarListTable(filters: FiltersType) {
name: string
}
const sm = useBreakpoint('sm')
const [dataTable, setDataTable] = useState<TableRow[]>([])
const [total, setTotal] = useState(0)
@@ -63,14 +65,15 @@ function RegistrarListTable(filters: FiltersType) {
fetchData({...filters, page, itemsPerPage})
}
}}
scroll={{y: '50vh'}}
scroll={sm ? {} : {y: '50vh'}}
size={sm ? 'small' : 'large'}
/>
)
}
export default function IcannRegistrarPage() {
const [activeTabKey, setActiveTabKey] = useState<string>('Accredited')
const sm = useBreakpoint('sm')
const contentList: Record<string, React.ReactNode> = {
Accredited: <>
@@ -125,6 +128,7 @@ export default function IcannRegistrarPage() {
activeTabKey={activeTabKey}
key={activeTabKey}
onTabChange={(k: string) => setActiveTabKey(k)}
size={sm ? 'small' : 'default'}
>
{contentList[activeTabKey]}

View File

@@ -11,6 +11,7 @@ import {getCountryCode} from '../../utils/functions/getCountryCode'
import {tldToEmoji} from '../../utils/functions/tldToEmoji'
import {BankOutlined, FlagOutlined, GlobalOutlined, TrademarkOutlined} from "@ant-design/icons"
import {Link} from "react-router-dom"
import useBreakpoint from "../../hooks/useBreakpoint"
const {Text, Paragraph} = Typography
@@ -30,6 +31,7 @@ function TldTable(filters: FiltersType) {
Country?: string
}
const sm = useBreakpoint('sm')
const [dataTable, setDataTable] = useState<TableRow[]>([])
const [total, setTotal] = useState(0)
@@ -110,14 +112,15 @@ function TldTable(filters: FiltersType) {
fetchData({...filters, page, itemsPerPage})
}
}}
scroll={{y: '50vh'}}
scroll={sm ? {} : {y: '50vh'}}
size={sm ? 'small' : 'large'}
/>
)
}
export default function TldPage() {
const [activeTabKey, setActiveTabKey] = useState<string>('gTLD')
const sm = useBreakpoint("sm")
const contentList: Record<string, React.ReactNode> = {
sTLD: <>
@@ -185,6 +188,7 @@ export default function TldPage() {
activeTabKey={activeTabKey}
key={activeTabKey}
onTabChange={(k: string) => setActiveTabKey(k)}
size={sm ? 'small' : 'default'}
>
{contentList[activeTabKey]}

View File

@@ -1,8 +1,10 @@
import React, {useEffect, useState} from 'react'
import type { FormProps} from 'antd'
import {FloatButton} from 'antd'
import {Empty, Flex, message, Skeleton} from 'antd'
import type {Domain} from '../../utils/api'
import { getDomain} from '../../utils/api'
import type {Domain, Watchlist} from '../../utils/api'
import {addDomainToWatchlist} from '../../utils/api'
import {getDomain} from '../../utils/api'
import type {AxiosError} from 'axios'
import {t} from 'ttag'
import type { FieldType} from '../../components/search/DomainSearchBar'
@@ -10,17 +12,19 @@ import {DomainSearchBar} from '../../components/search/DomainSearchBar'
import {DomainResult} from '../../components/search/DomainResult'
import {showErrorAPI} from '../../utils/functions/showErrorAPI'
import {useNavigate, useParams} from 'react-router-dom'
import {PlusOutlined} from '@ant-design/icons'
import WatchlistSelectionModal from '../../components/tracking/watchlist/WatchlistSelectionModal'
export default function DomainSearchPage() {
const {query} = useParams()
const [domain, setDomain] = useState<Domain | null>()
const [loading, setLoading] = useState<boolean>(false)
const domainLdhName = domain?.ldhName
const [loading, setLoading] = useState(false)
const [addToWatchlistModal, setAddToWatchlistModal] = useState(false)
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
navigate('/search/domain/' + values.ldhName)
@@ -41,7 +45,18 @@ export default function DomainSearchPage() {
onFinish({ldhName: query, isRefreshForced: false})
}, [])
return (
const addToWatchlist = async (watchlist: Watchlist) => {
await addDomainToWatchlist(watchlist, domain!.ldhName).then(() => {
setAddToWatchlistModal(false)
const ldhName = domain?.ldhName
messageApi.success(t`${ldhName} added to ${watchlist.name}`)
}).catch((e: AxiosError) => {
showErrorAPI(e, messageApi)
})
}
return <>
<Flex gap='middle' align='center' justify='center' vertical>
{contextHolder}
<DomainSearchBar initialValue={query} onFinish={onFinish}/>
@@ -57,5 +72,29 @@ export default function DomainSearchPage() {
}
</Skeleton>
</Flex>
)
{domain
&& <FloatButton
style={{
position: 'fixed',
insetInlineEnd: (100 - 40) / 2,
bottom: 100 - 40 / 2
}}
tooltip={t`Add to Watchlist`}
type="primary"
icon={<PlusOutlined/>}
onClick={() => setAddToWatchlistModal(true)}
/>
}
<WatchlistSelectionModal
open={addToWatchlistModal}
onFinish={addToWatchlist}
modalProps={{
title: t`Add ${domainLdhName} to a Watchlist`,
onCancel: () => setAddToWatchlistModal(false),
onClose: () => setAddToWatchlistModal(false),
cancelText: t`Cancel`,
okText: t`Add`
}}
/>
</>
}

View File

@@ -74,8 +74,7 @@ export default function WatchlistPage() {
return (
<Flex gap='middle' align='center' justify='center' vertical>
{contextHolder}
{(connectors != null) && (watchlists != null) && watchlists.length > 0 &&
{(connectors !== undefined) && (watchlists !== undefined) &&
<>
<CreateWatchlistButton onUpdateWatchlist={onCreateWatchlist} connectors={connectors} />
<Divider/>

View File

@@ -44,6 +44,13 @@ export async function patchWatchlist(token: string, watchlist: Partial<Watchlist
return response.data
}
export async function addDomainToWatchlist(watchlist: Watchlist, ldhName: string) {
const domains = watchlist.domains.map(d => '/api/domains/' + d.ldhName)
domains.push('/api/domains/' + ldhName)
return patchWatchlist(watchlist.token, {domains})
}
export async function deleteWatchlist(token: string): Promise<void> {
await request({
method: 'DELETE',

View File

@@ -6,32 +6,38 @@ import React from 'react'
import type {Event} from "../api"
import {t} from "ttag"
export function DomainToTag({domain}: { domain: { ldhName: string, deleted: boolean, status: string[], events?: Event[] } }) {
return (
<Link to={'/search/domain/' + domain.ldhName}>
<Badge dot={domain.events?.find(e =>
e.action === 'last changed' &&
!e.deleted &&
((new Date().getTime() - new Date(e.date).getTime()) < 7*24*60*60*1e3)
) !== undefined} color='blue' title={t`The domain name was updated less than a week ago.`}>
<Tag
color={
domain.deleted
? 'magenta'
: domain.status.includes('redemption period')
? 'yellow'
: domain.status.includes('pending delete') ? 'volcano' : 'default'
}
icon={
domain.deleted
? <DeleteOutlined/>
: domain.status.includes('redemption period')
? <ExclamationCircleOutlined/>
: domain.status.includes('pending delete') ? <DeleteOutlined/> : null
}
>{punycode.toUnicode(domain.ldhName)}
</Tag>
</Badge>
</Link>
)
export function DomainToTag({domain, link}: { domain: { ldhName: string, deleted: boolean, status: string[], events?: Event[] }, link?: boolean }) {
const tag = <Badge dot={domain.events?.find(e =>
e.action === 'last changed' &&
!e.deleted &&
((new Date().getTime() - new Date(e.date).getTime()) < 7*24*60*60*1e3)
) !== undefined} color='blue' title={t`The domain name was updated less than a week ago.`}>
<Tag
color={
domain.deleted
? 'magenta'
: domain.status.includes('redemption period')
? 'yellow'
: domain.status.includes('pending delete') ? 'volcano' : 'default'
}
icon={
domain.deleted
? <DeleteOutlined/>
: domain.status.includes('redemption period')
? <ExclamationCircleOutlined/>
: domain.status.includes('pending delete') ? <DeleteOutlined/> : null
}
>{punycode.toUnicode(domain.ldhName)}
</Tag>
</Badge>
if (link ?? true) {
return (
<Link to={'/search/domain/' + domain.ldhName}>
{tag}
</Link>
)
} else {
return tag
}
}

View File

@@ -10,6 +10,7 @@ export function statusToTag(s: string) {
<Tooltip
placement='bottomLeft'
title={rdapStatusCodeDetailTranslated[s as keyof typeof rdapStatusCodeDetailTranslated] || undefined}
key={s}
>
<Tag color={eppStatusCodeToColor(s)}>{s}</Tag>
</Tooltip>