Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
@@ -0,0 +1 @@
"""Domain mapping module for multi-domain SaaS routing."""
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db.common import CommonBase
class DomainMapping(CommonBase):
"""Maps an incoming domain/host to marketplace, tenant, branch, or consultant context.
Phase 7T.1 only stores and manages the mappings. Runtime domain resolution is added
in Phase 7T.2, so this table is intentionally safe and independent.
"""
__tablename__ = "domain_mappings"
__table_args__ = (
UniqueConstraint("domain_name", name="uq_domain_mappings_domain_name"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
domain_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
domain_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True)
parent_tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True)
verification_token: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
dns_txt_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
dns_txt_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
ssl_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="manual")
ssl_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_checked", index=True)
ssl_provider: Mapped[str | None] = mapped_column(String(60), nullable=True)
ssl_last_checked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ssl_not_after_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ssl_issuer: Mapped[str | None] = mapped_column(String(255), nullable=True)
ssl_subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
ssl_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
tenant = relationship("Tenant", foreign_keys=[tenant_id])
branch = relationship("Branch", foreign_keys=[branch_id])
consultant = relationship("ConsultantProfile", foreign_keys=[consultant_id])
parent_tenant = relationship("Tenant", foreign_keys=[parent_tenant_id])
+880
View File
@@ -0,0 +1,880 @@
from __future__ import annotations
import re
import secrets
import socket
import ssl
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.modules.consultants.models import ConsultantProfile
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.domain_management.models import DomainMapping
DOMAIN_TYPES: tuple[tuple[str, str], ...] = (
("marketplace", "Marketplace / SaaS platform"),
("audit_firm_domain", "Audit firm custom domain"),
("audit_firm_subdomain", "Audit firm platform subdomain"),
("consultant_custom_domain", "Consultant custom domain"),
("consultant_firm_domain", "Consultant under firm domain"),
("consultant_marketplace_subdomain", "Consultant platform subdomain"),
)
PLATFORM_SUBDOMAIN_BASE_DOMAINS: tuple[str, ...] = ("filingabc.com", "filingabc.local")
RESERVED_SUBDOMAIN_LABELS: set[str] = {"www", "mail", "smtp", "imap", "pop", "api", "admin", "app", "portal", "client", "clients", "staff", "system", "marketplace", "support", "billing"}
DOMAIN_STATUSES: tuple[tuple[str, str], ...] = (
("draft", "Draft"),
("pending_verification", "Pending verification"),
("active", "Active"),
("suspended", "Suspended"),
("inactive", "Inactive"),
)
SSL_MODES: tuple[tuple[str, str], ...] = (
("manual", "Manual / deployment managed"),
("coolify", "Coolify proxy"),
("caddy", "Caddy"),
("traefik", "Traefik"),
("cloudflare", "Cloudflare"),
)
SSL_STATUSES: tuple[tuple[str, str], ...] = (
("not_checked", "Not checked"),
("pending_dns", "Pending DNS/proxy"),
("pending_ssl", "Pending SSL"),
("active", "Active"),
("failed", "Failed"),
("manual", "Manual / external"),
)
_HOST_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$", re.IGNORECASE)
@dataclass(frozen=True)
class DomainSslCheckResult:
ok: bool
status: str
message: str
subject: str | None = None
issuer: str | None = None
not_after_utc: datetime | None = None
@dataclass(frozen=True)
class DomainSslProxySnippet:
provider: str
title: str
body: str
@dataclass(frozen=True)
class DomainMappingPayload:
domain_name: str
domain_type: str
tenant_id: int | None = None
branch_id: int | None = None
consultant_id: int | None = None
parent_tenant_id: int | None = None
is_primary: bool = False
is_verified: bool = False
status: str = "draft"
ssl_mode: str = "manual"
notes: str | None = None
def normalize_domain(domain_name: str) -> str:
value = (domain_name or "").strip().lower()
value = value.removeprefix("http://").removeprefix("https://")
value = value.split("/", 1)[0]
value = value.split(":", 1)[0]
return value.rstrip(".")
def validate_domain_payload(db: Session, payload: DomainMappingPayload, mapping_id: int | None = None) -> list[str]:
errors: list[str] = []
domain_name = normalize_domain(payload.domain_name)
if not domain_name:
errors.append("Domain name is required.")
elif not _HOST_RE.match(domain_name) and domain_name not in {"localhost"}:
errors.append("Enter a valid domain name, for example filingabc.com or arrr.filingabc.com.")
if payload.domain_type not in {code for code, _label in DOMAIN_TYPES}:
errors.append("Invalid domain type selected.")
if payload.status not in {code for code, _label in DOMAIN_STATUSES}:
errors.append("Invalid status selected.")
if payload.ssl_mode not in {code for code, _label in SSL_MODES}:
errors.append("Invalid SSL mode selected.")
existing_stmt = select(DomainMapping).where(DomainMapping.domain_name == domain_name)
if mapping_id:
existing_stmt = existing_stmt.where(DomainMapping.id != mapping_id)
if db.execute(existing_stmt).scalar_one_or_none():
errors.append("This domain is already mapped.")
if payload.tenant_id and not db.get(Tenant, payload.tenant_id):
errors.append("Selected audit firm was not found.")
if payload.parent_tenant_id and not db.get(Tenant, payload.parent_tenant_id):
errors.append("Selected parent audit firm was not found.")
if payload.branch_id and not db.get(Branch, payload.branch_id):
errors.append("Selected branch was not found.")
if payload.consultant_id and not db.get(ConsultantProfile, payload.consultant_id):
errors.append("Selected consultant was not found.")
if payload.domain_type in {"audit_firm_domain", "audit_firm_subdomain"} and not payload.tenant_id:
errors.append("Audit firm domains must be linked to an audit firm.")
if payload.domain_type in {"consultant_custom_domain", "consultant_firm_domain", "consultant_marketplace_subdomain"} and not payload.consultant_id:
errors.append("Consultant domains must be linked to a consultant profile.")
if payload.domain_type == "consultant_firm_domain" and not payload.parent_tenant_id:
errors.append("Consultant firm-domain mappings must have a parent audit firm.")
if payload.domain_type == "audit_firm_subdomain" and domain_name:
parts = domain_name.split(".")
if len(parts) < 3:
errors.append("Audit firm subdomain must be like auditfirm.filingabc.com.")
subdomain_label = parts[0] if parts else ""
if subdomain_label in RESERVED_SUBDOMAIN_LABELS:
errors.append(f"'{subdomain_label}' is a reserved subdomain label. Please use another audit firm code.")
if subdomain_label and not re.match(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", subdomain_label):
errors.append("Subdomain label may contain only lowercase letters, numbers and hyphen, and cannot start/end with hyphen.")
return errors
def make_verification_token() -> str:
return "af-domain-" + secrets.token_urlsafe(24).replace("-", "").replace("_", "")[:36]
def dns_txt_name_for(domain_name: str) -> str:
return f"_audit-firm-verify.{normalize_domain(domain_name)}"
def list_domain_mappings(db: Session, *, q: str = "", status: str = "", domain_type: str = "") -> list[DomainMapping]:
stmt: Select[tuple[DomainMapping]] = select(DomainMapping).order_by(DomainMapping.created_at_utc.desc(), DomainMapping.id.desc())
q = (q or "").strip().lower()
if q:
like = f"%{q}%"
stmt = stmt.where(func.lower(DomainMapping.domain_name).like(like))
if status:
stmt = stmt.where(DomainMapping.status == status)
if domain_type:
stmt = stmt.where(DomainMapping.domain_type == domain_type)
return list(db.execute(stmt).scalars().all())
def create_domain_mapping(db: Session, payload: DomainMappingPayload, *, user_id: int | None) -> DomainMapping:
domain_name = normalize_domain(payload.domain_name)
token = make_verification_token()
mapping = DomainMapping(
domain_name=domain_name,
domain_type=payload.domain_type,
tenant_id=payload.tenant_id,
branch_id=payload.branch_id,
consultant_id=payload.consultant_id,
parent_tenant_id=payload.parent_tenant_id,
is_primary=payload.is_primary,
is_verified=payload.is_verified,
status="active" if payload.is_verified and payload.status == "active" else payload.status,
verification_token=token,
dns_txt_name=dns_txt_name_for(domain_name),
dns_txt_value=token,
ssl_mode=payload.ssl_mode or "manual",
notes=(payload.notes or "").strip() or None,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(mapping)
db.commit()
db.refresh(mapping)
return mapping
def update_domain_mapping(db: Session, mapping: DomainMapping, payload: DomainMappingPayload, *, user_id: int | None) -> DomainMapping:
old_domain = mapping.domain_name
mapping.domain_name = normalize_domain(payload.domain_name)
mapping.domain_type = payload.domain_type
mapping.tenant_id = payload.tenant_id
mapping.branch_id = payload.branch_id
mapping.consultant_id = payload.consultant_id
mapping.parent_tenant_id = payload.parent_tenant_id
mapping.is_primary = payload.is_primary
mapping.is_verified = payload.is_verified
mapping.status = payload.status
mapping.ssl_mode = payload.ssl_mode or "manual"
mapping.notes = (payload.notes or "").strip() or None
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
if old_domain != mapping.domain_name or not mapping.verification_token:
mapping.verification_token = make_verification_token()
mapping.dns_txt_name = dns_txt_name_for(mapping.domain_name)
mapping.dns_txt_value = mapping.verification_token
mapping.is_verified = False
if mapping.status == "active":
mapping.status = "pending_verification"
db.commit()
db.refresh(mapping)
return mapping
def regenerate_verification_token(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainMapping:
mapping.verification_token = make_verification_token()
mapping.dns_txt_name = dns_txt_name_for(mapping.domain_name)
mapping.dns_txt_value = mapping.verification_token
mapping.is_verified = False
if mapping.status == "active":
mapping.status = "pending_verification"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(mapping)
return mapping
def mark_verified(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainMapping:
mapping.is_verified = True
mapping.status = "active"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(mapping)
return mapping
def slugify_subdomain_label(value: str) -> str:
"""Create a safe subdomain label from a tenant code/name."""
raw = (value or "").strip().lower()
raw = re.sub(r"[^a-z0-9-]+", "-", raw)
raw = re.sub(r"-+", "-", raw).strip("-")
if not raw:
raw = "firm"
if raw in RESERVED_SUBDOMAIN_LABELS:
raw = f"{raw}-firm"
return raw[:63].strip("-") or "firm"
def tenant_subdomain_label(tenant: Tenant) -> str:
code = getattr(tenant, "code", None) or getattr(tenant, "name", None) or f"firm-{tenant.id}"
return slugify_subdomain_label(str(code))
def build_tenant_subdomain(tenant: Tenant, base_domain: str = "filingabc.com") -> str:
base = normalize_domain(base_domain or "filingabc.com")
return f"{tenant_subdomain_label(tenant)}.{base}"
def list_tenant_subdomain_candidates(db: Session, *, base_domain: str = "filingabc.com") -> list[dict]:
"""Return all tenants with their suggested platform subdomain and existing mapping, if any."""
base = normalize_domain(base_domain or "filingabc.com")
tenants = db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all()
out: list[dict] = []
for tenant in tenants:
domain_name = build_tenant_subdomain(tenant, base)
mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == domain_name)).scalar_one_or_none()
out.append({"tenant": tenant, "domain_name": domain_name, "mapping": mapping})
return out
def create_or_get_tenant_subdomain_mapping(
db: Session,
*,
tenant_id: int,
base_domain: str = "filingabc.com",
branch_id: int | None = None,
mark_verified_active: bool = True,
user_id: int | None = None,
) -> tuple[DomainMapping, bool]:
"""Create auditfirm.filingabc.com mapping for a tenant without duplicating existing rows.
Returns (mapping, created). The platform owner normally verifies wildcard DNS/SSL once,
so mark_verified_active=True is safe for the owned *.filingabc.com style domain.
"""
tenant = db.get(Tenant, int(tenant_id))
if not tenant:
raise ValueError("Selected audit firm was not found.")
domain_name = build_tenant_subdomain(tenant, base_domain)
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == domain_name)).scalar_one_or_none()
if existing:
return existing, False
payload = DomainMappingPayload(
domain_name=domain_name,
domain_type="audit_firm_subdomain",
tenant_id=tenant.id,
branch_id=branch_id,
is_primary=False,
is_verified=bool(mark_verified_active),
status="active" if mark_verified_active else "pending_verification",
ssl_mode="coolify",
notes="Auto-created platform tenant subdomain. Ensure wildcard DNS/SSL for *.filingabc.com is configured in deployment.",
)
errors = validate_domain_payload(db, payload)
if errors:
raise ValueError(" ".join(errors))
mapping = create_domain_mapping(db, payload, user_id=user_id)
return mapping, True
def create_or_get_firm_custom_domain_mapping(
db: Session,
*,
tenant_id: int,
domain_name: str = "arrr.associates",
branch_id: int | None = None,
mark_verified_active: bool = False,
user_id: int | None = None,
) -> tuple[DomainMapping, bool]:
"""Create an audit-firm custom domain mapping such as arrr.associates.
This is intentionally separate from tenant subdomains. Custom domains normally
need DNS verification before activation, so mark_verified_active defaults to
False. For a domain owned by the platform owner and already configured in DNS,
an admin may tick mark_verified_active to make it active immediately.
Returns (mapping, created).
"""
tenant = db.get(Tenant, int(tenant_id))
if not tenant:
raise ValueError("Selected audit firm was not found.")
normalized_domain = normalize_domain(domain_name or "arrr.associates")
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized_domain)).scalar_one_or_none()
if existing:
return existing, False
payload = DomainMappingPayload(
domain_name=normalized_domain,
domain_type="audit_firm_domain",
tenant_id=tenant.id,
branch_id=branch_id,
is_primary=True,
is_verified=bool(mark_verified_active),
status="active" if mark_verified_active else "pending_verification",
ssl_mode="coolify",
notes=(
"Custom audit firm domain created from Phase 7T.6. "
"Ensure DNS points to the ERP server and SSL/proxy configuration is completed before live use."
),
)
errors = validate_domain_payload(db, payload)
if errors:
raise ValueError(" ".join(errors))
mapping = create_domain_mapping(db, payload, user_id=user_id)
return mapping, True
def consultant_subdomain_label(consultant: ConsultantProfile) -> str:
"""Create a safe domain label for consultant profile domains."""
raw = (getattr(consultant, "firm_name", None) or getattr(consultant, "contact_person", None) or f"consultant-{consultant.id}")
return slugify_subdomain_label(str(raw))
def build_consultant_platform_subdomain(consultant: ConsultantProfile, base_domain: str = "filingabc.com") -> str:
base = normalize_domain(base_domain or "filingabc.com")
return f"{consultant_subdomain_label(consultant)}.{base}"
def build_consultant_firm_domain(consultant: ConsultantProfile, firm_base_domain: str = "arrr.accountant") -> str:
base = normalize_domain(firm_base_domain or "arrr.accountant")
return f"{consultant_subdomain_label(consultant)}.{base}"
def list_consultant_domain_candidates(
db: Session,
*,
base_domain: str = "filingabc.com",
firm_base_domain: str = "arrr.accountant",
) -> list[dict]:
"""Return consultants with suggested marketplace and firm-linked profile domains."""
consultants = db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all()
out: list[dict] = []
for consultant in consultants:
platform_domain = build_consultant_platform_subdomain(consultant, base_domain)
firm_domain = build_consultant_firm_domain(consultant, firm_base_domain)
platform_mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == platform_domain)).scalar_one_or_none()
firm_mapping = db.execute(select(DomainMapping).where(DomainMapping.domain_name == firm_domain)).scalar_one_or_none()
out.append({
"consultant": consultant,
"platform_domain": platform_domain,
"platform_mapping": platform_mapping,
"firm_domain": firm_domain,
"firm_mapping": firm_mapping,
})
return out
def create_or_get_consultant_domain_mapping(
db: Session,
*,
consultant_id: int,
domain_type: str = "consultant_marketplace_subdomain",
domain_name: str | None = None,
base_domain: str = "filingabc.com",
firm_base_domain: str = "arrr.accountant",
parent_tenant_id: int | None = None,
mark_verified_active: bool = False,
user_id: int | None = None,
) -> tuple[DomainMapping, bool]:
"""Create consultant profile domain mapping without duplicating existing rows.
Supported examples:
consultant_marketplace_subdomain -> consultant.filingabc.com
consultant_firm_domain -> consultant.arrr.accountant
consultant_custom_domain -> abc.accountants
"""
consultant = db.get(ConsultantProfile, int(consultant_id))
if not consultant:
raise ValueError("Selected consultant profile was not found.")
if domain_type == "consultant_marketplace_subdomain":
normalized_domain = normalize_domain(domain_name or build_consultant_platform_subdomain(consultant, base_domain))
resolved_parent_tenant_id = parent_tenant_id
ssl_mode = "coolify"
default_notes = "Auto-created consultant marketplace profile subdomain. Ensure wildcard DNS/SSL for the platform base domain is configured."
default_verified = True
elif domain_type == "consultant_firm_domain":
normalized_domain = normalize_domain(domain_name or build_consultant_firm_domain(consultant, firm_base_domain))
resolved_parent_tenant_id = parent_tenant_id or consultant.tenant_id
ssl_mode = "coolify"
default_notes = "Consultant domain under an audit firm brand. Ensure DNS/SSL for the firm consultant domain is configured."
default_verified = False
elif domain_type == "consultant_custom_domain":
if not domain_name:
raise ValueError("Custom consultant domain is required.")
normalized_domain = normalize_domain(domain_name)
resolved_parent_tenant_id = parent_tenant_id
ssl_mode = "coolify"
default_notes = "Consultant custom domain. Verify DNS ownership before activation."
default_verified = False
else:
raise ValueError("Invalid consultant domain type selected.")
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized_domain)).scalar_one_or_none()
if existing:
return existing, False
verified = bool(mark_verified_active or default_verified)
payload = DomainMappingPayload(
domain_name=normalized_domain,
domain_type=domain_type,
tenant_id=consultant.tenant_id,
branch_id=consultant.branch_id,
consultant_id=consultant.id,
parent_tenant_id=resolved_parent_tenant_id,
is_primary=False,
is_verified=verified,
status="active" if verified else "pending_verification",
ssl_mode=ssl_mode,
notes=default_notes,
)
errors = validate_domain_payload(db, payload)
if errors:
raise ValueError(" ".join(errors))
mapping = create_domain_mapping(db, payload, user_id=user_id)
return mapping, True
def reference_data(db: Session) -> dict:
return {
"tenants": db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all(),
"branches": db.execute(select(Branch).order_by(Branch.name.asc())).scalars().all(),
"consultants": db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all(),
"domain_types": DOMAIN_TYPES,
"statuses": DOMAIN_STATUSES,
"ssl_modes": SSL_MODES,
"ssl_statuses": SSL_STATUSES,
}
@dataclass(frozen=True)
class DomainResolution:
"""Resolved runtime domain context used by Phase 7T.2 middleware."""
is_resolved: bool
host: str
mapping_id: int | None = None
domain_name: str | None = None
domain_type: str | None = None
tenant_id: int | None = None
tenant_code: str | None = None
branch_id: int | None = None
branch_code: str | None = None
consultant_id: int | None = None
parent_tenant_id: int | None = None
is_verified: bool = False
status: str | None = None
def normalize_request_host(host_header: str | None) -> str:
"""Normalise the HTTP Host / X-Forwarded-Host value for lookup.
Handles values such as:
localhost:8000
arrr.associates
arrr.associates:443
arrr.associates, proxy-host
"""
value = (host_header or "").strip().lower()
if not value:
return ""
value = value.split(",", 1)[0].strip()
value = value.removeprefix("http://").removeprefix("https://")
value = value.split("/", 1)[0]
if value.startswith("[") and "]" in value:
# IPv6 literal; keep without brackets/port for safety.
value = value.split("]", 1)[0].lstrip("[")
else:
value = value.split(":", 1)[0]
return value.rstrip(".")
def resolve_domain_context(db: Session, host: str) -> DomainResolution:
"""Resolve an incoming host to an active domain mapping.
Phase 7T.2 intentionally resolves only exact domain mappings. Wildcard/platform
subdomain inference comes later in Phase 7T.5, and custom-domain DNS checks come
in Phase 7T.8.
"""
normalized = normalize_domain(host)
if not normalized:
return DomainResolution(is_resolved=False, host="")
stmt = (
select(DomainMapping, Tenant.code, Branch.code)
.outerjoin(Tenant, Tenant.id == DomainMapping.tenant_id)
.outerjoin(Branch, Branch.id == DomainMapping.branch_id)
.where(DomainMapping.domain_name == normalized)
.where(DomainMapping.status == "active")
.where(DomainMapping.is_verified.is_(True))
.limit(1)
)
row = db.execute(stmt).first()
if not row:
return DomainResolution(is_resolved=False, host=normalized)
mapping, tenant_code, branch_code = row
return DomainResolution(
is_resolved=True,
host=normalized,
mapping_id=int(mapping.id),
domain_name=mapping.domain_name,
domain_type=mapping.domain_type,
tenant_id=mapping.tenant_id,
tenant_code=tenant_code,
branch_id=mapping.branch_id,
branch_code=branch_code,
consultant_id=mapping.consultant_id,
parent_tenant_id=mapping.parent_tenant_id,
is_verified=bool(mapping.is_verified),
status=mapping.status,
)
@dataclass(frozen=True)
class DomainDnsVerificationResult:
ok: bool
message: str
txt_name: str
expected_value: str
found_values: tuple[str, ...] = ()
def _normalise_txt_value(value: str) -> str:
value = (value or "").strip()
# TXT records may be returned with quotes and split chunks.
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
value = value[1:-1]
value = value.replace('" "', '').replace('"', '').strip()
return value
def _resolve_txt_with_dnspython(txt_name: str) -> tuple[list[str], str | None]:
try:
import dns.resolver # type: ignore
except Exception:
return [], "dnspython is not installed"
try:
answers = dns.resolver.resolve(txt_name, "TXT")
values: list[str] = []
for answer in answers:
try:
chunks = [part.decode("utf-8", errors="ignore") if isinstance(part, bytes) else str(part) for part in answer.strings]
values.append("".join(chunks))
except Exception:
values.append(str(answer).strip())
return values, None
except Exception as exc:
return [], str(exc)
def _resolve_txt_with_nslookup(txt_name: str) -> tuple[list[str], str | None]:
try:
completed = subprocess.run(
["nslookup", "-type=TXT", txt_name],
capture_output=True,
text=True,
timeout=12,
check=False,
)
except FileNotFoundError:
return [], "nslookup command is not available on this machine"
except Exception as exc:
return [], str(exc)
output = "\n".join([completed.stdout or "", completed.stderr or ""])
if completed.returncode != 0 and not output.strip():
return [], "DNS lookup failed"
values: list[str] = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
# Windows/Linux nslookup generally prints TXT values inside quotes.
quoted = re.findall(r'"([^"]+)"', line)
if quoted:
values.append("".join(quoted))
continue
if "text =" in line.lower():
values.append(line.split("=", 1)[1].strip())
if not values and output.strip():
# Keep a short diagnostic without storing full command noise.
return [], output.strip().splitlines()[-1][:240]
return values, None
def lookup_dns_txt_values(txt_name: str) -> tuple[list[str], str | None]:
"""Return TXT records for a DNS name using dnspython when available, else nslookup.
No new dependency is required. On Windows, nslookup is normally available by default.
"""
txt_name = normalize_domain(txt_name)
if not txt_name:
return [], "TXT name is empty"
values, error = _resolve_txt_with_dnspython(txt_name)
if values:
return [_normalise_txt_value(v) for v in values], None
ns_values, ns_error = _resolve_txt_with_nslookup(txt_name)
if ns_values:
return [_normalise_txt_value(v) for v in ns_values], None
return [], ns_error or error or "No TXT record found"
def verify_domain_dns_txt(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainDnsVerificationResult:
"""Verify a custom/platform domain using its DNS TXT token.
If the expected TXT value is found, the domain is marked verified and active. If not,
the mapping remains unverified and is moved to pending_verification unless suspended.
"""
txt_name = mapping.dns_txt_name or dns_txt_name_for(mapping.domain_name)
expected = mapping.dns_txt_value or mapping.verification_token or ""
if not expected:
mapping.verification_token = make_verification_token()
mapping.dns_txt_name = txt_name
mapping.dns_txt_value = mapping.verification_token
expected = mapping.dns_txt_value
db.commit()
db.refresh(mapping)
found_values, error = lookup_dns_txt_values(txt_name)
found_normalised = tuple(_normalise_txt_value(v) for v in found_values if _normalise_txt_value(v))
expected_normalised = _normalise_txt_value(expected)
matched = any(v == expected_normalised or expected_normalised in v for v in found_normalised)
now = datetime.now(timezone.utc)
if matched:
mapping.is_verified = True
mapping.status = "active"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = now
db.commit()
db.refresh(mapping)
return DomainDnsVerificationResult(
ok=True,
message="DNS TXT verification successful. Domain is now active.",
txt_name=txt_name,
expected_value=expected_normalised,
found_values=found_normalised,
)
if mapping.status not in {"suspended", "inactive"}:
mapping.status = "pending_verification"
mapping.is_verified = False
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = now
db.commit()
db.refresh(mapping)
msg = "DNS TXT record not found or value does not match."
if error:
msg = f"{msg} DNS response: {error}"
return DomainDnsVerificationResult(
ok=False,
message=msg,
txt_name=txt_name,
expected_value=expected_normalised,
found_values=found_normalised,
)
def list_domains_requiring_verification(db: Session) -> list[DomainMapping]:
stmt = (
select(DomainMapping)
.where(DomainMapping.is_verified.is_(False))
.where(DomainMapping.status.in_(["draft", "pending_verification"]))
.order_by(DomainMapping.updated_at_utc.desc(), DomainMapping.id.desc())
)
return list(db.execute(stmt).scalars().all())
def verify_all_pending_domains(db: Session, *, user_id: int | None, limit: int = 25) -> list[tuple[DomainMapping, DomainDnsVerificationResult]]:
results: list[tuple[DomainMapping, DomainDnsVerificationResult]] = []
for mapping in list_domains_requiring_verification(db)[: max(1, min(limit, 100))]:
result = verify_domain_dns_txt(db, mapping, user_id=user_id)
results.append((mapping, result))
return results
def _name_tuple_to_text(name_tuple) -> str | None:
try:
parts: list[str] = []
for section in name_tuple or []:
for key, value in section:
if value:
parts.append(f"{key}={value}")
return ", ".join(parts) if parts else None
except Exception:
return None
def _parse_ssl_not_after(value: str | None) -> datetime | None:
if not value:
return None
try:
dt = parsedate_to_datetime(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def check_domain_ssl_certificate(db: Session, mapping: DomainMapping, *, user_id: int | None = None, timeout: float = 5.0) -> DomainSslCheckResult:
"""Check whether the domain currently serves a valid TLS certificate on 443.
This does not issue certificates. Certificate issuance is handled by Coolify/Caddy/
Traefik/Cloudflare/Certbot. This function records readiness/status inside ERP.
"""
domain = normalize_domain(mapping.domain_name)
now = datetime.now(timezone.utc)
if not domain or domain == "localhost":
mapping.ssl_status = "manual"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_last_error = "Localhost does not require public SSL certificate checking."
db.commit()
return DomainSslCheckResult(False, "manual", mapping.ssl_last_error)
if not mapping.is_verified or mapping.status != "active":
mapping.ssl_status = "pending_dns"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_last_error = "Domain must be verified and active before SSL check."
db.commit()
return DomainSslCheckResult(False, "pending_dns", mapping.ssl_last_error)
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=timeout) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
subject = _name_tuple_to_text(cert.get("subject"))
issuer = _name_tuple_to_text(cert.get("issuer"))
not_after = _parse_ssl_not_after(cert.get("notAfter"))
mapping.ssl_status = "active"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_not_after_utc = not_after
mapping.ssl_subject = subject
mapping.ssl_issuer = issuer
mapping.ssl_last_error = None
db.commit()
db.refresh(mapping)
return DomainSslCheckResult(True, "active", "SSL certificate is active and trusted.", subject, issuer, not_after)
except Exception as exc:
mapping.ssl_status = "failed"
mapping.ssl_last_checked_at_utc = now
mapping.ssl_last_error = str(exc)[:1000]
db.commit()
db.refresh(mapping)
return DomainSslCheckResult(False, "failed", f"SSL check failed: {mapping.ssl_last_error}")
def mark_ssl_managed(db: Session, mapping: DomainMapping, *, provider: str, user_id: int | None = None) -> DomainMapping:
provider = (provider or mapping.ssl_mode or "manual").strip().lower()
valid = {code for code, _label in SSL_MODES}
if provider not in valid:
provider = "manual"
mapping.ssl_provider = provider
mapping.ssl_mode = provider
mapping.ssl_status = "manual" if provider in {"manual", "cloudflare"} else "pending_ssl"
mapping.updated_by_user_id = user_id
mapping.updated_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(mapping)
return mapping
def list_ssl_domains(db: Session) -> list[DomainMapping]:
stmt = (
select(DomainMapping)
.where(DomainMapping.status.in_(["active", "pending_verification", "draft"]))
.order_by(DomainMapping.domain_type.asc(), DomainMapping.domain_name.asc())
)
return list(db.execute(stmt).scalars().all())
def build_ssl_proxy_snippets(mapping: DomainMapping, *, app_upstream: str = "http://127.0.0.1:8000") -> list[DomainSslProxySnippet]:
domain = normalize_domain(mapping.domain_name)
if not domain or domain == "localhost":
return []
caddy = f"""{domain} {{
encode gzip
reverse_proxy {app_upstream}
}}"""
nginx = f"""server {{
listen 80;
server_name {domain};
location / {{
proxy_pass {app_upstream};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}}
}}
# After DNS points to this server:
# certbot --nginx -d {domain}"""
coolify = f"""Coolify setup checklist for {domain}
1. Open the Audit ERP application in Coolify.
2. Add domain: https://{domain}
3. Ensure DNS A/CNAME points to the Coolify server.
4. Enable Force HTTPS after certificate is issued.
5. Keep Host header forwarding enabled.
6. In ERP, verify DNS TXT and then run SSL check."""
return [
DomainSslProxySnippet("coolify", "Coolify domain setup", coolify),
DomainSslProxySnippet("caddy", "Caddy automatic HTTPS snippet", caddy),
DomainSslProxySnippet("nginx", "Nginx + Certbot snippet", nginx),
]
@@ -0,0 +1,167 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Consultant Profile Domains</h1>
<p class="mt-1 text-sm text-slate-500">Create domains for consultant public profile, login and lead capture pages. Examples: abc.accountants, consultant.filingabc.com, or yourname.arrr.accountant.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">All Domains</a>
<a href="/domains/tenant-subdomains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Tenant Subdomains</a>
<a href="/domains/firm-domain" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Firm Domain</a>
</div>
</div>
{% if errors %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<ul class="list-disc pl-5">
{% for err in errors %}<li>{{ err }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% if message %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{{ message }}</div>
{% endif %}
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div class="xl:col-span-2 rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Create Consultant Domain</h2>
<p class="mt-1 text-sm text-slate-500">Use platform subdomains for quick setup. Use custom domains only after the consultant points DNS to your ERP server.</p>
<form method="post" action="/domains/consultant-domains/create" class="mt-5 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Consultant</label>
<select name="consultant_id" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm" required>
<option value="">Select consultant</option>
{% for c in consultants %}
<option value="{{ c.id }}">{{ c.firm_name or c.contact_person }}{% if c.email %} — {{ c.email }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Domain type</label>
<select name="domain_type" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm">
<option value="consultant_marketplace_subdomain">Consultant platform subdomain</option>
<option value="consultant_firm_domain">Consultant under firm domain</option>
<option value="consultant_custom_domain">Consultant custom domain</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Marketplace base domain</label>
<input name="base_domain" value="{{ base_domain or 'filingabc.com' }}" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm" placeholder="filingabc.com" />
<p class="mt-1 text-xs text-slate-500">Used for consultant.filingabc.com.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Firm consultant base domain</label>
<input name="firm_base_domain" value="{{ firm_base_domain or 'arrr.accountant' }}" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm" placeholder="arrr.accountant" />
<p class="mt-1 text-xs text-slate-500">Used for yourname.arrr.accountant.</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Custom / override domain, optional</label>
<input name="domain_name" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm" placeholder="abc.accountants or yourname.arrr.accountant" />
<p class="mt-1 text-xs text-slate-500">Leave blank to auto-generate based on the selected consultant and base domain. Enter a value for custom domains.</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Parent audit firm, optional</label>
<select name="parent_tenant_id" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm">
<option value="">Use consultant's linked firm / not applicable</option>
{% for t in tenants %}
<option value="{{ t.id }}">{{ t.display_name or t.name }}</option>
{% endfor %}
</select>
</div>
<div class="rounded-2xl border bg-slate-50 p-4 text-sm text-slate-600">
<div class="font-medium text-slate-900">Activation rule</div>
<p class="mt-1">Platform subdomains can be active immediately if wildcard DNS/SSL is managed by you. Custom domains should normally remain pending until DNS verification is done.</p>
</div>
</div>
<label class="flex items-start gap-3 rounded-2xl border bg-slate-50 p-4 text-sm text-slate-700">
<input type="checkbox" name="mark_verified_active" value="1" class="mt-1 rounded border-slate-300" />
<span>
<span class="font-medium text-slate-900">Mark verified and active now</span><br />
Use only when DNS is already pointing to the ERP server and SSL/proxy is ready.
</span>
</label>
<div class="flex justify-end">
<button class="rounded-xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white shadow-sm">Create Consultant Domain</button>
</div>
</form>
</div>
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Recommended DNS</h3>
<div class="mt-3 space-y-2 text-sm text-slate-600">
<div><span class="font-medium text-slate-900">Platform:</span> *.filingabc.com → ERP server IP/proxy</div>
<div><span class="font-medium text-slate-900">Firm brand:</span> *.arrr.accountant → ERP server IP/proxy</div>
<div><span class="font-medium text-slate-900">Custom:</span> abc.accountants → ERP server IP/proxy</div>
<div><span class="font-medium text-slate-900">SSL:</span> Coolify / proxy / Cloudflare will be configured later in 7T.9.</div>
</div>
</div>
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Suggested domains</h3>
<p class="mt-2 text-sm text-slate-600">The suggestions below are generated from consultant firm name or contact person.</p>
</div>
</div>
</div>
<div class="overflow-hidden rounded-2xl border bg-white shadow-soft">
<div class="border-b bg-slate-50 p-4">
<h2 class="font-semibold text-slate-900">Consultant Domain Suggestions</h2>
</div>
{% if candidates %}
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Consultant</th>
<th class="p-3 text-left">Platform Domain</th>
<th class="p-3 text-left">Firm Domain</th>
<th class="p-3 text-left">Status</th>
</tr>
</thead>
<tbody>
{% for item in candidates %}
<tr class="border-t align-top">
<td class="p-3">
<div class="font-semibold text-slate-900">{{ item.consultant.firm_name or item.consultant.contact_person }}</div>
<div class="text-xs text-slate-500">{{ item.consultant.email or 'No email configured' }}</div>
</td>
<td class="p-3">
<div class="font-medium text-slate-800">{{ item.platform_domain }}</div>
{% if item.platform_mapping %}<a href="/domains/{{ item.platform_mapping.id }}" class="text-xs text-slate-900 underline">Open mapping</a>{% endif %}
</td>
<td class="p-3">
<div class="font-medium text-slate-800">{{ item.firm_domain }}</div>
{% if item.firm_mapping %}<a href="/domains/{{ item.firm_mapping.id }}" class="text-xs text-slate-900 underline">Open mapping</a>{% endif %}
</td>
<td class="p-3">
{% if item.platform_mapping or item.firm_mapping %}
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">Mapped</span>
{% else %}
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">Not mapped</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center text-sm text-slate-500">No consultants found.</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">{{ mapping.domain_name }}</h1>
<p class="mt-1 text-sm text-slate-500">Domain mapping details and DNS verification token.</p>
</div>
<div class="flex gap-2">
<a href="/domains/{{ mapping.id }}/ssl" class="rounded-xl border px-4 py-2 text-sm text-slate-700">SSL</a>
<a href="/domains/{{ mapping.id }}/edit" class="rounded-xl border px-4 py-2 text-sm text-slate-700">Edit</a>
<a href="/domains" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Back to Domains</a>
</div>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div class="rounded-2xl border bg-white p-5 shadow-soft lg:col-span-2">
<h2 class="text-lg font-semibold text-slate-900">Mapping</h2>
<dl class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 text-sm">
<div><dt class="text-slate-500">Type</dt><dd class="font-medium text-slate-900">{{ mapping.domain_type.replace('_', ' ')|title }}</dd></div>
<div><dt class="text-slate-500">Status</dt><dd class="font-medium text-slate-900">{{ mapping.status.replace('_', ' ')|title }}</dd></div>
<div><dt class="text-slate-500">Audit Firm</dt><dd>{{ mapping.tenant.display_name or mapping.tenant.name if mapping.tenant else '-' }}</dd></div>
<div><dt class="text-slate-500">Branch</dt><dd>{{ mapping.branch.name if mapping.branch else '-' }}</dd></div>
<div><dt class="text-slate-500">Consultant</dt><dd>{{ mapping.consultant.contact_person if mapping.consultant else '-' }}</dd></div>
<div><dt class="text-slate-500">Parent Firm</dt><dd>{{ mapping.parent_tenant.display_name or mapping.parent_tenant.name if mapping.parent_tenant else '-' }}</dd></div>
<div><dt class="text-slate-500">Primary</dt><dd>{{ 'Yes' if mapping.is_primary else 'No' }}</dd></div>
<div><dt class="text-slate-500">SSL Mode</dt><dd>{{ mapping.ssl_mode }}</dd></div>
<div><dt class="text-slate-500">SSL Status</dt><dd>{{ (mapping.ssl_status or "not_checked").replace("_", " ")|title }}</dd></div>
</dl>
{% if mapping.notes %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700">{{ mapping.notes }}</div>{% endif %}
</div>
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">DNS Verification</h2>
{% if dns_result is defined and dns_result %}
<div class="mt-3 rounded-xl {% if dns_result.ok %}bg-emerald-50 text-emerald-800{% else %}bg-red-50 text-red-800{% endif %} p-3 text-sm">
<div class="font-semibold">{{ 'Verification successful' if dns_result.ok else 'Verification failed' }}</div>
<div class="mt-1">{{ dns_result.message }}</div>
{% if dns_result.found_values %}
<div class="mt-2 text-xs">Found TXT: {{ dns_result.found_values|join(', ') }}</div>
{% endif %}
</div>
{% endif %}
{% if mapping.is_verified %}
<div class="mt-3 rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800">This domain is marked as verified.</div>
{% else %}
<div class="mt-3 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">Add the TXT record below at your DNS provider. Click Verify DNS after adding the TXT record at your DNS provider.</div>
{% endif %}
<div class="mt-4 space-y-3 text-sm">
<div><div class="text-slate-500">TXT Name</div><code class="block rounded-lg bg-slate-100 p-2 text-xs break-all">{{ mapping.dns_txt_name or '-' }}</code></div>
<div><div class="text-slate-500">TXT Value</div><code class="block rounded-lg bg-slate-100 p-2 text-xs break-all">{{ mapping.dns_txt_value or '-' }}</code></div>
</div>
<div class="mt-4 flex flex-col gap-2">
<form method="post" action="/domains/{{ mapping.id }}/verify-dns">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Verify DNS TXT Now</button>
</form>
<form method="post" action="/domains/{{ mapping.id }}/mark-verified">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="w-full rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white">Mark Verified Manually</button>
</form>
<form method="post" action="/domains/{{ mapping.id }}/regenerate-token">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="w-full rounded-xl border px-4 py-2 text-sm text-slate-700">Regenerate Token</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,98 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Audit Firm Custom Domain</h1>
<p class="mt-1 text-sm text-slate-500">Create a verified mapping for your own audit firm domain, for example arrr.associates.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">All Domains</a>
<a href="/domains/tenant-subdomains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Tenant Subdomains</a>
</div>
</div>
{% if errors %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<ul class="list-disc pl-5">
{% for err in errors %}<li>{{ err }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% if message %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{{ message }}</div>
{% endif %}
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div class="xl:col-span-2 rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Create / Link Firm Domain</h2>
<p class="mt-1 text-sm text-slate-500">Use this for your own branded firm domain. For other tenant firms, use auditfirm.filingabc.com subdomains first.</p>
<form method="post" action="/domains/firm-domain/create" class="mt-5 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div>
<label class="block text-sm font-medium text-slate-700">Firm domain</label>
<input name="domain_name" value="{{ domain_name or 'arrr.associates' }}" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm" placeholder="arrr.associates" />
<p class="mt-1 text-xs text-slate-500">Enter the domain only. Do not include http://, https:// or path.</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Audit firm / tenant</label>
<select name="tenant_id" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm" required>
<option value="">Select audit firm</option>
{% for t in tenants %}
<option value="{{ t.id }}">{{ t.display_name or t.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch, optional</label>
<select name="branch_id" class="mt-1 w-full rounded-xl border px-3 py-2 text-sm">
<option value="">Firm-level domain</option>
{% for b in branches %}
<option value="{{ b.id }}">{{ b.name }}</option>
{% endfor %}
</select>
</div>
</div>
<label class="flex items-start gap-3 rounded-2xl border bg-slate-50 p-4 text-sm text-slate-700">
<input type="checkbox" name="mark_verified_active" value="1" class="mt-1 rounded border-slate-300" />
<span>
<span class="font-medium text-slate-900">Mark verified and active now</span><br />
Use this only if DNS is already pointing to the ERP server and SSL/proxy is ready. Otherwise leave unchecked and verify later from the domain detail page.
</span>
</label>
<div class="flex justify-end">
<button class="rounded-xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white shadow-sm">Create Firm Domain</button>
</div>
</form>
</div>
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Recommended DNS</h3>
<div class="mt-3 space-y-2 text-sm text-slate-600">
<div><span class="font-medium text-slate-900">A record:</span> arrr.associates → your server IP</div>
<div><span class="font-medium text-slate-900">CNAME:</span> www.arrr.associates → arrr.associates</div>
<div><span class="font-medium text-slate-900">SSL:</span> configure in Coolify / proxy / Cloudflare</div>
</div>
</div>
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Verification</h3>
<p class="mt-2 text-sm text-slate-600">If not marked active now, open the created domain record and use the DNS TXT token shown there.</p>
{% if existing %}
<div class="mt-4 rounded-xl border bg-slate-50 p-3 text-sm">
<div class="font-semibold text-slate-900">Existing mapping found</div>
<div class="mt-1 text-slate-600">{{ existing.domain_name }} — {{ existing.status.replace('_', ' ')|title }}</div>
<a href="/domains/{{ existing.id }}" class="mt-2 inline-block text-slate-900 underline">Open mapping</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,104 @@
{% extends "ui/templates/base/layout.html" %}
{% set f = form if form else {} %}
{% block content %}
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold text-slate-900">{{ title }}</h1>
<p class="mt-1 text-sm text-slate-500">Create or update a domain mapping. Runtime resolver will use this table in Phase 7T.2.</p>
</div>
{% if errors %}
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
<div class="font-semibold">Please fix the following:</div>
<ul class="mt-2 list-disc pl-5">{% for e in errors %}<li>{{ e }}</li>{% endfor %}</ul>
</div>
{% endif %}
<form method="post" class="max-w-5xl rounded-2xl border bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Domain Name</span>
<input class="rounded-xl border px-3 py-2" name="domain_name" required placeholder="filingabc.com" value="{{ f.domain_name if f.domain_name is defined else (mapping.domain_name if mapping else '') }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Domain Type</span>
{% set selected_type = f.domain_type if f.domain_type is defined else (mapping.domain_type if mapping else 'marketplace') %}
<select class="rounded-xl border px-3 py-2" name="domain_type" required>
{% for code, label in domain_types %}<option value="{{ code }}" {% if selected_type == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Status</span>
{% set selected_status = f.status if f.status is defined else (mapping.status if mapping else 'draft') %}
<select class="rounded-xl border px-3 py-2" name="status">
{% for code, label in statuses %}<option value="{{ code }}" {% if selected_status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Firm</span>
{% set selected_tenant = f.tenant_id if f.tenant_id is defined else (mapping.tenant_id if mapping else '') %}
<select class="rounded-xl border px-3 py-2" name="tenant_id">
<option value="">Not applicable</option>
{% for t in tenants %}<option value="{{ t.id }}" {% if selected_tenant == t.id %}selected{% endif %}>{{ t.display_name or t.name }} ({{ t.code }})</option>{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch</span>
{% set selected_branch = f.branch_id if f.branch_id is defined else (mapping.branch_id if mapping else '') %}
<select class="rounded-xl border px-3 py-2" name="branch_id">
<option value="">Not applicable</option>
{% for b in branches %}<option value="{{ b.id }}" {% if selected_branch == b.id %}selected{% endif %}>{{ b.name }} (Firm {{ b.tenant_id }})</option>{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Consultant</span>
{% set selected_consultant = f.consultant_id if f.consultant_id is defined else (mapping.consultant_id if mapping else '') %}
<select class="rounded-xl border px-3 py-2" name="consultant_id">
<option value="">Not applicable</option>
{% for c in consultants %}<option value="{{ c.id }}" {% if selected_consultant == c.id %}selected{% endif %}>{{ c.contact_person }}{% if c.firm_name %} - {{ c.firm_name }}{% endif %}</option>{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Parent Firm for Consultant Domain</span>
{% set selected_parent = f.parent_tenant_id if f.parent_tenant_id is defined else (mapping.parent_tenant_id if mapping else '') %}
<select class="rounded-xl border px-3 py-2" name="parent_tenant_id">
<option value="">Not applicable</option>
{% for t in tenants %}<option value="{{ t.id }}" {% if selected_parent == t.id %}selected{% endif %}>{{ t.display_name or t.name }} ({{ t.code }})</option>{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SSL Mode</span>
{% set selected_ssl = f.ssl_mode if f.ssl_mode is defined else (mapping.ssl_mode if mapping else 'manual') %}
<select class="rounded-xl border px-3 py-2" name="ssl_mode">
{% for code, label in ssl_modes %}<option value="{{ code }}" {% if selected_ssl == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</label>
<div class="flex items-center gap-6 rounded-xl border bg-slate-50 px-4 py-3">
{% set primary_checked = f.is_primary if f.is_primary is defined else (mapping.is_primary if mapping else False) %}
{% set verified_checked = f.is_verified if f.is_verified is defined else (mapping.is_verified if mapping else False) %}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_primary" {% if primary_checked %}checked{% endif %}> Primary</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_verified" {% if verified_checked %}checked{% endif %}> Verified manually</label>
</div>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Notes</span>
<textarea class="rounded-xl border px-3 py-2" name="notes" rows="4">{{ f.notes if f.notes is defined else (mapping.notes if mapping else '') }}</textarea>
</label>
</div>
<div class="mt-5 flex gap-3">
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Save Domain</button>
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm text-slate-700">Cancel</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,82 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Domain Mapping</h1>
<p class="mt-1 text-sm text-slate-500">Map marketplace, audit firm, branch and consultant domains. Tenant subdomains like auditfirm.filingabc.com, firm domains like arrr.associates, and consultant profile domains like abc.accountants or yourname.arrr.accountant can be created from here.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/domains/ssl" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">SSL Automation</a>
<a href="/domains/verification" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Verify Domains</a>
<a href="/domains/firm-domain" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Firm Domain</a>
<a href="/domains/consultant-domains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Consultant Domains</a>
<a href="/domains/tenant-subdomains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Tenant Subdomains</a>
<a href="/domains/new" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm">Add Domain</a>
</div>
</div>
<form method="get" class="rounded-2xl border bg-white p-4 shadow-soft">
<div class="grid grid-cols-1 gap-3 md:grid-cols-4">
<input class="rounded-xl border px-3 py-2 text-sm" name="q" value="{{ filters.q }}" placeholder="Search domain" />
<select class="rounded-xl border px-3 py-2 text-sm" name="domain_type">
<option value="">All domain types</option>
{% for code, label in domain_types %}<option value="{{ code }}" {% if filters.domain_type == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<select class="rounded-xl border px-3 py-2 text-sm" name="status">
<option value="">All statuses</option>
{% for code, label in statuses %}<option value="{{ code }}" {% if filters.status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<button class="rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white">Filter</button>
</div>
</form>
<div class="overflow-hidden rounded-2xl border bg-white shadow-soft">
{% if mappings %}
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Domain</th>
<th class="p-3 text-left">Type</th>
<th class="p-3 text-left">Mapped To</th>
<th class="p-3 text-left">Status</th>
<th class="p-3 text-left">Verification</th>
<th class="p-3 text-left">Action</th>
</tr>
</thead>
<tbody>
{% for m in mappings %}
<tr class="border-t align-top">
<td class="p-3">
<div class="font-semibold text-slate-900">{{ m.domain_name }}</div>
<div class="text-xs text-slate-500">SSL: {{ m.ssl_mode }} / {{ (m.ssl_status or "not_checked").replace("_", " ")|title }}</div>
</td>
<td class="p-3">{{ m.domain_type.replace('_', ' ')|title }}</td>
<td class="p-3 text-slate-700">
{% if m.tenant %}<div>Firm: {{ m.tenant.display_name or m.tenant.name }}</div>{% endif %}
{% if m.branch %}<div>Branch: {{ m.branch.name }}</div>{% endif %}
{% if m.consultant %}<div>Consultant: {{ m.consultant.contact_person }}</div>{% endif %}
{% if not m.tenant and not m.branch and not m.consultant %}<span class="text-slate-400">Marketplace / platform</span>{% endif %}
</td>
<td class="p-3">
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">{{ m.status.replace('_', ' ')|title }}</span>
{% if m.is_primary %}<span class="ml-1 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700">Primary</span>{% endif %}
</td>
<td class="p-3">
{% if m.is_verified %}
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">Verified</span>
{% else %}
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">Pending</span>
{% endif %}
</td>
<td class="p-3"><div class="flex flex-col gap-1"><a class="text-slate-900 underline" href="/domains/{{ m.id }}">Open</a><a class="text-slate-600 underline" href="/domains/{{ m.id }}/ssl">SSL</a></div></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-8 text-center text-sm text-slate-500">No domain mappings found.</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,82 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">SSL Automation</h1>
<p class="mt-1 text-sm text-slate-500">Track SSL readiness for marketplace, audit firm and consultant domains. Certificate issuance is handled by your deployment proxy such as Coolify, Caddy, Traefik, Cloudflare or Nginx + Certbot.</p>
</div>
<div class="flex flex-wrap gap-2">
<form method="post" action="/domains/ssl/check-all">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm">Check Active SSL</button>
</form>
<a href="/domains/verification" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">DNS Verification</a>
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Domains</a>
</div>
</div>
{% if ssl_results is defined and ssl_results %}
<div class="rounded-2xl border bg-white p-4 shadow-soft">
<h2 class="text-sm font-semibold text-slate-900">Latest SSL check</h2>
<div class="mt-3 grid gap-2">
{% for mapping, result in ssl_results %}
<div class="rounded-xl {% if result.ok %}bg-emerald-50 text-emerald-800{% else %}bg-red-50 text-red-800{% endif %} p-3 text-sm">
<span class="font-semibold">{{ mapping.domain_name }}:</span> {{ result.message }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="text-sm text-slate-500">Verified active domains</div>
<div class="mt-2 text-3xl font-semibold text-slate-900">{{ domains|selectattr('is_verified')|selectattr('status', 'equalto', 'active')|list|length }}</div>
</div>
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="text-sm text-slate-500">SSL active</div>
<div class="mt-2 text-3xl font-semibold text-emerald-700">{{ domains|selectattr('ssl_status', 'equalto', 'active')|list|length }}</div>
</div>
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="text-sm text-slate-500">Needs attention</div>
<div class="mt-2 text-3xl font-semibold text-amber-700">{{ domains|rejectattr('ssl_status', 'equalto', 'active')|list|length }}</div>
</div>
</div>
<div class="overflow-hidden rounded-2xl border bg-white shadow-soft">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Domain</th>
<th class="p-3 text-left">Type</th>
<th class="p-3 text-left">DNS</th>
<th class="p-3 text-left">SSL Mode</th>
<th class="p-3 text-left">SSL Status</th>
<th class="p-3 text-left">Expiry</th>
<th class="p-3 text-left">Action</th>
</tr>
</thead>
<tbody>
{% for m in domains %}
<tr class="border-t align-top">
<td class="p-3 font-semibold text-slate-900">{{ m.domain_name }}</td>
<td class="p-3">{{ m.domain_type.replace('_', ' ')|title }}</td>
<td class="p-3">
{% if m.is_verified and m.status == 'active' %}<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">Verified</span>{% else %}<span class="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">Pending</span>{% endif %}
</td>
<td class="p-3">{{ m.ssl_mode or 'manual' }}</td>
<td class="p-3">
{% set ssl_status = m.ssl_status or 'not_checked' %}
<span class="rounded-full {% if ssl_status == 'active' %}bg-emerald-50 text-emerald-700{% elif ssl_status == 'failed' %}bg-red-50 text-red-700{% else %}bg-slate-100 text-slate-700{% endif %} px-2.5 py-1 text-xs font-medium">{{ ssl_status.replace('_', ' ')|title }}</span>
{% if m.ssl_last_error %}<div class="mt-1 max-w-sm truncate text-xs text-red-600">{{ m.ssl_last_error }}</div>{% endif %}
</td>
<td class="p-3 text-slate-600">{{ m.ssl_not_after_utc.strftime('%d-%m-%Y') if m.ssl_not_after_utc else '-' }}</td>
<td class="p-3"><a href="/domains/{{ m.id }}/ssl" class="text-slate-900 underline">Open SSL</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,75 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">SSL Setup: {{ mapping.domain_name }}</h1>
<p class="mt-1 text-sm text-slate-500">Use this page to track DNS, proxy and certificate readiness.</p>
</div>
<div class="flex gap-2">
<a href="/domains/{{ mapping.id }}" class="rounded-xl border px-4 py-2 text-sm text-slate-700">Domain Detail</a>
<a href="/domains/ssl" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Back to SSL</a>
</div>
</div>
{% if ssl_result is defined and ssl_result %}
<div class="rounded-2xl {% if ssl_result.ok %}bg-emerald-50 text-emerald-800{% else %}bg-red-50 text-red-800{% endif %} p-4 text-sm">
<div class="font-semibold">{{ ssl_result.status.replace('_', ' ')|title }}</div>
<div class="mt-1">{{ ssl_result.message }}</div>
</div>
{% endif %}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div class="rounded-2xl border bg-white p-5 shadow-soft lg:col-span-2">
<h2 class="text-lg font-semibold text-slate-900">Readiness checklist</h2>
<div class="mt-4 space-y-3 text-sm">
<div class="flex items-start gap-3"><span class="mt-0.5 rounded-full px-2 py-0.5 text-xs {% if mapping.is_verified and mapping.status == 'active' %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ 'Done' if mapping.is_verified and mapping.status == 'active' else 'Pending' }}</span><div><div class="font-medium text-slate-900">DNS TXT verification</div><div class="text-slate-500">The domain should be verified and active in ERP.</div></div></div>
<div class="flex items-start gap-3"><span class="mt-0.5 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">Manual</span><div><div class="font-medium text-slate-900">A/CNAME record</div><div class="text-slate-500">Point {{ mapping.domain_name }} to your Coolify/proxy server before certificate issue.</div></div></div>
<div class="flex items-start gap-3"><span class="mt-0.5 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">Proxy</span><div><div class="font-medium text-slate-900">Add domain in deployment proxy</div><div class="text-slate-500">Coolify/Caddy/Traefik/Nginx must route this domain to the FastAPI app.</div></div></div>
<div class="flex items-start gap-3"><span class="mt-0.5 rounded-full px-2 py-0.5 text-xs {% if mapping.ssl_status == 'active' %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ (mapping.ssl_status or 'not_checked').replace('_', ' ')|title }}</span><div><div class="font-medium text-slate-900">Certificate check</div><div class="text-slate-500">ERP checks public HTTPS on port 443 and records certificate expiry.</div></div></div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<form method="post" action="/domains/{{ mapping.id }}/ssl/check">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Check SSL Now</button>
</form>
<form method="post" action="/domains/{{ mapping.id }}/ssl/mark-managed" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<select name="provider" class="rounded-xl border px-3 py-2 text-sm">
<option value="coolify" {% if mapping.ssl_mode == 'coolify' %}selected{% endif %}>Coolify</option>
<option value="caddy" {% if mapping.ssl_mode == 'caddy' %}selected{% endif %}>Caddy</option>
<option value="traefik" {% if mapping.ssl_mode == 'traefik' %}selected{% endif %}>Traefik</option>
<option value="cloudflare" {% if mapping.ssl_mode == 'cloudflare' %}selected{% endif %}>Cloudflare</option>
<option value="manual" {% if mapping.ssl_mode == 'manual' %}selected{% endif %}>Manual</option>
</select>
<button class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700">Mark Managed</button>
</form>
</div>
</div>
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Certificate status</h2>
<dl class="mt-4 space-y-3 text-sm">
<div><dt class="text-slate-500">SSL Status</dt><dd class="font-medium text-slate-900">{{ (mapping.ssl_status or 'not_checked').replace('_', ' ')|title }}</dd></div>
<div><dt class="text-slate-500">SSL Mode</dt><dd>{{ mapping.ssl_mode or 'manual' }}</dd></div>
<div><dt class="text-slate-500">Last Checked</dt><dd>{{ mapping.ssl_last_checked_at_utc.strftime('%d-%m-%Y %H:%M') if mapping.ssl_last_checked_at_utc else '-' }}</dd></div>
<div><dt class="text-slate-500">Valid Until</dt><dd>{{ mapping.ssl_not_after_utc.strftime('%d-%m-%Y') if mapping.ssl_not_after_utc else '-' }}</dd></div>
<div><dt class="text-slate-500">Issuer</dt><dd class="break-words">{{ mapping.ssl_issuer or '-' }}</dd></div>
<div><dt class="text-slate-500">Subject</dt><dd class="break-words">{{ mapping.ssl_subject or '-' }}</dd></div>
</dl>
{% if mapping.ssl_last_error %}<div class="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-800">{{ mapping.ssl_last_error }}</div>{% endif %}
</div>
</div>
<div class="space-y-4">
<h2 class="text-lg font-semibold text-slate-900">Proxy snippets / setup notes</h2>
{% for snippet in snippets %}
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">{{ snippet.title }}</h3>
<pre class="mt-3 overflow-auto rounded-xl bg-slate-900 p-4 text-xs text-slate-100"><code>{{ snippet.body }}</code></pre>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,90 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Tenant Subdomains</h1>
<p class="mt-1 text-sm text-slate-500">Create audit firm platform subdomains like <strong>auditfirm.filingabc.com</strong>. This uses your existing domain_mappings table.</p>
</div>
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm text-slate-700">Back to Domains</a>
</div>
{% if errors %}
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
<div class="font-semibold">Please fix the following:</div>
<ul class="mt-2 list-disc pl-5">{% for e in errors %}<li>{{ e }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if message %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{{ message }}</div>{% endif %}
<form method="get" class="rounded-2xl border bg-white p-4 shadow-soft">
<div class="grid grid-cols-1 gap-3 md:grid-cols-4">
<label class="grid gap-1 md:col-span-2">
<span class="text-xs font-medium uppercase tracking-wide text-slate-500">Platform base domain</span>
<input class="rounded-xl border px-3 py-2 text-sm" name="base_domain" value="{{ base_domain }}" placeholder="filingabc.com" />
</label>
<div class="flex items-end"><button class="rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white">Refresh Suggestions</button></div>
</div>
<p class="mt-3 text-xs text-slate-500">For local testing you can use <code>filingabc.local</code>. For production use <code>filingabc.com</code> and configure wildcard DNS/SSL for <code>*.filingabc.com</code>.</p>
</form>
<div class="rounded-2xl border bg-white p-4 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Audit firm subdomain candidates</h2>
<div class="mt-4 overflow-hidden rounded-xl border">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Audit Firm</th>
<th class="p-3 text-left">Suggested Subdomain</th>
<th class="p-3 text-left">Current Status</th>
<th class="p-3 text-left">Action</th>
</tr>
</thead>
<tbody>
{% for item in candidates %}
{% set tenant = item.tenant %}
{% set mapping = item.mapping %}
<tr class="border-t align-top">
<td class="p-3">
<div class="font-semibold text-slate-900">{{ tenant.display_name or tenant.name }}</div>
<div class="text-xs text-slate-500">Code: {{ tenant.code }} · ID: {{ tenant.id }}</div>
</td>
<td class="p-3">
<code class="rounded-lg bg-slate-100 px-2 py-1 text-xs">{{ item.domain_name }}</code>
</td>
<td class="p-3">
{% if mapping %}
<div><span class="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">Created</span></div>
<div class="mt-2 text-xs text-slate-500">{{ mapping.status|title }}{% if mapping.is_verified %} · Verified{% else %} · Pending verification{% endif %}</div>
{% else %}
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">Not created</span>
{% endif %}
</td>
<td class="p-3">
{% if mapping %}
<a href="/domains/{{ mapping.id }}" class="rounded-xl border px-3 py-2 text-xs font-medium text-slate-700">Open Mapping</a>
{% else %}
<form method="post" action="/domains/tenant-subdomains/create" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="tenant_id" value="{{ tenant.id }}" />
<input type="hidden" name="base_domain" value="{{ base_domain }}" />
<label class="flex items-center gap-2 text-xs text-slate-600"><input type="checkbox" name="mark_verified_active" checked /> Mark active/verified</label>
<button class="rounded-xl bg-slate-900 px-3 py-2 text-xs font-medium text-white">Create Subdomain</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="p-8 text-center text-slate-500">No audit firms found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
<div class="font-semibold">Deployment note</div>
<p class="mt-1">This phase creates and resolves tenant subdomain mappings in the ERP. DNS/SSL is still deployment-level: point <code>*.{{ base_domain }}</code> to your server and configure wildcard SSL in Coolify/Caddy/Traefik/Nginx as applicable.</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Custom Domain Verification</h1>
<p class="mt-1 text-sm text-slate-500">Verify DNS TXT records for custom firm, consultant and marketplace domains before activating runtime routing.</p>
</div>
<div class="flex gap-2">
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700">Back to Domains</a>
<form method="post" action="/domains/verification/run">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="limit" value="25" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Verify Pending</button>
</form>
</div>
</div>
{% if results %}
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold text-slate-900">Verification Result</h2>
<div class="mt-4 space-y-3">
{% for mapping, result in results %}
<div class="rounded-xl border p-4 {% if result.ok %}border-emerald-200 bg-emerald-50{% else %}border-red-200 bg-red-50{% endif %}">
<div class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div class="font-semibold text-slate-900">{{ mapping.domain_name }}</div>
<span class="text-xs font-semibold {% if result.ok %}text-emerald-700{% else %}text-red-700{% endif %}">{{ 'Verified' if result.ok else 'Pending' }}</span>
</div>
<div class="mt-1 text-sm text-slate-700">{{ result.message }}</div>
{% if result.found_values %}<div class="mt-2 text-xs text-slate-600">Found: {{ result.found_values|join(', ') }}</div>{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="rounded-2xl border bg-white shadow-soft overflow-hidden">
<div class="border-b bg-slate-50 px-5 py-4">
<h2 class="text-lg font-semibold text-slate-900">Domains Awaiting DNS Verification</h2>
<p class="mt-1 text-sm text-slate-500">Add the TXT record at your DNS provider, wait for DNS propagation, then verify.</p>
</div>
{% if pending %}
<div class="divide-y">
{% for m in pending %}
<div class="grid grid-cols-1 gap-4 p-5 lg:grid-cols-12 lg:items-center">
<div class="lg:col-span-3">
<div class="font-semibold text-slate-900">{{ m.domain_name }}</div>
<div class="text-xs text-slate-500">{{ m.domain_type.replace('_', ' ')|title }}</div>
</div>
<div class="lg:col-span-6 text-sm">
<div class="text-slate-500">TXT Name</div>
<code class="mt-1 block rounded-lg bg-slate-100 p-2 text-xs break-all">{{ m.dns_txt_name or '-' }}</code>
<div class="mt-2 text-slate-500">TXT Value</div>
<code class="mt-1 block rounded-lg bg-slate-100 p-2 text-xs break-all">{{ m.dns_txt_value or '-' }}</code>
</div>
<div class="lg:col-span-3 flex flex-col gap-2">
<form method="post" action="/domains/{{ m.id }}/verify-dns">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Verify DNS</button>
</form>
<a href="/domains/{{ m.id }}" class="w-full rounded-xl border px-4 py-2 text-center text-sm font-medium text-slate-700">Open Details</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-sm text-slate-500">No pending domain verification records.</div>
{% endif %}
</div>
<div class="rounded-2xl border bg-white p-5 text-sm text-slate-600 shadow-soft">
<h3 class="font-semibold text-slate-900">DNS record format</h3>
<p class="mt-2">For a domain such as <strong>arrr.associates</strong>, add a TXT record at:</p>
<code class="mt-2 block rounded-lg bg-slate-100 p-2 text-xs">_audit-firm-verify.arrr.associates</code>
<p class="mt-2">The TXT value must exactly match the verification token shown for that domain.</p>
</div>
</div>
{% endblock %}
+733
View File
@@ -0,0 +1,733 @@
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.domain_management.models import DomainMapping
from app.modules.domain_management.services import (
DomainMappingPayload,
create_domain_mapping,
create_or_get_tenant_subdomain_mapping,
create_or_get_firm_custom_domain_mapping,
create_or_get_consultant_domain_mapping,
list_consultant_domain_candidates,
list_domain_mappings,
list_domains_requiring_verification,
verify_all_pending_domains,
verify_domain_dns_txt,
build_ssl_proxy_snippets,
check_domain_ssl_certificate,
list_ssl_domains,
mark_ssl_managed,
list_tenant_subdomain_candidates,
mark_verified,
reference_data,
regenerate_verification_token,
update_domain_mapping,
validate_domain_payload,
)
router = APIRouter(prefix="/domains", tags=["domain-management-ui"])
def _is_domain_admin(db, user) -> bool:
roles = set(get_user_roles(db, user.id))
return bool(roles.intersection({"System Admin", "Firm Admin"}))
def _base_ctx(request: Request, db, user, **ctx):
base = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
}
base.update(ctx)
return base
def _require_user(request: Request, db):
user = get_current_user(request, db=db)
if not user:
return None, RedirectResponse(url="/login", status_code=303)
if not _is_domain_admin(db, user):
return user, RedirectResponse(url="/system-settings", status_code=303)
return user, None
def _to_int(value: str | int | None) -> int | None:
try:
n = int(value or 0)
return n if n > 0 else None
except Exception:
return None
def _payload_from_form(
*,
domain_name: str,
domain_type: str,
tenant_id: str | int | None,
branch_id: str | int | None,
consultant_id: str | int | None,
parent_tenant_id: str | int | None,
is_primary: str | None,
is_verified: str | None,
status: str,
ssl_mode: str,
notes: str,
) -> DomainMappingPayload:
return DomainMappingPayload(
domain_name=domain_name,
domain_type=domain_type,
tenant_id=_to_int(tenant_id),
branch_id=_to_int(branch_id),
consultant_id=_to_int(consultant_id),
parent_tenant_id=_to_int(parent_tenant_id),
is_primary=bool(is_primary),
is_verified=bool(is_verified),
status=status or "draft",
ssl_mode=ssl_mode or "manual",
notes=notes,
)
@router.get("")
def domain_list(request: Request, q: str = "", status: str = "", domain_type: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mappings = list_domain_mappings(db, q=q, status=status, domain_type=domain_type)
refs = reference_data(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/list.html",
_base_ctx(
request,
db,
user,
title="Domain Mapping",
mappings=mappings,
filters={"q": q, "status": status, "domain_type": domain_type},
**refs,
),
)
finally:
db.close()
@router.get("/new")
def domain_create_page(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/form.html",
_base_ctx(
request,
db,
user,
title="Add Domain Mapping",
mapping=None,
errors=[],
form={},
**reference_data(db),
),
)
finally:
db.close()
@router.post("/new")
def domain_create_submit(
request: Request,
csrf_token: str = Form(...),
domain_name: str = Form(...),
domain_type: str = Form(...),
tenant_id: str = Form(""),
branch_id: str = Form(""),
consultant_id: str = Form(""),
parent_tenant_id: str = Form(""),
is_primary: str | None = Form(None),
is_verified: str | None = Form(None),
status: str = Form("draft"),
ssl_mode: str = Form("manual"),
notes: str = Form(""),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
payload = _payload_from_form(
domain_name=domain_name,
domain_type=domain_type,
tenant_id=tenant_id,
branch_id=branch_id,
consultant_id=consultant_id,
parent_tenant_id=parent_tenant_id,
is_primary=is_primary,
is_verified=is_verified,
status=status,
ssl_mode=ssl_mode,
notes=notes,
)
errors = validate_domain_payload(db, payload)
if errors:
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/form.html",
_base_ctx(request, db, user, title="Add Domain Mapping", mapping=None, errors=errors, form=payload.__dict__, **reference_data(db)),
status_code=400,
)
mapping = create_domain_mapping(db, payload, user_id=user.id)
return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303)
finally:
db.close()
@router.get("/tenant-subdomains")
def tenant_subdomain_page(request: Request, base_domain: str = "filingabc.com"):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
refs = reference_data(db)
candidates = list_tenant_subdomain_candidates(db, base_domain=base_domain)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/tenant_subdomains.html",
_base_ctx(
request,
db,
user,
title="Tenant Subdomains",
base_domain=base_domain,
candidates=candidates,
message="",
errors=[],
**refs,
),
)
finally:
db.close()
@router.post("/tenant-subdomains/create")
def tenant_subdomain_create(
request: Request,
csrf_token: str = Form(...),
tenant_id: str = Form(...),
base_domain: str = Form("filingabc.com"),
branch_id: str = Form(""),
mark_verified_active: str | None = Form(None),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
errors: list[str] = []
message = ""
try:
mapping, created = create_or_get_tenant_subdomain_mapping(
db,
tenant_id=int(tenant_id),
base_domain=base_domain,
branch_id=_to_int(branch_id),
mark_verified_active=bool(mark_verified_active),
user_id=user.id,
)
if created:
return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303)
message = f"Domain mapping already exists: {mapping.domain_name}"
except Exception as exc:
errors.append(str(exc))
refs = reference_data(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/tenant_subdomains.html",
_base_ctx(
request,
db,
user,
title="Tenant Subdomains",
base_domain=base_domain,
candidates=list_tenant_subdomain_candidates(db, base_domain=base_domain),
message=message,
errors=errors,
**refs,
),
status_code=400 if errors else 200,
)
finally:
db.close()
@router.get("/firm-domain")
def firm_custom_domain_page(request: Request, domain_name: str = "arrr.associates"):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
refs = reference_data(db)
existing = None
normalized = (domain_name or "arrr.associates").strip().lower().split("/", 1)[0].split(":", 1)[0].rstrip(".")
if normalized:
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized)).scalar_one_or_none()
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/firm_domain.html",
_base_ctx(
request,
db,
user,
title="Audit Firm Custom Domain",
domain_name=normalized or "arrr.associates",
existing=existing,
message="",
errors=[],
**refs,
),
)
finally:
db.close()
@router.post("/firm-domain/create")
def firm_custom_domain_create(
request: Request,
csrf_token: str = Form(...),
tenant_id: str = Form(...),
domain_name: str = Form("arrr.associates"),
branch_id: str = Form(""),
mark_verified_active: str | None = Form(None),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
errors: list[str] = []
message = ""
try:
mapping, created = create_or_get_firm_custom_domain_mapping(
db,
tenant_id=int(tenant_id),
domain_name=domain_name,
branch_id=_to_int(branch_id),
mark_verified_active=bool(mark_verified_active),
user_id=user.id,
)
if created:
return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303)
message = f"Domain mapping already exists: {mapping.domain_name}"
except Exception as exc:
errors.append(str(exc))
refs = reference_data(db)
normalized = (domain_name or "arrr.associates").strip().lower().split("/", 1)[0].split(":", 1)[0].rstrip(".")
existing = None
if normalized:
existing = db.execute(select(DomainMapping).where(DomainMapping.domain_name == normalized)).scalar_one_or_none()
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/firm_domain.html",
_base_ctx(
request,
db,
user,
title="Audit Firm Custom Domain",
domain_name=normalized or "arrr.associates",
existing=existing,
message=message,
errors=errors,
**refs,
),
status_code=400 if errors else 200,
)
finally:
db.close()
@router.get("/consultant-domains")
def consultant_domains_page(request: Request, base_domain: str = "filingabc.com", firm_base_domain: str = "arrr.accountant"):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
refs = reference_data(db)
candidates = list_consultant_domain_candidates(db, base_domain=base_domain, firm_base_domain=firm_base_domain)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/consultant_domains.html",
_base_ctx(
request,
db,
user,
title="Consultant Profile Domains",
base_domain=base_domain,
firm_base_domain=firm_base_domain,
candidates=candidates,
message="",
errors=[],
**refs,
),
)
finally:
db.close()
@router.post("/consultant-domains/create")
def consultant_domain_create(
request: Request,
csrf_token: str = Form(...),
consultant_id: str = Form(...),
domain_type: str = Form("consultant_marketplace_subdomain"),
domain_name: str = Form(""),
base_domain: str = Form("filingabc.com"),
firm_base_domain: str = Form("arrr.accountant"),
parent_tenant_id: str = Form(""),
mark_verified_active: str | None = Form(None),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
errors: list[str] = []
message = ""
try:
mapping, created = create_or_get_consultant_domain_mapping(
db,
consultant_id=int(consultant_id),
domain_type=domain_type,
domain_name=domain_name or None,
base_domain=base_domain,
firm_base_domain=firm_base_domain,
parent_tenant_id=_to_int(parent_tenant_id),
mark_verified_active=bool(mark_verified_active),
user_id=user.id,
)
if created:
return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303)
message = f"Domain mapping already exists: {mapping.domain_name}"
except Exception as exc:
errors.append(str(exc))
refs = reference_data(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/consultant_domains.html",
_base_ctx(
request,
db,
user,
title="Consultant Profile Domains",
base_domain=base_domain,
firm_base_domain=firm_base_domain,
candidates=list_consultant_domain_candidates(db, base_domain=base_domain, firm_base_domain=firm_base_domain),
message=message,
errors=errors,
**refs,
),
status_code=400 if errors else 200,
)
finally:
db.close()
@router.get("/verification")
def domain_verification_page(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
pending = list_domains_requiring_verification(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/verification.html",
_base_ctx(
request,
db,
user,
title="Custom Domain Verification",
pending=pending,
results=[],
),
)
finally:
db.close()
@router.post("/verification/run")
def domain_verification_run(request: Request, csrf_token: str = Form(...), limit: str = Form("25")):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
try:
n = int(limit or 25)
except Exception:
n = 25
results = verify_all_pending_domains(db, user_id=user.id, limit=n)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/verification.html",
_base_ctx(
request,
db,
user,
title="Custom Domain Verification",
pending=list_domains_requiring_verification(db),
results=results,
),
)
finally:
db.close()
@router.get("/ssl")
def ssl_dashboard(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
domains = list_ssl_domains(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/ssl.html",
_base_ctx(request, db, user, title="SSL Automation", domains=domains),
)
finally:
db.close()
@router.post("/ssl/check-all")
def ssl_check_all(request: Request, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
results = []
for mapping in list_ssl_domains(db)[:25]:
if mapping.status == "active" and mapping.is_verified:
result = check_domain_ssl_certificate(db, mapping, user_id=user.id)
results.append((mapping, result))
domains = list_ssl_domains(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/ssl.html",
_base_ctx(request, db, user, title="SSL Automation", domains=domains, ssl_results=results),
)
finally:
db.close()
@router.get("/{mapping_id}/ssl")
def ssl_detail(request: Request, mapping_id: int):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains/ssl", status_code=303)
snippets = build_ssl_proxy_snippets(mapping)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/ssl_detail.html",
_base_ctx(request, db, user, title=f"SSL: {mapping.domain_name}", mapping=mapping, snippets=snippets),
)
finally:
db.close()
@router.post("/{mapping_id}/ssl/check")
def ssl_check_one(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains/ssl", status_code=303)
result = check_domain_ssl_certificate(db, mapping, user_id=user.id)
snippets = build_ssl_proxy_snippets(mapping)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/ssl_detail.html",
_base_ctx(request, db, user, title=f"SSL: {mapping.domain_name}", mapping=mapping, snippets=snippets, ssl_result=result),
status_code=200 if result.ok else 400,
)
finally:
db.close()
@router.post("/{mapping_id}/ssl/mark-managed")
def ssl_mark_managed(request: Request, mapping_id: int, csrf_token: str = Form(...), provider: str = Form("manual")):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if mapping:
mark_ssl_managed(db, mapping, provider=provider, user_id=user.id)
return RedirectResponse(url=f"/domains/{mapping_id}/ssl", status_code=303)
finally:
db.close()
@router.get("/{mapping_id}")
def domain_detail(request: Request, mapping_id: int):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains", status_code=303)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/detail.html",
_base_ctx(request, db, user, title=mapping.domain_name, mapping=mapping),
)
finally:
db.close()
@router.get("/{mapping_id}/edit")
def domain_edit_page(request: Request, mapping_id: int):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains", status_code=303)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/form.html",
_base_ctx(request, db, user, title="Edit Domain Mapping", mapping=mapping, errors=[], form={}, **reference_data(db)),
)
finally:
db.close()
@router.post("/{mapping_id}/edit")
def domain_edit_submit(
request: Request,
mapping_id: int,
csrf_token: str = Form(...),
domain_name: str = Form(...),
domain_type: str = Form(...),
tenant_id: str = Form(""),
branch_id: str = Form(""),
consultant_id: str = Form(""),
parent_tenant_id: str = Form(""),
is_primary: str | None = Form(None),
is_verified: str | None = Form(None),
status: str = Form("draft"),
ssl_mode: str = Form("manual"),
notes: str = Form(""),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains", status_code=303)
payload = _payload_from_form(
domain_name=domain_name,
domain_type=domain_type,
tenant_id=tenant_id,
branch_id=branch_id,
consultant_id=consultant_id,
parent_tenant_id=parent_tenant_id,
is_primary=is_primary,
is_verified=is_verified,
status=status,
ssl_mode=ssl_mode,
notes=notes,
)
errors = validate_domain_payload(db, payload, mapping_id=mapping.id)
if errors:
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/form.html",
_base_ctx(request, db, user, title="Edit Domain Mapping", mapping=mapping, errors=errors, form=payload.__dict__, **reference_data(db)),
status_code=400,
)
update_domain_mapping(db, mapping, payload, user_id=user.id)
return RedirectResponse(url=f"/domains/{mapping.id}", status_code=303)
finally:
db.close()
@router.post("/{mapping_id}/verify-dns")
def domain_verify_dns(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains", status_code=303)
result = verify_domain_dns_txt(db, mapping, user_id=user.id)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/detail.html",
_base_ctx(request, db, user, title=mapping.domain_name, mapping=mapping, dns_result=result),
status_code=200 if result.ok else 400,
)
finally:
db.close()
@router.post("/{mapping_id}/regenerate-token")
def domain_regenerate_token(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if mapping:
regenerate_verification_token(db, mapping, user_id=user.id)
return RedirectResponse(url=f"/domains/{mapping_id}", status_code=303)
finally:
db.close()
@router.post("/{mapping_id}/mark-verified")
def domain_mark_verified(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if mapping:
mark_verified(db, mapping, user_id=user.id)
return RedirectResponse(url=f"/domains/{mapping_id}", status_code=303)
finally:
db.close()