From 87865e2c6d6f90ee25e6639ca581eac4c24d97d7 Mon Sep 17 00:00:00 2001 From: inorishio Date: Fri, 19 Dec 2025 12:58:58 +0100 Subject: [PATCH] First commit --- .gitignore | 76 ++++ README.md | 263 +++++++++++++ auth/__init__.py | 0 auth/azure_authenticator.py | 149 ++++++++ auth/graph_authenticator.py | 105 ++++++ config.py | 21 ++ main.py | 316 ++++++++++++++++ requirements.txt | 19 + services/__init__.py | 0 services/app_registration_service.py | 99 +++++ services/keyvault_service.py | 187 ++++++++++ services/secret_service.py | 155 ++++++++ ui/__init__.py | 0 ui/app_selection_frame.py | 89 +++++ ui/components/__init__.py | 10 + ui/components/tooltip.py | 139 +++++++ ui/components/unified_dropdown.py | 535 +++++++++++++++++++++++++++ ui/login_frame.py | 101 +++++ ui/main_window.py | 285 ++++++++++++++ ui/result_frame.py | 197 ++++++++++ ui/secret_generation_frame.py | 294 +++++++++++++++ ui/subscription_selection_frame.py | 89 +++++ utils/__init__.py | 0 utils/async_worker.py | 108 ++++++ utils/logger.py | 69 ++++ utils/sanitizer.py | 37 ++ 26 files changed, 3343 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 auth/__init__.py create mode 100644 auth/azure_authenticator.py create mode 100644 auth/graph_authenticator.py create mode 100644 config.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/app_registration_service.py create mode 100644 services/keyvault_service.py create mode 100644 services/secret_service.py create mode 100644 ui/__init__.py create mode 100644 ui/app_selection_frame.py create mode 100644 ui/components/__init__.py create mode 100644 ui/components/tooltip.py create mode 100644 ui/components/unified_dropdown.py create mode 100644 ui/login_frame.py create mode 100644 ui/main_window.py create mode 100644 ui/result_frame.py create mode 100644 ui/secret_generation_frame.py create mode 100644 ui/subscription_selection_frame.py create mode 100644 utils/__init__.py create mode 100644 utils/async_worker.py create mode 100644 utils/logger.py create mode 100644 utils/sanitizer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00b2493 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Virtual Environment +venv/ +env/ +ENV/ +.venv/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Distribution / packaging +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Logs +logs/ +*.log + +# Unit test / coverage +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Azure credentials cache +.azure/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Environment variables +.env +.env.local + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# Temporary files +*.bak +*.tmp +temp/ +tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5d0130 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# Azure Key Vault Secret Manager + +> A modern, user-friendly GUI application for managing Azure App Registration secrets and Key Vault integration. + +![Python](https://img.shields.io/badge/python-3.8+-blue.svg) +![License](https://img.shields.io/badge/license-MIT-green.svg) +![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg) + +## ✨ Features + +- 🔐 **Single Sign-On**: Interactive browser authentication - login once for both Microsoft Graph and Azure +- 🎯 **Auto-Detection**: Automatically detects your Azure tenant ID from logged-in account +- 📋 **Subscription Selection**: Choose your subscription from a dropdown (no more config files!) +- 🔍 **Smart Dropdowns**: Searchable, scrollable lists with keyboard navigation (Arrow keys, Page Up/Down, Home/End) +- 💡 **Tooltips**: Hover over items to see full names if truncated +- 🔑 **Secret Management**: Generate 50-year secrets with custom descriptions +- 🗑️ **Cleanup**: Optionally remove old secrets when creating new ones +- 💾 **Key Vault Integration**: Automatic storage with metadata tags +- 📋 **Copy to Clipboard**: One-click secret copying +- 🎨 **Modern UI**: Clean interface built with CustomTkinter (supports dark/light themes) +- ⚡ **Smooth Performance**: Optimized scrolling and no nested scroll lag + +## 📸 Screenshots + + +``` +[App Selection] [Secret Generation] [Result View] +``` + +## 🔧 Prerequisites + +- **Python 3.8+** (Python 3.11 recommended) +- **Azure Permissions**: + - Application.ReadWrite.All (Microsoft Graph API) + - Directory.Read.All (Microsoft Graph API) + - Key Vault Secrets Officer role on target Key Vaults + - Reader role on subscription/resource groups + +**Note**: No need to create an App Registration! The app uses the Azure CLI public client ID for authentication. + +## 🎨 Customization + +### Adding a Custom Icon + +To replace the default Python icon with your own: + +1. Create an icon file (`.ico` for Windows or `.png` for cross-platform) +2. Place it in one of these locations: + - `python-app/icon.ico` or `python-app/icon.png` + - `python-app/assets/icon.ico` or `python-app/assets/icon.png` +3. The application will automatically detect and use it on next launch + +**Recommended icon size**: 256x256 pixels + +## 📦 Installation + +### 1. Clone the Repository + +```bash +git clone https://github.com/yourusername/azure-keyvault-manager.git +cd azure-keyvault-manager/python-app +``` + +### 2. Create Virtual Environment + +**Windows:** +```bash +python -m venv venv +venv\Scripts\activate +``` + +**Linux/macOS:** +```bash +python3 -m venv venv +source venv/bin/activate +``` + +### 3. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Run the Application + +```bash +python main.py +``` + +**That's it!** No configuration files to edit - the app auto-detects everything. + +## 🚀 Usage + +### Quick Start Guide + +1. **Connect to Azure** + - Click **"Connect to Azure"** + - Browser opens automatically + - Sign in with your Azure account (admin credentials) + - ✅ Authentication completes (single login!) + +2. **Select Subscription** + - Choose your Azure subscription from the dropdown + - Apps and Key Vaults load automatically + +3. **Select App Registration** + - Click the App Registration dropdown + - Scroll through the list or use keyboard navigation: + - `↑` `↓` Arrow keys to navigate + - `Page Up` `Page Down` to jump + - `Home` `End` for first/last + - `Enter` to select + - `Esc` to close + - Hover for tooltips on long names + +4. **Generate Secret** + - Enter a description (e.g., "Production API Key 2025") + - Select a Key Vault + - *(Optional)* Check "Remove old secrets" + - Click **"Generate Secret"** + +5. **Copy & Save** + - Secret is displayed once + - Click **"Copy to Clipboard"** + - Secret is automatically stored in Key Vault with metadata + - Click **"Generate Another Secret"** to continue + +### Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `↓` `↑` | Navigate dropdown items | +| `Page Down` `Page Up` | Jump 5 items | +| `Home` `End` | First/Last item | +| `Enter` | Select item | +| `Escape` | Close dropdown | +| `Mouse Wheel` | Scroll in dropdown | + +## 📁 Project Structure + +``` +python-app/ +├── main.py # Application entry point +├── config.py # App settings (no secrets!) +├── requirements.txt # Python dependencies +├── auth/ +│ ├── graph_authenticator.py # Microsoft Graph authentication +│ └── azure_authenticator.py # Azure Resource Manager authentication +├── services/ +│ ├── app_registration_service.py # App registration operations +│ ├── secret_service.py # Secret generation/management +│ └── keyvault_service.py # Key Vault operations +├── ui/ +│ ├── components/ +│ │ ├── unified_dropdown.py # Custom dropdown component +│ │ └── tooltip.py # Tooltip utility +│ ├── main_window.py # Main application window +│ ├── login_frame.py # Authentication UI +│ ├── subscription_selection_frame.py +│ ├── app_selection_frame.py # App selection UI +│ ├── secret_generation_frame.py # Secret generation form +│ └── result_frame.py # Result display +└── utils/ + ├── sanitizer.py # Name sanitization + └── logger.py # Logging setup +``` + +## 🐛 Troubleshooting + +### Authentication Issues + +**Problem**: "Authentication failed" +- **Solution**: Ensure you have the required permissions in Azure AD +- Clear cached credentials: Delete `.azure` folder in your home directory +- Verify your account has access to the Azure subscription + +**Problem**: Double login prompts +- **Solution**: This has been fixed in the latest version - you should only login once + +### Permission Errors + +**Problem**: "Failed to list applications" +- **Solution**: Request `Application.ReadWrite.All` and `Directory.Read.All` permissions from your Azure AD admin + +**Problem**: "Failed to store secret in Key Vault" +- **Solution**: Ensure you have **Key Vault Secrets Officer** role on the target vault +- Check Key Vault network settings allow your IP address + +### UI Issues + +**Problem**: Dropdown list won't scroll +- **Solution**: Updated in latest version - mouse wheel now scrolls the dropdown properly + +**Problem**: Can't see all applications +- **Solution**: Use keyboard navigation (arrow keys) or mouse wheel to scroll through large lists + +### General Issues + +**Problem**: No subscriptions found +- **Solution**: Verify your account has at least Reader access to one Azure subscription + +**Problem**: No Key Vaults appear +- **Solution**: Create a Key Vault in your subscription or request access to existing ones + +## 📝 Logs + +Application logs are stored in: `logs/app_YYYYMMDD.log` + +Log levels: +- **INFO**: Normal operations +- **ERROR**: Failed operations with stack traces + +## 🔒 Security Best Practices + +- ✅ Secrets are **only displayed once** in the UI +- ✅ Secrets are **never logged** to files +- ✅ Authentication uses Azure Identity library (secure token caching) +- ✅ Uses Azure CLI public client ID (no app registration needed) +- ⚠️ **Always copy secrets immediately** - they cannot be retrieved later +- ⚠️ Store secrets in a secure password manager after generation + +## 🏗️ Building Executable (Optional) + +Create a standalone executable: + +```bash +pip install pyinstaller +pyinstaller --onefile --windowed --name AzureKeyVaultManager main.py +``` + +Output: `dist/AzureKeyVaultManager.exe` (Windows) or `dist/AzureKeyVaultManager` (Linux/macOS) + +**Note**: Executable size will be ~50-100MB due to bundled dependencies. + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 🙏 Acknowledgments + +- Built with [CustomTkinter](https://github.com/TomSchimansky/CustomTkinter) by Tom Schimansky +- Uses [Azure SDK for Python](https://github.com/Azure/azure-sdk-for-python) +- Uses [Microsoft Graph SDK for Python](https://github.com/microsoftgraph/msgraph-sdk-python) + +## 📮 Support + +For issues, questions, or suggestions: +- 🐛 [Open an issue](https://github.com/yourusername/azure-keyvault-manager/issues) +- 💬 [Start a discussion](https://github.com/yourusername/azure-keyvault-manager/discussions) + +--- + +**Made with ❤️ for Azure administrators** diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/azure_authenticator.py b/auth/azure_authenticator.py new file mode 100644 index 0000000..038857c --- /dev/null +++ b/auth/azure_authenticator.py @@ -0,0 +1,149 @@ +""" +Azure Authentication Module + +Handles authentication to Azure services for Key Vault management. +""" + +from azure.identity import InteractiveBrowserCredential +from azure.mgmt.resource import SubscriptionClient +from typing import Optional, List, Dict + + +class AzureAuthenticator: + """Handles Azure authentication for Key Vault and resource management.""" + + def __init__(self): + """ + Initialize the Azure authenticator. + """ + self.tenant_id: Optional[str] = None + self.subscription_id: Optional[str] = None + self.credential: Optional[InteractiveBrowserCredential] = None + self.subscriptions: List[Dict[str, str]] = [] + + def authenticate(self, credential: InteractiveBrowserCredential = None) -> bool: + """ + Authenticate to Azure. Can reuse credential from Graph authenticator. + + Args: + credential: Optional credential to reuse (from GraphAuthenticator) + + Returns: + bool: True if authentication succeeded, False otherwise + + Raises: + Exception: If authentication fails + """ + try: + if credential: + # Reuse credential from Graph authentication + self.credential = credential + else: + # Create new interactive browser credential with "organizations" tenant + # This allows the user to login with any organizational account + # additionally_allowed_tenants="*" allows acquiring tokens for any tenant + self.credential = InteractiveBrowserCredential( + tenant_id="organizations", + additionally_allowed_tenants=["*"] + ) + + # List all subscriptions (this will use the cached token from Graph auth) + sub_client = SubscriptionClient(self.credential) + subscriptions_list = list(sub_client.subscriptions.list()) + + # Extract tenant ID from the first subscription + if subscriptions_list: + # Get tenant ID from subscription (format: /subscriptions/{sub-id}) + # The tenant info is in the subscription object + first_sub = subscriptions_list[0] + self.tenant_id = first_sub.tenant_id if hasattr(first_sub, 'tenant_id') else None + if self.tenant_id: + print(f"Detected Tenant ID: {self.tenant_id}") + + if subscriptions_list: + print(f"Successfully authenticated to Azure. Found {len(subscriptions_list)} subscription(s).") + + # Store subscriptions for later selection + self.subscriptions = [ + { + 'id': sub.subscription_id, + 'name': sub.display_name + } + for sub in subscriptions_list + ] + + return True + + return False + + except Exception as e: + raise Exception(f"Azure authentication failed: {str(e)}") + + def get_credential(self) -> InteractiveBrowserCredential: + """ + Get the credential object. + + Returns: + InteractiveBrowserCredential: The credential object + + Raises: + Exception: If not authenticated + """ + if not self.credential: + raise Exception("Not authenticated. Call authenticate() first.") + return self.credential + + def get_subscriptions(self) -> List[Dict[str, str]]: + """ + Get the list of available subscriptions. + + Returns: + List[Dict]: List of subscriptions with 'id' and 'name' + """ + return self.subscriptions + + def set_subscription(self, subscription_id: str): + """ + Set the active subscription ID. + + Args: + subscription_id: The subscription ID to use + """ + self.subscription_id = subscription_id + + def get_subscription_id(self) -> str: + """ + Get the subscription ID. + + Returns: + str: The subscription ID + + Raises: + Exception: If subscription not set + """ + if not self.subscription_id: + raise Exception("Subscription not set. Call set_subscription() first.") + return self.subscription_id + + def get_tenant_id(self) -> str: + """ + Get the tenant ID. + + Returns: + str: The tenant ID + + Raises: + Exception: If not authenticated + """ + if not self.tenant_id: + raise Exception("Tenant ID not available. Call authenticate() first.") + return self.tenant_id + + def is_authenticated(self) -> bool: + """ + Check if currently authenticated. + + Returns: + bool: True if authenticated, False otherwise + """ + return self.credential is not None diff --git a/auth/graph_authenticator.py b/auth/graph_authenticator.py new file mode 100644 index 0000000..b12b103 --- /dev/null +++ b/auth/graph_authenticator.py @@ -0,0 +1,105 @@ +""" +Microsoft Graph Authentication Module + +Handles authentication to Microsoft Graph API for app registration management. +""" + +from azure.identity import InteractiveBrowserCredential +from msgraph import GraphServiceClient +from typing import Optional +import config + + +class GraphAuthenticator: + """Handles Microsoft Graph authentication and client creation.""" + + def __init__(self, client_id: str = None): + """ + Initialize the Graph authenticator. + + Args: + client_id: Application Client ID (defaults to config.CLIENT_ID) + """ + self.client_id = client_id or config.CLIENT_ID + self.credential: Optional[InteractiveBrowserCredential] = None + self.client: Optional[GraphServiceClient] = None + + async def authenticate(self) -> bool: + """ + Authenticate to Microsoft Graph using interactive browser login. + + Returns: + bool: True if authentication succeeded, False otherwise + + Raises: + Exception: If authentication fails + """ + try: + # Create interactive browser credential + # Using "organizations" allows login with any organizational account + # additionally_allowed_tenants="*" allows acquiring tokens for any tenant (needed for Key Vault access) + self.credential = InteractiveBrowserCredential( + tenant_id="organizations", + client_id=self.client_id, + additionally_allowed_tenants=["*"] + ) + + # Define scopes for Microsoft Graph + scopes = ['https://graph.microsoft.com/.default'] + + # Create Graph service client + self.client = GraphServiceClient( + credentials=self.credential, + scopes=scopes + ) + + # Authenticate to Graph API FIRST + # This triggers the initial browser auth for Graph scope + # Then Management API will use SSO (single sign-on) from this auth + me = await self.client.me.get() + + if me: + print(f"Successfully authenticated as: {me.display_name} ({me.user_principal_name})") + return True + + return False + + except Exception as e: + raise Exception(f"Graph authentication failed: {str(e)}") + + def get_client(self) -> GraphServiceClient: + """ + Get the authenticated Graph service client. + + Returns: + GraphServiceClient: The authenticated client + + Raises: + Exception: If not authenticated + """ + if not self.client: + raise Exception("Not authenticated. Call authenticate() first.") + return self.client + + def get_credential(self) -> InteractiveBrowserCredential: + """ + Get the credential object for reuse in other Azure services. + + Returns: + InteractiveBrowserCredential: The credential object + + Raises: + Exception: If not authenticated + """ + if not self.credential: + raise Exception("Not authenticated. Call authenticate() first.") + return self.credential + + def is_authenticated(self) -> bool: + """ + Check if currently authenticated. + + Returns: + bool: True if authenticated, False otherwise + """ + return self.client is not None and self.credential is not None diff --git a/config.py b/config.py new file mode 100644 index 0000000..c163297 --- /dev/null +++ b/config.py @@ -0,0 +1,21 @@ +""" +Configuration file for Azure Key Vault Secret Manager + +This application automatically detects your tenant ID and allows you to +select your subscription after authentication. No manual configuration needed! +""" + +# Azure CLI Client ID (public client for interactive browser auth) +# This is the well-known Azure CLI client ID and does not need to be changed +CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" + +# Application Settings +APP_SECRET_EXPIRATION_YEARS = 50 + +# Required Microsoft Graph API Permissions (Delegated): +# - Application.ReadWrite.All +# - Directory.Read.All +# +# Required Azure RBAC Roles: +# - Key Vault Secrets Officer (or Contributor) on target Key Vaults +# - Reader on subscription/resource groups diff --git a/main.py b/main.py new file mode 100644 index 0000000..7106c4b --- /dev/null +++ b/main.py @@ -0,0 +1,316 @@ +""" +Azure Key Vault Secret Manager + +Main application entry point. +""" + +import asyncio +import customtkinter as ctk +from tkinter import messagebox +import sys + +# Import modules +import config +from auth.graph_authenticator import GraphAuthenticator +from auth.azure_authenticator import AzureAuthenticator +from services.app_registration_service import AppRegistrationService +from services.secret_service import SecretService +from services.keyvault_service import KeyVaultService +from ui.main_window import MainWindow +from utils.logger import setup_logger + +# Set appearance +ctk.set_appearance_mode("system") +ctk.set_default_color_theme("blue") + + +class Application: + """Main application class.""" + + def __init__(self): + """Initialize the application.""" + self.logger = setup_logger() + + # Initialize async worker FIRST (before any async operations) + from utils.async_worker import AsyncWorker + self.async_worker = AsyncWorker() + self.async_worker.start() + + # Authentication + self.graph_auth = GraphAuthenticator() + self.azure_auth = AzureAuthenticator() + + # Services (will be initialized after authentication) + self.app_service = None + self.secret_service = None + self.vault_service = None + + # Data + self.apps = [] + self.vaults = [] + self.subscriptions = [] + self.selected_app = None + self.selected_subscription = None + + # Create main window + self.window = MainWindow( + on_connect=self.handle_connect, + on_subscription_selected=self.handle_subscription_selected, + on_app_selected=self.handle_app_selected, + on_generate_secret=self.handle_generate_secret, + on_generate_another=self.handle_generate_another + ) + + def handle_connect(self): + """Handle authentication button click.""" + self.window.set_connecting() + + # Submit to async worker instead of creating new loop + def on_complete(future): + """Handle authentication completion in main thread.""" + try: + future.result() # Raises if authentication failed + except Exception as e: + self.logger.error(f"Authentication failed: {str(e)}") + self.window.after(0, lambda: messagebox.showerror( + "Authentication Error", + f"Failed to authenticate:\n\n{str(e)}\n\nPlease try again." + )) + self.window.after(0, self.window.enable_connect_button) + + future = self.async_worker.submit(self._authenticate()) + future.add_done_callback(on_complete) + + async def _authenticate(self): + """Perform authentication.""" + try: + self.logger.info("Starting authentication...") + + # Authenticate to Microsoft Graph + await self.graph_auth.authenticate() + + # Authenticate to Azure (reuse credential) + credential = self.graph_auth.get_credential() + self.azure_auth.authenticate(credential) + + # Initialize Graph services + graph_client = self.graph_auth.get_client() + self.app_service = AppRegistrationService(graph_client) + self.secret_service = SecretService(graph_client) + + # Update UI + self.window.set_authenticated(True) + + self.logger.info("Authentication successful") + + # Load subscriptions (should use SSO from Graph auth above) + await self._load_subscriptions() + + except Exception as e: + self.logger.error(f"Authentication failed: {str(e)}") + messagebox.showerror( + "Authentication Error", + f"Failed to authenticate:\n\n{str(e)}\n\nPlease try again." + ) + self.window.enable_connect_button() + + async def _load_subscriptions(self): + """Load Azure subscriptions.""" + try: + self.window.set_loading_subscriptions(True) + self.logger.info("Loading subscriptions...") + + # Get subscriptions from authenticator + self.subscriptions = self.azure_auth.get_subscriptions() + self.window.set_subscriptions(self.subscriptions) + self.window.set_loading_subscriptions(False) + + self.logger.info(f"Loaded {len(self.subscriptions)} subscription(s)") + + except Exception as e: + self.logger.error(f"Failed to load subscriptions: {str(e)}") + messagebox.showerror( + "Load Error", + f"Failed to load subscriptions:\n\n{str(e)}" + ) + + def handle_subscription_selected(self, subscription): + """Handle subscription selection.""" + self.selected_subscription = subscription + self.logger.info(f"Selected subscription: {subscription['name']} ({subscription['id']})") + + # Set the subscription in the azure authenticator + self.azure_auth.set_subscription(subscription['id']) + + # Initialize KeyVault service with the selected subscription + self.vault_service = KeyVaultService( + self.azure_auth.get_credential(), + self.azure_auth.get_subscription_id() + ) + + # Enable UI elements + self.window.set_subscription_selected() + + # Submit data loading to async worker (no threading.Thread needed) + def on_complete(future): + """Handle data loading completion.""" + try: + future.result() + except Exception as e: + self.logger.error(f"Failed to load data: {str(e)}") + self.window.after(0, lambda: messagebox.showerror( + "Load Error", + f"Failed to load data:\n\n{str(e)}" + )) + + future = self.async_worker.submit(self._load_data()) + future.add_done_callback(on_complete) + + async def _load_data(self): + """Load app registrations and Key Vaults.""" + try: + # Load apps + self.window.set_loading_apps(True) + self.logger.info("Loading app registrations...") + self.apps = await self.app_service.list_applications() + self.window.set_apps(self.apps) + self.window.set_loading_apps(False) + self.logger.info(f"Loaded {len(self.apps)} app registrations") + + # Load Key Vaults + self.window.set_loading_vaults(True) + self.logger.info("Loading Key Vaults...") + self.vaults = self.vault_service.list_keyvaults() + self.window.set_vaults(self.vaults) + self.window.set_loading_vaults(False) + self.logger.info(f"Loaded {len(self.vaults)} Key Vaults") + + except Exception as e: + self.logger.error(f"Failed to load data: {str(e)}") + messagebox.showerror( + "Load Error", + f"Failed to load data:\n\n{str(e)}" + ) + + def handle_app_selected(self, app): + """Handle app selection.""" + self.selected_app = app + self.logger.info(f"Selected app: {app['display_name']}") + + def handle_generate_secret(self, description: str, vault: dict, remove_old: bool): + """Handle secret generation.""" + # Submit to async worker instead of creating new loop + def on_complete(future): + """Handle generation completion.""" + try: + future.result() + except Exception as e: + self.logger.error(f"Failed to generate secret: {str(e)}") + self.window.after(0, lambda: self.window.set_generating(False)) + self.window.after(0, lambda: messagebox.showerror( + "Generation Error", + f"Failed to generate secret:\n\n{str(e)}" + )) + + future = self.async_worker.submit(self._generate_secret(description, vault, remove_old)) + future.add_done_callback(on_complete) + + async def _generate_secret(self, description: str, vault: dict, remove_old: bool): + """Generate secret asynchronously.""" + try: + # Validate inputs + app = self.window.get_selected_app() + if not app: + messagebox.showwarning("Validation Error", "Please select an app registration.") + return + + if not description: + messagebox.showwarning("Validation Error", "Please enter a secret description.") + return + + if not vault: + messagebox.showwarning("Validation Error", "Please select a Key Vault.") + return + + self.window.set_generating(True) + self.logger.info(f"Generating secret for app: {app['display_name']}") + + # Create secret + secret_result = await self.secret_service.create_secret( + app_object_id=app['id'], + description=description + ) + + self.logger.info(f"Secret created successfully. Key ID: {secret_result['key_id']}") + + # Remove old secrets if requested + removed_count = 0 + if remove_old: + self.logger.info("Removing old secrets...") + removed_count = await self.secret_service.remove_old_secrets( + app_object_id=app['id'], + keep_key_id=secret_result['key_id'] + ) + self.logger.info(f"Removed {removed_count} old secret(s)") + + # Store in Key Vault + self.logger.info(f"Storing secret in Key Vault: {vault['name']}") + sanitized_name = self.vault_service.store_secret( + vault_name=vault['name'], + secret_name=app['display_name'], + secret_value=secret_result['secret_text'], + description=description, + secret_id=secret_result['key_id'], + expires=secret_result['end_datetime'] + ) + + self.logger.info(f"Secret stored successfully: {sanitized_name}") + + # Show result in the main window (no popup needed - result frame shows all info) + self.window.show_result( + secret_name=sanitized_name, + vault_name=vault['name'], + secret_value=secret_result['secret_text'], + removed_count=removed_count + ) + + # Reset generating state + self.window.set_generating(False) + + except Exception as e: + self.logger.error(f"Failed to generate secret: {str(e)}") + self.window.set_generating(False) + messagebox.showerror( + "Generation Error", + f"Failed to generate secret:\n\n{str(e)}" + ) + + def handle_generate_another(self): + """Handle generate another secret.""" + self.window.reset_form() + self.logger.info("Form reset for another secret generation") + + def cleanup(self): + """Cleanup resources on application exit.""" + self.logger.info("Shutting down application...") + if hasattr(self, 'async_worker'): + self.async_worker.stop() + + def _on_closing(self): + """Handle window close event.""" + self.cleanup() + self.window.destroy() + + def run(self): + """Run the application.""" + self.logger.info("Starting Azure Key Vault Secret Manager") + + # Register cleanup on window close + self.window.protocol("WM_DELETE_WINDOW", self._on_closing) + + self.window.mainloop() + + +if __name__ == "__main__": + app = Application() + app.run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c74ebf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +# GUI Framework +customtkinter==5.2.2 +pillow==10.3.0 + +# Azure Authentication +azure-identity==1.16.0 + +# Microsoft Graph +msgraph-sdk==1.3.0 + +# Azure Key Vault +azure-keyvault-secrets==4.8.0 +azure-mgmt-keyvault==10.3.0 + +# Azure Management +azure-mgmt-resource==23.1.1 + +# Utilities +python-dateutil==2.9.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/app_registration_service.py b/services/app_registration_service.py new file mode 100644 index 0000000..98e459b --- /dev/null +++ b/services/app_registration_service.py @@ -0,0 +1,99 @@ +""" +App Registration Service + +Handles operations related to Azure AD app registrations. +""" + +from msgraph import GraphServiceClient +from typing import List, Dict + + +class AppRegistrationService: + """Service for managing app registrations via Microsoft Graph.""" + + def __init__(self, graph_client: GraphServiceClient): + """ + Initialize the app registration service. + + Args: + graph_client: Authenticated Graph service client + """ + self.graph_client = graph_client + + async def list_applications(self) -> List[Dict[str, str]]: + """ + Get all app registrations from Azure AD. + + Returns: + List[Dict]: List of app registrations with id, app_id, and display_name + + Raises: + Exception: If the API call fails + """ + try: + apps = [] + + # Get all applications + result = await self.graph_client.applications.get() + + if result and result.value: + for app in result.value: + apps.append({ + 'id': app.id, # Object ID + 'app_id': app.app_id, # Application (client) ID + 'display_name': app.display_name + }) + + # Handle pagination if there are more than 100 apps + while result and result.odata_next_link: + # Continue fetching next page + from kiota_abstractions.base_request_configuration import RequestConfiguration + request_config = RequestConfiguration() + request_config.url = result.odata_next_link + + result = await self.graph_client.applications.get(request_configuration=request_config) + + if result and result.value: + for app in result.value: + apps.append({ + 'id': app.id, + 'app_id': app.app_id, + 'display_name': app.display_name + }) + + # Sort alphabetically by display name + apps.sort(key=lambda x: x['display_name'].lower()) + + return apps + + except Exception as e: + raise Exception(f"Failed to list applications: {str(e)}") + + async def get_application(self, app_object_id: str) -> Dict[str, any]: + """ + Get a specific app registration by its object ID. + + Args: + app_object_id: The object ID of the app registration + + Returns: + Dict: App registration details + + Raises: + Exception: If the API call fails + """ + try: + app = await self.graph_client.applications.by_application_id(app_object_id).get() + + if app: + return { + 'id': app.id, + 'app_id': app.app_id, + 'display_name': app.display_name, + 'password_credentials': app.password_credentials + } + + return None + + except Exception as e: + raise Exception(f"Failed to get application: {str(e)}") diff --git a/services/keyvault_service.py b/services/keyvault_service.py new file mode 100644 index 0000000..ff12d35 --- /dev/null +++ b/services/keyvault_service.py @@ -0,0 +1,187 @@ +""" +Key Vault Service + +Handles operations related to Azure Key Vault. +""" + +from azure.identity import InteractiveBrowserCredential +from azure.keyvault.secrets import SecretClient +from azure.mgmt.keyvault import KeyVaultManagementClient +from datetime import datetime +from typing import List, Dict +import re + + +class KeyVaultService: + """Service for managing Key Vault operations.""" + + def __init__(self, credential: InteractiveBrowserCredential, subscription_id: str): + """ + Initialize the Key Vault service. + + Args: + credential: Authenticated credential + subscription_id: Azure subscription ID + """ + self.credential = credential + self.subscription_id = subscription_id + self.mgmt_client = KeyVaultManagementClient(credential, subscription_id) + + def list_keyvaults(self, resource_group: str = None) -> List[Dict[str, str]]: + """ + List all Key Vaults in the subscription. + + Args: + resource_group: Optional resource group to filter by + + Returns: + List[Dict]: List of Key Vaults with name, id, location, and resource_group + + Raises: + Exception: If the API call fails + """ + try: + vaults = [] + + if resource_group: + # Get vaults from specific resource group + vault_list = self.mgmt_client.vaults.list_by_resource_group(resource_group) + else: + # Get all vaults in subscription + vault_list = self.mgmt_client.vaults.list_by_subscription() + + for vault in vault_list: + # Extract resource group from vault ID + # Format: /subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/... + rg_name = vault.id.split('/')[4] if len(vault.id.split('/')) > 4 else '' + + vaults.append({ + 'name': vault.name, + 'id': vault.id, + 'location': vault.location, + 'resource_group': rg_name + }) + + # Sort by name + vaults.sort(key=lambda x: x['name'].lower()) + + return vaults + + except Exception as e: + raise Exception(f"Failed to list Key Vaults: {str(e)}") + + def store_secret( + self, + vault_name: str, + secret_name: str, + secret_value: str, + description: str, + secret_id: str, + expires: datetime + ) -> str: + """ + Store a secret in Key Vault with tags. + + Args: + vault_name: Name of the Key Vault + secret_name: Name for the secret (will be sanitized) + secret_value: The secret value to store + description: Description tag + secret_id: Secret ID tag (from app registration) + expires: Expiration date + + Returns: + str: The sanitized secret name + + Raises: + Exception: If the operation fails + """ + try: + # Sanitize the secret name + sanitized_name = self._sanitize_name(secret_name) + + # Construct vault URL + vault_url = f"https://{vault_name}.vault.azure.net" + + # Create secret client + secret_client = SecretClient(vault_url=vault_url, credential=self.credential) + + # Create tags + tags = { + 'Description': description, + 'SecretId': secret_id + } + + # Set the secret + secret = secret_client.set_secret( + name=sanitized_name, + value=secret_value, + tags=tags, + expires_on=expires + ) + + return secret.name + + except Exception as e: + raise Exception(f"Failed to store secret in Key Vault: {str(e)}") + + def _sanitize_name(self, name: str) -> str: + """ + Sanitize a name for use in Key Vault. + Key Vault secret names can only contain alphanumeric characters and hyphens. + + Args: + name: The name to sanitize + + Returns: + str: Sanitized name + + """ + if not name: + return name + + # Replace any non-alphanumeric character (except hyphens) with hyphen + sanitized = re.sub(r'[^0-9a-zA-Z-]', '-', name) + + # Remove consecutive hyphens + sanitized = re.sub(r'-+', '-', sanitized) + + # Remove leading/trailing hyphens + sanitized = sanitized.strip('-') + + return sanitized + + def get_secret(self, vault_name: str, secret_name: str) -> Dict[str, any]: + """ + Retrieve a secret from Key Vault. + + Args: + vault_name: Name of the Key Vault + secret_name: Name of the secret + + Returns: + Dict: Secret properties including value and tags + + Raises: + Exception: If the operation fails + """ + try: + # Construct vault URL + vault_url = f"https://{vault_name}.vault.azure.net" + + # Create secret client + secret_client = SecretClient(vault_url=vault_url, credential=self.credential) + + # Get the secret + secret = secret_client.get_secret(secret_name) + + return { + 'name': secret.name, + 'value': secret.value, + 'tags': secret.properties.tags, + 'expires_on': secret.properties.expires_on, + 'created_on': secret.properties.created_on + } + + except Exception as e: + raise Exception(f"Failed to retrieve secret from Key Vault: {str(e)}") diff --git a/services/secret_service.py b/services/secret_service.py new file mode 100644 index 0000000..81b6aed --- /dev/null +++ b/services/secret_service.py @@ -0,0 +1,155 @@ +""" +Secret Service + +Handles creation and removal of app registration secrets. +""" + +from msgraph import GraphServiceClient +from msgraph.generated.models.password_credential import PasswordCredential +from msgraph.generated.applications.item.add_password.add_password_post_request_body import AddPasswordPostRequestBody +from msgraph.generated.applications.item.remove_password.remove_password_post_request_body import RemovePasswordPostRequestBody +from datetime import datetime, timedelta +from typing import Dict, List, Any +import config + + +class SecretService: + """Service for managing app registration secrets.""" + + def __init__(self, graph_client: GraphServiceClient): + """ + Initialize the secret service. + + Args: + graph_client: Authenticated Graph service client + """ + self.graph_client = graph_client + + async def create_secret( + self, + app_object_id: str, + description: str, + years: int = None + ) -> Dict[str, Any]: + """ + Create a new secret for an app registration. + + Args: + app_object_id: The object ID of the app registration + description: Display name/description for the secret + years: Number of years until expiration (defaults to config.APP_SECRET_EXPIRATION_YEARS) + + Returns: + Dict: Contains secret_text, key_id, and end_datetime + + Raises: + Exception: If the API call fails + """ + try: + if years is None: + years = config.APP_SECRET_EXPIRATION_YEARS + + # Calculate expiration date + end_date = datetime.now() + timedelta(days=365 * years) + + # Create password credential + password_cred = PasswordCredential() + password_cred.display_name = description + password_cred.end_date_time = end_date + + # Create request body + request_body = AddPasswordPostRequestBody() + request_body.password_credential = password_cred + + # Call Graph API to add password + result = await self.graph_client.applications.by_application_id( + app_object_id + ).add_password.post(request_body) + + if result: + return { + 'secret_text': result.secret_text, + 'key_id': str(result.key_id), + 'end_datetime': result.end_date_time, + 'display_name': result.display_name + } + + raise Exception("No result returned from add_password") + + except Exception as e: + raise Exception(f"Failed to create secret: {str(e)}") + + async def remove_old_secrets( + self, + app_object_id: str, + keep_key_id: str + ) -> int: + """ + Remove all secrets except the specified one. + + Args: + app_object_id: The object ID of the app registration + keep_key_id: The key ID to keep (newly created secret) + + Returns: + int: Number of secrets removed + + Raises: + Exception: If the API call fails + """ + try: + removed_count = 0 + + # Get the app with its password credentials + app = await self.graph_client.applications.by_application_id(app_object_id).get() + + if app and app.password_credentials: + for cred in app.password_credentials: + # Remove if it's not the one we want to keep + if str(cred.key_id) != str(keep_key_id): + # Create request body + request_body = RemovePasswordPostRequestBody() + request_body.key_id = cred.key_id + + # Call Graph API to remove password + await self.graph_client.applications.by_application_id( + app_object_id + ).remove_password.post(request_body) + + removed_count += 1 + + return removed_count + + except Exception as e: + raise Exception(f"Failed to remove old secrets: {str(e)}") + + async def list_secrets(self, app_object_id: str) -> List[Dict[str, Any]]: + """ + List all secrets for an app registration (metadata only, not the secret values). + + Args: + app_object_id: The object ID of the app registration + + Returns: + List[Dict]: List of secret metadata + + Raises: + Exception: If the API call fails + """ + try: + app = await self.graph_client.applications.by_application_id(app_object_id).get() + + secrets = [] + if app and app.password_credentials: + for cred in app.password_credentials: + secrets.append({ + 'key_id': str(cred.key_id), + 'display_name': cred.display_name, + 'start_datetime': cred.start_date_time, + 'end_datetime': cred.end_date_time + }) + + return secrets + + except Exception as e: + raise Exception(f"Failed to list secrets: {str(e)}") diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/app_selection_frame.py b/ui/app_selection_frame.py new file mode 100644 index 0000000..3175591 --- /dev/null +++ b/ui/app_selection_frame.py @@ -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) diff --git a/ui/components/__init__.py b/ui/components/__init__.py new file mode 100644 index 0000000..c928112 --- /dev/null +++ b/ui/components/__init__.py @@ -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'] diff --git a/ui/components/tooltip.py b/ui/components/tooltip.py new file mode 100644 index 0000000..d9a33f7 --- /dev/null +++ b/ui/components/tooltip.py @@ -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("", self._on_enter, add="+") + self.widget.bind("", self._on_leave, add="+") + self.widget.bind("", 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("") + self.widget.unbind("") + self.widget.unbind("") + except: + pass diff --git a/ui/components/unified_dropdown.py b/ui/components/unified_dropdown.py new file mode 100644 index 0000000..34250bb --- /dev/null +++ b/ui/components/unified_dropdown.py @@ -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("", lambda e: self._open_dropdown()) + self.dropdown_button.bind("", lambda e: self._open_dropdown()) + self.dropdown_button.bind("", 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("", popup_scroll, add="+") + self.popup_frame.bind("", popup_scroll, add="+") + self.popup_frame._parent_canvas.bind("", popup_scroll, add="+") + + # Bind to all item buttons so scrolling works when hovering over them + for btn in self.item_buttons: + btn.bind("", 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("", lambda e: self._close_dropdown()) + self.popup_window.bind("", self._navigate_down) + self.popup_window.bind("", self._navigate_up) + self.popup_window.bind("", self._navigate_page_down) # PageDown in Tkinter + self.popup_window.bind("", self._navigate_page_up) # PageUp in Tkinter + self.popup_window.bind("", self._navigate_home) + self.popup_window.bind("", self._navigate_end) + self.popup_window.bind("", self._confirm_selection) + self.popup_window.bind("", lambda e: self._close_dropdown()) + + # Set focus to popup + self.popup_window.focus_set() + + # Bind click outside to close + self.popup_window.bind("", 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("") + if self.popup_frame: + self.popup_frame.unbind("") + self.popup_frame._parent_canvas.unbind("") + # Unbind from buttons + for btn in self.item_buttons: + btn.unbind("") + 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) diff --git a/ui/login_frame.py b/ui/login_frame.py new file mode 100644 index 0000000..4828f5a --- /dev/null +++ b/ui/login_frame.py @@ -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") diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..2e11a7e --- /dev/null +++ b/ui/main_window.py @@ -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("", 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.") diff --git a/ui/result_frame.py b/ui/result_frame.py new file mode 100644 index 0000000..03559a2 --- /dev/null +++ b/ui/result_frame.py @@ -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() diff --git a/ui/secret_generation_frame.py b/ui/secret_generation_frame.py new file mode 100644 index 0000000..78d82ad --- /dev/null +++ b/ui/secret_generation_frame.py @@ -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("", 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("", lambda e: self._close_vault_popup()) + self.vault_popup.bind("", 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() diff --git a/ui/subscription_selection_frame.py b/ui/subscription_selection_frame.py new file mode 100644 index 0000000..361be69 --- /dev/null +++ b/ui/subscription_selection_frame.py @@ -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) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/async_worker.py b/utils/async_worker.py new file mode 100644 index 0000000..d8d6b73 --- /dev/null +++ b/utils/async_worker.py @@ -0,0 +1,108 @@ +""" +Async Worker Module + +Provides a persistent async worker thread for executing coroutines. +Solves "Event loop is closed" errors by maintaining a single event loop. +""" + +import asyncio +import threading +from typing import Any, Optional +from concurrent.futures import Future +import logging + + +class AsyncWorker: + """ + Persistent async worker thread for executing coroutines. + + This worker maintains a single event loop for the application's lifetime, + solving the "Event loop is closed" error by ensuring all async operations + use the same loop and credentials remain valid. + """ + + def __init__(self): + self.loop: Optional[asyncio.AbstractEventLoop] = None + self.thread: Optional[threading.Thread] = None + self.running = False + self.logger = logging.getLogger(__name__) + + def start(self): + """Start the async worker thread.""" + if self.running: + return + + self.running = True + self.thread = threading.Thread( + target=self._run_loop, + daemon=True, + name="AsyncWorker" + ) + self.thread.start() + + # Wait for loop to be ready + import time + while self.loop is None: + time.sleep(0.01) + + def _run_loop(self): + """Run the event loop in the worker thread.""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + self.logger.info("AsyncWorker event loop started") + + try: + self.loop.run_forever() + finally: + self.loop.close() + self.logger.info("AsyncWorker event loop closed") + + def submit(self, coro) -> Future: + """ + Submit a coroutine to be executed in the worker loop. + + Args: + coro: Coroutine to execute + + Returns: + Future that will contain the result + """ + if not self.running: + raise RuntimeError("AsyncWorker not started") + + result_future = Future() + + def callback(): + """Execute coroutine and set result in future.""" + try: + task = asyncio.ensure_future(coro, loop=self.loop) + + def done_callback(task_future): + try: + result = task_future.result() + result_future.set_result(result) + except Exception as e: + result_future.set_exception(e) + + task.add_done_callback(done_callback) + except Exception as e: + result_future.set_exception(e) + + self.loop.call_soon_threadsafe(callback) + return result_future + + def stop(self): + """Stop the async worker thread.""" + if not self.running: + return + + self.running = False + + if self.loop: + self.loop.call_soon_threadsafe(self.loop.stop) + + if self.thread: + self.thread.join(timeout=5.0) + + self.logger.info("AsyncWorker stopped") diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..d4a87cc --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,69 @@ +""" +Logging Utility + +Configures logging for the application. +""" + +import logging +import os +from datetime import datetime + + +def setup_logger(name: str = 'AzureKeyVaultManager', log_file: str = None, level=logging.INFO): + """ + Set up and configure a logger. + + Args: + name: Logger name + log_file: Optional log file path (defaults to logs/app_{date}.log) + level: Logging level (default: INFO) + + Returns: + logging.Logger: Configured logger instance + """ + logger = logging.getLogger(name) + logger.setLevel(level) + + # Remove existing handlers + logger.handlers = [] + + # Create formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # File handler (optional) + if log_file: + # Create logs directory if it doesn't exist + log_dir = os.path.dirname(log_file) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir) + + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + else: + # Default log file in logs directory + log_dir = 'logs' + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + log_file = os.path.join(log_dir, f'app_{datetime.now().strftime("%Y%m%d")}.log') + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +# Create default logger instance +logger = setup_logger() diff --git a/utils/sanitizer.py b/utils/sanitizer.py new file mode 100644 index 0000000..87e8057 --- /dev/null +++ b/utils/sanitizer.py @@ -0,0 +1,37 @@ +""" +Name Sanitization Utility + +Sanitizes names for use in Azure Key Vault. +""" + +import re + + +def sanitize_name(name: str) -> str: + """ + Sanitize a name for use in Azure Key Vault. + Key Vault secret names can only contain alphanumeric characters and hyphens. + + Args: + name: The name to sanitize + + Returns: + str: Sanitized name + + Example: + >>> sanitize_name("My App (Production) #2024") + 'My-App-Production-2024' + """ + if not name: + return name + + # Replace any non-alphanumeric character (except hyphens) with hyphen + sanitized = re.sub(r'[^0-9a-zA-Z-]', '-', name) + + # Remove consecutive hyphens + sanitized = re.sub(r'-+', '-', sanitized) + + # Remove leading/trailing hyphens + sanitized = sanitized.strip('-') + + return sanitized