# Copyright (C) 2025 James Brotosky, Brandon Wickline # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import logging from typing import List, Optional 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): """Widget for generating OTPs for selected devices.""" # 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: Optional[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) generate_button = Button( "Generate OTP", id="generate_button", variant="primary" ) generate_button.styles.width = "100%" 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 == "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 # type: ignore 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