360 lines
13 KiB
Python
360 lines
13 KiB
Python
import logging
|
|
from typing import List
|
|
|
|
from textual.containers import Horizontal, Vertical
|
|
from textual.css.query import NoMatches
|
|
from textual.message import Message
|
|
from textual.reactive import reactive
|
|
from textual.widget import Widget
|
|
from textual.widgets import (
|
|
Button,
|
|
Footer,
|
|
Header,
|
|
Input,
|
|
RadioButton,
|
|
RadioSet,
|
|
Static,
|
|
TextArea,
|
|
)
|
|
|
|
from models.agent import Agent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OTPGenerator(Widget):
|
|
# Reactive properties to track form completion
|
|
requestor_filled = reactive(False)
|
|
reasoning_filled = reactive(False)
|
|
duration_selected = reactive(True) # Default is selected
|
|
otp_generated = reactive(False)
|
|
|
|
class OTPInfo(Message):
|
|
def __init__(
|
|
self, devices: List[Agent], requestor: str, reasoning: str, duration: int
|
|
):
|
|
super().__init__()
|
|
self.devices = devices
|
|
self.requestor = requestor
|
|
self.reasoning = reasoning
|
|
self.duration = duration
|
|
|
|
# Duration options in minutes
|
|
DURATION_OPTIONS = [
|
|
(15, "15 minutes"),
|
|
(60, "1 hour"),
|
|
(360, "6 hours"),
|
|
(1440, "1 day"),
|
|
(10080, "7 days"),
|
|
]
|
|
|
|
def __init__(self, devices: List[Agent]):
|
|
"""Initialize with a list of Agent objects."""
|
|
super().__init__()
|
|
self.devices = devices
|
|
|
|
def watch_requestor_filled(self, old_value: bool, new_value: bool) -> None:
|
|
"""Update button state when requestor changes."""
|
|
self._update_button_state()
|
|
|
|
def watch_reasoning_filled(self, old_value: bool, new_value: bool) -> None:
|
|
"""Update button state when reasoning changes."""
|
|
self._update_button_state()
|
|
|
|
def watch_otp_generated(self, old_value: bool, new_value: bool) -> None:
|
|
"""Update button state when OTP is generated."""
|
|
self._update_button_state()
|
|
|
|
def _update_button_state(self) -> None:
|
|
"""Enable/disable the generate button based on form state."""
|
|
try:
|
|
button = self.query_one("#generate_button", Button)
|
|
# Enable only if all fields filled and OTP not yet generated
|
|
button.disabled = not (
|
|
self.requestor_filled
|
|
and self.reasoning_filled
|
|
and not self.otp_generated
|
|
)
|
|
except NoMatches:
|
|
pass
|
|
|
|
def compose(self):
|
|
yield Header(show_clock=True, icon="⚙")
|
|
title_text = Static(
|
|
f"🎫 Generate One Time Passes for {len(self.devices)} device(s)",
|
|
id="otpgen_title",
|
|
)
|
|
title_text.styles.margin = (0, 0, 1, 0)
|
|
yield title_text
|
|
|
|
with Horizontal() as main_layout:
|
|
main_layout.styles.height = "auto"
|
|
|
|
# Left side - Inputs and controls
|
|
with Vertical() as left_side:
|
|
left_side.styles.width = "1fr"
|
|
left_side.styles.height = "auto"
|
|
|
|
# Requestor input
|
|
requestor_label = Static("Who is requesting OTP?")
|
|
requestor_label.styles.margin = (0, 0, 0, 0)
|
|
yield requestor_label
|
|
|
|
requestor_box = Input(
|
|
placeholder="Enter requestor name", id="requestor_input"
|
|
)
|
|
requestor_box.styles.margin = (0, 0, 1, 0)
|
|
yield requestor_box
|
|
|
|
# Reasoning input
|
|
reasoning_label = Static("What work are they doing?")
|
|
reasoning_label.styles.margin = (0, 0, 0, 0)
|
|
yield reasoning_label
|
|
|
|
reasoning_box = Input(
|
|
placeholder="Enter reason for OTP", id="reasoning_input"
|
|
)
|
|
reasoning_box.styles.margin = (0, 0, 1, 0)
|
|
yield reasoning_box
|
|
|
|
# Duration selection
|
|
duration_label = Static("Duration:")
|
|
duration_label.styles.margin = (0, 0, 0, 0)
|
|
yield duration_label
|
|
|
|
with RadioSet(id="duration_radio") as radio_set:
|
|
radio_set.styles.margin = (0, 0, 1, 0)
|
|
for minutes, label in self.DURATION_OPTIONS:
|
|
radio = RadioButton(label, id=f"duration_{minutes}")
|
|
if minutes == 360: # Default to 6 hours
|
|
radio.value = True
|
|
yield radio
|
|
|
|
# Buttons in a horizontal layout
|
|
with Horizontal() as button_row:
|
|
button_row.styles.height = "auto"
|
|
button_row.styles.margin = (1, 0, 0, 0)
|
|
|
|
back_button = Button("← Back", id="back_button")
|
|
back_button.styles.width = "1fr"
|
|
yield back_button
|
|
|
|
generate_button = Button(
|
|
"Generate OTP", id="generate_button", variant="primary"
|
|
)
|
|
generate_button.styles.width = "2fr"
|
|
yield generate_button
|
|
|
|
# Right side - Show device list initially, then output after generation
|
|
with Vertical() as right_side:
|
|
right_side.styles.width = "2fr"
|
|
right_side.styles.height = "100%"
|
|
|
|
output_label = Static(
|
|
f"Selected Devices ({len(self.devices)}):", id="output_label"
|
|
)
|
|
output_label.styles.margin = (0, 0, 0, 0)
|
|
yield output_label
|
|
|
|
# Container for either device list or output
|
|
with Vertical(id="output_container") as output_container:
|
|
output_container.styles.height = "1fr"
|
|
output_container.styles.margin = (1, 0, 0, 0)
|
|
output_container.styles.overflow_y = "auto"
|
|
output_container.styles.border = ("round", "green")
|
|
|
|
# Show device list initially
|
|
device_list_text = "\n".join(
|
|
f"• {device.hostname}" for device in self.devices
|
|
)
|
|
device_display = Static(device_list_text, id="device_display")
|
|
yield device_display
|
|
|
|
# Copy to clipboard button (hidden initially)
|
|
copy_button = Button("📋 Copy to Clipboard", id="copy_clipboard_button")
|
|
copy_button.styles.margin = (1, 0, 0, 0)
|
|
copy_button.styles.display = "none"
|
|
yield copy_button
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
"""Set initial button state."""
|
|
self._update_button_state()
|
|
|
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
"""Handle input field changes."""
|
|
input_id = event.input.id
|
|
|
|
if input_id == "requestor_input":
|
|
self.requestor_filled = bool(event.value.strip())
|
|
elif input_id == "reasoning_input":
|
|
self.reasoning_filled = bool(event.value.strip())
|
|
|
|
def on_button_pressed(self, event: Button.Pressed):
|
|
btn_id = event.button.id
|
|
|
|
if btn_id == "back_button":
|
|
|
|
while len(self.app.screen_stack) > 2:
|
|
self.app.pop_screen()
|
|
|
|
event.stop()
|
|
|
|
elif btn_id == "copy_clipboard_button":
|
|
try:
|
|
output_area = self.query_one("#otp_output", TextArea)
|
|
text_to_copy = output_area.text
|
|
|
|
import pyperclip
|
|
|
|
pyperclip.copy(text_to_copy)
|
|
self.app.notify(
|
|
"✅ Copied to clipboard!", severity="information", timeout=2
|
|
)
|
|
except ImportError:
|
|
self.app.notify(
|
|
"⚠️ pyperclip not installed. Run: pip install pyperclip",
|
|
severity="warning",
|
|
)
|
|
except Exception as e:
|
|
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
|
|
event.stop()
|
|
|
|
elif btn_id == "generate_button":
|
|
try:
|
|
requestor = self.query_one("#requestor_input", Input).value.strip()
|
|
reasoning = self.query_one("#reasoning_input", Input).value.strip()
|
|
|
|
radio_set = self.query_one("#duration_radio", RadioSet)
|
|
selected_button_id = (
|
|
radio_set.pressed_button.id if radio_set.pressed_button else None
|
|
)
|
|
|
|
if not selected_button_id:
|
|
self._show_error("Please select a duration")
|
|
return
|
|
|
|
duration = int(selected_button_id.replace("duration_", ""))
|
|
|
|
if not requestor or not reasoning:
|
|
self._show_error("Please fill in all fields")
|
|
return
|
|
|
|
self.otp_generated = True
|
|
|
|
# Access API from the app - this is the key change!
|
|
api = self.app.api
|
|
|
|
output_lines = [
|
|
"Requested OTP Codes:",
|
|
"=" * 25,
|
|
]
|
|
|
|
otp_dict = {}
|
|
for device in self.devices:
|
|
try:
|
|
otp_code = api.otp_generate(device.agentid, duration, reasoning)
|
|
otp_dict[device.hostname] = otp_code
|
|
logger.debug(f"Generated OTP for {device.hostname}: {otp_code}")
|
|
except Exception as e:
|
|
otp_dict[device.hostname] = f"ERROR: {str(e)}"
|
|
logger.error(
|
|
f"Failed to generate OTP for {device.hostname}: {e}"
|
|
)
|
|
|
|
for hostname, otp_code in otp_dict.items():
|
|
output_lines.append(f"{hostname} | {otp_code}")
|
|
|
|
output_lines.append("=" * 25)
|
|
result_text = "\n".join(output_lines)
|
|
self._show_result(result_text)
|
|
|
|
# Post message with the OTP info
|
|
self.post_message(
|
|
self.OTPInfo(self.devices, requestor, reasoning, duration)
|
|
)
|
|
event.stop()
|
|
|
|
except NoMatches:
|
|
self._show_error("UI elements not found")
|
|
except Exception as e:
|
|
self._show_error(f"Error: {str(e)}")
|
|
logger.exception("Error generating OTP")
|
|
|
|
def _show_error(self, message: str):
|
|
"""Display error message in output area."""
|
|
try:
|
|
container = self.query_one("#output_container", Vertical)
|
|
try:
|
|
device_display = self.query_one("#device_display", Static)
|
|
device_display.remove()
|
|
except NoMatches:
|
|
pass
|
|
|
|
try:
|
|
output_area = self.query_one("#otp_output", TextArea)
|
|
except NoMatches:
|
|
output_area = TextArea(id="otp_output", read_only=True)
|
|
container.mount(output_area)
|
|
|
|
output_area.text = f"❌ ERROR: {message}"
|
|
except Exception as e:
|
|
logger.debug(f"Error showing error message: {e}")
|
|
|
|
def _show_result(self, message: str):
|
|
"""Display result message in output area."""
|
|
try:
|
|
container = self.query_one("#output_container", Vertical)
|
|
try:
|
|
device_display = self.query_one("#device_display", Static)
|
|
device_display.remove()
|
|
except NoMatches:
|
|
pass
|
|
|
|
try:
|
|
output_area = self.query_one("#otp_output", TextArea)
|
|
except NoMatches:
|
|
output_area = TextArea(id="otp_output", read_only=True)
|
|
container.mount(output_area)
|
|
|
|
output_area.text = message
|
|
output_label = self.query_one("#output_label", Static)
|
|
output_label.update("Generated OTP Details:")
|
|
copy_button = self.query_one("#copy_clipboard_button", Button)
|
|
copy_button.styles.display = "block"
|
|
except Exception as e:
|
|
logger.debug(f"Error showing result: {e}")
|
|
|
|
def display_otp_result(self, result_text: str):
|
|
"""Display OTP generation result in the output area."""
|
|
try:
|
|
container = self.query_one("#output_container", Vertical)
|
|
try:
|
|
device_display = self.query_one("#device_display", Static)
|
|
device_display.remove()
|
|
except NoMatches:
|
|
pass
|
|
|
|
try:
|
|
output_area = self.query_one("#otp_output", TextArea)
|
|
except NoMatches:
|
|
output_area = TextArea(id="otp_output", read_only=True)
|
|
container.mount(output_area)
|
|
|
|
output_area.text = result_text
|
|
except Exception as e:
|
|
logger.debug(f"Error displaying OTP result: {e}")
|
|
|
|
def clear_form(self):
|
|
"""Clear all input fields and reset state."""
|
|
try:
|
|
self.query_one("#requestor_input", Input).value = ""
|
|
self.query_one("#reasoning_input", Input).value = ""
|
|
self.query_one("#otp_output", TextArea).text = ""
|
|
self.otp_generated = False
|
|
self.requestor_filled = False
|
|
self.reasoning_filled = False
|
|
self._update_button_state()
|
|
except NoMatches:
|
|
pass
|