Merge branch 'feat/namecom-support'

This commit is contained in:
Maël Gangloff 2025-01-08 20:49:12 +01:00
commit 8f7b81bff3
No known key found for this signature in database
GPG Key ID: 11FDC81C24A7F629
7 changed files with 352 additions and 188 deletions

View File

@ -1,8 +1,8 @@
import type { FormInstance} from 'antd'
import type {FormInstance} from 'antd'
import {Alert, Button, Checkbox, Form, Input, Popconfirm, Select, Space, Typography} from 'antd'
import React, {useState} from 'react'
import type {Connector} from '../../../utils/api/connectors'
import { ConnectorProvider} from '../../../utils/api/connectors'
import {ConnectorProvider} from '../../../utils/api/connectors'
import {t} from 'ttag'
import {BankOutlined} from '@ant-design/icons'
import {
@ -21,7 +21,7 @@ const formItemLayoutWithOutLabel = {
}
export function ConnectorForm({form, onCreate}: { form: FormInstance, onCreate: (values: Connector) => void }) {
const [provider, setProvider] = useState<string>()
const [provider, setProvider] = useState<ConnectorProvider>()
const ovhFields = ovhFieldsFunction()
const ovhEndpointList = ovhEndpointListFunction()
const ovhSubsidiaryList = ovhSubsidiaryListFunction()
@ -62,174 +62,191 @@ export function ConnectorForm({form, onCreate}: { form: FormInstance, onCreate:
/>
</Form.Item>
{
provider === ConnectorProvider.OVH && <>
{
Object.keys(ovhFields).map(fieldName => <Form.Item
key={ovhFields[fieldName as keyof typeof ovhFields]}
label={ovhFields[fieldName as keyof typeof ovhFields]}
name={['authData', fieldName]}
rules={[{required: true, message: t`Required`}]}
>
<Input autoComplete='off'/>
</Form.Item>)
}
<Form.Item
label={t`OVH Endpoint`}
name={['authData', 'apiEndpoint']}
rules={[{required: true, message: t`Required`}]}
>
<Select options={ovhEndpointList} optionFilterProp='label'/>
</Form.Item>
<Form.Item
label={t`OVH subsidiary`}
name={['authData', 'ovhSubsidiary']}
rules={[{required: true, message: t`Required`}]}
>
<Select options={ovhSubsidiaryList} optionFilterProp='label'/>
</Form.Item>
<Form.Item
label={t`OVH pricing mode`}
name={['authData', 'pricingMode']}
rules={[{required: true, message: t`Required`}]}
>
<Popconfirm
title={t`Confirm pricing mode`}
description={t`Are you sure about this setting? This may result in additional charges from the API Provider`}
onCancel={() => {
form.resetFields(['authData'])
setOvhPricingModeValue(undefined)
setOpen(false)
}}
onConfirm={() => setOpen(false)}
open={open}
>
<Select
options={ovhPricingMode} optionFilterProp='label' value={ovhPricingModeValue}
onChange={(value: string) => {
setOvhPricingModeValue(value)
form.setFieldValue(['authData', 'pricingMode'], value)
if (value !== 'create-default') {
setOpen(true)
}
}}
/>
</Popconfirm>
</Form.Item>
</>
}
{
provider === ConnectorProvider.GANDI && <>
<Form.Item
label={t`Personal Access Token (PAT)`}
name={['authData', 'token']}
rules={[{required: true, message: t`Required`}]}
>
<Input autoComplete='off'/>
</Form.Item>
<Form.Item
label={t`Organization sharing ID`}
name={['authData', 'sharingId']}
help={<Typography.Text
type='secondary'
>{t`It indicates the organization that will pay for the ordered product`}
</Typography.Text>}
required={false}
>
<Input autoComplete='off' placeholder='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'/>
</Form.Item>
</>
}
{
provider === ConnectorProvider.AUTODNS && <>
<Alert
message={t`This provider does not provide a list of supported TLD. Please double check if the domain you want to register is supported.`}
type='warning'
/>
<br/>
<Form.Item
label={t`AutoDNS Username`}
name={['authData', 'username']}
help={<Typography.Text
type='secondary'
>{t`Attention: AutoDNS do not support 2-Factor Authentication on API Users for automated systems`}
</Typography.Text>}
rules={[{required: true, message: t`Required`}]}
>
<Input autoComplete='off' required/>
</Form.Item>
<Form.Item
label={t`AutoDNS Password`}
name={['authData', 'password']}
rules={[{required: true, message: t`Required`}]}
required
>
<Input.Password autoComplete='off' required placeholder=''/>
</Form.Item>
<Form.Item
label={t`Owner nic-handle`}
name={['authData', 'contactid']}
help={<Typography.Text
type='secondary'
>{t`The nic-handle of the domain name owner`}<a
href='https://cloud.autodns.com/contacts/domain'
>{t`You can get it from this page`}
</a>
</Typography.Text>}
rules={[{required: true, message: t`Required`}]}
required
>
<Input autoComplete='off' required placeholder=''/>
</Form.Item>
<Form.Item
label={t`Context Value`}
name={['authData', 'context']}
help={<Typography.Text
type='secondary'
>{t`If you not sure, use the default value 4`}
</Typography.Text>}
required={false}
>
<Input autoComplete='off' required={false} placeholder='4'/>
</Form.Item>
<Form.Item
valuePropName='checked'
label={t`Owner confirmation`}
name={['authData', 'ownerConfirm']}
rules={[{required: true, message: t`Required`}]}
>
<Checkbox
required
>{t`Owner confirms his consent of domain order jobs`}
</Checkbox>
</Form.Item>
</>
}
{
provider === ConnectorProvider.NAMECHEAP && <>
<Form.Item
label={t`Username`}
name={['authData', 'ApiUser']}
>
<Input autoComplete='off'/>
</Form.Item>
<Form.Item
label={t`API key`}
name={['authData', 'ApiKey']}
>
<Input autoComplete='off'/>
</Form.Item>
</>
}
{
provider !== undefined && <>
{
[ConnectorProvider.AutoDNS, ConnectorProvider['Name.com']].includes(provider) && <Alert
message={t`This provider does not provide a list of supported TLD. Please double check if the domain you want to register is supported.`}
type='warning'
style={{marginBottom: '2em'}}
/>
}
{
provider === ConnectorProvider.OVHcloud && <>
{
Object.keys(ovhFields).map(fieldName => <Form.Item
key={ovhFields[fieldName as keyof typeof ovhFields]}
label={ovhFields[fieldName as keyof typeof ovhFields]}
name={['authData', fieldName]}
rules={[{required: true, message: t`Required`}]}
>
<Input autoComplete='off'/>
</Form.Item>)
}
<Form.Item
label={t`OVH Endpoint`}
name={['authData', 'apiEndpoint']}
rules={[{required: true, message: t`Required`}]}
>
<Select options={ovhEndpointList} optionFilterProp='label'/>
</Form.Item>
<Form.Item
label={t`OVH subsidiary`}
name={['authData', 'ovhSubsidiary']}
rules={[{required: true, message: t`Required`}]}
>
<Select options={ovhSubsidiaryList} optionFilterProp='label'/>
</Form.Item>
<Form.Item
label={t`OVH pricing mode`}
name={['authData', 'pricingMode']}
rules={[{required: true, message: t`Required`}]}
>
<Popconfirm
title={t`Confirm pricing mode`}
description={t`Are you sure about this setting? This may result in additional charges from the API Provider`}
onCancel={() => {
form.resetFields(['authData'])
setOvhPricingModeValue(undefined)
setOpen(false)
}}
onConfirm={() => setOpen(false)}
open={open}
>
<Select
options={ovhPricingMode} optionFilterProp='label' value={ovhPricingModeValue}
onChange={(value: string) => {
setOvhPricingModeValue(value)
form.setFieldValue(['authData', 'pricingMode'], value)
if (value !== 'create-default') {
setOpen(true)
}
}}
/>
</Popconfirm>
</Form.Item>
</>
}
{
provider === ConnectorProvider.Gandi && <>
<Form.Item
label={t`Personal Access Token (PAT)`}
name={['authData', 'token']}
rules={[{required: true, message: t`Required`}]}
>
<Input autoComplete='off'/>
</Form.Item>
<Form.Item
label={t`Organization sharing ID`}
name={['authData', 'sharingId']}
help={<Typography.Text
type='secondary'
>{t`It indicates the organization that will pay for the ordered product`}
</Typography.Text>}
required={false}
>
<Input autoComplete='off' placeholder='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'/>
</Form.Item>
</>
}
{
provider === ConnectorProvider.AutoDNS && <>
<br/>
<Form.Item
label={t`AutoDNS Username`}
name={['authData', 'username']}
help={<Typography.Text
type='secondary'
>{t`Attention: AutoDNS do not support 2-Factor Authentication on API Users for automated systems`}
</Typography.Text>}
rules={[{required: true, message: t`Required`}]}
>
<Input autoComplete='off' required/>
</Form.Item>
<Form.Item
label={t`AutoDNS Password`}
name={['authData', 'password']}
rules={[{required: true, message: t`Required`}]}
required
>
<Input.Password autoComplete='off' required placeholder=''/>
</Form.Item>
<Form.Item
label={t`Owner nic-handle`}
name={['authData', 'contactid']}
help={<Typography.Text
type='secondary'
>{t`The nic-handle of the domain name owner`}<a
href='https://cloud.autodns.com/contacts/domain'
>{t`You can get it from this page`}
</a>
</Typography.Text>}
rules={[{required: true, message: t`Required`}]}
required
>
<Input autoComplete='off' required placeholder=''/>
</Form.Item>
<Form.Item
label={t`Context Value`}
name={['authData', 'context']}
help={<Typography.Text
type='secondary'
>{t`If you not sure, use the default value 4`}
</Typography.Text>}
required={false}
>
<Input autoComplete='off' required={false} placeholder='4'/>
</Form.Item>
<Form.Item
valuePropName='checked'
label={t`Owner confirmation`}
name={['authData', 'ownerConfirm']}
rules={[{required: true, message: t`Required`}]}
>
<Checkbox
required
>{t`Owner confirms his consent of domain order jobs`}
</Checkbox>
</Form.Item>
</>
}
{
provider === ConnectorProvider.Namecheap && <>
<Form.Item
label={t`Username`}
name={['authData', 'ApiUser']}
>
<Input autoComplete='off'/>
</Form.Item>
<Form.Item
label={t`API key`}
name={['authData', 'ApiKey']}
>
<Input autoComplete='off'/>
</Form.Item>
</>
}
{
provider === ConnectorProvider['Name.com'] && <>
<Form.Item
label={t`Username`}
name={['authData', 'username']}
>
<Input autoComplete='off'/>
</Form.Item>
<Form.Item
label={t`API key`}
name={['authData', 'token']}
>
<Input autoComplete='off'/>
</Form.Item>
</>
}
<Form.Item
valuePropName='checked'
label={t`API Terms of Service`}

View File

@ -3,7 +3,8 @@ import {jt, t} from 'ttag'
import {DeleteFilled} from '@ant-design/icons'
import React from 'react'
import type {Connector} from '../../../utils/api/connectors'
import {deleteConnector} from '../../../utils/api/connectors'
import { ConnectorProvider, deleteConnector} from '../../../utils/api/connectors'
import {tosHyperlink} from "../../../utils/providers"
const {useToken} = theme
@ -24,12 +25,13 @@ export function ConnectorsList({connectors, onDelete}: { connectors: ConnectorEl
{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>
{t`Connector ${connector.provider}`}<Typography.Text code>{connector.id}</Typography.Text>
{t`Connector ${connectorName}`}<Typography.Text code>{connector.id}</Typography.Text>
</Space>}
size='small'
style={{width: '100%'}}
@ -44,8 +46,16 @@ export function ConnectorsList({connectors, onDelete}: { connectors: ConnectorEl
>
<Typography.Paragraph>{jt`Creation date: ${createdAt}`}</Typography.Paragraph>
<Typography.Paragraph>{t`Used in: ${watchlistCount} Watchlist`}</Typography.Paragraph>
<Card.Meta description={t`You can stop using a connector at any time. To delete a connector, you must remove it from each linked Watchlist.
The creation date corresponds to the date on which you consented to the creation of the connector and on which you declared in particular that you fulfilled the conditions of use of the supplier's API, waived the right of withdrawal and were of the minimum age to consent to these conditions.`}/>
<Card.Meta description={
<>
{t`You can stop using a connector at any time. To delete a connector, you must remove it from each linked Watchlist.
The creation date corresponds to the date on which you consented to the creation of the connector and on which you declared in particular that you fulfilled the conditions of use of the supplier's API, waived the right of withdrawal and were of the minimum age to consent to these conditions.`}
&nbsp;
<Typography.Link href={tosHyperlink(connector.provider)}>
{t`The Providers conditions are accessible by following this hyperlink.`}
</Typography.Link>
</>
}/>
</Card>
<Divider/>
</>

View File

@ -2,10 +2,11 @@ import {request} from './index'
import type {ConnectorElement} from '../../components/tracking/connector/ConnectorsList'
export enum ConnectorProvider {
OVH = 'ovh',
GANDI = 'gandi',
AUTODNS = 'autodns',
NAMECHEAP = 'namecheap'
OVHcloud = 'ovh',
Gandi = 'gandi',
AutoDNS = 'autodns',
Namecheap = 'namecheap',
'Name.com' = 'namecom'
}
export interface Connector {

View File

@ -5,7 +5,7 @@ import React from 'react'
export const helpGetTokenLink = (provider?: string) => {
switch (provider) {
case ConnectorProvider.OVH:
case ConnectorProvider.OVHcloud:
return (
<Typography.Link
target='_blank'
@ -15,24 +15,30 @@ export const helpGetTokenLink = (provider?: string) => {
</Typography.Link>
)
case ConnectorProvider.GANDI:
case ConnectorProvider.Gandi:
return (
<Typography.Link target='_blank' href='https://admin.gandi.net/organizations/account/pat'>
{t`Retrieve a Personal Access Token from your customer account on the Provider's website`}
</Typography.Link>
)
case ConnectorProvider.NAMECHEAP:
case ConnectorProvider.Namecheap:
return (
<Typography.Link target='_blank' href='https://ap.www.namecheap.com/settings/tools/apiaccess/'>
{t`Retreive an API key and whitelist this instance's IP address on Namecheap's website`}
</Typography.Link>
)
case ConnectorProvider.AUTODNS:
case ConnectorProvider.AutoDNS:
return (
<Typography.Link target='_blank' href='https://en.autodns.com/domain-robot-api/'>
{t`Because of some limitations in API of AutoDNS, we suggest to create an dedicated user for API with limited rights`}
</Typography.Link>
)
case ConnectorProvider['Name.com']:
return (
<Typography.Link target='_blank' href='https://www.name.com/account/settings/api'>
{t`Retrieve a set of tokens from your customer account on the Provider's website`}
</Typography.Link>
)
default:
return <></>
}
@ -40,14 +46,16 @@ export const helpGetTokenLink = (provider?: string) => {
export const tosHyperlink = (provider?: string) => {
switch (provider) {
case ConnectorProvider.OVH:
return 'https://www.ovhcloud.com/fr/terms-and-conditions/contracts/'
case ConnectorProvider.GANDI:
case ConnectorProvider.OVHcloud:
return 'https://www.ovhcloud.com/en/terms-and-conditions/contracts/'
case ConnectorProvider.Gandi:
return 'https://www.gandi.net/en/contracts/terms-of-service'
case ConnectorProvider.NAMECHEAP:
case ConnectorProvider.Namecheap:
return 'https://www.namecheap.com/legal/universal/universal-tos/'
case ConnectorProvider.AUTODNS:
case ConnectorProvider.AutoDNS:
return 'https://www.internetx.com/agb/'
case ConnectorProvider['Name.com']:
return 'https://www.name.com/policies/'
default:
return ''
}

View File

@ -5,6 +5,7 @@ namespace App\Config;
use App\Service\Connector\AutodnsProvider;
use App\Service\Connector\GandiProvider;
use App\Service\Connector\NamecheapProvider;
use App\Service\Connector\NameComProvider;
use App\Service\Connector\OvhProvider;
enum ConnectorProvider: string
@ -13,6 +14,7 @@ enum ConnectorProvider: string
case GANDI = 'gandi';
case AUTODNS = 'autodns';
case NAMECHEAP = 'namecheap';
case NAMECOM = 'namecom';
public function getConnectorProvider(): string
{
@ -21,6 +23,7 @@ enum ConnectorProvider: string
ConnectorProvider::GANDI => GandiProvider::class,
ConnectorProvider::AUTODNS => AutodnsProvider::class,
ConnectorProvider::NAMECHEAP => NamecheapProvider::class,
ConnectorProvider::NAMECOM => NameComProvider::class,
};
}
}

View File

@ -131,8 +131,6 @@ class Domain
'pending transfer',
'pending update',
'add period',
'client hold',
'server hold',
];
public function __construct()

View File

@ -0,0 +1,127 @@
<?php
namespace App\Service\Connector;
use App\Entity\Domain;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Autoconfigure(public: true)]
class NameComProvider extends AbstractProvider
{
public function __construct(CacheItemPoolInterface $cacheItemPool,
private readonly HttpClientInterface $client,
private readonly KernelInterface $kernel)
{
parent::__construct($cacheItemPool);
}
private const BASE_URL = 'https://api.name.com';
private const DEV_BASE_URL = 'https://api.dev.name.com';
/**
* Order a domain name with the Gandi API.
*
* @throws \Exception
* @throws TransportExceptionInterface
* @throws DecodingExceptionInterface
*/
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new \InvalidArgumentException('Domain name cannot be null');
}
$this->client->request(
'POST',
'/v4/domains',
(new HttpOptions())
->setHeader('Accept', 'application/json')
->setAuthBasic($this->authData['username'], $this->authData['token'])
->setBaseUri($dryRun ? self::DEV_BASE_URL : self::BASE_URL)
->setJson([
'domain' => [
[
'domainName' => $domain->getLdhName(),
'locked' => false,
'autorenewEnabled' => false,
],
'purchaseType' => 'registration',
'years' => 1,
// 'tldRequirements' => []
],
])
->toArray()
)->toArray();
}
public function verifySpecificAuthData(array $authData): array
{
$username = $authData['username'];
$token = $authData['token'];
if (
!is_string($username) || empty($username)
|| !is_string($token) || empty($token)
) {
throw new BadRequestHttpException('Bad authData schema');
}
return [
'username' => $authData['username'],
'token' => $authData['token'],
];
}
public function isSupported(Domain ...$domainList): bool
{
return true;
}
protected function getSupportedTldList(): array
{
return [];
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.namecom.supported-tld');
}
/**
* @throws TransportExceptionInterface
*/
protected function assertAuthentication(): void
{
try {
$response = $this->client->request(
'GET',
'/v4/hello',
(new HttpOptions())
->setHeader('Accept', 'application/json')
->setAuthBasic($this->authData['username'], $this->authData['token'])
->setBaseUri($this->kernel->isDebug() ? self::DEV_BASE_URL : self::BASE_URL)
->toArray()
);
} catch (\Exception) {
throw new BadRequestHttpException('Invalid Login');
}
if (Response::HTTP_OK !== $response->getStatusCode()) {
throw new BadRequestHttpException('The status of these credentials is not valid');
}
}
}