- replace CLI menu with multi-panel Textual interface (sidebar & data table) - implement asynchronous loading for domains and DNS records via worker threads - add support for optional 2FA/TOTP auto-unlock and manual TAN modal - introduce .env configuration fallback for stored credentials - add modals for creating, editing, and deleting DNS records - enable inline row editing via mouse double-click and keyboard navigation - update project documentation in README.md
126 lines
5.4 KiB
Python
126 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""INWX DNS TUI Client
|
|
Author: Pascal Bouquet <pascal@ptbos.de>
|
|
License: GNU General Public License v3.0 (GPL-3.0)
|
|
Year: 2026
|
|
"""
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.containers import Vertical, Horizontal
|
|
from textual.widgets import Label, Input, Button, Select
|
|
from textual.screen import ModalScreen
|
|
|
|
RECORD_TYPES = [
|
|
("A", "A"),
|
|
("AAAA", "AAAA"),
|
|
("CNAME", "CNAME"),
|
|
("MX", "MX"),
|
|
("TXT", "TXT"),
|
|
("SRV", "SRV"),
|
|
("CAA", "CAA"),
|
|
("NS", "NS"),
|
|
("PTR", "PTR"),
|
|
("TLSA", "TLSA"),
|
|
]
|
|
|
|
class LoginModal(ModalScreen[tuple[str, str] | None]):
|
|
"""Login-Maske bei fehlender oder fehlerhafter .env."""
|
|
def compose(self) -> ComposeResult:
|
|
with Vertical(classes="dialog-box"):
|
|
yield Label("🔐 INWX Login", classes="dialog-title")
|
|
yield Input(placeholder="Benutzername", id="user_input")
|
|
yield Input(placeholder="Passwort", password=True, id="pass_input")
|
|
with Horizontal(classes="dialog-buttons"):
|
|
yield Button("Einloggen", variant="primary", id="btn_submit", classes="modal-btn")
|
|
yield Button("Abbrechen", variant="error", id="btn_cancel", classes="modal-btn")
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "btn_submit":
|
|
user = self.query_one("#user_input", Input).value.strip()
|
|
pwd = self.query_one("#pass_input", Input).value.strip()
|
|
self.dismiss((user, pwd))
|
|
else:
|
|
self.dismiss(None)
|
|
|
|
|
|
class TwoFactorModal(ModalScreen[str | None]):
|
|
"""Fragt den 6-stelligen 2FA / TOTP-Code ab."""
|
|
def compose(self) -> ComposeResult:
|
|
with Vertical(classes="dialog-box"):
|
|
yield Label("🛡️ 2FA-Bestätigung", classes="dialog-title")
|
|
yield Label("6-stelligen TAN-Code eingeben:")
|
|
yield Input(placeholder="z.B. 123456", id="tan_input", max_length=6)
|
|
with Horizontal(classes="dialog-buttons"):
|
|
yield Button("Bestätigen", variant="primary", id="btn_submit_tan", classes="modal-btn")
|
|
yield Button("Abbrechen", variant="error", id="btn_cancel", classes="modal-btn")
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "btn_submit_tan":
|
|
tan = self.query_one("#tan_input", Input).value.strip()
|
|
self.dismiss(tan)
|
|
else:
|
|
self.dismiss(None)
|
|
|
|
|
|
class RecordEditModal(ModalScreen[dict | None]):
|
|
"""Dialog zum Anlegen und Bearbeiten von DNS-Records."""
|
|
def __init__(self, record: dict | None = None, default_domain: str = ""):
|
|
super().__init__()
|
|
self.record = record
|
|
self.default_domain = default_domain
|
|
|
|
def compose(self) -> ComposeResult:
|
|
is_edit = self.record is not None
|
|
title = f"Record ID {self.record['id']} bearbeiten" if is_edit else f"Neuer Record für {self.default_domain}"
|
|
|
|
with Vertical(classes="dialog-box"):
|
|
yield Label(title, classes="dialog-title")
|
|
|
|
if not is_edit:
|
|
yield Label("Name / Host (@ oder z.B. mail):", classes="field-label")
|
|
yield Input(placeholder="@", value="@", id="rec_name")
|
|
|
|
yield Label("Record-Typ (Auswählen oder eintippen):", classes="field-label")
|
|
with Horizontal(classes="type-row"):
|
|
yield Select(
|
|
RECORD_TYPES,
|
|
value="A",
|
|
prompt="Typ wählen",
|
|
id="rec_type_select",
|
|
allow_blank=False
|
|
)
|
|
yield Input(value="A", placeholder="Typ", id="rec_type_input")
|
|
|
|
yield Label("Inhalt / Ziel:", classes="field-label")
|
|
val_content = str(self.record.get("content", "")) if is_edit else ""
|
|
yield Input(placeholder="z.B. IP, mx01.domain.tld, Text", value=val_content, id="rec_content")
|
|
|
|
yield Label("TTL (Sekunden):", classes="field-label")
|
|
val_ttl = str(self.record.get("ttl", "3600")) if is_edit else "3600"
|
|
yield Input(placeholder="3600", value=val_ttl, id="rec_ttl")
|
|
|
|
with Horizontal(classes="dialog-buttons"):
|
|
yield Button("Speichern", variant="success", id="btn_save", classes="modal-btn")
|
|
yield Button("Abbrechen", variant="error", id="btn_cancel", classes="modal-btn")
|
|
|
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
"""Überträgt die Dropdown-Auswahl direkt in das Text-Eingabefeld."""
|
|
if event.select.id == "rec_type_select" and event.value != Select.BLANK:
|
|
type_input = self.query_one("#rec_type_input", Input)
|
|
type_input.value = str(event.value)
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "btn_save":
|
|
content = self.query_one("#rec_content", Input).value.strip()
|
|
ttl = self.query_one("#rec_ttl", Input).value.strip() or "3600"
|
|
|
|
if self.record:
|
|
data = {"content": content, "ttl": ttl}
|
|
else:
|
|
name = self.query_one("#rec_name", Input).value.strip()
|
|
rec_type = self.query_one("#rec_type_input", Input).value.strip().upper()
|
|
data = {"name": name, "type": rec_type, "content": content, "ttl": ttl}
|
|
self.dismiss(data)
|
|
else:
|
|
self.dismiss(None)
|