White-Label Phase 4: Custom-Domains (CRUD + DNS-Verify)
- DomainController /api/my-domains: Liste (Standard-Host + edgeTarget), Anlegen (pending), DNS-Verify (CNAME/A → Plattform-Edge), Löschen - Domain-API auf read-only beschränkt → verified-Domains nur via verifiziertem Controller (kein TLS-Gate-Bypass) - DomainsView neu: Standard-Adresse, eigene Domains, DNS-Anleitung, Prüfen-Button; Reseller-Topnav „Domains" - APP_EDGE_IP optional (sonst Portal-DNS-Auflösung) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a233c34599
commit
c542b2f9be
@ -52,6 +52,9 @@ APP_PORTAL_DOMAIN=localhost
|
|||||||
# Reverse-Proxy, dem X-Forwarded-* vertraut wird. Dev: kein echter Proxy → leer.
|
# Reverse-Proxy, dem X-Forwarded-* vertraut wird. Dev: kein echter Proxy → leer.
|
||||||
# Prod (.env.prod.local): das private Netz von Caddy, z. B. 10.0.0.0/16.
|
# Prod (.env.prod.local): das private Netz von Caddy, z. B. 10.0.0.0/16.
|
||||||
TRUSTED_PROXIES=127.0.0.1
|
TRUSTED_PROXIES=127.0.0.1
|
||||||
|
# Öffentliche IP der Caddy-Edge für Custom-Domain-Verifizierung. Optional:
|
||||||
|
# leer → es wird die DNS-Auflösung von APP_PORTAL_DOMAIN genutzt.
|
||||||
|
APP_EDGE_IP=
|
||||||
###< App ###
|
###< App ###
|
||||||
|
|
||||||
###> S3 / Object Storage (Druck-Assets) ###
|
###> S3 / Object Storage (Druck-Assets) ###
|
||||||
|
|||||||
196
backend/src/Controller/DomainController.php
Normal file
196
backend/src/Controller/DomainController.php
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Company;
|
||||||
|
use App\Entity\Domain;
|
||||||
|
use App\Entity\Reseller;
|
||||||
|
use App\Security\TenantContext;
|
||||||
|
use App\Service\TenantResolver;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Domains eines Tenants (KONZEPT §11): jeder Reseller/jede Firma kann
|
||||||
|
* neben der Standard-Subdomain eigene Domains hinterlegen. Aktivierung per
|
||||||
|
* CNAME/A auf die Plattform-Edge + DNS-Verifizierung; danach stellt Caddy
|
||||||
|
* On-Demand automatisch ein TLS-Zertifikat aus.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||||
|
final class DomainController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly TenantContext $tenant,
|
||||||
|
private readonly TenantResolver $resolver,
|
||||||
|
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
|
||||||
|
private readonly string $portalDomain,
|
||||||
|
#[Autowire('%env(default::APP_EDGE_IP)%')]
|
||||||
|
private readonly ?string $edgeIp,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/my-domains', name: 'my_domains_list', methods: ['GET'])]
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
[$scope, $entity] = $this->target();
|
||||||
|
$defaultHost = $entity instanceof Company
|
||||||
|
? $this->resolver->companyHost($entity)
|
||||||
|
: $this->resolver->resellerHost($entity);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'scope' => $scope,
|
||||||
|
'defaultHost' => $defaultHost,
|
||||||
|
'edgeTarget' => $this->portalDomain,
|
||||||
|
'edgeIp' => $this->edgeIp(),
|
||||||
|
'domains' => array_map([$this, 'serialize'], $this->ownDomains($entity)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/my-domains', name: 'my_domains_create', methods: ['POST'])]
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
[, $entity] = $this->target();
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$hostname = $this->normalizeHostname((string) ($data['hostname'] ?? ''));
|
||||||
|
if (null === $hostname) {
|
||||||
|
throw new BadRequestHttpException('Ungültiger Hostname.');
|
||||||
|
}
|
||||||
|
if (null !== $this->em->getRepository(Domain::class)->findOneBy(['hostname' => $hostname])) {
|
||||||
|
throw new BadRequestHttpException('Diese Domain ist bereits hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = (new Domain())
|
||||||
|
->setHostname($hostname)
|
||||||
|
->setType(Domain::TYPE_CUSTOM)
|
||||||
|
->setStatus(Domain::STATUS_PENDING);
|
||||||
|
if ($entity instanceof Company) {
|
||||||
|
$domain->setCompany($entity);
|
||||||
|
} else {
|
||||||
|
$domain->setReseller($entity);
|
||||||
|
}
|
||||||
|
$this->em->persist($domain);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->serialize($domain), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/my-domains/{id}/verify', name: 'my_domains_verify', methods: ['POST'])]
|
||||||
|
public function verify(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$domain = $this->ownDomain($id);
|
||||||
|
$verified = $this->dnsPointsToUs($domain->getHostname());
|
||||||
|
$domain->setStatus($verified ? Domain::STATUS_VERIFIED : Domain::STATUS_FAILED);
|
||||||
|
$domain->setVerificationCheckedAt(new \DateTimeImmutable());
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'domain' => $this->serialize($domain),
|
||||||
|
'ok' => $verified,
|
||||||
|
'message' => $verified
|
||||||
|
? 'Domain verifiziert. Das TLS-Zertifikat wird beim ersten Aufruf automatisch ausgestellt.'
|
||||||
|
: 'DNS zeigt noch nicht auf die Plattform. Bitte CNAME/A-Eintrag prüfen (Propagierung kann dauern).',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/my-domains/{id}', name: 'my_domains_delete', methods: ['DELETE'])]
|
||||||
|
public function delete(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$domain = $this->ownDomain($id);
|
||||||
|
$this->em->remove($domain);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0:string,1:Company|Reseller} */
|
||||||
|
private function target(): array
|
||||||
|
{
|
||||||
|
$company = $this->tenant->getCompany();
|
||||||
|
if ($company instanceof Company) {
|
||||||
|
return ['company', $company];
|
||||||
|
}
|
||||||
|
$reseller = $this->tenant->getReseller();
|
||||||
|
if ($reseller instanceof Reseller) {
|
||||||
|
return ['reseller', $reseller];
|
||||||
|
}
|
||||||
|
throw new AccessDeniedHttpException('Kein Domain-Kontext.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Domain[] */
|
||||||
|
private function ownDomains(Company|Reseller $entity): array
|
||||||
|
{
|
||||||
|
$criteria = $entity instanceof Company ? ['company' => $entity] : ['reseller' => $entity];
|
||||||
|
|
||||||
|
return $this->em->getRepository(Domain::class)->findBy($criteria, ['hostname' => 'ASC']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownDomain(string $id): Domain
|
||||||
|
{
|
||||||
|
$domain = $this->em->getRepository(Domain::class)->find(Uuid::fromString($id));
|
||||||
|
if (!$domain instanceof Domain) {
|
||||||
|
throw new NotFoundHttpException('Domain nicht gefunden.');
|
||||||
|
}
|
||||||
|
[, $entity] = $this->target();
|
||||||
|
$owns = $entity instanceof Company
|
||||||
|
? $domain->getCompany()?->getId()->equals($entity->getId())
|
||||||
|
: $domain->getReseller()?->getId()->equals($entity->getId()) && null === $domain->getCompany();
|
||||||
|
if (true !== $owns) {
|
||||||
|
throw new AccessDeniedHttpException('Domain gehört nicht zum eigenen Kontext.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob der Hostname (per A oder via CNAME) auf die Plattform-Edge zeigt. */
|
||||||
|
private function dnsPointsToUs(string $hostname): bool
|
||||||
|
{
|
||||||
|
$hostIps = gethostbynamel($hostname);
|
||||||
|
if (false === $hostIps || [] === $hostIps) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$ourIps = null !== $this->edgeIp() ? [$this->edgeIp()] : (gethostbynamel($this->portalDomain) ?: []);
|
||||||
|
|
||||||
|
return [] !== array_intersect($hostIps, $ourIps);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function edgeIp(): ?string
|
||||||
|
{
|
||||||
|
$ip = trim((string) $this->edgeIp);
|
||||||
|
|
||||||
|
return '' !== $ip ? $ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeHostname(string $host): ?string
|
||||||
|
{
|
||||||
|
$host = strtolower(trim($host));
|
||||||
|
$host = preg_replace('#^https?://#', '', $host) ?? $host;
|
||||||
|
$host = rtrim($host, '/.');
|
||||||
|
if (1 !== preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $host)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serialize(Domain $domain): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (string) $domain->getId(),
|
||||||
|
'hostname' => $domain->getHostname(),
|
||||||
|
'type' => $domain->getType(),
|
||||||
|
'status' => $domain->getStatus(),
|
||||||
|
'tlsStatus' => $domain->getTlsStatus(),
|
||||||
|
'checkedAt' => $domain->getVerificationCheckedAt()?->format(\DateTimeInterface::ATOM),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use App\Repository\DomainRepository;
|
use App\Repository\DomainRepository;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
@ -13,8 +15,13 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
* Gehört zu genau EINEM Reseller ODER einer Firma (Firmen-Domain ist via
|
* Gehört zu genau EINEM Reseller ODER einer Firma (Firmen-Domain ist via
|
||||||
* company.reseller implizit auch reseller-zugeordnet).
|
* company.reseller implizit auch reseller-zugeordnet).
|
||||||
*/
|
*/
|
||||||
|
// Schreiben läuft ausschließlich über DomainController (DNS-Verifizierung),
|
||||||
|
// damit keine 'verified'-Domain das On-Demand-TLS-Gate umgehen kann.
|
||||||
#[ORM\Entity(repositoryClass: DomainRepository::class)]
|
#[ORM\Entity(repositoryClass: DomainRepository::class)]
|
||||||
#[ApiResource(security: "is_granted('ROLE_RESELLER_ADMIN')")]
|
#[ApiResource(
|
||||||
|
operations: [new GetCollection(), new Get()],
|
||||||
|
security: "is_granted('ROLE_RESELLER_ADMIN')",
|
||||||
|
)]
|
||||||
class Domain implements ResellerOwnedInterface
|
class Domain implements ResellerOwnedInterface
|
||||||
{
|
{
|
||||||
public const TYPE_SUBDOMAIN = 'subdomain';
|
public const TYPE_SUBDOMAIN = 'subdomain';
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const topNav = computed<NavItem[]>(() => {
|
|||||||
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin },
|
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin },
|
||||||
{ label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin },
|
{ label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin },
|
||||||
{ label: 'Branding', to: '/app/branding', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isResellerAdmin },
|
{ label: 'Branding', to: '/app/branding', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isResellerAdmin },
|
||||||
|
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isResellerAdmin },
|
||||||
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above },
|
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above },
|
||||||
].filter((i) => i.show)
|
].filter((i) => i.show)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,101 +1,131 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { list, create, remove } from '@/api/resources'
|
import client from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
|
|
||||||
interface Domain {
|
interface DomainItem {
|
||||||
'@id': string; id: string; hostname: string; type: string; status: string; tlsStatus: string; company: string
|
id: string; hostname: string; type: string; status: string; tlsStatus: string; checkedAt: string | null
|
||||||
|
}
|
||||||
|
interface DomainsDto {
|
||||||
|
scope: string; defaultHost: string; edgeTarget: string; edgeIp: string | null; domains: DomainItem[]
|
||||||
}
|
}
|
||||||
interface Company { '@id': string; name: string }
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const data = ref<DomainsDto | null>(null)
|
||||||
// Beispiel-IP für Custom-Domains (A-Record). In Prod aus Config.
|
|
||||||
const PLATFORM_IP = '203.0.113.10'
|
|
||||||
|
|
||||||
const domains = ref<Domain[]>([])
|
|
||||||
const companies = ref<Company[]>([])
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const blank = () => ({ hostname: '', type: 'custom', company: '' })
|
const hostname = ref('')
|
||||||
const form = ref(blank())
|
const busy = ref('')
|
||||||
|
const notice = ref('')
|
||||||
const ownCompanyIri = computed(() =>
|
|
||||||
auth.user?.company ? `/api/companies/${auth.user.company.id}` : companies.value[0]?.['@id'] ?? '',
|
|
||||||
)
|
|
||||||
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c.name])))
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
;[companies.value, domains.value] = await Promise.all([
|
try {
|
||||||
list<Company>('companies').then((r) => r.member).catch(() => []),
|
data.value = (await client.get<DomainsDto>('/my-domains')).data
|
||||||
list<Domain>('domains').then((r) => r.member),
|
} catch {
|
||||||
])
|
error.value = 'Domains konnten nicht geladen werden.'
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
function openCreate() { form.value = blank(); form.value.company = ownCompanyIri.value; error.value = ''; showForm.value = true }
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
saving.value = true; error.value = ''
|
saving.value = true; error.value = ''
|
||||||
try {
|
try {
|
||||||
await create('domains', { hostname: form.value.hostname, type: form.value.type, company: form.value.company || ownCompanyIri.value })
|
await client.post('/my-domains', { hostname: hostname.value })
|
||||||
showForm.value = false; await load()
|
showForm.value = false; hostname.value = ''
|
||||||
} catch { error.value = 'Speichern fehlgeschlagen (Hostname evtl. schon vergeben).' } finally { saving.value = false }
|
await load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { message?: string } } }
|
||||||
|
error.value = err.response?.data?.message ?? 'Speichern fehlgeschlagen.'
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(d: DomainItem) {
|
||||||
|
busy.value = d.id; notice.value = ''
|
||||||
|
try {
|
||||||
|
const { data: res } = await client.post<{ ok: boolean; message: string }>(`/my-domains/${d.id}/verify`)
|
||||||
|
notice.value = res.message
|
||||||
|
await load()
|
||||||
|
} catch { notice.value = 'Prüfung fehlgeschlagen.' } finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(d: DomainItem) {
|
||||||
|
if (!confirm(`Domain „${d.hostname}" löschen?`)) return
|
||||||
|
await client.delete(`/my-domains/${d.id}`)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: string) {
|
||||||
|
return s === 'verified' ? 'Verifiziert' : s === 'failed' ? 'Nicht erreichbar' : 'Ausstehend'
|
||||||
|
}
|
||||||
|
function statusClass(s: string) {
|
||||||
|
return s === 'verified' ? 'badge-active' : s === 'failed' ? 'badge-danger' : 'badge-inactive'
|
||||||
}
|
}
|
||||||
async function del(d: Domain) { if (confirm(`Domain „${d.hostname}" löschen?`)) { await remove(d['@id']); await load() } }
|
|
||||||
function statusClass(s: string) { return s === 'verified' ? 'badge-active' : 'badge-inactive' }
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div><h1>Domains</h1><p class="muted">Subdomains & eigene Domains der Firmenkunden</p></div>
|
<div><h1>Domains</h1><p class="muted">Ihre Standard-Adresse und eigene Domains.</p></div>
|
||||||
<button class="btn btn-primary" @click="openCreate">+ Domain hinzufügen</button>
|
<button class="btn btn-primary" @click="showForm = true">+ Eigene Domain</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
|
<p v-if="loading" class="muted">Lädt…</p>
|
||||||
|
|
||||||
|
<template v-else-if="data">
|
||||||
|
<div class="card default-card">
|
||||||
|
<div>
|
||||||
|
<div class="muted sm">Standard-Adresse</div>
|
||||||
|
<strong class="host">{{ data.defaultHost }}</strong>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-active">aktiv</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<thead><tr><th>Hostname</th><th>Typ</th><th>Status</th><th>TLS</th><th>Firma</th><th></th></tr></thead>
|
<thead><tr><th>Eigene Domain</th><th>Status</th><th>TLS</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="loading"><td colspan="6" class="empty">Lädt…</td></tr>
|
<tr v-if="!data.domains.length"><td colspan="4" class="empty">Noch keine eigene Domain hinterlegt.</td></tr>
|
||||||
<tr v-else-if="!domains.length"><td colspan="6" class="empty">Noch keine Domains.</td></tr>
|
<tr v-for="d in data.domains" :key="d.id">
|
||||||
<tr v-for="d in domains" :key="d.id">
|
|
||||||
<td><strong>{{ d.hostname }}</strong></td>
|
<td><strong>{{ d.hostname }}</strong></td>
|
||||||
<td class="muted">{{ d.type === 'custom' ? 'Eigene' : 'Subdomain' }}</td>
|
<td><span class="badge" :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td>
|
||||||
<td><span class="badge" :class="statusClass(d.status)">{{ d.status }}</span></td>
|
<td class="muted">{{ d.status === 'verified' ? 'automatisch' : '–' }}</td>
|
||||||
<td class="muted">{{ d.tlsStatus }}</td>
|
<td class="right">
|
||||||
<td class="muted">{{ companyMap[d.company] ?? '' }}</td>
|
<button v-if="d.status !== 'verified'" class="btn btn-soft btn-sm" :disabled="busy === d.id" @click="verify(d)">
|
||||||
<td class="right"><button class="btn btn-ghost btn-sm" @click="del(d)">Löschen</button></td>
|
{{ busy === d.id ? 'Prüfe…' : 'Prüfen' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="del(d)">Löschen</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="notice" class="notice">{{ notice }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Modal v-if="showForm" title="Domain hinzufügen" @close="showForm = false">
|
<Modal v-if="showForm" title="Eigene Domain hinzufügen" @close="showForm = false">
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Typ</label>
|
<label>Hostname</label>
|
||||||
<select class="input" v-model="form.type">
|
<input class="input" v-model="hostname" required placeholder="karten.ihre-firma.de" />
|
||||||
<option value="custom">Eigene Domain</option>
|
|
||||||
<option value="subdomain">Subdomain</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Hostname</label><input class="input" v-model="form.hostname" required placeholder="visitenkarte.firma.de" /></div>
|
<div class="hint" v-if="data">
|
||||||
<div v-if="form.type === 'custom'" class="hint">
|
Richten Sie bei Ihrem DNS-Anbieter einen Eintrag auf die Plattform ein:
|
||||||
Nach dem Anlegen einen <strong>A-Record</strong> auf <code>{{ PLATFORM_IP }}</code> setzen.
|
<ul>
|
||||||
Die Plattform prüft die Domain und stellt automatisch ein TLS-Zertifikat aus.
|
<li>Subdomain (z. B. <code>karten.ihre-firma.de</code>): <strong>CNAME</strong> →
|
||||||
</div>
|
<code>{{ data.edgeTarget }}</code></li>
|
||||||
<div class="field" v-if="auth.isResellerAdmin || auth.isPlatformAdmin">
|
<li>Haupt-Domain (z. B. <code>ihre-firma.de</code>): <strong>A-Record</strong> →
|
||||||
<label>Firma</label>
|
<code>{{ data.edgeIp || 'IP der Plattform-Edge' }}</code></li>
|
||||||
<select class="input" v-model="form.company">
|
</ul>
|
||||||
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
|
Danach „Prüfen" – das TLS-Zertifikat wird beim ersten Aufruf automatisch ausgestellt.
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
|
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
|
||||||
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
|
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Hinzufügen' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -105,14 +135,22 @@ onMounted(load)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
|
||||||
.page-head .muted { margin: .2rem 0 0; }
|
.page-head .muted { margin: .2rem 0 0; }
|
||||||
|
.sm { font-size: .8rem; }
|
||||||
|
.default-card { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.default-card .host { font-size: 1.1rem; display: block; margin-top: .2rem; }
|
||||||
.tbl { width: 100%; border-collapse: collapse; }
|
.tbl { width: 100%; border-collapse: collapse; }
|
||||||
.tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
|
.tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
|
||||||
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; }
|
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
|
||||||
.tbl tr:last-child td { border-bottom: none; }
|
.tbl tr:last-child td { border-bottom: none; }
|
||||||
.right { text-align: right; }
|
.right { text-align: right; white-space: nowrap; }
|
||||||
|
.right .btn + .btn { margin-left: .4rem; }
|
||||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
||||||
|
.badge-danger { background: #fdecea; color: var(--danger); }
|
||||||
.hint { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .8rem .9rem; font-size: .85rem; margin-bottom: .9rem; }
|
.hint { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .8rem .9rem; font-size: .85rem; margin-bottom: .9rem; }
|
||||||
|
.hint ul { margin: .5rem 0; padding-left: 1.1rem; }
|
||||||
|
.hint li { margin: .25rem 0; }
|
||||||
.hint code { background: #fff; padding: 1px 6px; border-radius: 5px; }
|
.hint code { background: #fff; padding: 1px 6px; border-radius: 5px; }
|
||||||
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
|
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
|
||||||
.error { color: var(--danger); font-size: .88rem; }
|
.error { color: var(--danger); font-size: .88rem; }
|
||||||
|
.notice { margin-top: .8rem; background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .7rem .9rem; font-size: .88rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user