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:
Thomas Peterson 2026-06-09 17:24:57 +02:00
parent a233c34599
commit c542b2f9be
5 changed files with 314 additions and 69 deletions

View File

@ -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) ###

View 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),
];
}
}

View File

@ -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';

View File

@ -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)
}) })

View File

@ -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>