First commit

This commit is contained in:
2025-12-19 12:58:58 +01:00
parent cba66667f3
commit 87865e2c6d
26 changed files with 3343 additions and 0 deletions
View File
+89
View File
@@ -0,0 +1,89 @@
"""
App Selection Frame
UI component for selecting an app registration.
"""
import customtkinter as ctk
from typing import List, Dict, Callable, Optional
from ui.components import UnifiedDropdown
class AppSelectionFrame(ctk.CTkFrame):
"""Frame for app registration selection."""
def __init__(self, parent, on_app_selected: Callable = None):
"""
Initialize the app selection frame.
Args:
parent: Parent widget
on_app_selected: Callback function when an app is selected
"""
super().__init__(parent)
self.on_app_selected = on_app_selected
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Create unified dropdown
self.dropdown = UnifiedDropdown(
self,
title="App Registration Selection",
on_selection_changed=self._on_app_selected,
show_count=True,
display_key='display_name',
max_dropdown_height=400
)
self.dropdown.grid(row=0, column=0, sticky="ew")
self.dropdown.set_placeholder("Please connect to Azure first")
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_apps(self, apps: List[Dict[str, str]]):
"""
Set the list of applications.
Args:
apps: List of app dictionaries with 'id', 'app_id', and 'display_name'
"""
self.dropdown.set_items(apps)
def _on_app_selected(self, app: Dict):
"""
Handle app selection.
Args:
app: The selected app dictionary
"""
if self.on_app_selected:
self.on_app_selected(app)
def get_selected_app(self) -> Optional[Dict[str, str]]:
"""
Get the currently selected app.
Returns:
Dict: Selected app or None
"""
return self.dropdown.get_selected()
def set_enabled(self, enabled: bool):
"""
Enable or disable the frame.
Args:
enabled: Whether to enable the frame
"""
self.dropdown.set_enabled(enabled)
def set_loading(self, loading: bool):
"""
Set loading state.
Args:
loading: Whether currently loading
"""
self.dropdown.set_loading(loading)
+10
View File
@@ -0,0 +1,10 @@
"""
UI Components Package
Custom reusable UI components for the application.
"""
from .unified_dropdown import UnifiedDropdown
from .tooltip import ToolTip
__all__ = ['UnifiedDropdown', 'ToolTip']
+139
View File
@@ -0,0 +1,139 @@
"""
ToolTip Component
Lightweight tooltip widget that shows on hover with configurable delay.
"""
import tkinter as tk
from typing import Optional
class ToolTip:
"""
Creates a tooltip for a given widget.
Shows full text on hover if the displayed text is truncated.
"""
def __init__(
self,
widget: tk.Widget,
text: str,
delay: int = 500,
wrap_length: int = 300
):
"""
Initialize the tooltip.
Args:
widget: The widget to attach the tooltip to
text: The text to display in the tooltip
delay: Delay in milliseconds before showing tooltip
wrap_length: Maximum width in pixels before wrapping text
"""
self.widget = widget
self.text = text
self.delay = delay
self.wrap_length = wrap_length
self.tooltip_window: Optional[tk.Toplevel] = None
self.after_id: Optional[str] = None
# Bind hover events
self.widget.bind("<Enter>", self._on_enter, add="+")
self.widget.bind("<Leave>", self._on_leave, add="+")
self.widget.bind("<ButtonPress>", self._on_leave, add="+")
def _on_enter(self, event=None):
"""Handle mouse enter event."""
# Schedule tooltip to appear after delay
self._cancel_scheduled()
self.after_id = self.widget.after(self.delay, self._show_tooltip)
def _on_leave(self, event=None):
"""Handle mouse leave event."""
self._cancel_scheduled()
self._hide_tooltip()
def _cancel_scheduled(self):
"""Cancel scheduled tooltip appearance."""
if self.after_id:
self.widget.after_cancel(self.after_id)
self.after_id = None
def _show_tooltip(self):
"""Display the tooltip window."""
if self.tooltip_window or not self.text:
return
# Get widget position
x = self.widget.winfo_rootx() + 20
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
# Create tooltip window
self.tooltip_window = tk.Toplevel(self.widget)
self.tooltip_window.wm_overrideredirect(True) # Remove window decorations
# Handle screen edge cases
screen_width = self.widget.winfo_screenwidth()
screen_height = self.widget.winfo_screenheight()
# Create label with text
label = tk.Label(
self.tooltip_window,
text=self.text,
justify=tk.LEFT,
background="#ffffe0", # Light yellow background
foreground="#000000", # Black text
relief=tk.SOLID,
borderwidth=1,
wraplength=self.wrap_length,
font=("TkDefaultFont", 9),
padx=8,
pady=6
)
label.pack()
# Update to get actual size
self.tooltip_window.update_idletasks()
tooltip_width = self.tooltip_window.winfo_width()
tooltip_height = self.tooltip_window.winfo_height()
# Adjust position if near screen edges
if x + tooltip_width > screen_width:
x = screen_width - tooltip_width - 10
if y + tooltip_height > screen_height:
y = self.widget.winfo_rooty() - tooltip_height - 5
# Position the tooltip
self.tooltip_window.wm_geometry(f"+{x}+{y}")
def _hide_tooltip(self):
"""Hide the tooltip window."""
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
def update_text(self, text: str):
"""
Update the tooltip text.
Args:
text: New text to display
"""
self.text = text
if self.tooltip_window:
self._hide_tooltip()
def destroy(self):
"""Clean up the tooltip."""
self._cancel_scheduled()
self._hide_tooltip()
# Unbind events
try:
self.widget.unbind("<Enter>")
self.widget.unbind("<Leave>")
self.widget.unbind("<ButtonPress>")
except:
pass
+535
View File
@@ -0,0 +1,535 @@
"""
Unified Dropdown Component
Custom dropdown widget with popup list, keyboard navigation, and tooltips.
"""
import customtkinter as ctk
import tkinter as tk
from typing import List, Dict, Callable, Optional, Any
from .tooltip import ToolTip
class UnifiedDropdown(ctk.CTkFrame):
"""
Unified dropdown component with popup list.
Provides consistent selection UI across the application with:
- Popup list that expands below button
- Scrollable list for large datasets
- Keyboard navigation (arrows, PageUp/Down, Home/End)
- Tooltips on hover
- Automatic positioning
"""
def __init__(
self,
parent,
title: str = "",
on_selection_changed: Callable = None,
show_count: bool = True,
display_key: str = "display_name",
display_format: Callable = None,
max_dropdown_height: int = 400,
button_width: int = 600,
button_height: int = 40
):
"""
Initialize the unified dropdown.
Args:
parent: Parent widget
title: Title label text
on_selection_changed: Callback when selection changes
show_count: Whether to show item count label
display_key: Key to use for display text from item dict
display_format: Optional formatter function for display text
max_dropdown_height: Maximum height of dropdown popup
button_width: Width of the dropdown button
button_height: Height of the dropdown button
"""
super().__init__(parent)
self.title = title
self.on_selection_changed = on_selection_changed
self.show_count = show_count
self.display_key = display_key
self.display_format = display_format
self.max_dropdown_height = max_dropdown_height
self.button_width = button_width
self.button_height = button_height
# State
self.items: List[Dict] = []
self.selected_item: Optional[Dict] = None
self.current_index: int = -1
self.item_buttons: List[ctk.CTkButton] = []
self.item_tooltips: List[ToolTip] = []
# Popup window
self.popup_window: Optional[tk.Toplevel] = None
self.popup_frame: Optional[ctk.CTkScrollableFrame] = None
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Build UI
self._build_ui()
def _build_ui(self):
"""Build the dropdown UI."""
# Title label (only show if title is not empty)
if self.title:
self.title_label = ctk.CTkLabel(
self,
text=self.title,
font=ctk.CTkFont(size=18, weight="bold")
)
self.title_label.grid(row=0, column=0, padx=20, pady=(20, 5), sticky="w")
else:
self.title_label = None
# Count label (optional)
if self.show_count:
self.count_label = ctk.CTkLabel(
self,
text="0 items found",
font=ctk.CTkFont(size=12),
text_color="gray"
)
self.count_label.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="w")
# Dropdown button
self.dropdown_button = ctk.CTkButton(
self,
text="Please connect to Azure first",
command=self._toggle_dropdown,
width=self.button_width,
height=self.button_height,
font=ctk.CTkFont(size=14),
anchor="w",
state="disabled"
)
row_offset = 2 if self.show_count else 1
self.dropdown_button.grid(row=row_offset, column=0, padx=20, pady=(0, 20), sticky="ew")
# Bind keyboard events to button
self.dropdown_button.bind("<Down>", lambda e: self._open_dropdown())
self.dropdown_button.bind("<Up>", lambda e: self._open_dropdown())
self.dropdown_button.bind("<space>", lambda e: self._toggle_dropdown())
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_items(self, items: List[Dict]):
"""
Set the list of items.
Args:
items: List of item dictionaries
"""
self.items = items
self.current_index = -1
# Update count label
if self.show_count:
count_text = f"{len(items)} item{'s' if len(items) != 1 else ''} found"
self.count_label.configure(text=count_text)
# Enable/disable button
if items:
self.dropdown_button.configure(state="normal")
# Auto-select first item
if items:
self._select_item(0, trigger_callback=False)
else:
self.dropdown_button.configure(
state="disabled",
text="No items found"
)
self.selected_item = None
def _get_display_text(self, item: Dict) -> str:
"""
Get display text for an item.
Args:
item: Item dictionary
Returns:
Display text string
"""
if self.display_format:
return self.display_format(item)
elif self.display_key in item:
return str(item[self.display_key])
elif 'name' in item:
return str(item['name'])
elif 'display_name' in item:
return str(item['display_name'])
else:
return str(item)
def _toggle_dropdown(self):
"""Toggle dropdown popup visibility."""
if self.popup_window:
self._close_dropdown()
else:
self._open_dropdown()
def _open_dropdown(self):
"""Open the dropdown popup."""
if self.popup_window or not self.items:
return
# Create popup window
self.popup_window = tk.Toplevel(self)
self.popup_window.wm_overrideredirect(True) # Remove window decorations
self.popup_window.wm_attributes("-topmost", True) # Always on top
# Calculate dynamic height based on number of items
# Each item: 40px button + 4px padding = 44px per item
item_height = 44
calculated_height = len(self.items) * item_height + 10 # +10 for padding
# Use calculated height, but cap at max_dropdown_height
popup_height = min(calculated_height, self.max_dropdown_height)
# Create scrollable frame for items
self.popup_frame = ctk.CTkScrollableFrame(
self.popup_window,
width=self.button_width - 20,
height=popup_height
)
self.popup_frame.pack(fill="both", expand=True)
# Configure scroll speed to match main window (40px per scroll unit = 2x faster)
self.popup_frame._parent_canvas.configure(yscrollincrement=40)
# Clear previous items
self.item_buttons.clear()
for tooltip in self.item_tooltips:
tooltip.destroy()
self.item_tooltips.clear()
# Create button for each item
for idx, item in enumerate(self.items):
display_text = self._get_display_text(item)
# Truncate long text
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
btn = ctk.CTkButton(
self.popup_frame,
text=truncated,
command=lambda i=idx: self._select_item(i, close_popup=True),
font=ctk.CTkFont(size=14),
height=40,
fg_color="transparent",
border_width=1,
border_color="gray50",
hover_color=("gray70", "gray30"),
anchor="w"
)
btn.grid(row=idx, column=0, padx=5, pady=2, sticky="ew")
self.popup_frame.grid_columnconfigure(0, weight=1)
# Add tooltip if text was truncated
if len(display_text) > max_chars:
tooltip = ToolTip(btn, display_text, delay=500)
self.item_tooltips.append(tooltip)
self.item_buttons.append(btn)
# Bind mouse wheel to popup (prevent scrolling main window)
def popup_scroll(event):
"""Handle mouse wheel in popup."""
if event.delta > 0:
self.popup_frame._parent_canvas.yview_scroll(-1, "units")
else:
self.popup_frame._parent_canvas.yview_scroll(1, "units")
return "break" # Stop event propagation
# Bind to both popup window and frame to capture all events
self.popup_window.bind("<MouseWheel>", popup_scroll, add="+")
self.popup_frame.bind("<MouseWheel>", popup_scroll, add="+")
self.popup_frame._parent_canvas.bind("<MouseWheel>", popup_scroll, add="+")
# Bind to all item buttons so scrolling works when hovering over them
for btn in self.item_buttons:
btn.bind("<MouseWheel>", popup_scroll, add="+")
# Highlight currently selected item
if self.current_index >= 0:
self._highlight_item(self.current_index)
# Position popup
self._position_popup()
# Bind keyboard events (use correct Tkinter key names)
self.popup_window.bind("<Escape>", lambda e: self._close_dropdown())
self.popup_window.bind("<Down>", self._navigate_down)
self.popup_window.bind("<Up>", self._navigate_up)
self.popup_window.bind("<Next>", self._navigate_page_down) # PageDown in Tkinter
self.popup_window.bind("<Prior>", self._navigate_page_up) # PageUp in Tkinter
self.popup_window.bind("<Home>", self._navigate_home)
self.popup_window.bind("<End>", self._navigate_end)
self.popup_window.bind("<Return>", self._confirm_selection)
self.popup_window.bind("<FocusOut>", lambda e: self._close_dropdown())
# Set focus to popup
self.popup_window.focus_set()
# Bind click outside to close
self.popup_window.bind("<Button-1>", self._check_click_outside, add="+")
def _close_dropdown(self):
"""Close the dropdown popup."""
if self.popup_window:
# Clean up tooltips
for tooltip in self.item_tooltips:
tooltip.destroy()
self.item_tooltips.clear()
# Unbind mouse wheel from popup widgets
try:
self.popup_window.unbind("<MouseWheel>")
if self.popup_frame:
self.popup_frame.unbind("<MouseWheel>")
self.popup_frame._parent_canvas.unbind("<MouseWheel>")
# Unbind from buttons
for btn in self.item_buttons:
btn.unbind("<MouseWheel>")
except:
pass
# Destroy popup
self.popup_window.destroy()
self.popup_window = None
self.popup_frame = None
self.item_buttons.clear()
def _position_popup(self):
"""Position the popup window below the button."""
# Update to get accurate dimensions
self.popup_window.update_idletasks()
# Get button position
x = self.dropdown_button.winfo_rootx()
y = self.dropdown_button.winfo_rooty() + self.dropdown_button.winfo_height()
# Get screen dimensions
screen_width = self.winfo_screenwidth()
screen_height = self.winfo_screenheight()
# Get popup dimensions
popup_width = self.popup_window.winfo_width()
popup_height = self.popup_window.winfo_height()
# Adjust if near screen edges
if x + popup_width > screen_width:
x = screen_width - popup_width - 10
# Position above if not enough space below
if y + popup_height > screen_height:
y = self.dropdown_button.winfo_rooty() - popup_height
self.popup_window.wm_geometry(f"+{x}+{y}")
def _check_click_outside(self, event):
"""Check if click was outside popup and close if so."""
if self.popup_window:
x, y = event.x_root, event.y_root
popup_x = self.popup_window.winfo_rootx()
popup_y = self.popup_window.winfo_rooty()
popup_width = self.popup_window.winfo_width()
popup_height = self.popup_window.winfo_height()
if not (popup_x <= x <= popup_x + popup_width and
popup_y <= y <= popup_y + popup_height):
self._close_dropdown()
def _select_item(self, index: int, trigger_callback: bool = True, close_popup: bool = False):
"""
Select an item by index.
Args:
index: Item index
trigger_callback: Whether to trigger selection callback
close_popup: Whether to close popup after selection
"""
if 0 <= index < len(self.items):
self.current_index = index
self.selected_item = self.items[index]
# Update button text
display_text = self._get_display_text(self.selected_item)
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
self.dropdown_button.configure(text=truncated)
# Trigger callback
if trigger_callback and self.on_selection_changed:
self.on_selection_changed(self.selected_item)
# Close popup if requested
if close_popup:
self._close_dropdown()
def _highlight_item(self, index: int):
"""
Highlight an item in the popup.
Args:
index: Item index
"""
if not self.item_buttons:
return
for i, btn in enumerate(self.item_buttons):
if i == index:
btn.configure(
fg_color=("gray75", "gray25"),
border_color="blue"
)
else:
btn.configure(
fg_color="transparent",
border_color="gray50"
)
def _navigate_down(self, event=None):
"""Navigate to next item."""
if not self.items:
return
new_index = (self.current_index + 1) % len(self.items)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_up(self, event=None):
"""Navigate to previous item."""
if not self.items:
return
new_index = (self.current_index - 1) % len(self.items)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_page_down(self, event=None):
"""Jump down 5 items."""
if not self.items:
return
new_index = min(self.current_index + 5, len(self.items) - 1)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_page_up(self, event=None):
"""Jump up 5 items."""
if not self.items:
return
new_index = max(self.current_index - 5, 0)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_home(self, event=None):
"""Jump to first item."""
if not self.items:
return
self.current_index = 0
self._highlight_item(0)
self._scroll_to_item(0)
def _navigate_end(self, event=None):
"""Jump to last item."""
if not self.items:
return
new_index = len(self.items) - 1
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _confirm_selection(self, event=None):
"""Confirm current selection and close popup."""
if self.current_index >= 0:
self._select_item(self.current_index, trigger_callback=True, close_popup=True)
def _scroll_to_item(self, index: int):
"""
Scroll popup to make item visible.
Args:
index: Item index
"""
if not self.popup_frame or not self.item_buttons:
return
# Get the button widget
if 0 <= index < len(self.item_buttons):
btn = self.item_buttons[index]
# Calculate scroll position
# Note: CTkScrollableFrame uses internal canvas
btn.update_idletasks()
def get_selected(self) -> Optional[Dict]:
"""
Get the currently selected item.
Returns:
Selected item dictionary or None
"""
return self.selected_item
def set_enabled(self, enabled: bool):
"""
Enable or disable the dropdown.
Args:
enabled: Whether to enable the dropdown
"""
if enabled and self.items:
self.dropdown_button.configure(state="normal")
else:
self.dropdown_button.configure(state="disabled")
def set_loading(self, loading: bool):
"""
Set loading state.
Args:
loading: Whether currently loading
"""
if loading:
self.dropdown_button.configure(state="disabled", text="Loading...")
if self.show_count:
self.count_label.configure(text="Loading...")
else:
if self.items:
self.dropdown_button.configure(state="normal")
if self.show_count:
count_text = f"{len(self.items)} item{'s' if len(self.items) != 1 else ''} found"
self.count_label.configure(text=count_text)
else:
self.dropdown_button.configure(state="disabled", text="No items found")
if self.show_count:
self.count_label.configure(text="0 items found")
def set_placeholder(self, text: str):
"""
Set placeholder text when no items.
Args:
text: Placeholder text
"""
if not self.items:
self.dropdown_button.configure(text=text)
+101
View File
@@ -0,0 +1,101 @@
"""
Login Frame
UI component for authentication.
"""
import customtkinter as ctk
from typing import Callable
class LoginFrame(ctk.CTkFrame):
"""Frame for authentication UI."""
def __init__(self, parent, on_connect: Callable = None):
"""
Initialize the login frame.
Args:
parent: Parent widget
on_connect: Callback function when connect button is clicked
"""
super().__init__(parent)
self.on_connect = on_connect
self.is_authenticated = False
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Title
self.title_label = ctk.CTkLabel(
self,
text="Authentication",
font=ctk.CTkFont(size=18, weight="bold")
)
self.title_label.grid(row=0, column=0, columnspan=3, padx=20, pady=(20, 10), sticky="w")
# Status indicator (colored circle)
self.status_indicator = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=20),
text_color="red"
)
self.status_indicator.grid(row=1, column=0, padx=(20, 5), pady=10)
# Status text
self.status_label = ctk.CTkLabel(
self,
text="Not Connected",
font=ctk.CTkFont(size=14)
)
self.status_label.grid(row=1, column=1, padx=5, pady=10, sticky="w")
# Connect button
self.connect_button = ctk.CTkButton(
self,
text="Connect to Azure",
command=self._on_connect_clicked,
width=200,
height=40,
font=ctk.CTkFont(size=14, weight="bold")
)
self.connect_button.grid(row=1, column=2, padx=20, pady=10, sticky="e")
# Configure grid
self.grid_columnconfigure(1, weight=1)
def _on_connect_clicked(self):
"""Handle connect button click."""
if self.on_connect:
self.on_connect()
def set_authenticated(self, authenticated: bool):
"""
Update authentication status.
Args:
authenticated: Whether authentication succeeded
"""
self.is_authenticated = authenticated
if authenticated:
self.status_indicator.configure(text_color="green")
self.status_label.configure(text="Connected")
self.connect_button.configure(state="disabled", text="Connected")
else:
self.status_indicator.configure(text_color="red")
self.status_label.configure(text="Not Connected")
self.connect_button.configure(state="normal", text="Connect to Azure")
def set_connecting(self):
"""Set UI to connecting state."""
self.status_indicator.configure(text_color="orange")
self.status_label.configure(text="Connecting...")
self.connect_button.configure(state="disabled", text="Connecting...")
def enable_button(self):
"""Enable the connect button (for retry after error)."""
if not self.is_authenticated:
self.connect_button.configure(state="normal", text="Connect to Azure")
+285
View File
@@ -0,0 +1,285 @@
"""
Main Window
Main GUI window that integrates all frames.
"""
import customtkinter as ctk
from ui.login_frame import LoginFrame
from ui.subscription_selection_frame import SubscriptionSelectionFrame
from ui.app_selection_frame import AppSelectionFrame
from ui.secret_generation_frame import SecretGenerationFrame
from ui.result_frame import ResultFrame
from typing import Callable, Dict, List
class MainWindow(ctk.CTk):
"""Main application window."""
def __init__(
self,
on_connect: Callable = None,
on_subscription_selected: Callable = None,
on_app_selected: Callable = None,
on_generate_secret: Callable = None,
on_generate_another: Callable = None
):
"""
Initialize the main window.
Args:
on_connect: Callback for authentication
on_subscription_selected: Callback when subscription is selected
on_app_selected: Callback when app is selected
on_generate_secret: Callback when generate secret is clicked
on_generate_another: Callback when generate another is clicked
"""
super().__init__()
# Configure window
self.title("Azure Key Vault Secret Manager")
self.geometry("950x850")
# Set application icon (if available)
self._set_icon()
# Configure grid
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# Title
self.title_label = ctk.CTkLabel(
self,
text="Azure Key Vault Secret Manager",
font=ctk.CTkFont(size=28, weight="bold")
)
self.title_label.grid(row=0, column=0, padx=20, pady=20, sticky="n")
# Scrollable frame for content with optimized scrolling
self.scroll_frame = ctk.CTkScrollableFrame(
self,
scrollbar_button_color=("gray75", "gray25"),
scrollbar_button_hover_color=("gray65", "gray35")
)
self.scroll_frame.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="nsew")
self.scroll_frame.grid_columnconfigure(0, weight=1)
# Optimize scroll step for smoother scrolling (40px = 2x faster than default 20px)
self.scroll_frame._parent_canvas.configure(yscrollincrement=40)
# Add smooth mouse wheel scrolling
def smooth_scroll(event):
"""Handle smooth mouse wheel scrolling."""
# Platform-specific delta handling
if event.delta > 0:
delta = -1 # Scroll up
else:
delta = 1 # Scroll down
self.scroll_frame._parent_canvas.yview_scroll(delta, "units")
return "break" # Prevent event propagation
# Bind mouse wheel event (Windows/Mac)
self.scroll_frame._parent_canvas.bind_all("<MouseWheel>", smooth_scroll, add="+")
# Login Frame
self.login_frame = LoginFrame(self.scroll_frame, on_connect=on_connect)
self.login_frame.grid(row=0, column=0, padx=0, pady=(0, 15), sticky="ew")
# Subscription Selection Frame
self.subscription_selection_frame = SubscriptionSelectionFrame(
self.scroll_frame,
on_subscription_selected=on_subscription_selected
)
self.subscription_selection_frame.grid(row=1, column=0, padx=0, pady=(0, 15), sticky="ew")
self.subscription_selection_frame.set_enabled(False)
# App Selection Frame
self.app_selection_frame = AppSelectionFrame(
self.scroll_frame,
on_app_selected=on_app_selected
)
self.app_selection_frame.grid(row=2, column=0, padx=0, pady=(0, 15), sticky="ew")
self.app_selection_frame.set_enabled(False)
# Secret Generation Frame
self.secret_generation_frame = SecretGenerationFrame(
self.scroll_frame,
on_generate=on_generate_secret
)
self.secret_generation_frame.grid(row=3, column=0, padx=0, pady=(0, 15), sticky="ew")
self.secret_generation_frame.set_enabled(False)
# Result Frame (initially hidden)
self.result_frame = ResultFrame(
self.scroll_frame,
on_generate_another=on_generate_another
)
self.result_frame.grid(row=4, column=0, padx=0, pady=(0, 15), sticky="ew")
self.result_frame.hide()
def set_authenticated(self, authenticated: bool):
"""
Update UI after authentication.
Args:
authenticated: Whether authentication succeeded
"""
self.login_frame.set_authenticated(authenticated)
if authenticated:
self.subscription_selection_frame.set_enabled(True)
def set_connecting(self):
"""Set UI to connecting state."""
self.login_frame.set_connecting()
def enable_connect_button(self):
"""Enable connect button for retry."""
self.login_frame.enable_button()
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
"""
Set the list of subscriptions.
Args:
subscriptions: List of subscription dictionaries
"""
self.subscription_selection_frame.set_subscriptions(subscriptions)
def set_subscription_selected(self):
"""Enable UI after subscription is selected."""
self.app_selection_frame.set_enabled(True)
self.secret_generation_frame.set_enabled(True)
def set_apps(self, apps: List[Dict[str, str]]):
"""
Set the list of applications.
Args:
apps: List of app dictionaries
"""
self.app_selection_frame.set_apps(apps)
def set_vaults(self, vaults: List[Dict[str, str]]):
"""
Set the list of Key Vaults.
Args:
vaults: List of vault dictionaries
"""
self.secret_generation_frame.set_vaults(vaults)
def set_loading_subscriptions(self, loading: bool):
"""
Set loading subscriptions state.
Args:
loading: Whether currently loading
"""
self.subscription_selection_frame.set_loading(loading)
def set_loading_apps(self, loading: bool):
"""
Set loading apps state.
Args:
loading: Whether currently loading
"""
self.app_selection_frame.set_loading(loading)
def set_loading_vaults(self, loading: bool):
"""
Set loading vaults state.
Args:
loading: Whether currently loading
"""
self.secret_generation_frame.set_loading_vaults(loading)
def set_generating(self, generating: bool):
"""
Set generating secret state.
Args:
generating: Whether currently generating
"""
self.secret_generation_frame.set_generating(generating)
def show_result(
self,
secret_name: str,
vault_name: str,
secret_value: str,
removed_count: int = 0
):
"""
Show secret generation result.
Args:
secret_name: The sanitized secret name
vault_name: The Key Vault name
secret_value: The secret value
removed_count: Number of old secrets removed
"""
self.result_frame.show_result(secret_name, vault_name, secret_value, removed_count)
def reset_form(self):
"""Reset the secret generation form."""
self.secret_generation_frame.reset()
self.result_frame.hide()
def get_selected_app(self) -> Dict[str, str]:
"""Get the currently selected app."""
return self.app_selection_frame.get_selected_app()
def get_description(self) -> str:
"""Get the secret description."""
return self.secret_generation_frame.get_description()
def get_selected_vault(self) -> Dict[str, str]:
"""Get the currently selected vault."""
return self.secret_generation_frame.get_selected_vault()
def get_remove_old_secrets(self) -> bool:
"""Get whether to remove old secrets."""
return self.secret_generation_frame.get_remove_old_secrets()
def _set_icon(self):
"""Set the application icon if available."""
import os
from pathlib import Path
# Get the directory where the script is located
script_dir = Path(__file__).parent.parent
# Try common icon file names and locations
icon_paths = [
script_dir / "icon.ico",
script_dir / "assets" / "icon.ico",
script_dir / "icon.png",
script_dir / "assets" / "icon.png",
]
for icon_path in icon_paths:
if icon_path.exists():
try:
if icon_path.suffix == '.ico':
# Use .ico file directly (Windows)
self.iconbitmap(str(icon_path))
print(f"Icon loaded: {icon_path}")
return
elif icon_path.suffix == '.png':
# Use .png file with iconphoto (cross-platform)
import tkinter as tk
from PIL import Image, ImageTk
img = Image.open(icon_path)
photo = ImageTk.PhotoImage(img)
self.iconphoto(True, photo)
print(f"Icon loaded: {icon_path}")
return
except Exception as e:
print(f"Failed to load icon from {icon_path}: {str(e)}")
# No icon found - use default
print("No custom icon found. Using default application icon.")
+197
View File
@@ -0,0 +1,197 @@
"""
Result Frame
UI component for displaying secret generation results.
"""
import customtkinter as ctk
from typing import Callable
class ResultFrame(ctk.CTkFrame):
"""Frame for displaying secret generation results."""
def __init__(self, parent, on_generate_another: Callable = None):
"""
Initialize the result frame.
Args:
parent: Parent widget
on_generate_another: Callback function when "Generate Another" is clicked
"""
super().__init__(parent)
self.on_generate_another = on_generate_another
# Configure frame with success colors
self.configure(
corner_radius=10,
border_width=3,
border_color="green",
fg_color=("#E8F5E9", "#1B5E20") # Light green / Dark green
)
# Success title
self.success_label = ctk.CTkLabel(
self,
text="✓ Secret Generated Successfully!",
font=ctk.CTkFont(size=20, weight="bold"),
text_color="green"
)
self.success_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(20, 15), sticky="w")
# Secret Name Label
self.name_title_label = ctk.CTkLabel(
self,
text="Secret Name:",
font=ctk.CTkFont(size=14, weight="bold")
)
self.name_title_label.grid(row=1, column=0, padx=20, pady=(0, 5), sticky="w")
self.name_value_label = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=14)
)
self.name_value_label.grid(row=1, column=1, padx=20, pady=(0, 5), sticky="w")
# Key Vault Label
self.vault_title_label = ctk.CTkLabel(
self,
text="Key Vault:",
font=ctk.CTkFont(size=14, weight="bold")
)
self.vault_title_label.grid(row=2, column=0, padx=20, pady=(0, 5), sticky="w")
self.vault_value_label = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=14)
)
self.vault_value_label.grid(row=2, column=1, padx=20, pady=(0, 5), sticky="w")
# Old Secrets Removed Label (initially hidden)
self.removed_title_label = ctk.CTkLabel(
self,
text="Old Secrets Removed:",
font=ctk.CTkFont(size=14, weight="bold")
)
self.removed_title_label.grid(row=3, column=0, padx=20, pady=(0, 15), sticky="w")
self.removed_title_label.grid_remove() # Hide initially
self.removed_value_label = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=14)
)
self.removed_value_label.grid(row=3, column=1, padx=20, pady=(0, 15), sticky="w")
self.removed_value_label.grid_remove() # Hide initially
# Secret Value Label
self.secret_label = ctk.CTkLabel(
self,
text="Secret Value (copy this now):",
font=ctk.CTkFont(size=14, weight="bold")
)
self.secret_label.grid(row=4, column=0, columnspan=2, padx=20, pady=(10, 5), sticky="w")
# Secret Value Textbox (read-only)
self.secret_textbox = ctk.CTkTextbox(
self,
height=80,
font=ctk.CTkFont(family="Consolas", size=12),
wrap="word"
)
self.secret_textbox.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
# Buttons frame
self.buttons_frame = ctk.CTkFrame(self, fg_color="transparent")
self.buttons_frame.grid(row=6, column=0, columnspan=2, padx=20, pady=(0, 20))
# Copy button
self.copy_button = ctk.CTkButton(
self.buttons_frame,
text="Copy to Clipboard",
command=self._on_copy_clicked,
width=180,
height=40,
font=ctk.CTkFont(size=14, weight="bold")
)
self.copy_button.grid(row=0, column=0, padx=(0, 10))
# Generate Another button
self.another_button = ctk.CTkButton(
self.buttons_frame,
text="Generate Another Secret",
command=self._on_another_clicked,
width=200,
height=40,
font=ctk.CTkFont(size=14, weight="bold")
)
self.another_button.grid(row=0, column=1, padx=(10, 0))
# Configure grid
self.grid_columnconfigure(1, weight=1)
# Initially hide the frame
self.grid_remove()
def show_result(
self,
secret_name: str,
vault_name: str,
secret_value: str,
removed_count: int = 0
):
"""
Display secret generation result.
Args:
secret_name: The sanitized secret name
vault_name: The Key Vault name
secret_value: The secret value
removed_count: Number of old secrets removed (0 if not applicable)
"""
# Update labels
self.name_value_label.configure(text=secret_name)
self.vault_value_label.configure(text=vault_name)
# Show/hide removed count
if removed_count > 0:
self.removed_value_label.configure(text=str(removed_count))
self.removed_title_label.grid()
self.removed_value_label.grid()
else:
self.removed_title_label.grid_remove()
self.removed_value_label.grid_remove()
# Set secret value in textbox
self.secret_textbox.delete("1.0", "end")
self.secret_textbox.insert("1.0", secret_value)
self.secret_textbox.configure(state="disabled")
# Show the frame
self.grid()
def _on_copy_clicked(self):
"""Handle copy button click."""
# Get secret value from textbox
secret_value = self.secret_textbox.get("1.0", "end").strip()
# Copy to clipboard
self.clipboard_clear()
self.clipboard_append(secret_value)
# Show temporary confirmation
original_text = self.copy_button.cget("text")
self.copy_button.configure(text="✓ Copied!")
self.after(2000, lambda: self.copy_button.configure(text=original_text))
def _on_another_clicked(self):
"""Handle generate another button click."""
if self.on_generate_another:
self.on_generate_another()
def hide(self):
"""Hide the result frame."""
self.grid_remove()
+294
View File
@@ -0,0 +1,294 @@
"""
Secret Generation Frame
UI component for secret generation form.
"""
import customtkinter as ctk
from typing import List, Dict, Callable, Optional
class SecretGenerationFrame(ctk.CTkFrame):
"""Frame for secret generation form."""
def __init__(self, parent, on_generate: Callable = None):
"""
Initialize the secret generation frame.
Args:
parent: Parent widget
on_generate: Callback function when generate button is clicked
"""
super().__init__(parent)
self.on_generate = on_generate
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Title
self.title_label = ctk.CTkLabel(
self,
text="Secret Generation",
font=ctk.CTkFont(size=18, weight="bold")
)
self.title_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(20, 15), sticky="w")
# Secret Description Label
self.description_label = ctk.CTkLabel(
self,
text="Secret Description:",
font=ctk.CTkFont(size=14)
)
self.description_label.grid(row=1, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w")
# Secret Description Entry
self.description_entry = ctk.CTkEntry(
self,
placeholder_text="e.g., Production API Key 2025",
height=40,
font=ctk.CTkFont(size=14)
)
self.description_entry.grid(row=2, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
# Key Vault Label
self.vault_label = ctk.CTkLabel(
self,
text="Select Key Vault:",
font=ctk.CTkFont(size=14)
)
self.vault_label.grid(row=3, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w")
# Key Vault Dropdown Button (simplified inline version)
self.vault_dropdown_button = ctk.CTkButton(
self,
text="Please select a subscription first",
command=self._open_vault_dropdown,
width=600,
height=40,
font=ctk.CTkFont(size=14),
anchor="w",
state="disabled"
)
self.vault_dropdown_button.grid(row=4, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
# Store vaults and selection
self.vaults = []
self.selected_vault = None
self.vault_popup = None
# Remove old secrets checkbox
self.remove_old_checkbox = ctk.CTkCheckBox(
self,
text="Remove old secrets after creating new one",
font=ctk.CTkFont(size=14)
)
self.remove_old_checkbox.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="w")
# Generate button
self.generate_button = ctk.CTkButton(
self,
text="Generate Secret",
command=self._on_generate_clicked,
height=50,
font=ctk.CTkFont(size=16, weight="bold"),
state="disabled"
)
self.generate_button.grid(row=6, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="ew")
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_vaults(self, vaults: List[Dict[str, str]]):
"""
Set the list of Key Vaults.
Args:
vaults: List of vault dictionaries with 'name' and 'resource_group'
"""
self.vaults = vaults
if vaults:
self.vault_dropdown_button.configure(state="normal")
# Auto-select first vault
self._select_vault(vaults[0])
else:
self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found")
self.selected_vault = None
def _open_vault_dropdown(self):
"""Open the vault selection popup."""
if not self.vaults or self.vault_popup:
return
import tkinter as tk
# Create popup
self.vault_popup = tk.Toplevel(self)
self.vault_popup.wm_overrideredirect(True)
self.vault_popup.wm_attributes("-topmost", True)
# Calculate height
item_height = 44
calculated_height = len(self.vaults) * item_height + 10
popup_height = min(calculated_height, 300)
# Create scrollable frame
scroll_frame = ctk.CTkScrollableFrame(
self.vault_popup,
width=580,
height=popup_height
)
scroll_frame.pack(fill="both", expand=True)
# Configure scroll speed to match main window (40px per scroll unit = 2x faster)
scroll_frame._parent_canvas.configure(yscrollincrement=40)
# Create buttons
for idx, vault in enumerate(self.vaults):
display_text = f"{vault['name']} (RG: {vault['resource_group']})"
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
btn = ctk.CTkButton(
scroll_frame,
text=truncated,
command=lambda v=vault: self._select_vault_and_close(v),
font=ctk.CTkFont(size=14),
height=40,
fg_color="transparent",
border_width=1,
border_color="gray50",
hover_color=("gray70", "gray30"),
anchor="w"
)
btn.grid(row=idx, column=0, padx=5, pady=2, sticky="ew")
scroll_frame.grid_columnconfigure(0, weight=1)
# Bind mouse wheel
def popup_scroll(event):
if event.delta > 0:
scroll_frame._parent_canvas.yview_scroll(-1, "units")
else:
scroll_frame._parent_canvas.yview_scroll(1, "units")
return "break"
btn.bind("<MouseWheel>", popup_scroll, add="+")
# Position popup
self.vault_popup.update_idletasks()
x = self.vault_dropdown_button.winfo_rootx()
y = self.vault_dropdown_button.winfo_rooty() + self.vault_dropdown_button.winfo_height()
self.vault_popup.wm_geometry(f"+{x}+{y}")
# Bind events
self.vault_popup.bind("<Escape>", lambda e: self._close_vault_popup())
self.vault_popup.bind("<FocusOut>", lambda e: self._close_vault_popup())
self.vault_popup.focus_set()
def _select_vault(self, vault: Dict):
"""Select a vault and update button text."""
self.selected_vault = vault
display_text = f"{vault['name']} (RG: {vault['resource_group']})"
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
self.vault_dropdown_button.configure(text=truncated)
def _select_vault_and_close(self, vault: Dict):
"""Select vault and close popup."""
self._select_vault(vault)
self._close_vault_popup()
def _close_vault_popup(self):
"""Close the vault popup."""
if self.vault_popup:
self.vault_popup.destroy()
self.vault_popup = None
def _on_generate_clicked(self):
"""Handle generate button click."""
if self.on_generate:
description = self.description_entry.get().strip()
remove_old = self.remove_old_checkbox.get()
vault = self.get_selected_vault()
if description and vault:
self.on_generate(description, vault, remove_old)
def get_selected_vault(self) -> Optional[Dict[str, str]]:
"""
Get the currently selected Key Vault.
Returns:
Dict: Selected vault or None
"""
return self.selected_vault
def get_description(self) -> str:
"""
Get the secret description.
Returns:
str: Description text
"""
return self.description_entry.get().strip()
def get_remove_old_secrets(self) -> bool:
"""
Get whether to remove old secrets.
Returns:
bool: True if checkbox is checked
"""
return self.remove_old_checkbox.get()
def set_enabled(self, enabled: bool):
"""
Enable or disable the frame.
Args:
enabled: Whether to enable the frame
"""
if enabled:
self.description_entry.configure(state="normal")
if self.vaults:
self.vault_dropdown_button.configure(state="normal")
self.remove_old_checkbox.configure(state="normal")
self.generate_button.configure(state="normal")
else:
self.description_entry.configure(state="disabled")
self.vault_dropdown_button.configure(state="disabled")
self.remove_old_checkbox.configure(state="disabled")
self.generate_button.configure(state="disabled")
def set_generating(self, generating: bool):
"""
Set generating state.
Args:
generating: Whether currently generating
"""
if generating:
self.generate_button.configure(state="disabled", text="Generating...")
else:
self.generate_button.configure(state="normal", text="Generate Secret")
def set_loading_vaults(self, loading: bool):
"""
Set loading vaults state.
Args:
loading: Whether currently loading
"""
if loading:
self.vault_dropdown_button.configure(state="disabled", text="Loading Key Vaults...")
else:
if self.vaults:
self.vault_dropdown_button.configure(state="normal")
else:
self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found")
def reset(self):
"""Reset the form to initial state."""
self.description_entry.delete(0, 'end')
self.remove_old_checkbox.deselect()
+89
View File
@@ -0,0 +1,89 @@
"""
Subscription Selection Frame
UI component for selecting an Azure subscription.
"""
import customtkinter as ctk
from typing import List, Dict, Callable, Optional
from ui.components import UnifiedDropdown
class SubscriptionSelectionFrame(ctk.CTkFrame):
"""Frame for subscription selection."""
def __init__(self, parent, on_subscription_selected: Callable = None):
"""
Initialize the subscription selection frame.
Args:
parent: Parent widget
on_subscription_selected: Callback function when a subscription is selected
"""
super().__init__(parent)
self.on_subscription_selected = on_subscription_selected
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Create unified dropdown
self.dropdown = UnifiedDropdown(
self,
title="Subscription Selection",
on_selection_changed=self._on_subscription_selected,
show_count=True,
display_key='name',
max_dropdown_height=300
)
self.dropdown.grid(row=0, column=0, sticky="ew")
self.dropdown.set_placeholder("Please connect to Azure first")
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
"""
Set the list of subscriptions.
Args:
subscriptions: List of subscription dictionaries with 'id' and 'name'
"""
self.dropdown.set_items(subscriptions)
def _on_subscription_selected(self, subscription: Dict):
"""
Handle subscription selection.
Args:
subscription: The selected subscription dictionary
"""
if self.on_subscription_selected:
self.on_subscription_selected(subscription)
def get_selected_subscription(self) -> Optional[Dict[str, str]]:
"""
Get the currently selected subscription.
Returns:
Dict: Selected subscription or None
"""
return self.dropdown.get_selected()
def set_enabled(self, enabled: bool):
"""
Enable or disable the frame.
Args:
enabled: Whether to enable the frame
"""
self.dropdown.set_enabled(enabled)
def set_loading(self, loading: bool):
"""
Set loading state.
Args:
loading: Whether currently loading
"""
self.dropdown.set_loading(loading)