Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Domain mapping module for multi-domain SaaS routing."""
|
||||
@@ -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])
|
||||
@@ -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 %}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user