From 64d707bd8b8bbc713f19e947fc47b405e5510941 Mon Sep 17 00:00:00 2001 From: inorishio Date: Thu, 29 Jan 2026 09:57:44 +0100 Subject: [PATCH] Rust --- .gitignore | 76 +-- Cargo.toml | 46 ++ LICENSE | 21 + README.md | 374 ++++++------- assets/README.md | 50 ++ assets/background.jpg | Bin 0 -> 40489 bytes assets/default_background.png | Bin 0 -> 40489 bytes assets/icon.ico | Bin 0 -> 432254 bytes auth/__init__.py | 0 auth/azure_authenticator.py | 209 ------- auth/graph_authenticator.py | 117 ---- build.bat | 16 - build.rs | 35 ++ build.sh | 15 - config.py | 21 - config.toml | 75 +++ hook-customtkinter.py | 8 - main.py | 323 ----------- requirements.txt | 19 - services/__init__.py | 0 services/app_registration_service.py | 99 ---- services/keyvault_service.py | 187 ------- services/secret_service.py | 155 ------ src/app.rs | 519 ++++++++++++++++++ src/auth/azure_auth.rs | 596 ++++++++++++++++++++ src/auth/mod.rs | 4 + src/auth/token_cache.rs | 88 +++ src/azure/graph_client.rs | 150 +++++ src/azure/keyvault_client.rs | 186 +++++++ src/azure/mod.rs | 10 + src/azure/models.rs | 96 ++++ src/azure/vault_discovery.rs | 165 ++++++ src/config/mod.rs | 2 + src/config/window_config.rs | 267 +++++++++ src/error.rs | 94 ++++ src/lib.rs | 2 + src/main.rs | 80 +++ src/state/app_state.rs | 136 +++++ src/state/async_operations.rs | 55 ++ src/state/mod.rs | 4 + src/ui/app_list_view.rs | 214 ++++++++ src/ui/auth_view.rs | 66 +++ src/ui/background.rs | 170 ++++++ src/ui/components.rs | 61 +++ src/ui/keyvault_select_view.rs | 323 +++++++++++ src/ui/mod.rs | 12 + src/ui/secret_create_view.rs | 106 ++++ 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 | 781 --------------------------- ui/login_frame.py | 101 ---- ui/main_window.py | 299 ---------- ui/result_frame.py | 197 ------- ui/secret_generation_frame.py | 183 ------- ui/subscription_selection_frame.py | 89 --- utils/__init__.py | 0 utils/async_worker.py | 108 ---- utils/logger.py | 69 --- utils/sanitizer.py | 37 -- 61 files changed, 3811 insertions(+), 3543 deletions(-) create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 assets/README.md create mode 100644 assets/background.jpg create mode 100644 assets/default_background.png create mode 100644 assets/icon.ico delete mode 100644 auth/__init__.py delete mode 100644 auth/azure_authenticator.py delete mode 100644 auth/graph_authenticator.py delete mode 100644 build.bat create mode 100644 build.rs delete mode 100644 build.sh delete mode 100644 config.py create mode 100644 config.toml delete mode 100644 hook-customtkinter.py delete mode 100644 main.py delete mode 100644 requirements.txt delete mode 100644 services/__init__.py delete mode 100644 services/app_registration_service.py delete mode 100644 services/keyvault_service.py delete mode 100644 services/secret_service.py create mode 100644 src/app.rs create mode 100644 src/auth/azure_auth.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/token_cache.rs create mode 100644 src/azure/graph_client.rs create mode 100644 src/azure/keyvault_client.rs create mode 100644 src/azure/mod.rs create mode 100644 src/azure/models.rs create mode 100644 src/azure/vault_discovery.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/window_config.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/state/app_state.rs create mode 100644 src/state/async_operations.rs create mode 100644 src/state/mod.rs create mode 100644 src/ui/app_list_view.rs create mode 100644 src/ui/auth_view.rs create mode 100644 src/ui/background.rs create mode 100644 src/ui/components.rs create mode 100644 src/ui/keyvault_select_view.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/secret_create_view.rs delete mode 100644 ui/__init__.py delete mode 100644 ui/app_selection_frame.py delete mode 100644 ui/components/__init__.py delete mode 100644 ui/components/tooltip.py delete mode 100644 ui/components/unified_dropdown.py delete mode 100644 ui/login_frame.py delete mode 100644 ui/main_window.py delete mode 100644 ui/result_frame.py delete mode 100644 ui/secret_generation_frame.py delete mode 100644 ui/subscription_selection_frame.py delete mode 100644 utils/__init__.py delete mode 100644 utils/async_worker.py delete mode 100644 utils/logger.py delete mode 100644 utils/sanitizer.py diff --git a/.gitignore b/.gitignore index 00b2493..3731bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,55 +1,9 @@ -# Virtual Environment -venv/ -env/ -ENV/ -.venv/ +# Generated by Cargo +/target/ +Cargo.lock -# 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/ +# Environment files +.env # IDE .vscode/ @@ -57,20 +11,10 @@ coverage.xml *.swp *.swo *~ + +# OS .DS_Store +Thumbs.db -# Environment variables -.env -.env.local - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# Temporary files -*.bak -*.tmp -temp/ -tmp/ +# Logs +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d0777dd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "create-app-secret" +version = "0.1.0" +edition = "2021" + +[dependencies] +# GUI Framework +eframe = "0.30" +egui = "0.30" + +# Azure SDKs +graph-rs-sdk = { version = "2.0", features = ["interactive-auth"] } +graph-oauth = "2.0" +azure_identity = "0.31" +azure_security_keyvault_secrets = "0.10" +azure_core = "0.31" +azure_mgmt_keyvault = "0.21" # For Key Vault discovery + +# Async Integration +tokio = { version = "1.42", features = ["full"] } +poll-promise = { version = "0.3", features = ["tokio"] } # Bridge async ops with egui + +# Security & Serialization +keyring = "3.6" # Secure token storage +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +anyhow = "1.0" +thiserror = "1.0" +zeroize = { version = "1.7", features = ["derive"] } # Secure memory clearing + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Additional utilities +chrono = { version = "0.4", features = ["serde"] } +reqwest = { version = "0.12", features = ["json"] } +url = "2.5" +open = "5.0" # Open browser +axum = "0.7" # Simple HTTP server for OAuth callback +urlencoding = "2.1" # URL encoding for OAuth parameters +image = { version = "0.25", features = ["png", "jpeg", "ico"] } # Image loading for backgrounds and icons + +[build-dependencies] +winres = "0.1.12" # Windows resource compiler for icon and version info diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2db27e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Azure App Registration Manager Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f5d0130..73fc8e0 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,225 @@ -# Azure Key Vault Secret Manager +# Azure App Registration Manager -> A modern, user-friendly GUI application for managing Azure App Registration secrets and Key Vault integration. +A cross-platform Rust GUI application for managing Azure App Registrations and Key Vault secrets. -![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 -## ✨ Features +- **Interactive Azure Authentication**: Browser-based OAuth 2.0 login flow +- **App Registration Management**: View and manage your Azure App Registrations +- **Client Secret Creation**: Generate new client secrets with automatic expiration +- **Key Vault Integration**: Securely store secrets in Azure Key Vault +- **Cross-Platform**: Works on Windows, Linux, and macOS +- **Secure Token Storage**: Uses OS-level secure storage (Credential Manager/Keychain) +- **Zero Configuration**: No app registration or credential files needed -- 🔐 **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 +## Prerequisites -## 📸 Screenshots +- Rust 1.70+ (install from [rustup.rs](https://rustup.rs)) +- Azure subscription with appropriate permissions - -``` -[App Selection] [Secret Generation] [Result View] -``` +## Quick Start -## 🔧 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 +### Installation and Run ```bash -git clone https://github.com/yourusername/azure-keyvault-manager.git -cd azure-keyvault-manager/python-app +git clone +cd azure-app-manager +cargo run --release ``` -### 2. Create Virtual Environment +That's it! No configuration needed. Click "Sign In with Azure" and authenticate. -**Windows:** -```bash -python -m venv venv -venv\Scripts\activate -``` +### How It Works -**Linux/macOS:** -```bash -python3 -m venv venv -source venv/bin/activate -``` +This application uses Microsoft's **Azure CLI public client ID**, which is pre-approved for accessing Microsoft Graph and Azure Management APIs. You authenticate with your own Azure AD account and permissions. No app registration or configuration files needed. -### 3. Install Dependencies +## Usage + +### Run the Application ```bash -pip install -r requirements.txt +cargo run --release ``` -### 4. Run the Application +Or run the compiled binary: ```bash -python main.py +./target/release/azure-app-manager ``` -**That's it!** No configuration files to edit - the app auto-detects everything. +### Workflow -## 🚀 Usage +1. **Sign In**: Click "Sign In with Azure" and complete authentication in your browser +2. **Select App**: Browse your app registrations and select one +3. **Create Secret**: Click "Create Secret" and enter a description +4. **Save to Vault**: Select a Key Vault and enter a name for the secret +5. **Done**: The secret is securely stored in your Key Vault -### Quick Start Guide +## Architecture -1. **Connect to Azure** - - Click **"Connect to Azure"** - - Browser opens automatically - - Sign in with your Azure account (admin credentials) - - ✅ Authentication completes (single login!) +### Technology Stack -2. **Select Subscription** - - Choose your Azure subscription from the dropdown - - Apps and Key Vaults load automatically +- **GUI Framework**: egui/eframe (immediate-mode, cross-platform) +- **Azure SDKs**: + - `graph-rs-sdk`: Microsoft Graph API integration + - `azure_security_keyvault_secrets`: Key Vault operations + - `azure_mgmt_keyvault`: Key Vault discovery +- **Async Runtime**: Tokio +- **Async-GUI Bridge**: poll-promise +- **Secure Storage**: keyring (OS-level credential storage) -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 +### 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 +src/ +├── main.rs # Application entry point +├── app.rs # Main app logic (eframe::App implementation) +├── error.rs # Error types +├── auth/ # Authentication +│ ├── azure_auth.rs # OAuth flow +│ └── token_cache.rs # Secure token storage +├── azure/ # Azure API clients +│ ├── graph_client.rs # Microsoft Graph API +│ ├── keyvault_client.rs # Key Vault operations +│ ├── vault_discovery.rs # Key Vault listing +│ └── models.rs # Data models +├── state/ # Application state +│ ├── app_state.rs # Central state management +│ └── async_operations.rs # Async operation tracking +└── ui/ # UI views + ├── auth_view.rs # Login screen + ├── app_list_view.rs # App registration list + ├── secret_create_view.rs # Secret creation form + ├── keyvault_select_view.rs # Key Vault selection + └── components.rs # Reusable UI components ``` -## 🐛 Troubleshooting +## Security Features -### Authentication Issues +### Token Security -**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 +- Access tokens stored in OS-level secure storage: + - **Windows**: Credential Manager + - **macOS**: Keychain + - **Linux**: Secret Service (gnome-keyring/kwallet) +- Automatic token refresh before expiration +- Secure memory clearing with `zeroize` -**Problem**: Double login prompts -- **Solution**: This has been fixed in the latest version - you should only login once +### Secret Handling -### Permission Errors +- Secrets wrapped in `SensitiveString` with automatic memory zeroing +- No disk persistence of secrets +- Custom Debug implementation prevents accidental logging +- Immediate prompt to save to Key Vault -**Problem**: "Failed to list applications" -- **Solution**: Request `Application.ReadWrite.All` and `Directory.Read.All` permissions from your Azure AD admin +## Platform-Specific Notes -**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 +### macOS -### UI Issues +Due to limitations in the graph-rs-sdk, macOS uses **device code flow** instead of interactive browser flow: -**Problem**: Dropdown list won't scroll -- **Solution**: Updated in latest version - mouse wheel now scrolls the dropdown properly +1. A code will be displayed in the application +2. Open the provided URL in your browser +3. Enter the code and complete authentication +4. Return to the application -**Problem**: Can't see all applications -- **Solution**: Use keyboard navigation (arrow keys) or mouse wheel to scroll through large lists +### Linux -### 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: +Requires a secret service backend (gnome-keyring or kwallet) for secure token storage: ```bash -pip install pyinstaller -pyinstaller --onefile --windowed --name AzureKeyVaultManager main.py +# Ubuntu/Debian +sudo apt install gnome-keyring + +# Arch Linux +sudo pacman -S gnome-keyring ``` -Output: `dist/AzureKeyVaultManager.exe` (Windows) or `dist/AzureKeyVaultManager` (Linux/macOS) +### Windows -**Note**: Executable size will be ~50-100MB due to bundled dependencies. +No additional dependencies required. Uses Windows Credential Manager. -## 🤝 Contributing +## Troubleshooting -Contributions are welcome! Please feel free to submit a Pull Request. +### Authentication Fails -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 +- Ensure you have appropriate permissions in your Azure AD tenant +- Check your internet connection +- Review logs with `LOG_LEVEL=debug cargo run` +- Some organizations may have conditional access policies that require MFA or compliant devices -## 📄 License +### No Key Vaults Found -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +- Verify you have Key Vaults in your subscription +- Check that your user has appropriate RBAC permissions +- Ensure the Management API scope was granted -## 🙏 Acknowledgments +### Token Cache Errors -- 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) +- On Linux: Install and start gnome-keyring or kwallet +- On macOS: Check Keychain Access permissions +- On Windows: Check Windows Credential Manager -## 📮 Support +## Development -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) +### Run in Debug Mode ---- +```bash +cargo run +``` -**Made with ❤️ for Azure administrators** +### Run Tests + +```bash +cargo test +``` + +### Enable Debug Logging + +```bash +LOG_LEVEL=debug cargo run +``` + +## Building for Release + +### Current Platform + +```bash +cargo build --release +``` + +### Cross-Platform (requires setup) + +```bash +# Windows +cargo build --release --target x86_64-pc-windows-msvc + +# Linux +cargo build --release --target x86_64-unknown-linux-gnu + +# macOS +cargo build --release --target x86_64-apple-darwin +``` + +## Contributing + +Contributions are welcome. Please ensure: + +- Code follows Rust best practices +- All tests pass +- Security considerations are maintained +- Documentation is updated + +## License + +MIT License - See LICENSE file for details + +## Acknowledgments + +- Microsoft Graph SDK team for graph-rs-sdk +- Azure SDK for Rust team +- egui framework creators + +## Support + +For issues and feature requests, please use the GitHub issue tracker. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..9636000 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,50 @@ +# Assets Directory + +This directory contains application assets for the Azure App Registration Manager. + +## Application Icon (icon.ico) + +To add a custom application icon: + +1. **Create or obtain an icon file** in `.ico` format with multiple resolutions: + - 16×16 pixels + - 32×32 pixels + - 48×48 pixels + - 256×256 pixels + +2. **Convert from PNG/JPG to ICO:** + - Use online tools like: https://convertio.co/png-ico/ + - Or use GIMP/Photoshop with ICO plugin + - Or use ImageMagick: `convert icon.png -define icon:auto-resize=256,48,32,16 icon.ico` + +3. **Place the file here:** + - Save as: `assets/icon.ico` + - The build script (`build.rs`) will automatically embed it into the executable + +4. **No icon yet?** + - The application will build successfully without an icon + - The executable will use the default Windows application icon + - You can add an icon later and rebuild + +## Icon Design Suggestions + +For an Azure App Registration Manager, consider: +- Azure logo colors (blue tones) +- Key or lock symbol (for security/secrets) +- Cloud + gear/settings icon +- Simplified "A" + "R" monogram + +## Testing the Icon + +After adding `icon.ico`: +1. Run: `cargo build --release` +2. Check the executable properties in Windows Explorer +3. Right-click `target\release\azure-app-manager.exe` → Properties +4. The icon should appear in the Properties dialog and in Windows Explorer + +## Background Images (Optional) + +You can also place background images here: +- `background.png` or `background.jpg` +- Referenced in `config.toml` +- Displayed in the application UI if configured diff --git a/assets/background.jpg b/assets/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4764a1d722532d74a928c2d991f0cd85f2a83cca GIT binary patch literal 40489 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 AQvd(} literal 0 HcmV?d00001 diff --git a/assets/default_background.png b/assets/default_background.png new file mode 100644 index 0000000000000000000000000000000000000000..4764a1d722532d74a928c2d991f0cd85f2a83cca GIT binary patch literal 40489 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 AQvd(} literal 0 HcmV?d00001 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5c47cbdb82d8e2520a2f95e067687b1bc1ad26c2 GIT binary patch literal 432254 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≀`asf}T6qQSy^PB$*;n)MqZ-~ada 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�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 literal 0 HcmV?d00001 diff --git a/auth/__init__.py b/auth/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/auth/azure_authenticator.py b/auth/azure_authenticator.py deleted file mode 100644 index a9c2c0f..0000000 --- a/auth/azure_authenticator.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -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 discover_tenant_id(self) -> tuple[str, InteractiveBrowserCredential, List[Dict[str, str]]]: - """ - Discover user's tenant ID by authenticating with organizations endpoint. - - Returns: - tuple: (tenant_id, credential, subscriptions) - The discovered tenant ID, credential to reuse, and subscription list - - Raises: - Exception: If no subscriptions found or authentication fails - """ - try: - # Create temporary credential with "organizations" for discovery - temp_credential = InteractiveBrowserCredential( - tenant_id="organizations", - additionally_allowed_tenants=["*"] - ) - - # List subscriptions to extract tenant - sub_client = SubscriptionClient(temp_credential) - subscriptions_list = list(sub_client.subscriptions.list()) - - if not subscriptions_list: - raise Exception("No Azure subscriptions found. Please ensure you have access to at least one subscription.") - - # Extract tenant ID from first subscription - tenant_id = subscriptions_list[0].tenant_id - print(f"Discovered Tenant ID: {tenant_id}") - - # Convert subscriptions to dict format - subscriptions = [ - { - 'id': sub.subscription_id, - 'name': sub.display_name - } - for sub in subscriptions_list - ] - - # Return tenant_id, credential, and subscriptions for reuse - return tenant_id, temp_credential, subscriptions - - except Exception as e: - raise Exception(f"Failed to discover tenant: {str(e)}") - - def authenticate(self, credential: InteractiveBrowserCredential = None, tenant_id: str = None, subscriptions: List[Dict[str, str]] = None) -> bool: - """ - Authenticate to Azure. Can reuse credential, use specific tenant, or discover tenant. - - Args: - credential: Optional credential to reuse (from GraphAuthenticator) - tenant_id: Optional specific tenant ID to use - subscriptions: Optional pre-fetched subscriptions list (avoids re-listing) - - Returns: - bool: True if authentication succeeded, False otherwise - - Raises: - Exception: If authentication fails - """ - try: - if credential: - # Reuse credential (recommended path to avoid multiple auth prompts) - self.credential = credential - else: - # Create credential with specific tenant ID - if not tenant_id: - raise Exception("Either credential or tenant_id must be provided") - - self.tenant_id = tenant_id - - # Create NEW credential with specific tenant_id (avoids redirect) - self.credential = InteractiveBrowserCredential( - tenant_id=tenant_id, - additionally_allowed_tenants=["*"] - ) - - # Use provided subscriptions or list them if not provided - if subscriptions: - # Reuse pre-fetched subscriptions (avoids duplicate API call) - self.subscriptions = subscriptions - - # Set tenant ID if provided - if tenant_id: - self.tenant_id = tenant_id - - print(f"Using cached authentication. Found {len(subscriptions)} subscription(s).") - - return True - else: - # List all subscriptions (fallback for legacy code path) - sub_client = SubscriptionClient(self.credential) - subscriptions_list = list(sub_client.subscriptions.list()) - - # Extract tenant ID if not already set - if subscriptions_list and not self.tenant_id: - 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 deleted file mode 100644 index 9484264..0000000 --- a/auth/graph_authenticator.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -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, tenant_id: str = None, credential: InteractiveBrowserCredential = None, skip_validation: bool = False) -> bool: - """ - Authenticate to Microsoft Graph using interactive browser login. - - Args: - tenant_id: Optional specific tenant ID (recommended to avoid double auth) - credential: Optional credential to reuse (avoids creating new credential) - skip_validation: Skip the me.get() validation call (use when reusing credential to avoid extra auth) - - Returns: - bool: True if authentication succeeded, False otherwise - - Raises: - Exception: If authentication fails - """ - try: - if credential: - # Reuse provided credential (recommended to avoid multiple auth prompts) - self.credential = credential - else: - # Use specific tenant if provided, otherwise fall back to "organizations" - auth_tenant = tenant_id if tenant_id else "organizations" - - # Create interactive browser credential - self.credential = InteractiveBrowserCredential( - tenant_id=auth_tenant, - 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 - ) - - # Validate authentication (unless skip_validation is True) - if not skip_validation: - me = await self.client.me.get() - - if me: - print(f"Successfully authenticated as: {me.display_name} ({me.user_principal_name})") - return True - - return False - else: - print("Skipping Graph validation (using cached authentication)") - return True - - 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/build.bat b/build.bat deleted file mode 100644 index 5aec295..0000000 --- a/build.bat +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -echo Building Azure Key Vault Secret Manager... -echo. - -REM Clean previous builds -if exist "dist" rmdir /s /q "dist" -if exist "build" rmdir /s /q "build" - -REM Build executable -pyinstaller build.spec --clean --noconfirm - -echo. -echo Build complete! -echo Executable location: dist\AzureKeyVaultSecretManager.exe -echo. -pause diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..582a730 --- /dev/null +++ b/build.rs @@ -0,0 +1,35 @@ +#[cfg(windows)] +extern crate winres; + +fn main() { + #[cfg(windows)] + { + let mut res = winres::WindowsResource::new(); + + // Set icon if it exists + if std::path::Path::new("assets/icon.ico").exists() { + res.set_icon("assets/icon.ico"); + } + + // Set language to English + res.set_language(0x0009); + + // Set product information + res.set("ProductName", "Create App Secret"); + res.set("FileDescription", "Create Azure App Registration Secrets and Save to Key Vault"); + res.set("CompanyName", "Gemeente Vught"); + res.set("LegalCopyright", "Copyright © 2026"); + res.set("OriginalFilename", "Create-App-Secret.exe"); + + // Get version from Cargo.toml + let version = env!("CARGO_PKG_VERSION"); + res.set("ProductVersion", version); + res.set("FileVersion", version); + + // Fail build if resource compilation fails + res.compile().expect("Failed to compile Windows resources"); + + // Rebuild when icon changes + println!("cargo:rerun-if-changed=assets/icon.ico"); + } +} diff --git a/build.sh b/build.sh deleted file mode 100644 index 589c1be..0000000 --- a/build.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -echo "Building Azure Key Vault Secret Manager..." -echo "" - -# Clean previous builds -rm -rf dist build - -# Build executable -pyinstaller build.spec --clean --noconfirm - -echo "" -echo "Build complete!" -echo "Executable location: dist/AzureKeyVaultSecretManager.exe" -echo "" -read -p "Press enter to continue..." diff --git a/config.py b/config.py deleted file mode 100644 index c163297..0000000 --- a/config.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -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/config.toml b/config.toml new file mode 100644 index 0000000..e0cad02 --- /dev/null +++ b/config.toml @@ -0,0 +1,75 @@ +[window] +width = 1024.0 +height = 768.0 +min_width = 800.0 +min_height = 600.0 +transparency = 1.0 +use_transparency = true +position_offset_x = 0.0 +position_offset_y = -30.0 + +[text_sizing] +# Font sizes for different text elements (in pixels) +heading = 20.0 # Section headers +body = 16.0 # Regular text +small = 14.0 # Small text (hints, labels) +button = 20.0 # Button text + +[azure] +# Azure-specific configuration +secret_expiration_years = 50 # How many years until created secrets expire (default: 50) + +[appearance] +# Leave empty to use embedded default background, or specify custom path +# Examples: "./assets/background.jpg", "C:/path/to/image.png" +background_image = "./assets/background.jpg" +background_opacity = 1.0 +background_blur = 0.0 +fallback_color = [30, 30, 40] # Used if image fails to load + +[colors.text] +# Text colors for different states (applies to card titles, labels, etc.) +normal = [255, 255, 255] +inactive = [255, 255, 255] +hover = [255, 255, 255] +select = [80, 150, 255] + +[colors.slider] +# Slider/scrollbar colors for different states +inactive = [239, 124, 0] +hover = [255, 124, 0] +active = [255, 124, 0] + +[colors.widget_borders] +# Widget border colors for textboxes, buttons, etc. +# Controls the outline/stroke around interactive elements +inactive = [60, 60, 70] # Noninteractive and inactive states +hover = [120, 120, 140] # When hovering over widget +active = [120, 120, 140] # When clicking/interacting with widget + +[colors.warning] +# Warning message colors +text = [255, 200, 100] # Warning text color (amber/orange) +background = [80, 60, 30] # Warning box background color + +[colors.info_box] +# Information box colors (for secret details display) +background = [40, 40, 60] # Info box background color +border = [239, 124, 0] # Info box border color (orange) + +[colors.create_secret_info] +# Info box on "Create Secret" screen (shows "Creating secret for: X") +background = [40, 40, 60] # Info box background color (purple/dark blue) +text = [239, 124, 0] # Info box text color (light blue/lavender) + +[colors.cards.app_list] +# App list card colors (RGBA format - last value is alpha/transparency) +selected_bg = [179, 87, 0, 180] +selected_border = [255, 124, 0] +unselected_bg = [40, 40, 60, 150] + +[colors.cards.keyvault] +# Key vault card colors (RGBA format - last value is alpha/transparency) +selected_bg = [179, 87, 0, 180] +selected_border = [255, 124, 0] +unselected_bg = [40, 40, 60, 150] diff --git a/hook-customtkinter.py b/hook-customtkinter.py deleted file mode 100644 index 6808f9b..0000000 --- a/hook-customtkinter.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -PyInstaller runtime hook for CustomTkinter. -Ensures theme assets are found in bundled executable. -""" -from PyInstaller.utils.hooks import collect_data_files, collect_submodules - -datas = collect_data_files('customtkinter') -hiddenimports = collect_submodules('customtkinter') diff --git a/main.py b/main.py deleted file mode 100644 index 63e54b0..0000000 --- a/main.py +++ /dev/null @@ -1,323 +0,0 @@ -""" -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 with tenant discovery.""" - try: - self.logger.info("Starting authentication with tenant discovery...") - - # PHASE 1: Discover tenant ID using "organizations" endpoint (single auth prompt) - self.logger.info("Discovering tenant ID...") - discovered_tenant_id, orgs_credential, subscriptions = self.azure_auth.discover_tenant_id() - - self.logger.info(f"Discovered tenant: {discovered_tenant_id}") - - # PHASE 2: Reuse the "organizations" credential for Graph - # Skip validation to avoid triggering Graph API auth here - it will auth when first used - self.logger.info("Initializing Microsoft Graph with shared credential...") - await self.graph_auth.authenticate(credential=orgs_credential, skip_validation=True) - - self.logger.info("Initializing Azure with shared credential...") - self.azure_auth.authenticate(credential=orgs_credential, tenant_id=discovered_tenant_id, subscriptions=subscriptions) - - # 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 (already loaded during discover_tenant_id) - 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 deleted file mode 100644 index 7c74ebf..0000000 --- a/requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 diff --git a/services/app_registration_service.py b/services/app_registration_service.py deleted file mode 100644 index 98e459b..0000000 --- a/services/app_registration_service.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -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 deleted file mode 100644 index ff12d35..0000000 --- a/services/keyvault_service.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -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 deleted file mode 100644 index 81b6aed..0000000 --- a/services/secret_service.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -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/src/app.rs b/src/app.rs new file mode 100644 index 0000000..9b4cc0c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,519 @@ +use crate::auth::AzureAuthenticator; +use crate::azure::{GraphApiClient, KeyVaultClient, VaultDiscovery}; +use crate::config::Config; +use crate::state::{AppState, AuthStatus, ViewState}; +use crate::ui::*; +use poll_promise::Promise; +use std::sync::Arc; + +pub struct AzureAppManager { + state: AppState, + auth: Arc, + graph_client: GraphApiClient, + keyvault_client: KeyVaultClient, + vault_discovery: VaultDiscovery, + config: Config, + position_applied: bool, +} + +impl AzureAppManager { + pub fn new(cc: &eframe::CreationContext<'_>, auth: Arc, config: Config) -> Self { + let graph_client = GraphApiClient::new(Arc::clone(&auth)); + let keyvault_client = KeyVaultClient::new(Arc::clone(&auth)); + let vault_discovery = VaultDiscovery::new(Arc::clone(&auth)); + + let mut state = AppState::new(); + + // Initialize background renderer + let background_renderer = Some(Arc::new(BackgroundRenderer::new( + &cc.egui_ctx, + &config.appearance.background_image, + config.appearance.background_opacity, + config.appearance.fallback_color, + ))); + state.background_renderer = background_renderer; + + // Check if already authenticated + if auth.is_authenticated() { + state.auth_status = AuthStatus::Authenticated; + state.current_view = ViewState::AppList; + } + + Self { + state, + auth, + graph_client, + keyvault_client, + vault_discovery, + config, + position_applied: false, + } + } + + fn handle_auth_view(&mut self, ctx: &egui::Context) { + if let Some(action) = show_auth_view(ctx, &mut self.state) { + match action { + AuthAction::SignIn => { + self.start_authentication(); + } + } + } + + // Poll authentication promise + if let Some(promise) = self.state.operations.authenticate.take() { + match promise.try_take() { + Ok(Ok(_token)) => { + self.state.auth_status = AuthStatus::Authenticated; + self.state.current_view = ViewState::AppList; + self.state.clear_messages(); + + // Load user info and applications + self.load_user_info(); + self.load_applications(); + } + Ok(Err(e)) => { + self.state.auth_status = AuthStatus::NotAuthenticated; + self.state.set_error(e.user_friendly_message()); + } + Err(promise) => { + self.state.operations.authenticate = Some(promise); + ctx.request_repaint(); + } + } + } + + // Poll sign out operation + if let Some(promise) = self.state.operations.sign_out.take() { + match promise.try_take() { + Ok(Ok(())) => { + tracing::info!("Sign out successful, resetting state"); + + // NOW it's safe to reset state - token is cleared + let background = self.state.background_renderer.clone(); + self.state = AppState::new(); + self.state.background_renderer = background; + } + Ok(Err(e)) => { + tracing::error!("Sign out failed: {}", e); + self.state.set_error(format!("Sign out failed: {}", e.user_friendly_message())); + self.state.auth_status = AuthStatus::NotAuthenticated; + } + Err(promise) => { + self.state.operations.sign_out = Some(promise); + ctx.request_repaint(); + } + } + } + } + + fn handle_app_list_view(&mut self, ctx: &egui::Context) { + if let Some(action) = show_app_list_view(ctx, &mut self.state, &self.config) { + match action { + AppListAction::Refresh => { + self.load_applications(); + } + AppListAction::SelectApp(app) => { + self.state.selected_app = Some(app); + } + AppListAction::CreateSecret => { + self.state.current_view = ViewState::CreateSecret; + self.state.selected_keyvault = None; // Clear any previously selected keyvault + self.state.clear_messages(); + } + AppListAction::SignOut => { + self.sign_out(); + } + } + } + + // Poll load applications promise + if let Some(promise) = self.state.operations.load_applications.take() { + match promise.try_take() { + Ok(Ok(apps)) => { + self.state.applications = apps; + self.state.clear_messages(); + } + Ok(Err(e)) => { + self.state.set_error(e.user_friendly_message()); + } + Err(promise) => { + self.state.operations.load_applications = Some(promise); + ctx.request_repaint(); + } + } + } + + // Poll load user promise + if let Some(promise) = self.state.operations.load_user.take() { + match promise.try_take() { + Ok(Ok(user)) => { + self.state.user_info = Some(user); + } + Ok(Err(e)) => { + tracing::warn!("Failed to load user info: {}", e); + } + Err(promise) => { + self.state.operations.load_user = Some(promise); + ctx.request_repaint(); + } + } + } + } + + fn handle_create_secret_view(&mut self, ctx: &egui::Context) { + if let Some(action) = show_secret_create_view(ctx, &mut self.state, &self.config) { + match action { + SecretCreateAction::Create => { + self.create_secret(); + } + SecretCreateAction::Back => { + self.state.current_view = ViewState::AppList; + self.state.secret_description.clear(); + self.state.clear_messages(); + } + } + } + + // Poll create secret promise + if let Some(promise) = self.state.operations.create_secret.take() { + match promise.try_take() { + Ok(Ok(secret)) => { + self.state.created_secret = Some(secret); + self.state.current_view = ViewState::SelectKeyVault; + self.state.clear_messages(); + + // Auto-generate secret name suggestion + if let Some(app) = &self.state.selected_app { + let safe_name = app + .display_name + .to_lowercase() + .replace(" ", "-") + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect::(); + self.state.secret_name_for_vault = + format!("{}", safe_name); + } + + // Load vaults + self.load_vaults(); + } + Ok(Err(e)) => { + self.state.set_error(e.user_friendly_message()); + } + Err(promise) => { + self.state.operations.create_secret = Some(promise); + ctx.request_repaint(); + } + } + } + } + + fn handle_keyvault_select_view(&mut self, ctx: &egui::Context) { + if let Some(action) = show_keyvault_select_view(ctx, &mut self.state, &self.config) { + match action { + KeyVaultSelectAction::SelectVault(vault) => { + self.state.selected_keyvault = Some(vault); + } + KeyVaultSelectAction::SaveToVault => { + self.save_to_vault(); + } + KeyVaultSelectAction::RefreshVaults => { + self.load_vaults(); + } + KeyVaultSelectAction::Skip => { + self.state.set_success( + "Secret created but not saved to Key Vault. Remember to save it manually!" + .to_string(), + ); + self.state.reset_for_new_secret(); + self.state.current_view = ViewState::AppList; + } + KeyVaultSelectAction::BackToAppList => { + self.state.reset_for_new_secret(); + self.state.current_view = ViewState::AppList; + } + } + } + + // Poll load vaults promise + if let Some(promise) = self.state.operations.load_vaults.take() { + match promise.try_take() { + Ok(Ok(vaults)) => { + self.state.key_vaults = vaults; + self.state.clear_messages(); + } + Ok(Err(e)) => { + self.state.set_error(e.user_friendly_message()); + } + Err(promise) => { + self.state.operations.load_vaults = Some(promise); + ctx.request_repaint(); + } + } + } + + // Poll save to vault promise + if let Some(promise) = self.state.operations.save_to_vault.take() { + match promise.try_take() { + Ok(Ok(())) => { + self.state.set_success("Secret saved to Key Vault successfully!".to_string()); + self.state.reset_for_new_secret(); + self.state.current_view = ViewState::AppList; + } + Ok(Err(e)) => { + self.state.set_error(e.user_friendly_message()); + } + Err(promise) => { + self.state.operations.save_to_vault = Some(promise); + ctx.request_repaint(); + } + } + } + } + + fn start_authentication(&mut self) { + self.state.auth_status = AuthStatus::Authenticating; + self.state.clear_messages(); + + let auth = Arc::clone(&self.auth); + self.state.operations.authenticate = Some(Promise::spawn_async(async move { + auth.authenticate().await + })); + } + + fn load_user_info(&mut self) { + let client = self.graph_client.clone(); + self.state.operations.load_user = + Some(Promise::spawn_async(async move { client.get_current_user().await })); + } + + fn load_applications(&mut self) { + self.state.clear_messages(); + let client = self.graph_client.clone(); + self.state.operations.load_applications = + Some(Promise::spawn_async(async move { client.list_applications().await })); + } + + fn create_secret(&mut self) { + if let Some(app) = &self.state.selected_app { + let client = self.graph_client.clone(); + let object_id = app.id.clone(); + let app_id = app.app_id.clone(); + let app_name = app.display_name.clone(); + let description = self.state.secret_description.clone(); + let expiration_years = self.config.azure.secret_expiration_years; + + self.state.clear_messages(); + + self.state.operations.create_secret = Some(Promise::spawn_async(async move { + client.add_password(&object_id, &app_id, &app_name, &description, expiration_years).await + })); + } + } + + fn load_vaults(&mut self) { + self.state.clear_messages(); + let discovery = self.vault_discovery.clone(); + self.state.operations.load_vaults = Some(Promise::spawn_async(async move { + discovery.list_accessible_vaults().await + })); + } + + fn save_to_vault(&mut self) { + if let (Some(secret), Some(vault)) = (&self.state.created_secret, &self.state.selected_keyvault) { + let client = self.keyvault_client.clone(); + let vault_url = vault.vault_uri.clone(); + let secret_name = self.state.secret_name_for_vault.clone(); + let secret_value = secret.secret_value.expose_secret().clone(); + let key_id = secret.key_id.clone(); + let description = secret.display_name.clone(); + + // Parse expiration date to Unix timestamp if available + let expires_at = secret.expires_at.as_ref().and_then(|exp_str| { + chrono::DateTime::parse_from_rfc3339(exp_str) + .ok() + .map(|dt| dt.timestamp()) + }); + + self.state.clear_messages(); + + self.state.operations.save_to_vault = Some(Promise::spawn_async(async move { + client.set_secret( + &vault_url, + &secret_name, + &secret_value, + Some(&key_id), + Some(&description), + expires_at, + ).await + })); + } + } + + fn sign_out(&mut self) { + // Set signing out state to prevent re-login during sign-out + self.state.auth_status = AuthStatus::NotAuthenticated; + self.state.current_view = ViewState::Login; + + let auth = Arc::clone(&self.auth); + self.state.operations.sign_out = Some(Promise::spawn_async(async move { + auth.sign_out().await + })); + } +} + +impl eframe::App for AzureAppManager { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + // Use the fallback color from config to match background + let color = self.config.appearance.fallback_color; + [ + color[0] as f32 / 255.0, + color[1] as f32 / 255.0, + color[2] as f32 / 255.0, + 1.0, // Full opacity + ] + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Customize colors to match app theme + let mut visuals = egui::Visuals::dark(); + + // Get color configuration with defaults + let colors = self.config.colors.as_ref(); + + // Apply colors from config + if let Some(color_config) = colors { + // Separate text colors from slider colors: + // - fg_stroke controls text and icons (use text colors) + // - bg_fill and bg_stroke control slider/scrollbar appearance (use slider colors) + let slider = &color_config.slider; + let text = &color_config.text; + + // Apply TEXT colors to fg_stroke (this controls text in widgets like card titles) + visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::from_rgb( + text.normal[0], text.normal[1], text.normal[2] + ); + visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_rgb( + text.inactive[0], text.inactive[1], text.inactive[2] + ); + visuals.widgets.hovered.fg_stroke.color = egui::Color32::from_rgb( + text.hover[0], text.hover[1], text.hover[2] + ); + visuals.widgets.active.fg_stroke.color = egui::Color32::from_rgb( + text.hover[0], text.hover[1], text.hover[2] // Use hover color for active + ); + + // Apply SLIDER colors to bg_fill (this controls slider/scrollbar appearance) + visuals.widgets.noninteractive.bg_fill = egui::Color32::from_rgb( + slider.inactive[0], slider.inactive[1], slider.inactive[2] + ); + visuals.widgets.inactive.bg_fill = egui::Color32::from_rgb( + slider.inactive[0], slider.inactive[1], slider.inactive[2] + ); + visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb( + slider.hover[0], slider.hover[1], slider.hover[2] + ); + visuals.widgets.active.bg_fill = egui::Color32::from_rgb( + slider.active[0], slider.active[1], slider.active[2] + ); + + // Background strokes for widget borders (textbox, buttons) + let borders = &color_config.widget_borders; + visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, + egui::Color32::from_rgb(borders.inactive[0], borders.inactive[1], borders.inactive[2]) + ); + visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, + egui::Color32::from_rgb(borders.inactive[0], borders.inactive[1], borders.inactive[2]) + ); + visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, + egui::Color32::from_rgb(borders.hover[0], borders.hover[1], borders.hover[2]) + ); + visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, + egui::Color32::from_rgb(borders.active[0], borders.active[1], borders.active[2]) + ); + + // Selection/highlight color for drag handle + visuals.selection.bg_fill = egui::Color32::from_rgb( + text.select[0], text.select[1], text.select[2] + ); + visuals.selection.stroke = egui::Stroke::new(0.0, egui::Color32::from_rgb( + text.select[0], text.select[1], text.select[2] + )); + + // Apply text color override for non-widget text + visuals.override_text_color = Some(egui::Color32::from_rgb( + text.normal[0], text.normal[1], text.normal[2] + )); + } else { + // Fallback to old behavior if no config + visuals.override_text_color = Some(egui::Color32::from_rgb(255, 255, 255)); + visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::from_rgb(255, 255, 255); + visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_rgb(255, 255, 255); + visuals.widgets.hovered.fg_stroke.color = egui::Color32::from_rgb(255, 255, 255); + visuals.widgets.active.fg_stroke.color = egui::Color32::from_rgb(255, 255, 255); + } + + // Scrollbar background & input field color + visuals.extreme_bg_color = egui::Color32::from_rgb(25, 25, 35); + + ctx.set_visuals(visuals); + + // Apply style modifications (scrollbar colors and text sizing) + let mut style = (*ctx.style()).clone(); + + // Make scrollbars use bg_fill instead of fg_stroke + // This separates scrollbar colors from text colors + style.spacing.scroll.foreground_color = false; + + // Apply text sizing from config + if let Some(text_sizing) = &self.config.text_sizing { + style.text_styles.insert( + egui::TextStyle::Heading, + egui::FontId::proportional(text_sizing.heading), + ); + style.text_styles.insert( + egui::TextStyle::Body, + egui::FontId::proportional(text_sizing.body), + ); + style.text_styles.insert( + egui::TextStyle::Small, + egui::FontId::proportional(text_sizing.small), + ); + style.text_styles.insert( + egui::TextStyle::Button, + egui::FontId::proportional(text_sizing.button), + ); + } + + ctx.set_style(style); + + // Apply position offset FIRST, before rendering anything + if !self.position_applied && (self.config.window.position_offset_x != 0.0 || self.config.window.position_offset_y != 0.0) { + let outer_rect = ctx.input(|i| i.viewport().outer_rect); + if let Some(outer_rect) = outer_rect { + // Get current position (should be centered) + let current_pos = outer_rect.left_top(); + // Apply offset from the centered position + let new_pos = egui::pos2( + current_pos.x + self.config.window.position_offset_x, + current_pos.y + self.config.window.position_offset_y, + ); + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(new_pos)); + self.position_applied = true; + } + } + + // Render fullscreen background (now happens after position is set) + if let Some(bg) = &self.state.background_renderer { + bg.render_fullscreen(ctx); + } + + match self.state.current_view { + ViewState::Login => self.handle_auth_view(ctx), + ViewState::AppList => self.handle_app_list_view(ctx), + ViewState::CreateSecret => self.handle_create_secret_view(ctx), + ViewState::SelectKeyVault => self.handle_keyvault_select_view(ctx), + } + } +} diff --git a/src/auth/azure_auth.rs b/src/auth/azure_auth.rs new file mode 100644 index 0000000..dd97585 --- /dev/null +++ b/src/auth/azure_auth.rs @@ -0,0 +1,596 @@ +use crate::auth::token_cache::{CachedToken, TokenCache}; +use crate::error::{AppError, AppResult}; +use axum::{extract::Query, response::Html, routing::get, Router}; +use serde::Deserialize; +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; + +// Microsoft Graph Command Line Tools public client ID - pre-authorized for Graph API +const GRAPH_CLI_CLIENT_ID: &str = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; +const AZURE_TENANT: &str = "organizations"; // Multi-tenant support +const REDIRECT_URI: &str = "http://localhost"; // Standard redirect URI for this public client + +// Scopes - Only request Graph API scopes during initial authentication +// We'll use the refresh token to get Management API tokens separately +const SCOPES: &[&str] = &[ + "https://graph.microsoft.com/Application.ReadWrite.All", + "https://graph.microsoft.com/Directory.Read.All", + "offline_access", +]; + +#[derive(Debug, Deserialize)] +struct AuthCallback { + code: Option, + error: Option, + error_description: Option, +} + +pub struct AzureAuthenticator { + token_cache: TokenCache, +} + +impl AzureAuthenticator { + pub fn new() -> AppResult { + let token_cache = TokenCache::new()?; + Ok(Self { token_cache }) + } + + pub async fn authenticate(&self) -> AppResult { + // Check if we have a cached valid token + if let Some(cached_token) = self.token_cache.load_token()? { + tracing::info!("Using cached token"); + return Ok(cached_token.access_token); + } + + // Perform new authentication + tracing::info!("Starting interactive browser authentication"); + self.perform_interactive_auth().await + } + + async fn perform_interactive_auth(&self) -> AppResult { + // For Microsoft Graph CLI public client, we MUST use port 80 (http://localhost) + // because that's the only redirect URI registered for this client ID + + tracing::debug!("Step 1: Binding to port 80"); + + // Try to bind with SO_REUSEADDR - should work immediately if previous connection closed properly + let socket = tokio::net::TcpSocket::new_v4() + .map_err(|e| AppError::AuthenticationError(format!("Failed to create socket: {}", e)))?; + + socket.set_reuseaddr(true) + .map_err(|e| AppError::AuthenticationError(format!("Failed to set SO_REUSEADDR: {}", e)))?; + + socket.bind("127.0.0.1:80".parse().unwrap()) + .map_err(|e| AppError::AuthenticationError( + format!("Failed to bind to port 80: {}. The port may be in use by another application. Please close any other applications using port 80 and try again.", e) + ))?; + + let listener = socket.listen(128) + .map_err(|e| AppError::AuthenticationError(format!("Failed to listen on port 80: {}", e)))?; + + tracing::info!("Successfully bound to port 80"); + + // Always use port 80 redirect URI since that's what we bound to + let redirect_uri = REDIRECT_URI.to_string(); // "http://localhost" + tracing::info!("Using redirect URI: {}", redirect_uri); + + // Build authorization URL + let auth_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}", + AZURE_TENANT, + GRAPH_CLI_CLIENT_ID, + urlencoding::encode(&redirect_uri), + urlencoding::encode(&SCOPES.join(" ")) + ); + + tracing::info!("Opening browser for authentication..."); + if let Err(e) = open::that(&auth_url) { + tracing::warn!("Failed to open browser automatically: {}", e); + println!("\nPlease visit this URL to authenticate:\n{}\n", auth_url); + } + + tracing::debug!("Step 2: Creating shutdown channel"); + // Create shutdown channel for graceful server shutdown + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let shutdown_tx = Arc::new(Mutex::new(Some(shutdown_tx))); + + // Create channel to receive auth code + let (tx, rx) = oneshot::channel(); + let tx = Arc::new(Mutex::new(Some(tx))); + + tracing::debug!("Step 3: Creating callback handler"); + // Create callback handler + let callback_handler = { + let tx = Arc::clone(&tx); + let shutdown_for_callback = Arc::clone(&shutdown_tx); + move |Query(params): Query| async move { + if let Some(error) = params.error { + let error_msg = params.error_description.unwrap_or(error); + tracing::error!("Authentication error: {}", error_msg); + if let Some(sender) = tx.lock().unwrap().take() { + let _ = sender.send(Err(error_msg)); + } + // Signal server shutdown after response + if let Some(shutdown) = shutdown_for_callback.lock().unwrap().take() { + let _ = shutdown.send(()); + } + return Html("

Authentication Failed

Error: See application for details.

You can close this window.

"); + } + + if let Some(code) = params.code { + tracing::info!("Authorization code received"); + if let Some(sender) = tx.lock().unwrap().take() { + let _ = sender.send(Ok(code)); + } + // Signal server shutdown after response + if let Some(shutdown) = shutdown_for_callback.lock().unwrap().take() { + let _ = shutdown.send(()); + } + Html("

✓ Authentication Successful!

You can close this window and return to the application.

") + } else { + // Signal server shutdown after response + if let Some(shutdown) = shutdown_for_callback.lock().unwrap().take() { + let _ = shutdown.send(()); + } + Html("

Authentication Failed

No authorization code received.

") + } + } + }; + + // Build the router - use root path since redirect URI is http://localhost + let app = Router::new().route("/", get(callback_handler)); + + tracing::info!("Waiting for authentication... (timeout: 3 minutes)"); + + tracing::debug!("Step 4: Starting server with graceful shutdown"); + // Spawn server with graceful shutdown + let server_handle = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown_rx.await.ok(); + tracing::debug!("Server received shutdown signal"); + }) + .await + }); + + // Wait for auth code with timeout + tracing::debug!("Step 5: Waiting for authorization code from browser..."); + let auth_code = match tokio::time::timeout(std::time::Duration::from_secs(180), rx).await { + Ok(result) => { + tracing::debug!("Timeout completed, processing channel result"); + match result { + Ok(code_result) => { + tracing::debug!("Channel received result"); + match code_result { + Ok(code) => { + tracing::debug!("Successfully received authorization code"); + code + } + Err(e) => { + tracing::error!("Authentication failed: {}", e); + // Signal shutdown on error + if let Some(shutdown) = shutdown_tx.lock().unwrap().take() { + let _ = shutdown.send(()); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(2), + server_handle + ).await; + return Err(AppError::AuthenticationError(format!("Authentication failed: {}", e))); + } + } + } + Err(_) => { + tracing::error!("Callback channel closed unexpectedly"); + // Signal shutdown on error + if let Some(shutdown) = shutdown_tx.lock().unwrap().take() { + let _ = shutdown.send(()); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(2), + server_handle + ).await; + return Err(AppError::AuthenticationError("Callback channel closed".to_string())); + } + } + } + Err(_) => { + tracing::error!("Authentication timeout after 3 minutes"); + // Signal shutdown on timeout + if let Some(shutdown) = shutdown_tx.lock().unwrap().take() { + let _ = shutdown.send(()); + } + let _ = tokio::time::timeout( + std::time::Duration::from_secs(2), + server_handle + ).await; + return Err(AppError::AuthenticationError("Authentication timeout after 3 minutes".to_string())); + } + }; + + tracing::debug!("Step 6: Authorization code received, waiting for server shutdown"); + // Wait for server to finish gracefully (should be quick since shutdown was already signaled) + match tokio::time::timeout(std::time::Duration::from_secs(2), server_handle).await { + Ok(result) => { + if let Err(e) = result { + tracing::warn!("Server shutdown error: {}", e); + } else { + tracing::debug!("Server shutdown complete"); + } + } + Err(_) => { + tracing::warn!("Server shutdown timeout, but continuing anyway"); + } + } + + // Exchange authorization code for token + tracing::debug!("Step 7: Creating HTTP client"); + let client = reqwest::Client::new(); + + tracing::info!("Exchanging authorization code for access token..."); + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + AZURE_TENANT + ); + + tracing::debug!("Token URL: {}", token_url); + let params = [ + ("client_id", GRAPH_CLI_CLIENT_ID), + ("grant_type", "authorization_code"), + ("code", &auth_code), + ("redirect_uri", &redirect_uri), + ("scope", &SCOPES.join(" ")), + ]; + + tracing::debug!("Step 8: Sending token request"); + let token_response = client + .post(&token_url) + .form(¶ms) + .send() + .await + .map_err(|e| { + tracing::error!("Token HTTP request failed: {}", e); + AppError::AuthenticationError(format!("Token request failed: {}", e)) + })?; + + tracing::debug!("Step 9: Token response received with status: {}", token_response.status()); + + if !token_response.status().is_success() { + let status = token_response.status(); + let error_text = token_response.text().await.unwrap_or_default(); + return Err(AppError::AuthenticationError(format!( + "Token exchange failed with status {}: {}", + status, error_text + ))); + } + + let token_json: serde_json::Value = token_response.json().await.map_err(|e| { + AppError::AuthenticationError(format!("Failed to parse token response: {}", e)) + })?; + + let access_token = token_json["access_token"] + .as_str() + .ok_or_else(|| AppError::AuthenticationError("No access token in response".to_string()))? + .to_string(); + + let refresh_token = token_json["refresh_token"].as_str().map(|s| s.to_string()); + let expires_in = token_json["expires_in"].as_i64().unwrap_or(3600); + let expires_at = chrono::Utc::now().timestamp() + expires_in; + + // Save token to cache + let cached_token = CachedToken { + access_token: access_token.clone(), + refresh_token, + expires_at, + }; + + self.token_cache.save_token(&cached_token)?; + tracing::info!("✓ Authentication successful! Token cached."); + + Ok(access_token) + } + + pub async fn get_access_token(&self) -> AppResult { + // Check if token needs refresh + if self.token_cache.token_expires_soon(5)? { + tracing::info!("Token expires soon, re-authenticating..."); + self.token_cache.clear_token()?; + return self.perform_interactive_auth().await; + } + + // Load token from cache + if let Some(cached_token) = self.token_cache.load_token()? { + return Ok(cached_token.access_token); + } + + Err(AppError::AuthenticationError( + "No access token available. Please authenticate first.".to_string(), + )) + } + + /// Get a token for a specific Azure resource scope using the refresh token + async fn get_token_for_scope(&self, scope: &str) -> AppResult { + tracing::debug!("Getting token for scope: {}", scope); + + // Load cached token to get refresh token + let cached = self.token_cache.load_token()?.ok_or_else(|| { + AppError::AuthenticationError("Not authenticated. Please sign in first.".to_string()) + })?; + + let refresh_token = cached.refresh_token.as_ref().ok_or_else(|| { + AppError::AuthenticationError("No refresh token available. Please re-authenticate.".to_string()) + })?; + + // Exchange refresh token for a token with the requested scope + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + AZURE_TENANT + ); + + let client = reqwest::Client::new(); + let params = [ + ("client_id", GRAPH_CLI_CLIENT_ID), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token.as_str()), + ("scope", scope), + ]; + + let token_response = client + .post(&token_url) + .form(¶ms) + .send() + .await + .map_err(|e| { + AppError::AuthenticationError(format!("Failed to get token for {}: {}", scope, e)) + })?; + + if !token_response.status().is_success() { + let status = token_response.status(); + let error_text = token_response.text().await.unwrap_or_default(); + return Err(AppError::AuthenticationError(format!( + "Token request for {} failed with status {}: {}", + scope, status, error_text + ))); + } + + let token_json: serde_json::Value = token_response.json().await.map_err(|e| { + AppError::AuthenticationError(format!("Failed to parse token response: {}", e)) + })?; + + let access_token = token_json["access_token"] + .as_str() + .ok_or_else(|| AppError::AuthenticationError("No access token in response".to_string()))? + .to_string(); + + tracing::debug!("✓ Token obtained for scope: {}", scope); + Ok(access_token) + } + + /// Get a token specifically for Azure Management API + pub async fn get_management_token(&self) -> AppResult { + tracing::info!("Getting Azure Management API token"); + self.get_token_for_scope("https://management.azure.com/.default").await + } + + /// Get a token specifically for Azure Key Vault + /// This attempts to use the refresh token, but may fail if Key Vault access wasn't consented + pub async fn get_keyvault_token(&self) -> AppResult { + tracing::info!("Getting Azure Key Vault token via token exchange"); + + // First check if we're authenticated + if !self.is_authenticated() { + return Err(AppError::AuthenticationError( + "Not authenticated. Please sign in first.".to_string() + )); + } + + // Try to get Key Vault token via refresh token + tracing::debug!("Exchanging refresh token for Key Vault access token"); + match self.get_token_for_scope("https://vault.azure.net/.default").await { + Ok(token) => Ok(token), + Err(e) => { + tracing::error!("Failed to get Key Vault token: {}", e); + Err(AppError::KeyVaultError( + format!("Failed to get Key Vault access token. The Microsoft Graph CLI client may not have permission to access Key Vault. Error: {}", e) + )) + } + } + } + + /// Perform interactive authentication specifically for Key Vault + /// This opens a new browser window to get consent for Key Vault access + #[allow(dead_code)] + pub async fn authenticate_keyvault(&self) -> AppResult { + tracing::info!("Starting interactive Key Vault authentication"); + + // Try to bind to port for OAuth callback + let ports_to_try = vec![80, 8080, 8400, 1234]; + let mut listener = None; + let mut bound_port = 0; + + for port in ports_to_try { + // Create socket with SO_REUSEADDR to allow immediate port reuse + match tokio::net::TcpSocket::new_v4() { + Ok(socket) => { + // Enable SO_REUSEADDR to prevent TIME_WAIT issues on repeated auth + if let Err(e) = socket.set_reuseaddr(true) { + tracing::debug!("Failed to set SO_REUSEADDR on port {}: {}", port, e); + } + + match socket.bind(format!("127.0.0.1:{}", port).parse().unwrap()) { + Ok(_) => { + match socket.listen(128) { + Ok(l) => { + tracing::info!("Successfully bound to port {} with SO_REUSEADDR", port); + bound_port = port; + listener = Some(l); + break; + } + Err(e) => { + tracing::debug!("Failed to listen on port {}: {}", port, e); + continue; + } + } + } + Err(e) => { + tracing::debug!("Failed to bind to port {}: {}", port, e); + continue; + } + } + } + Err(e) => { + tracing::debug!("Failed to create socket for port {}: {}", port, e); + continue; + } + } + } + + let listener = listener.ok_or_else(|| { + AppError::AuthenticationError( + "Failed to bind to any standard OAuth port (80, 8080, 8400, 1234). Please ensure these ports are available.".to_string() + ) + })?; + + // Build the redirect URI based on the bound port + let redirect_uri = if bound_port == 80 { + REDIRECT_URI.to_string() + } else { + format!("http://localhost:{}", bound_port) + }; + + tracing::info!("Using redirect URI: {}", redirect_uri); + + // Key Vault specific scope + let keyvault_scope = "https://vault.azure.net/user_impersonation offline_access"; + + // Build authorization URL for Key Vault + let auth_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}", + AZURE_TENANT, + GRAPH_CLI_CLIENT_ID, + urlencoding::encode(&redirect_uri), + urlencoding::encode(keyvault_scope) + ); + + tracing::info!("Opening browser for Key Vault authentication..."); + if let Err(e) = open::that(&auth_url) { + tracing::warn!("Failed to open browser automatically: {}", e); + println!("\nPlease visit this URL to authenticate for Key Vault access:\n{}\n", auth_url); + } + + // Create channel to receive auth code + let (tx, rx) = oneshot::channel(); + let tx = Arc::new(Mutex::new(Some(tx))); + + // Create callback handler + let callback_handler = { + let tx = Arc::clone(&tx); + move |Query(params): Query| async move { + if let Some(error) = params.error { + let error_msg = params.error_description.unwrap_or(error); + tracing::error!("Key Vault authentication error: {}", error_msg); + if let Some(sender) = tx.lock().unwrap().take() { + let _ = sender.send(Err(error_msg)); + } + return Html("

Key Vault Authentication Failed

Error: See application for details.

You can close this window.

"); + } + + if let Some(code) = params.code { + tracing::info!("Key Vault authorization code received"); + if let Some(sender) = tx.lock().unwrap().take() { + let _ = sender.send(Ok(code)); + } + Html("

✓ Key Vault Authentication Successful!

You can close this window and return to the application.

") + } else { + Html("

Authentication Failed

No authorization code received.

") + } + } + }; + + // Build the router + let app = Router::new().route("/", get(callback_handler)); + + tracing::info!("Waiting for Key Vault authentication... (timeout: 5 minutes)"); + + // Spawn server + let server_handle = tokio::spawn(async move { + axum::serve(listener, app).await + }); + + // Wait for auth code with timeout (5 minutes) + let auth_code = tokio::time::timeout(std::time::Duration::from_secs(300), rx) + .await + .map_err(|_| { + AppError::AuthenticationError( + "Key Vault authentication timeout after 5 minutes".to_string(), + ) + })? + .map_err(|_| AppError::AuthenticationError("Callback channel closed".to_string()))? + .map_err(|e| AppError::AuthenticationError(format!("Key Vault authentication failed: {}", e)))?; + + // Give the server more time to send the response before stopping + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Stop the server + server_handle.abort(); + + // Add a small delay after aborting to let the port fully release + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Exchange authorization code for token + tracing::info!("Exchanging authorization code for Key Vault access token..."); + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + AZURE_TENANT + ); + + let client = reqwest::Client::new(); + let params = [ + ("client_id", GRAPH_CLI_CLIENT_ID), + ("grant_type", "authorization_code"), + ("code", &auth_code), + ("redirect_uri", &redirect_uri), + ("scope", keyvault_scope), + ]; + + let token_response = client + .post(&token_url) + .form(¶ms) + .send() + .await + .map_err(|e| { + AppError::AuthenticationError(format!("Key Vault token request failed: {}", e)) + })?; + + if !token_response.status().is_success() { + let status = token_response.status(); + let error_text = token_response.text().await.unwrap_or_default(); + return Err(AppError::AuthenticationError(format!( + "Key Vault token exchange failed with status {}: {}", + status, error_text + ))); + } + + let token_json: serde_json::Value = token_response.json().await.map_err(|e| { + AppError::AuthenticationError(format!("Failed to parse Key Vault token response: {}", e)) + })?; + + let access_token = token_json["access_token"] + .as_str() + .ok_or_else(|| AppError::AuthenticationError("No access token in Key Vault response".to_string()))? + .to_string(); + + tracing::info!("✓ Key Vault authentication successful!"); + Ok(access_token) + } + + pub async fn sign_out(&self) -> AppResult<()> { + self.token_cache.clear_token()?; + tracing::info!("User signed out"); + Ok(()) + } + + pub fn is_authenticated(&self) -> bool { + self.token_cache.load_token().ok().flatten().is_some() + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..59541ce --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,4 @@ +pub mod azure_auth; +pub mod token_cache; + +pub use azure_auth::AzureAuthenticator; diff --git a/src/auth/token_cache.rs b/src/auth/token_cache.rs new file mode 100644 index 0000000..5d68616 --- /dev/null +++ b/src/auth/token_cache.rs @@ -0,0 +1,88 @@ +use crate::error::{AppError, AppResult}; +use keyring::Entry; +use serde::{Deserialize, Serialize}; + +const SERVICE_NAME: &str = "create-app-secret"; +const TOKEN_KEY: &str = "access_token"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedToken { + pub access_token: String, + pub refresh_token: Option, + pub expires_at: i64, // Unix timestamp +} + +pub struct TokenCache { + entry: Entry, +} + +impl TokenCache { + pub fn new() -> AppResult { + let entry = Entry::new(SERVICE_NAME, TOKEN_KEY) + .map_err(|e| AppError::TokenCacheError(e.to_string()))?; + Ok(Self { entry }) + } + + pub fn save_token(&self, token: &CachedToken) -> AppResult<()> { + let json = serde_json::to_string(token)?; + self.entry + .set_password(&json) + .map_err(|e| AppError::TokenCacheError(e.to_string()))?; + tracing::debug!("Token saved to secure storage"); + Ok(()) + } + + pub fn load_token(&self) -> AppResult> { + match self.entry.get_password() { + Ok(json) => { + let token: CachedToken = serde_json::from_str(&json)?; + + // Check if token is expired + let now = chrono::Utc::now().timestamp(); + if token.expires_at <= now { + tracing::debug!("Cached token has expired"); + self.clear_token()?; + return Ok(None); + } + + tracing::debug!("Token loaded from secure storage"); + Ok(Some(token)) + } + Err(keyring::Error::NoEntry) => { + tracing::debug!("No cached token found"); + Ok(None) + } + Err(e) => Err(AppError::TokenCacheError(e.to_string())), + } + } + + pub fn clear_token(&self) -> AppResult<()> { + match self.entry.delete_credential() { + Ok(_) => { + tracing::debug!("Token cleared from secure storage"); + Ok(()) + } + Err(keyring::Error::NoEntry) => { + tracing::debug!("No token to clear"); + Ok(()) + } + Err(e) => Err(AppError::TokenCacheError(e.to_string())), + } + } + + pub fn token_expires_soon(&self, minutes: i64) -> AppResult { + if let Some(token) = self.load_token()? { + let now = chrono::Utc::now().timestamp(); + let expires_in = token.expires_at - now; + Ok(expires_in < (minutes * 60)) + } else { + Ok(true) // No token = expired + } + } +} + +impl Default for TokenCache { + fn default() -> Self { + Self::new().expect("Failed to create token cache") + } +} diff --git a/src/azure/graph_client.rs b/src/azure/graph_client.rs new file mode 100644 index 0000000..5b68f2b --- /dev/null +++ b/src/azure/graph_client.rs @@ -0,0 +1,150 @@ +use crate::auth::AzureAuthenticator; +use crate::azure::models::{Application, CreatedSecret, PasswordCredential, SensitiveString, UserInfo}; +use crate::error::{AppError, AppResult}; +use graph_rs_sdk::{Graph, GraphClient}; +use std::sync::Arc; + +pub struct GraphApiClient { + auth: Arc, +} + +impl GraphApiClient { + pub fn new(auth: Arc) -> Self { + Self { auth } + } + + async fn get_client(&self) -> AppResult { + let token = self.auth.get_access_token().await?; + let client = Graph::new(&token); + Ok(client) + } + + pub async fn list_applications(&self) -> AppResult> { + tracing::debug!("Fetching application registrations"); + + let client = self.get_client().await?; + + let response = client + .applications() + .list_application() + .send() + .await + .map_err(|e| AppError::GraphApiError(format!("Failed to list applications: {}", e)))?; + + let body = response + .text() + .await + .map_err(|e| AppError::GraphApiError(format!("Failed to read response: {}", e)))?; + + let json: serde_json::Value = serde_json::from_str(&body)?; + + let apps: Vec<_> = json["value"] + .as_array() + .ok_or_else(|| AppError::GraphApiError("Invalid response format".to_string()))? + .iter() + .filter_map(|app| serde_json::from_value(app.clone()).ok()) + .collect(); + + tracing::debug!("Fetched {} applications", apps.len()); + Ok(apps) + } + + pub async fn add_password( + &self, + object_id: &str, + app_id: &str, + app_name: &str, + display_name: &str, + expiration_years: u32, + ) -> AppResult { + tracing::info!("Creating new password for app '{}' (App ID: {}, Object ID: {}) with {}-year expiration", app_name, app_id, object_id, expiration_years); + + let client = self.get_client().await?; + + // Calculate expiration date (current time + years) + let end_date = chrono::Utc::now() + chrono::Duration::days((expiration_years as i64) * 365); + let end_date_str = end_date.to_rfc3339(); + + // Prepare the request body with expiration + let password_credential = serde_json::json!({ + "passwordCredential": { + "displayName": display_name, + "endDateTime": end_date_str + } + }); + + tracing::debug!("Request payload: {}", serde_json::to_string_pretty(&password_credential).unwrap_or_default()); + + let response = client + .application(object_id) + .add_password(&password_credential) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to create password for object ID '{}': {}", object_id, e); + AppError::GraphApiError(format!("Failed to create password for '{}': {}. Make sure you have Application.ReadWrite.All permissions.", app_name, e)) + })?; + + let body = response + .text() + .await + .map_err(|e| AppError::GraphApiError(format!("Failed to read response: {}", e)))?; + + tracing::debug!("Response body: {}", body); + + let credential: PasswordCredential = serde_json::from_str(&body)?; + + tracing::debug!("Parsed credential - keyId: {:?}, secretText present: {}", + credential.key_id, credential.secret_text.is_some()); + + let secret_value = credential + .secret_text + .clone() + .ok_or_else(|| { + AppError::GraphApiError("No secret text in response".to_string()) + })?; + + let created_secret = CreatedSecret { + app_id: app_id.to_string(), + app_name: app_name.to_string(), + key_id: credential.key_id.clone().unwrap_or_default(), + display_name: display_name.to_string(), + secret_value: SensitiveString::from(secret_value), + expires_at: credential.end_date_time.clone(), + }; + + tracing::info!("✓ Password created successfully for app: {} (App ID: {})", app_name, app_id); + Ok(created_secret) + } + + pub async fn get_current_user(&self) -> AppResult { + tracing::debug!("Fetching current user info"); + + let client = self.get_client().await?; + + let response = client + .me() + .get_user() + .send() + .await + .map_err(|e| AppError::GraphApiError(format!("Failed to get user info: {}", e)))?; + + let body = response + .text() + .await + .map_err(|e| AppError::GraphApiError(format!("Failed to read response: {}", e)))?; + + let user_info: UserInfo = serde_json::from_str(&body)?; + + tracing::debug!("Fetched user: {}", user_info.display_name); + Ok(user_info) + } +} + +impl Clone for GraphApiClient { + fn clone(&self) -> Self { + Self { + auth: Arc::clone(&self.auth), + } + } +} diff --git a/src/azure/keyvault_client.rs b/src/azure/keyvault_client.rs new file mode 100644 index 0000000..78dce3d --- /dev/null +++ b/src/azure/keyvault_client.rs @@ -0,0 +1,186 @@ +use crate::auth::AzureAuthenticator; +use crate::error::{AppError, AppResult}; +use std::sync::Arc; + +pub struct KeyVaultClient { + auth: Arc, +} + +impl KeyVaultClient { + pub fn new(auth: Arc) -> Self { + Self { auth } + } + + pub async fn set_secret( + &self, + vault_url: &str, + secret_name: &str, + secret_value: &str, + key_id: Option<&str>, + description: Option<&str>, + expires_at: Option, + ) -> AppResult<()> { + tracing::info!("Setting secret '{}' in vault: {}", secret_name, vault_url); + + // Ensure vault URL is properly formatted + let vault_url = if !vault_url.starts_with("https://") { + format!("https://{}", vault_url) + } else { + vault_url.to_string() + }; + + // Remove trailing slash if present + let vault_url = vault_url.trim_end_matches('/'); + + // Get access token with Key Vault scope + let token = self.get_keyvault_token().await?; + + // Build the REST API URL + let api_url = format!("{}/secrets/{}?api-version=7.4", vault_url, secret_name); + + // Create tags (like PowerShell does) + let mut tags = serde_json::Map::new(); + if let Some(desc) = description { + tags.insert("Description".to_string(), serde_json::json!(desc)); + } + if let Some(kid) = key_id { + tags.insert("SecretId".to_string(), serde_json::json!(kid)); + } + + // Create request body with value, tags, and expiration (matching PowerShell) + let mut body = serde_json::json!({ + "value": secret_value, + "tags": tags, + }); + + // Add expiration if provided + if let Some(exp) = expires_at { + body["attributes"] = serde_json::json!({ + "exp": exp + }); + } + + tracing::debug!("Making request to: {}", api_url); + tracing::debug!("Request body: {}", serde_json::to_string_pretty(&body).unwrap_or_default()); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| AppError::KeyVaultError(format!("Failed to create HTTP client: {}", e)))?; + + let response = client + .put(&api_url) + .bearer_auth(&token) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to send request to Key Vault: {}", e); + if e.is_timeout() { + AppError::KeyVaultError("Request timed out after 30 seconds. Please check your network connection.".to_string()) + } else { + AppError::KeyVaultError(format!("Failed to send request: {}", e)) + } + })?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + tracing::error!("Key Vault API error: {} - {}", status, error_text); + return Err(AppError::KeyVaultError(format!( + "Failed to set secret (status {}): {}. Make sure you have 'Key Vault Secrets Officer' or 'Key Vault Contributor' role.", + status, error_text + ))); + } + + tracing::info!("✓ Secret '{}' saved successfully to {}", secret_name, vault_url); + Ok(()) + } + + /// Get a token specifically for Key Vault operations + async fn get_keyvault_token(&self) -> AppResult { + tracing::info!("Getting Key Vault token via refresh token exchange"); + self.auth.get_keyvault_token().await + } + + #[allow(dead_code)] + pub async fn get_secret(&self, vault_url: &str, secret_name: &str) -> AppResult { + tracing::debug!("Getting secret '{}' from vault: {}", secret_name, vault_url); + + let vault_url = if !vault_url.starts_with("https://") { + format!("https://{}", vault_url) + } else { + vault_url.to_string() + }; + + let vault_url = vault_url.trim_end_matches('/'); + let token = self.get_keyvault_token().await?; + let api_url = format!("{}/secrets/{}?api-version=7.4", vault_url, secret_name); + + let client = reqwest::Client::new(); + let response = client + .get(&api_url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| AppError::KeyVaultError(format!("Failed to get secret: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(AppError::KeyVaultError(format!( + "Failed to get secret (status {}): {}", + status, error_text + ))); + } + + let json: serde_json::Value = response.json().await + .map_err(|e| AppError::KeyVaultError(format!("Failed to parse response: {}", e)))?; + + Ok(json["value"].as_str().unwrap_or_default().to_string()) + } + + #[allow(dead_code)] + pub async fn delete_secret(&self, vault_url: &str, secret_name: &str) -> AppResult<()> { + tracing::debug!("Deleting secret '{}' from vault: {}", secret_name, vault_url); + + let vault_url = if !vault_url.starts_with("https://") { + format!("https://{}", vault_url) + } else { + vault_url.to_string() + }; + + let vault_url = vault_url.trim_end_matches('/'); + let token = self.get_keyvault_token().await?; + let api_url = format!("{}/secrets/{}?api-version=7.4", vault_url, secret_name); + + let client = reqwest::Client::new(); + let response = client + .delete(&api_url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| AppError::KeyVaultError(format!("Failed to delete secret: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(AppError::KeyVaultError(format!( + "Failed to delete secret (status {}): {}", + status, error_text + ))); + } + + tracing::info!("Secret '{}' deleted successfully", secret_name); + Ok(()) + } +} + +impl Clone for KeyVaultClient { + fn clone(&self) -> Self { + Self { + auth: Arc::clone(&self.auth), + } + } +} diff --git a/src/azure/mod.rs b/src/azure/mod.rs new file mode 100644 index 0000000..2763532 --- /dev/null +++ b/src/azure/mod.rs @@ -0,0 +1,10 @@ +pub mod graph_client; +pub mod keyvault_client; +pub mod models; +pub mod vault_discovery; + +pub use graph_client::GraphApiClient; +pub use keyvault_client::KeyVaultClient; +#[allow(unused_imports)] +pub use models::*; +pub use vault_discovery::VaultDiscovery; diff --git a/src/azure/models.rs b/src/azure/models.rs new file mode 100644 index 0000000..8d9bb61 --- /dev/null +++ b/src/azure/models.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Application { + pub id: String, + #[serde(rename = "appId")] + pub app_id: String, + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "createdDateTime")] + pub created_date_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasswordCredential { + #[serde(rename = "customKeyIdentifier")] + pub custom_key_identifier: Option, + #[serde(rename = "displayName")] + pub display_name: Option, + #[serde(rename = "endDateTime")] + pub end_date_time: Option, + pub hint: Option, + #[serde(rename = "keyId")] + pub key_id: Option, + #[serde(rename = "secretText")] + pub secret_text: Option, + #[serde(rename = "startDateTime")] + pub start_date_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyVault { + pub id: String, + pub name: String, + pub location: String, + #[serde(rename = "vaultUri")] + pub vault_uri: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "userPrincipalName")] + pub user_principal_name: String, +} + +/// A secure string that zeroes its memory on drop +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct SensitiveString { + #[zeroize(skip)] + inner: String, +} + +impl SensitiveString { + pub fn new(s: String) -> Self { + Self { inner: s } + } + + pub fn as_str(&self) -> &str { + &self.inner + } + + pub fn expose_secret(&self) -> &String { + &self.inner + } +} + +impl std::fmt::Debug for SensitiveString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[REDACTED]") + } +} + +impl From for SensitiveString { + fn from(s: String) -> Self { + Self::new(s) + } +} + +impl From<&str> for SensitiveString { + fn from(s: &str) -> Self { + Self::new(s.to_string()) + } +} + +#[derive(Debug, Clone)] +pub struct CreatedSecret { + pub app_id: String, + pub app_name: String, + pub key_id: String, + pub display_name: String, + pub secret_value: SensitiveString, + pub expires_at: Option, +} diff --git a/src/azure/vault_discovery.rs b/src/azure/vault_discovery.rs new file mode 100644 index 0000000..08c4a96 --- /dev/null +++ b/src/azure/vault_discovery.rs @@ -0,0 +1,165 @@ +use crate::azure::models::KeyVault; +use crate::auth::AzureAuthenticator; +use crate::error::{AppError, AppResult}; +use std::sync::Arc; + +pub struct VaultDiscovery { + auth: Arc, +} + +impl VaultDiscovery { + pub fn new(auth: Arc) -> Self { + Self { auth } + } + + pub async fn list_accessible_vaults(&self) -> AppResult> { + tracing::info!("Discovering accessible Key Vaults across all subscriptions"); + + // Get Azure Management API token (different from Graph API token) + let token = self.auth.get_management_token().await?; + + // Get list of subscriptions first + let subscriptions = self.list_subscriptions(&token).await?; + + let mut all_vaults = Vec::new(); + + // For each subscription, list vaults + for subscription_id in subscriptions { + match self.list_vaults_in_subscription(&token, &subscription_id).await { + Ok(mut vaults) => { + all_vaults.append(&mut vaults); + } + Err(e) => { + tracing::warn!( + "Failed to list vaults in subscription {}: {}", + subscription_id, + e + ); + // Continue with other subscriptions + } + } + } + + tracing::info!("Found {} accessible Key Vaults", all_vaults.len()); + Ok(all_vaults) + } + + async fn list_subscriptions(&self, token: &str) -> AppResult> { + tracing::debug!("Fetching Azure subscriptions"); + let client = reqwest::Client::new(); + let url = "https://management.azure.com/subscriptions?api-version=2020-01-01"; + + let response = client + .get(url) + .bearer_auth(token) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to send request to Azure Management API: {}", e); + AppError::NetworkError(e.to_string()) + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::error!("Failed to list subscriptions: {} - {}", status, body); + return Err(AppError::KeyVaultError(format!( + "Failed to list subscriptions: {} - {}. Make sure you have the 'Reader' role on your subscriptions.", + status, body + ))); + } + + let body = response + .text() + .await + .map_err(|e| AppError::NetworkError(e.to_string()))?; + + let json: serde_json::Value = serde_json::from_str(&body)?; + + let subscriptions: Vec = json["value"] + .as_array() + .ok_or_else(|| AppError::KeyVaultError("Invalid subscription list format".to_string()))? + .iter() + .filter_map(|sub| sub["subscriptionId"].as_str().map(String::from)) + .collect(); + + tracing::info!("Found {} Azure subscriptions", subscriptions.len()); + + if subscriptions.is_empty() { + tracing::warn!("No subscriptions found. User may not have access to any subscriptions."); + } + + Ok(subscriptions) + } + + async fn list_vaults_in_subscription( + &self, + token: &str, + subscription_id: &str, + ) -> AppResult> { + let client = reqwest::Client::new(); + let url = format!( + "https://management.azure.com/subscriptions/{}/resources?$filter=resourceType eq 'Microsoft.KeyVault/vaults'&api-version=2021-04-01", + subscription_id + ); + + let response = client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(|e| AppError::NetworkError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::KeyVaultError(format!( + "Failed to list vaults in subscription {}: {} - {}", + subscription_id, status, body + ))); + } + + let body = response + .text() + .await + .map_err(|e| AppError::NetworkError(e.to_string()))?; + + let json: serde_json::Value = serde_json::from_str(&body)?; + + let vaults: Vec = json["value"] + .as_array() + .ok_or_else(|| AppError::KeyVaultError("Invalid vault list format".to_string()))? + .iter() + .filter_map(|vault| { + let id = vault["id"].as_str()?.to_string(); + let name = vault["name"].as_str()?.to_string(); + let location = vault["location"].as_str()?.to_string(); + + // Construct vault URI + let vault_uri = format!("https://{}.vault.azure.net/", name); + + Some(KeyVault { + id, + name, + location, + vault_uri, + }) + }) + .collect(); + + tracing::debug!( + "Found {} vaults in subscription {}", + vaults.len(), + subscription_id + ); + Ok(vaults) + } +} + +impl Clone for VaultDiscovery { + fn clone(&self) -> Self { + Self { + auth: Arc::clone(&self.auth), + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..27e9ee7 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,2 @@ +pub mod window_config; +pub use window_config::{Config, load_config}; diff --git a/src/config/window_config.rs b/src/config/window_config.rs new file mode 100644 index 0000000..de88607 --- /dev/null +++ b/src/config/window_config.rs @@ -0,0 +1,267 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WindowConfig { + pub width: f32, + pub height: f32, + pub min_width: f32, + pub min_height: f32, + pub transparency: f32, + pub use_transparency: bool, + pub position_offset_x: f32, + pub position_offset_y: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextSizing { + pub heading: f32, + pub body: f32, + pub small: f32, + pub button: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppearanceConfig { + pub background_image: String, + pub background_opacity: f32, + pub background_blur: f32, + pub fallback_color: [u8; 3], +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextColors { + pub normal: [u8; 3], + pub inactive: [u8; 3], + pub hover: [u8; 3], + pub select: [u8; 3], +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SliderColors { + pub inactive: [u8; 3], + pub hover: [u8; 3], + pub active: [u8; 3], +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WidgetBorderColors { + pub inactive: [u8; 3], // For noninteractive and inactive states + pub hover: [u8; 3], // For hovered state + pub active: [u8; 3], // For active/pressed state +} + +fn default_widget_borders() -> WidgetBorderColors { + WidgetBorderColors { + inactive: [60, 60, 70], // Current hardcoded dark grey + hover: [120, 120, 140], // Current hardcoded lighter grey + active: [120, 120, 140], // Same as hover + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WarningColors { + pub text: [u8; 3], // Warning text color + pub background: [u8; 3], // Warning box background color +} + +fn default_warning_colors() -> WarningColors { + WarningColors { + text: [255, 200, 100], // Default amber/orange + background: [80, 60, 30], // Default dark orange background + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InfoBoxColors { + pub background: [u8; 3], // Info box background color + pub border: [u8; 3], // Info box border color +} + +fn default_info_box_colors() -> InfoBoxColors { + InfoBoxColors { + background: [40, 40, 60], // Default dark blue-grey + border: [239, 124, 0], // Default orange border + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSecretInfoColors { + pub background: [u8; 3], // Info box background color + pub text: [u8; 3], // Info box text color +} + +fn default_create_secret_info_colors() -> CreateSecretInfoColors { + CreateSecretInfoColors { + background: [40, 40, 60], // Default purple/dark blue + text: [239, 124, 0], // Default orange + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardColors { + pub selected_bg: [u8; 4], // RGBA + pub selected_border: [u8; 3], // RGB + pub unselected_bg: [u8; 4], // RGBA +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardConfig { + pub app_list: CardColors, + pub keyvault: CardColors, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColorConfig { + pub text: TextColors, + pub slider: SliderColors, + pub cards: CardConfig, + #[serde(default = "default_widget_borders")] + pub widget_borders: WidgetBorderColors, + #[serde(default = "default_warning_colors")] + pub warning: WarningColors, + #[serde(default = "default_info_box_colors")] + pub info_box: InfoBoxColors, + #[serde(default = "default_create_secret_info_colors")] + pub create_secret_info: CreateSecretInfoColors, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AzureConfig { + pub secret_expiration_years: u32, +} + +fn default_azure_config() -> AzureConfig { + AzureConfig { + secret_expiration_years: 50, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub window: WindowConfig, + pub appearance: AppearanceConfig, + #[serde(default)] + pub colors: Option, + #[serde(default)] + pub text_sizing: Option, + #[serde(default = "default_azure_config")] + pub azure: AzureConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + window: WindowConfig { + width: 1024.0, + height: 768.0, + min_width: 800.0, + min_height: 600.0, + transparency: 0.0, + use_transparency: false, + position_offset_x: 0.0, + position_offset_y: 0.0, + }, + appearance: AppearanceConfig { + background_image: String::new(), // Empty = use embedded default, or specify custom path + background_opacity: 1.0, + background_blur: 0.0, + fallback_color: [30, 30, 40], + }, + colors: Some(ColorConfig { + text: TextColors { + normal: [255, 255, 255], + inactive: [255, 255, 255], + hover: [255, 255, 255], + select: [80, 150, 255], + }, + slider: SliderColors { + inactive: [239, 124, 0], + hover: [255, 124, 0], + active: [255, 124, 0], + }, + cards: CardConfig { + app_list: CardColors { + selected_bg: [179, 87, 0, 180], + selected_border: [255, 124, 0], + unselected_bg: [40, 40, 60, 150], + }, + keyvault: CardColors { + selected_bg: [179, 87, 0, 180], + selected_border: [255, 124, 0], + unselected_bg: [40, 40, 60, 150], + }, + }, + widget_borders: WidgetBorderColors { + inactive: [60, 60, 70], + hover: [120, 120, 140], + active: [120, 120, 140], + }, + warning: WarningColors { + text: [255, 200, 100], + background: [80, 60, 30], + }, + info_box: InfoBoxColors { + background: [40, 40, 60], + border: [239, 124, 0], + }, + create_secret_info: CreateSecretInfoColors { + background: [40, 40, 60], + text: [239, 124, 0], + }, + }), + text_sizing: Some(TextSizing { + heading: 20.0, + body: 16.0, + small: 14.0, + button: 20.0, + }), + azure: AzureConfig { + secret_expiration_years: 50, + }, + } + } +} + +pub fn load_config() -> Config { + let config_path = "./config.toml"; + + tracing::info!("Loading config from: {}", config_path); + tracing::info!("Current working directory: {:?}", std::env::current_dir().ok()); + + if Path::new(config_path).exists() { + tracing::info!("Config file found"); + match fs::read_to_string(config_path) { + Ok(contents) => { + tracing::info!("Config file read successfully, {} bytes", contents.len()); + match toml::from_str::(&contents) { + Ok(config) => { + tracing::info!("Config parsed successfully!"); + tracing::info!(" - Background image: {}", config.appearance.background_image); + tracing::info!(" - Background opacity: {}", config.appearance.background_opacity); + tracing::info!(" - Fallback color: {:?}", config.appearance.fallback_color); + return config; + }, + Err(e) => { + tracing::error!("Failed to parse config.toml: {}. Using defaults.", e); + } + } + } + Err(e) => { + tracing::error!("Failed to read config.toml: {}. Using defaults.", e); + } + } + } else { + tracing::warn!("Config file not found at {}, creating default", config_path); + // Create default config file + let default_config = Config::default(); + if let Ok(toml_string) = toml::to_string_pretty(&default_config) { + let _ = fs::write(config_path, toml_string); + tracing::info!("Created default config.toml"); + } + } + + tracing::warn!("Using default config values"); + Config::default() +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..87c14f6 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,94 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("Authentication error: {0}")] + AuthenticationError(String), + + #[error("Graph API error: {0}")] + GraphApiError(String), + + #[error("Key Vault error: {0}")] + KeyVaultError(String), + + #[error("Token cache error: {0}")] + TokenCacheError(String), + + #[error("Network error: {0}")] + NetworkError(String), + + #[error("Configuration error: {0}")] + #[allow(dead_code)] + ConfigError(String), + + #[error("Serialization error: {0}")] + SerializationError(String), + + #[error("Invalid input: {0}")] + #[allow(dead_code)] + InvalidInput(String), + + #[error("Operation cancelled")] + #[allow(dead_code)] + Cancelled, + + #[error("Unknown error: {0}")] + Unknown(String), +} + +impl AppError { + pub fn user_friendly_message(&self) -> String { + match self { + AppError::AuthenticationError(msg) => { + format!("Authentication failed: {}", msg) + } + AppError::GraphApiError(msg) => { + format!("Failed to communicate with Azure: {}", msg) + } + AppError::KeyVaultError(msg) => { + format!("Key Vault operation failed: {}", msg) + } + AppError::TokenCacheError(msg) => { + format!("Failed to access secure token storage: {}", msg) + } + AppError::NetworkError(msg) => { + format!("Network error: {}. Please check your connection.", msg) + } + AppError::ConfigError(msg) => { + format!("Configuration error: {}. Please check your .env file.", msg) + } + AppError::SerializationError(msg) => { + format!("Data processing error: {}", msg) + } + AppError::InvalidInput(msg) => { + format!("Invalid input: {}", msg) + } + AppError::Cancelled => { + "Operation was cancelled".to_string() + } + AppError::Unknown(msg) => { + format!("An unexpected error occurred: {}", msg) + } + } + } +} + +impl From for AppError { + fn from(error: anyhow::Error) -> Self { + AppError::Unknown(error.to_string()) + } +} + +impl From for AppError { + fn from(error: serde_json::Error) -> Self { + AppError::SerializationError(error.to_string()) + } +} + +impl From for AppError { + fn from(error: keyring::Error) -> Self { + AppError::TokenCacheError(error.to_string()) + } +} + +pub type AppResult = Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4e497c3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod error; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0030531 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,80 @@ +// Hide console window in release builds +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod auth; +mod azure; +mod config; +mod error; +mod state; +mod ui; + +use app::AzureAppManager; +use auth::AzureAuthenticator; +use std::sync::Arc; + +/// Load application icon from embedded ICO file +fn load_app_icon() -> egui::IconData { + // Load the .ico file at compile time + let icon_bytes = include_bytes!("../assets/icon.ico"); + + // Parse ICO file using image crate + let image = image::load_from_memory(icon_bytes) + .expect("Failed to load icon.ico - file may be corrupted") + .into_rgba8(); + + let (width, height) = image.dimensions(); + + egui::IconData { + rgba: image.into_raw(), + width, + height, + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + tracing::info!("Starting Create App Secret"); + + // Create authenticator with Azure CLI public client (no configuration needed) + let auth = Arc::new( + AzureAuthenticator::new() + .expect("Failed to create authenticator"), + ); + + // Load configuration + let app_config = config::load_config(); + + // Load application icon + let icon_data = load_app_icon(); + + // Configure eframe options + let viewport_builder = egui::ViewportBuilder::default() + .with_inner_size([app_config.window.width, app_config.window.height]) + .with_min_inner_size([app_config.window.min_width, app_config.window.min_height]) + .with_transparent(app_config.window.use_transparency) + .with_icon(icon_data); + + let options = eframe::NativeOptions { + viewport: viewport_builder, + centered: true, // Always start centered, will apply offset in first frame if needed + ..Default::default() + }; + + // Start the application + eframe::run_native( + "Create App Secret", + options, + Box::new(move |cc| Ok(Box::new(AzureAppManager::new(cc, auth, app_config)))), + )?; + + Ok(()) +} diff --git a/src/state/app_state.rs b/src/state/app_state.rs new file mode 100644 index 0000000..648a5e2 --- /dev/null +++ b/src/state/app_state.rs @@ -0,0 +1,136 @@ +use crate::azure::models::{Application, CreatedSecret, KeyVault, UserInfo}; +use crate::state::async_operations::AsyncOperations; +use crate::ui::BackgroundRenderer; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthStatus { + NotAuthenticated, + Authenticating, + Authenticated, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewState { + Login, + AppList, + CreateSecret, + SelectKeyVault, +} + +pub struct AppState { + // Authentication + pub auth_status: AuthStatus, + pub user_info: Option, + + // Data + pub applications: Vec, + pub selected_app: Option, + pub key_vaults: Vec, + pub selected_keyvault: Option, + + // Created secret (temporary, should be saved to vault ASAP) + pub created_secret: Option, + + // Async operations + pub operations: AsyncOperations, + + // UI State + pub current_view: ViewState, + pub error_message: Option, + pub success_message: Option, + + // Form inputs + pub secret_description: String, + pub secret_name_for_vault: String, + pub app_search_filter: String, + pub vault_search_filter: String, + + // Background rendering + pub background_renderer: Option>, +} + +impl AppState { + pub fn new() -> Self { + Self { + auth_status: AuthStatus::NotAuthenticated, + user_info: None, + applications: Vec::new(), + selected_app: None, + key_vaults: Vec::new(), + selected_keyvault: None, + created_secret: None, + operations: AsyncOperations::new(), + current_view: ViewState::Login, + error_message: None, + success_message: None, + secret_description: String::new(), + secret_name_for_vault: String::new(), + app_search_filter: String::new(), + vault_search_filter: String::new(), + background_renderer: None, + } + } + + pub fn clear_messages(&mut self) { + self.error_message = None; + self.success_message = None; + } + + pub fn set_error(&mut self, error: String) { + self.error_message = Some(error); + self.success_message = None; + } + + pub fn set_success(&mut self, message: String) { + self.success_message = Some(message); + self.error_message = None; + } + + pub fn filtered_applications(&self) -> Vec { + if self.app_search_filter.is_empty() { + return self.applications.clone(); + } + + let filter_lower = self.app_search_filter.to_lowercase(); + self.applications + .iter() + .filter(|app| { + app.display_name.to_lowercase().contains(&filter_lower) + || app.app_id.to_lowercase().contains(&filter_lower) + }) + .cloned() + .collect() + } + + pub fn filtered_vaults(&self) -> Vec { + if self.vault_search_filter.is_empty() { + return self.key_vaults.clone(); + } + + let filter_lower = self.vault_search_filter.to_lowercase(); + self.key_vaults + .iter() + .filter(|vault| { + vault.name.to_lowercase().contains(&filter_lower) + || vault.location.to_lowercase().contains(&filter_lower) + }) + .cloned() + .collect() + } + + pub fn reset_for_new_secret(&mut self) { + self.secret_description.clear(); + self.secret_name_for_vault.clear(); + self.created_secret = None; + self.selected_app = None; + self.selected_keyvault = None; // Clear the selected keyvault for next secret + self.clear_messages(); + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} diff --git a/src/state/async_operations.rs b/src/state/async_operations.rs new file mode 100644 index 0000000..9ca3aa5 --- /dev/null +++ b/src/state/async_operations.rs @@ -0,0 +1,55 @@ +use crate::azure::models::{Application, CreatedSecret, KeyVault, UserInfo}; +use crate::error::AppResult; +use poll_promise::Promise; + +pub struct AsyncOperations { + pub authenticate: Option>>, + pub load_user: Option>>, + pub load_applications: Option>>>, + pub create_secret: Option>>, + pub load_vaults: Option>>>, + pub save_to_vault: Option>>, + pub sign_out: Option>>, +} + +impl AsyncOperations { + pub fn new() -> Self { + Self { + authenticate: None, + load_user: None, + load_applications: None, + create_secret: None, + load_vaults: None, + save_to_vault: None, + sign_out: None, + } + } + + #[allow(dead_code)] + pub fn is_any_pending(&self) -> bool { + self.authenticate.is_some() + || self.load_user.is_some() + || self.load_applications.is_some() + || self.create_secret.is_some() + || self.load_vaults.is_some() + || self.save_to_vault.is_some() + || self.sign_out.is_some() + } + + #[allow(dead_code)] + pub fn clear_all(&mut self) { + self.authenticate = None; + self.load_user = None; + self.load_applications = None; + self.create_secret = None; + self.load_vaults = None; + self.save_to_vault = None; + self.sign_out = None; + } +} + +impl Default for AsyncOperations { + fn default() -> Self { + Self::new() + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..817a3f5 --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,4 @@ +pub mod app_state; +pub mod async_operations; + +pub use app_state::{AppState, AuthStatus, ViewState}; diff --git a/src/ui/app_list_view.rs b/src/ui/app_list_view.rs new file mode 100644 index 0000000..4ce893b --- /dev/null +++ b/src/ui/app_list_view.rs @@ -0,0 +1,214 @@ +use crate::azure::models::Application; +use crate::state::AppState; +use crate::ui::components::{ + show_error_message, show_loading_spinner, show_section_header, show_success_message, show_text, +}; +use egui::{Context, ScrollArea, Ui}; + +pub fn show_app_list_view(ctx: &Context, state: &mut AppState, config: &crate::config::Config) -> Option { + let mut action = None; + + // Handle keyboard navigation for app list + // Check early, before text edits might consume the input + let any_text_focused = ctx.memory(|m| m.focused().is_some()); + + if !any_text_focused { + let filtered_apps = state.filtered_applications(); + + if !filtered_apps.is_empty() { + let current_idx = state.selected_app.as_ref() + .and_then(|sel| filtered_apps.iter().position(|a| a.id == sel.id)); + + ctx.input(|i| { + let mut target_idx = None; + + if i.key_pressed(egui::Key::ArrowDown) { + target_idx = Some(current_idx.map_or(0, |idx| (idx + 1) % filtered_apps.len())); + } + + if i.key_pressed(egui::Key::ArrowUp) { + target_idx = Some(current_idx.map_or( + filtered_apps.len() - 1, + |idx| if idx > 0 { idx - 1 } else { filtered_apps.len() - 1 } + )); + } + + if let Some(idx) = target_idx { + if let Some(app) = filtered_apps.get(idx) { + action = Some(AppListAction::SelectApp(app.clone())); + } + } + }); + } + } + + egui::TopBottomPanel::top("top_panel") + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::Margin::symmetric(20.0, 10.0))) + .show_separator_line(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + show_section_header(ui, "App Registrations"); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Sign Out").clicked() { + action = Some(AppListAction::SignOut); + } + + ui.add_space(10.0); // Replace separator with space + + if ui.button("🔄 Refresh").clicked() { + action = Some(AppListAction::Refresh); + } + }); + }); + + if let Some(user) = &state.user_info { + show_text(ui, &format!("Logged in as: {}", user.display_name)); + } + }); + + egui::CentralPanel::default() + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .inner_margin(egui::Margin { + left: 20.0, + right: 20.0, + top: 20.0, + bottom: 80.0, // Extra margin so cards don't show behind bottom panel + })) + .show(ctx, |ui| { + // Show messages + if let Some(error) = &state.error_message { + show_error_message(ui, error); + ui.add_space(10.0); + } + + if let Some(success) = &state.success_message { + show_success_message(ui, success); + ui.add_space(10.0); + } + + // Show loading spinner if loading + if state.operations.load_applications.is_some() { + show_loading_spinner(ui, "Loading applications..."); + return; + } + + // Search box + ui.horizontal(|ui| { + show_text(ui, "Search:"); + ui.text_edit_singleline(&mut state.app_search_filter); + }); + ui.add_space(10.0); + + let filtered_apps = state.filtered_applications(); + + if filtered_apps.is_empty() { + ui.centered_and_justified(|ui| { + if state.applications.is_empty() { + show_text(ui, "No app registrations found. Click Refresh to load."); + } else { + show_text(ui, "No matching applications found."); + } + }); + } else { + show_text(ui, &format!("Found {} applications", filtered_apps.len())); + ui.add_space(5.0); + + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(ui.available_width() - 20.0); // Add margin for scrollbar + for app in filtered_apps { + show_app_card(ui, &app, &state.selected_app, &mut action, config); + } + }); + } + }); + + egui::TopBottomPanel::bottom("bottom_panel") + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::Margin::symmetric(20.0, 15.0))) + .show_separator_line(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + let is_app_selected = state.selected_app.is_some(); + + if ui + .add_enabled(is_app_selected, egui::Button::new("Create Secret").min_size(egui::vec2(120.0, 32.0))) + .clicked() + { + action = Some(AppListAction::CreateSecret); + } + + if !is_app_selected { + show_text(ui, "Select an app registration to create a secret"); + } + }); + }); + + action +} + +fn show_app_card( + ui: &mut Ui, + app: &Application, + selected_app: &Option, + action: &mut Option, + config: &crate::config::Config, +) { + let is_selected = selected_app + .as_ref() + .map(|selected| selected.id == app.id) + .unwrap_or(false); + + // Get card colors from config with fallback to defaults + let card_colors = config.colors.as_ref() + .map(|c| &c.cards.app_list); + + let frame = if is_selected { + let bg = card_colors.map(|c| c.selected_bg).unwrap_or([60, 60, 100, 180]); + let border = card_colors.map(|c| c.selected_border).unwrap_or([100, 150, 255]); + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(bg[0], bg[1], bg[2], bg[3])) + .inner_margin(10.0) + .rounding(5.0) + .stroke(egui::Stroke::new(2.0, egui::Color32::from_rgb(border[0], border[1], border[2]))) + } else { + let bg = card_colors.map(|c| c.unselected_bg).unwrap_or([40, 40, 60, 150]); + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(bg[0], bg[1], bg[2], bg[3])) + .inner_margin(10.0) + .rounding(5.0) + }; + + let response = frame.show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + ui.strong(&app.display_name); + ui.label(format!("App ID: {}", app.app_id)); + if let Some(created) = &app.created_date_time { + ui.label(format!("Created: {}", created)); + } + }); + }); + + // Make the entire card clickable + if response.response.interact(egui::Sense::click()).clicked() && !is_selected { + *action = Some(AppListAction::SelectApp(app.clone())); + } + + ui.add_space(5.0); +} + +pub enum AppListAction { + Refresh, + SelectApp(Application), + CreateSecret, + SignOut, +} diff --git a/src/ui/auth_view.rs b/src/ui/auth_view.rs new file mode 100644 index 0000000..4de25b9 --- /dev/null +++ b/src/ui/auth_view.rs @@ -0,0 +1,66 @@ +use crate::state::{AppState, AuthStatus}; +use crate::ui::components::{show_error_message, show_loading_spinner, show_section_header, show_text}; +use egui::{Align, Context, Layout}; + +pub fn show_auth_view(ctx: &Context, state: &mut AppState) -> Option { + let mut action = None; + + egui::CentralPanel::default() + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .inner_margin(egui::Margin::same(20.0))) + .show(ctx, |ui| { + ui.with_layout(Layout::top_down(Align::Center), |ui| { + ui.add_space(100.0); + + show_section_header(ui, "Azure App Registration Manager"); + ui.add_space(20.0); + + show_text(ui, "Manage Azure App Registrations and Key Vault secrets"); + ui.add_space(40.0); + + // Show sign-out progress if operation is in progress + if state.operations.sign_out.is_some() { + show_loading_spinner(ui, "Signing out..."); + ui.add_space(10.0); + show_text(ui, "Clearing cached credentials"); + } else { + match state.auth_status { + AuthStatus::NotAuthenticated => { + if ui.button("Sign In with Azure").clicked() { + action = Some(AuthAction::SignIn); + } + + ui.add_space(20.0); + + if let Some(error) = &state.error_message { + show_error_message(ui, error); + } + } + AuthStatus::Authenticating => { + show_loading_spinner(ui, "Authenticating..."); + ui.add_space(10.0); + show_text(ui, "Please complete authentication in your browser"); + } + AuthStatus::Authenticated => { + show_text(ui, "Authenticated successfully!"); + ui.add_space(10.0); + show_loading_spinner(ui, "Loading..."); + } + } + } + + ui.add_space(40.0); + + show_text(ui, "ℹ No configuration required"); + show_text(ui, "Uses Microsoft Azure CLI public client"); + show_text(ui, "Works with any Azure AD tenant"); + }); + }); + + action +} + +pub enum AuthAction { + SignIn, +} diff --git a/src/ui/background.rs b/src/ui/background.rs new file mode 100644 index 0000000..df9d386 --- /dev/null +++ b/src/ui/background.rs @@ -0,0 +1,170 @@ +use egui::{Color32, ColorImage, Context, Rect, TextureHandle, TextureOptions, Ui}; + +// Embed background image at compile time for instant loading +const DEFAULT_BACKGROUND: &[u8] = include_bytes!("../../assets/background.jpg"); + +pub struct BackgroundRenderer { + texture: Option, + fallback_color: Color32, + opacity: f32, +} + +impl BackgroundRenderer { + pub fn new( + ctx: &Context, + _image_path: &str, + opacity: f32, + fallback_color: [u8; 3], + ) -> Self { + tracing::info!("Initializing background renderer with embedded image"); + + // Always use embedded background for instant loading + let texture = Self::load_embedded_texture(ctx); + + if texture.is_some() { + tracing::info!("Background texture loaded from embedded image with opacity: {}", opacity); + } else { + tracing::info!("Using fallback color: {:?}", fallback_color); + } + + Self { + texture, + fallback_color: Color32::from_rgb( + fallback_color[0], + fallback_color[1], + fallback_color[2], + ), + opacity, + } + } + + fn load_embedded_texture(ctx: &Context) -> Option { + tracing::info!("Loading embedded default background"); + match image::load_from_memory(DEFAULT_BACKGROUND) { + Ok(img) => { + let size = [img.width() as usize, img.height() as usize]; + tracing::info!("Embedded image loaded: {}x{} pixels", size[0], size[1]); + let image_buffer = img.to_rgba8(); + let pixels = image_buffer.as_flat_samples(); + let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()); + + let texture = ctx.load_texture( + "embedded_background", + color_image, + TextureOptions::LINEAR, + ); + tracing::info!("Embedded texture created successfully"); + Some(texture) + } + Err(e) => { + tracing::error!("Failed to load embedded background: {}", e); + None + } + } + } + + #[allow(dead_code)] + fn load_texture(ctx: &Context, path: &str) -> Option { + tracing::info!("Attempting to load texture from: {}", path); + match image::open(path) { + Ok(img) => { + let size = [img.width() as usize, img.height() as usize]; + tracing::info!("Image loaded successfully: {}x{} pixels", size[0], size[1]); + let image_buffer = img.to_rgba8(); + let pixels = image_buffer.as_flat_samples(); + let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()); + + let texture = ctx.load_texture( + path, + color_image, + TextureOptions::LINEAR, + ); + tracing::info!("Texture created with ID: {:?}", texture.id()); + Some(texture) + } + Err(e) => { + tracing::error!("Failed to load background image from '{}': {}", path, e); + tracing::error!("Error details: {:?}", e); + None + } + } + } + + /// Render background to entire screen context (call FIRST, before any panels) + pub fn render_fullscreen(&self, ctx: &Context) { + let screen_rect = ctx.screen_rect(); + + // Use screen-space painting (lowest possible layer) + let painter = ctx.layer_painter(egui::LayerId::background()); + + // Fill entire screen with background color first + painter.rect_filled(screen_rect, 0.0, self.fallback_color); + + if let Some(texture) = &self.texture { + // Calculate UV coordinates to fit image properly (cover the screen) + let texture_aspect = texture.aspect_ratio(); + let screen_aspect = screen_rect.width() / screen_rect.height(); + + let uv_rect = if texture_aspect > screen_aspect { + // Image is wider - crop left side to show right side (where logo is) + let scale = screen_aspect / texture_aspect; + let offset = 1.0 - scale; + Rect::from_min_max( + egui::pos2(offset, 0.0), + egui::pos2(1.0, 1.0) + ) + } else { + // Image is taller - keep top visible (where logo is) + let scale = texture_aspect / screen_aspect; + Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1.0, scale) + ) + }; + + // Draw background image with proper cropping + let tint = Color32::from_rgba_unmultiplied( + 255, + 255, + 255, + (self.opacity * 255.0) as u8, + ); + + painter.image( + texture.id(), + screen_rect, + uv_rect, + tint, + ); + tracing::debug!("Background image rendered fullscreen with UV rect {:?}", uv_rect); + } + } + + #[allow(dead_code)] + pub fn render(&self, ui: &mut Ui) { + let rect = ui.max_rect(); + + // Always draw fallback first + ui.painter().rect_filled(rect, 0.0, self.fallback_color); + + if let Some(texture) = &self.texture { + // Draw background image on top + let tint = Color32::from_rgba_unmultiplied( + 255, + 255, + 255, + (self.opacity * 255.0) as u8, + ); + + ui.painter().image( + texture.id(), + rect, + Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + tint, + ); + tracing::trace!("Background image rendered to rect {:?} with opacity {}", rect, self.opacity); + } else { + tracing::trace!("No texture, only fallback color rendered"); + } + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs new file mode 100644 index 0000000..89089c7 --- /dev/null +++ b/src/ui/components.rs @@ -0,0 +1,61 @@ +use egui::{Color32, RichText, Ui}; + +pub fn show_error_message(ui: &mut Ui, message: &str) { + ui.colored_label(Color32::from_rgb(255, 100, 100), format!("❌ {}", message)); +} + +pub fn show_success_message(ui: &mut Ui, message: &str) { + ui.colored_label(Color32::from_rgb(100, 255, 100), format!("✓ {}", message)); +} + +pub fn show_loading_spinner(ui: &mut Ui, message: &str) { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.spinner(); + ui.label(RichText::new(message).size(21.0)); + }); +} + +pub fn show_section_header(ui: &mut Ui, text: &str) { + ui.heading(RichText::new(text).size(21.0)); +} + +pub fn show_text(ui: &mut Ui, text: &str) { + ui.label(RichText::new(text).size(14.0)); +} + +pub fn show_info_box(ui: &mut Ui, text: &str, config: &crate::config::Config) { + // Get colors from config with fallback to defaults + let colors = config.colors.as_ref() + .map(|c| &c.create_secret_info); + + let bg_color = colors + .map(|c| Color32::from_rgb(c.background[0], c.background[1], c.background[2])) + .unwrap_or(Color32::from_rgb(50, 50, 80)); + + let text_color = colors + .map(|c| Color32::from_rgb(c.text[0], c.text[1], c.text[2])) + .unwrap_or(Color32::from_rgb(200, 200, 255)); + + egui::Frame::none() + .fill(bg_color) + .inner_margin(10.0) + .rounding(5.0) + .show(ui, |ui| { + ui.label(RichText::new(text).color(text_color)); + }); +} + +pub fn show_warning_box(ui: &mut Ui, text: &str, warning_color: Option<[u8; 3]>) { + // Use provided color or fall back to default amber/orange + let text_color = warning_color + .map(|c| Color32::from_rgb(c[0], c[1], c[2])) + .unwrap_or(Color32::from_rgb(255, 200, 100)); + + egui::Frame::none() + .fill(Color32::from_rgb(80, 60, 30)) + .inner_margin(10.0) + .rounding(5.0) + .show(ui, |ui| { + ui.label(RichText::new(format!("⚠ {}", text)).color(text_color)); + }); +} diff --git a/src/ui/keyvault_select_view.rs b/src/ui/keyvault_select_view.rs new file mode 100644 index 0000000..9962ddb --- /dev/null +++ b/src/ui/keyvault_select_view.rs @@ -0,0 +1,323 @@ +use crate::azure::models::KeyVault; +use crate::state::AppState; +use crate::ui::components::{ + show_error_message, show_loading_spinner, show_section_header, show_success_message, + show_text, show_warning_box, +}; +use egui::{Context, ScrollArea, Ui}; + +pub fn show_keyvault_select_view( + ctx: &Context, + state: &mut AppState, + config: &crate::config::Config, +) -> Option { + let mut action = None; + + egui::TopBottomPanel::top("top_panel") + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::Margin::symmetric(20.0, 10.0))) + .show_separator_line(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("⬅ Back to App List").clicked() { + action = Some(KeyVaultSelectAction::BackToAppList); + } + + ui.add_space(10.0); // Replace separator with space + show_section_header(ui, "Save Secret to Key Vault"); + }); + }); + + egui::CentralPanel::default() + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .inner_margin(egui::Margin { + left: 20.0, + right: 20.0, + top: 20.0, + bottom: 80.0, // Extra margin so cards don't show behind bottom panel + })) + .show(ctx, |ui| { + if let Some(error) = &state.error_message { + show_error_message(ui, error); + ui.add_space(10.0); + } + + if let Some(success) = &state.success_message { + show_success_message(ui, success); + ui.add_space(10.0); + } + + if let Some(secret) = &state.created_secret { + let warning_color = config.colors.as_ref() + .map(|c| c.warning.text); + + show_warning_box( + ui, + "Important: This secret value will not be shown again. Please save it to a Key Vault now.", + warning_color, + ); + ui.add_space(20.0); + + // Get info box colors from config + let info_box_colors = config.colors.as_ref() + .map(|c| &c.info_box); + + let bg_color = info_box_colors + .map(|c| egui::Color32::from_rgb(c.background[0], c.background[1], c.background[2])) + .unwrap_or(egui::Color32::from_rgb(40, 40, 60)); + + let border_color = info_box_colors + .map(|c| egui::Color32::from_rgb(c.border[0], c.border[1], c.border[2])) + .unwrap_or(egui::Color32::from_rgb(239, 124, 0)); + + egui::Frame::none() + .fill(bg_color) + .inner_margin(10.0) + .rounding(5.0) + .stroke(egui::Stroke::new(1.0, border_color)) + .show(ui, |ui| { + ui.strong("Secret Created Successfully:"); + ui.add_space(5.0); + show_text(ui, &format!("App: {}", secret.app_name)); + show_text(ui, &format!("App ID: {}", secret.app_id)); + show_text(ui, &format!("Description: {}", secret.display_name)); + if let Some(expires_at) = &secret.expires_at { + show_text(ui, &format!("Expires: {}", expires_at)); + } + }); + + ui.add_space(10.0); + + // Display the secret value in a selectable/copyable box + ui.strong("Secret Value (copy this now!):"); + ui.add_space(5.0); + + egui::Frame::none() + .fill(egui::Color32::from_rgb(40, 40, 60)) + .inner_margin(10.0) + .rounding(5.0) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(239, 124, 0))) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Show the secret value in a monospace, selectable text edit + let mut secret_text = secret.secret_value.as_str().to_string(); + let button_height = 30.0; // Consistent height for both widgets + let available_width = ui.available_width() - 90.0; // Reserve space for copy button + + ui.add( + egui::TextEdit::singleline(&mut secret_text) + .font(egui::TextStyle::Monospace) + .desired_width(available_width) + .min_size(egui::vec2(available_width, button_height)) + .vertical_align(egui::Align::Center) + ); + + // Copy button with matching height + if ui.add_sized( + egui::vec2(80.0, button_height), + egui::Button::new("📋 Copy") + ).clicked() { + ui.output_mut(|o| o.copied_text = secret.secret_value.as_str().to_string()); + } + }); + }); + + ui.add_space(20.0); + + // Loading vaults + if state.operations.load_vaults.is_some() { + show_loading_spinner(ui, "Loading Key Vaults..."); + ui.add_space(10.0); + show_text(ui, "Please wait while we discover your Azure Key Vaults..."); + } + // Saving to vault + else if state.operations.save_to_vault.is_some() { + show_loading_spinner(ui, "Saving secret to Key Vault..."); + ui.add_space(10.0); + show_text(ui, "This may take a few moments..."); + } + // Show vault selection UI + else { + show_vault_selection_ui(ui, state, &mut action, config); + } + + } else { + show_text(ui, "No secret to save"); + if ui.button("⬅ Back to App List").clicked() { + action = Some(KeyVaultSelectAction::BackToAppList); + } + } + }); + + egui::TopBottomPanel::bottom("bottom_panel") + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::Margin::symmetric(20.0, 15.0))) + .show_separator_line(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + let can_save = state.selected_keyvault.is_some() + && !state.secret_name_for_vault.is_empty() + && state.created_secret.is_some(); + + if ui + .add_enabled(can_save, egui::Button::new("Save to Key Vault").min_size(egui::vec2(140.0, 32.0))) + .clicked() + { + action = Some(KeyVaultSelectAction::SaveToVault); + } + + if !can_save { + show_text(ui, "Select a Key Vault to continue"); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add(egui::Button::new("Skip (Not Recommended)").min_size(egui::vec2(160.0, 32.0))).clicked() { + action = Some(KeyVaultSelectAction::Skip); + } + }); + }); + }); + + action +} + +fn show_vault_selection_ui( + ui: &mut Ui, + state: &mut AppState, + action: &mut Option, + config: &crate::config::Config, +) { + // Get context from ui to check keyboard input + let ctx = ui.ctx(); + + // Handle keyboard navigation for vault list + let any_text_focused = ctx.memory(|m| m.focused().is_some()); + + if !any_text_focused { + let filtered_vaults = state.filtered_vaults(); + + if !filtered_vaults.is_empty() { + let current_idx = state.selected_keyvault.as_ref() + .and_then(|sel| filtered_vaults.iter().position(|v| v.id == sel.id)); + + ctx.input(|i| { + let mut target_idx = None; + + if i.key_pressed(egui::Key::ArrowDown) { + target_idx = Some(current_idx.map_or(0, |idx| (idx + 1) % filtered_vaults.len())); + } + + if i.key_pressed(egui::Key::ArrowUp) { + target_idx = Some(current_idx.map_or( + filtered_vaults.len() - 1, + |idx| if idx > 0 { idx - 1 } else { filtered_vaults.len() - 1 } + )); + } + + if let Some(idx) = target_idx { + if let Some(vault) = filtered_vaults.get(idx) { + *action = Some(KeyVaultSelectAction::SelectVault(vault.clone())); + } + } + }); + } + } + + // Search box + ui.horizontal(|ui| { + show_text(ui, "Search Key Vaults:"); + ui.text_edit_singleline(&mut state.vault_search_filter); + + if ui.button("🔄 Refresh").clicked() { + *action = Some(KeyVaultSelectAction::RefreshVaults); + } + }); + ui.add_space(10.0); + + let filtered_vaults = state.filtered_vaults(); + + if filtered_vaults.is_empty() { + ui.centered_and_justified(|ui| { + if state.key_vaults.is_empty() { + show_text(ui, "No Key Vaults found. Click Refresh to load."); + } else { + show_text(ui, "No matching Key Vaults found."); + } + }); + } else { + show_text(ui, &format!("Select a Key Vault ({} available):", filtered_vaults.len())); + ui.add_space(10.0); + + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(ui.available_width() - 20.0); // Add margin for scrollbar + for vault in filtered_vaults { + show_vault_card(ui, &vault, &state.selected_keyvault, action, config); + } + }); + } +} + +fn show_vault_card( + ui: &mut Ui, + vault: &KeyVault, + selected_vault: &Option, + action: &mut Option, + config: &crate::config::Config, +) { + let is_selected = selected_vault + .as_ref() + .map(|selected| selected.id == vault.id) + .unwrap_or(false); + + // Get card colors from config with fallback to defaults + let card_colors = config.colors.as_ref() + .map(|c| &c.cards.keyvault); + + let frame = if is_selected { + let bg = card_colors.map(|c| c.selected_bg).unwrap_or([60, 100, 60, 180]); + let border = card_colors.map(|c| c.selected_border).unwrap_or([239, 124, 0]); + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(bg[0], bg[1], bg[2], bg[3])) + .inner_margin(10.0) + .rounding(5.0) + .stroke(egui::Stroke::new(2.0, egui::Color32::from_rgb(border[0], border[1], border[2]))) + } else { + let bg = card_colors.map(|c| c.unselected_bg).unwrap_or([40, 60, 40, 150]); + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(bg[0], bg[1], bg[2], bg[3])) + .inner_margin(10.0) + .rounding(5.0) + }; + + let response = frame.show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + ui.strong(&vault.name); + ui.label(format!("Location: {}", vault.location)); + ui.label(format!("URI: {}", vault.vault_uri)); + }); + }); + + // Make the entire card clickable + if response.response.interact(egui::Sense::click()).clicked() && !is_selected { + *action = Some(KeyVaultSelectAction::SelectVault(vault.clone())); + tracing::info!("Vault selected by clicking card: {}", vault.name); + } + + ui.add_space(5.0); +} + +pub enum KeyVaultSelectAction { + SelectVault(KeyVault), + SaveToVault, + RefreshVaults, + Skip, + BackToAppList, +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..2d99897 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,12 @@ +pub mod app_list_view; +pub mod auth_view; +pub mod background; +pub mod components; +pub mod keyvault_select_view; +pub mod secret_create_view; + +pub use app_list_view::{show_app_list_view, AppListAction}; +pub use auth_view::{show_auth_view, AuthAction}; +pub use background::BackgroundRenderer; +pub use keyvault_select_view::{show_keyvault_select_view, KeyVaultSelectAction}; +pub use secret_create_view::{show_secret_create_view, SecretCreateAction}; diff --git a/src/ui/secret_create_view.rs b/src/ui/secret_create_view.rs new file mode 100644 index 0000000..7147a1e --- /dev/null +++ b/src/ui/secret_create_view.rs @@ -0,0 +1,106 @@ +use crate::config::Config; +use crate::state::AppState; +use crate::ui::components::{ + show_error_message, show_info_box, show_loading_spinner, show_section_header, show_text, +}; +use egui::Context; + +pub fn show_secret_create_view(ctx: &Context, state: &mut AppState, config: &Config) -> Option { + let mut action = None; + + egui::TopBottomPanel::top("top_panel") + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::Margin::symmetric(20.0, 10.0))) + .show_separator_line(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("⬅ Back").clicked() { + action = Some(SecretCreateAction::Back); + } + + ui.add_space(10.0); // Replace separator with space + show_section_header(ui, "Create Client Secret"); + }); + }); + + egui::CentralPanel::default() + .frame(egui::Frame::none() + .fill(egui::Color32::TRANSPARENT) + .inner_margin(egui::Margin { + left: 20.0, + right: 20.0, + top: 20.0, + bottom: 80.0, // Extra margin + })) + .show(ctx, |ui| { + if let Some(error) = &state.error_message { + show_error_message(ui, error); + ui.add_space(10.0); + } + + if let Some(app) = &state.selected_app { + show_info_box( + ui, + &format!( + "Creating secret for: {}\nApp ID: {}", + app.display_name, app.app_id + ), + config, + ); + ui.add_space(20.0); + + if state.operations.create_secret.is_some() { + show_loading_spinner(ui, "Creating secret..."); + ui.add_space(10.0); + show_text(ui, "This may take a few moments..."); + } else { + show_text(ui, "Secret Description:"); + ui.text_edit_singleline(&mut state.secret_description); + ui.add_space(5.0); + show_text(ui, "This name will help you identify the secret later."); + ui.add_space(20.0); + + show_text(ui, "Note:"); + show_text(ui, &format!("• The secret will be created with an expiration of {} years", config.azure.secret_expiration_years)); + show_text(ui, "• The secret value will only be shown once"); + show_text(ui, "• You will be prompted to save it to a Key Vault immediately"); + + ui.add_space(30.0); + + ui.horizontal(|ui| { + let can_create = !state.secret_description.is_empty(); + + if ui + .add_enabled(can_create, egui::Button::new("Create Secret")) + .clicked() + { + action = Some(SecretCreateAction::Create); + } + + if ui.button("Cancel").clicked() { + action = Some(SecretCreateAction::Back); + } + }); + + if state.secret_description.is_empty() { + ui.add_space(10.0); + show_text(ui, "Please enter a description to continue"); + } + } + } else { + show_text(ui, "No app registration selected"); + if ui.button("⬅ Back to App List").clicked() { + action = Some(SecretCreateAction::Back); + } + } + }); + + action +} + +pub enum SecretCreateAction { + Create, + Back, +} diff --git a/ui/__init__.py b/ui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ui/app_selection_frame.py b/ui/app_selection_frame.py deleted file mode 100644 index 3175591..0000000 --- a/ui/app_selection_frame.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -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 deleted file mode 100644 index c928112..0000000 --- a/ui/components/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -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 deleted file mode 100644 index d9a33f7..0000000 --- a/ui/components/tooltip.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -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 deleted file mode 100644 index edc804c..0000000 --- a/ui/components/unified_dropdown.py +++ /dev/null @@ -1,781 +0,0 @@ -""" -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] = [] - - # Search state - self.all_items: List[Dict] = [] # Store complete unfiltered list - self.filtered_items: List[Dict] = [] # Currently filtered items - self.search_entry: Optional[ctk.CTkEntry] = None - self.search_query: str = "" - - # Popup window - self.popup_window: Optional[tk.Toplevel] = None - self.popup_frame: Optional[ctk.CTkScrollableFrame] = None - self._closing = False # Flag to prevent race conditions - self._popup_height = 0 # Store calculated popup height - - # 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()) - - # Bind button click to ensure toggle works even when popup has focus - self.dropdown_button.bind("", self._on_button_click, add="+") - - # 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 - """ - # Store complete list and initialize filtered list - self.all_items = items - self.filtered_items = items.copy() - self.items = self.filtered_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 _on_button_click(self, event): - """Handle explicit button click to ensure toggle works.""" - # This is called on Button-1 event, which happens before the command callback - # We'll let the command callback (_toggle_dropdown) handle the actual toggle - # But we need to make sure the event propagates correctly - pass - - def _toggle_dropdown(self): - """Toggle dropdown popup visibility.""" - # Check if we're in the middle of closing (to prevent race conditions) - if self._closing: - return - - 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 - self.popup_window.wm_resizable(False, False) # Prevent resizing - - # Set background color to prevent white flash - appearance_mode = ctk.get_appearance_mode() - if appearance_mode == "Dark": - bg_color = "#2b2b2b" - else: - bg_color = "#dbdbdb" - self.popup_window.configure(bg=bg_color) - - self.popup_window.withdraw() # Hide initially to prevent flash in top-left corner - - # Create search entry at top of popup - self.search_entry = ctk.CTkEntry( - self.popup_window, - placeholder_text="Search...", - height=32, - font=ctk.CTkFont(size=13) - ) - self.search_entry.pack(fill="x", padx=5, pady=(5, 0)) - self.search_entry.focus_set() - - # Bind search events - self.search_entry.bind("", self._on_search_changed) - self.search_entry.bind("", lambda e: self._close_dropdown()) - - # Calculate dynamic height based on number of items - # Show first 10 items (or all if less than 10) - # Each item: 40px button + 2px borders (1px top + 1px bottom) + 4px padding (2px top + 2px bottom) = 46px per item - item_height = 46 - max_visible_items = 10 - items_to_show = min(len(self.items), max_visible_items) - - # Height calculation: (items × 46px) + extra padding for frame borders + search box height - # Add 62px total: 42px for search box (32px height + 5px top padding + 5px spacing) + 20px for frame borders - calculated_height = (items_to_show * item_height) + 62 - - self._popup_height = calculated_height - - # Set window size explicitly and fix dimensions - self.popup_window.wm_geometry(f"{self.button_width}x{self._popup_height}") - self.popup_window.wm_minsize(self.button_width, self._popup_height) - self.popup_window.wm_maxsize(self.button_width, self._popup_height) - - # Create scrollable frame for items with fixed width - # Frame height = window height - padding - search box height (10px pack padding total + 42px search) - frame_height = self._popup_height - 52 - - self.popup_frame = ctk.CTkScrollableFrame( - self.popup_window, - width=self.button_width - 20, - height=frame_height - ) - self.popup_frame.pack(fill="both", expand=True, padx=5, pady=5) - - # 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] + "..." - - # Add extra padding for first and last items to prevent border cutoff - if idx == 0: - pady_val = (3, 2) # Extra padding at top - elif idx == len(self.items) - 1: - pady_val = (2, 3) # Extra padding at bottom - else: - pady_val = 2 - - 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", - text_color=("gray10", "gray90") - ) - btn.grid(row=idx, column=0, padx=5, pady=pady_val, 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 to search entry for navigation - self.search_entry.bind("", lambda e: self._focus_first_item()) - self.search_entry.bind("", lambda e: self._navigate_end()) - self.search_entry.bind("", self._navigate_page_down) # PageDown in Tkinter - self.search_entry.bind("", self._navigate_page_up) # PageUp in Tkinter - self.search_entry.bind("", self._navigate_home) - self.search_entry.bind("", self._navigate_end) - self.search_entry.bind("", self._confirm_selection) - - # Bind keyboard events to popup window as well (for when focus moves) - 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) - self.popup_window.bind("", self._navigate_page_up) - self.popup_window.bind("", self._navigate_home) - self.popup_window.bind("", self._navigate_end) - self.popup_window.bind("", self._confirm_selection) - - # Bind any printable character to redirect focus to search entry - self.popup_window.bind("", self._redirect_to_search) - - # Keep focus on search entry (don't override with popup_window.focus_set()) - # Focus was already set on search_entry at line 233 - - # 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 and not self._closing: - # Set flag to prevent race conditions - self._closing = True - - # Clean up search state - self.search_entry = None - self.search_query = "" - self.filtered_items = self.all_items.copy() - self.items = self.filtered_items - - # 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() - - # Reset flag after a short delay to allow event handling to complete - self.after(100, lambda: setattr(self, '_closing', False)) - - def _position_popup(self): - """Position the popup window below the button.""" - # Force complete update to ensure all widgets are rendered - self.popup_window.update_idletasks() - self.dropdown_button.update_idletasks() - - # Get button position (ensure widgets are fully laid out) - btn_x = self.dropdown_button.winfo_rootx() - btn_y = self.dropdown_button.winfo_rooty() - btn_height = self.dropdown_button.winfo_height() - - # Position BELOW the button (not in the middle) - x = btn_x - y = btn_y + btn_height - - # Fallback if coordinates are invalid (0,0 means not rendered yet) - if btn_x == 0 and btn_y == 0: - # Wait a bit and try again - self.popup_window.after(10, self._position_popup) - return - - # Get screen dimensions - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - - # Use stored dimensions (already calculated in _open_dropdown) - popup_width = self.button_width - popup_height = self._popup_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 = btn_y - popup_height - - # Set position explicitly (size already set in _open_dropdown) - self.popup_window.wm_geometry(f"+{x}+{y}") - - # Now show the popup (after positioning to prevent flash in top-left) - self.popup_window.deiconify() - - 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() - - # Check if click is inside popup - inside_popup = (popup_x <= x <= popup_x + popup_width and - popup_y <= y <= popup_y + popup_height) - - # Check if click is on the dropdown button (to allow toggling) - btn_x = self.dropdown_button.winfo_rootx() - btn_y = self.dropdown_button.winfo_rooty() - btn_width = self.dropdown_button.winfo_width() - btn_height = self.dropdown_button.winfo_height() - inside_button = (btn_x <= x <= btn_x + btn_width and - btn_y <= y <= btn_y + btn_height) - - # Close if click is outside both popup and button - if not inside_popup and not inside_button: - 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=("#3b8ed0", "#1f538d"), - border_color=("#3b8ed0", "#1f538d") - ) - 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 _on_search_changed(self, event=None): - """Handle search query change and filter items.""" - query = self.search_entry.get().strip().lower() - self.search_query = query - - if not query: - # Show all items - self.filtered_items = self.all_items.copy() - else: - # Filter items based on display text (case-insensitive) - self.filtered_items = [ - item for item in self.all_items - if query in self._get_display_text(item).lower() - ] - - # Rebuild popup with filtered items - self._rebuild_popup_items() - - def _rebuild_popup_items(self): - """Rebuild popup item list with filtered items.""" - if not self.popup_frame: - return - - # Clear existing buttons and tooltips - for btn in self.item_buttons: - btn.destroy() - self.item_buttons.clear() - - for tooltip in self.item_tooltips: - tooltip.destroy() - self.item_tooltips.clear() - - # Update items reference - self.items = self.filtered_items - - # Show "no results" if empty - if not self.filtered_items: - no_results_label = ctk.CTkLabel( - self.popup_frame, - text="No matching items found", - font=ctk.CTkFont(size=14), - text_color="gray" - ) - no_results_label.grid(row=0, column=0, padx=10, pady=20) - return - - # Recreate buttons - for idx, item in enumerate(self.filtered_items): - display_text = self._get_display_text(item) - max_chars = 60 - truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..." - - # Add extra padding for first and last items - if idx == 0: - pady_val = (3, 2) - elif idx == len(self.filtered_items) - 1: - pady_val = (2, 3) - else: - pady_val = 2 - - 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", - text_color=("gray10", "gray90") - ) - btn.grid(row=idx, column=0, padx=5, pady=pady_val, sticky="ew") - self.popup_frame.grid_columnconfigure(0, weight=1) - - # Add tooltip if truncated - if len(display_text) > max_chars: - tooltip = ToolTip(btn, display_text, delay=500) - self.item_tooltips.append(tooltip) - - # Bind mouse wheel - def popup_scroll(event): - 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" - - btn.bind("", popup_scroll, add="+") - self.item_buttons.append(btn) - - # Reset current index and highlight first item - self.current_index = 0 - self._highlight_item(0) - - def _focus_first_item(self): - """Move focus from search to first item.""" - if self.item_buttons: - self.current_index = 0 - self._highlight_item(0) - self.popup_window.focus_set() - - def _redirect_to_search(self, event): - """Redirect keyboard input to search entry.""" - # Ignore special keys that are already handled - if event.keysym in ['Escape', 'Down', 'Up', 'Next', 'Prior', 'Home', 'End', 'Return', - 'Left', 'Right', 'Tab', 'Shift_L', 'Shift_R', 'Control_L', - 'Control_R', 'Alt_L', 'Alt_R']: - return - - # Focus search entry if not already focused - if self.search_entry and str(self.focus_get()) != str(self.search_entry): - self.search_entry.focus_set() - # Insert the character that was typed - if len(event.char) == 1 and event.char.isprintable(): - # Get current cursor position - current_pos = self.search_entry.index(tk.INSERT) - # Insert character at cursor position - self.search_entry.insert(current_pos, event.char) - # Trigger search update - self._on_search_changed() - - 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 deleted file mode 100644 index 4828f5a..0000000 --- a/ui/login_frame.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -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 deleted file mode 100644 index 04a36d7..0000000 --- a/ui/main_window.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -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") - - # Center window on screen - window_width = 950 - window_height = 850 - - # Get screen dimensions - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - - # Calculate center position - center_x = int((screen_width - window_width) / 2) - center_y = int((screen_height - window_height) / 2) - - # Set geometry with center position - self.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") - - # 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 deleted file mode 100644 index 03559a2..0000000 --- a/ui/result_frame.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -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 deleted file mode 100644 index bdb3b00..0000000 --- a/ui/secret_generation_frame.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -Secret Generation Frame - -UI component for secret generation form. -""" - -import customtkinter as ctk -from typing import List, Dict, Callable, Optional -from ui.components.unified_dropdown import UnifiedDropdown - - -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 Dropdown (using UnifiedDropdown) - self.vault_dropdown = UnifiedDropdown( - self, - title="Select Key Vault:", - on_selection_changed=self._on_vault_selected, - show_count=False, - display_format=lambda v: f"{v['name']} (RG: {v['resource_group']})", - max_dropdown_height=300, - button_width=600, - button_height=40 - ) - self.vault_dropdown.grid(row=3, column=0, columnspan=2, padx=0, pady=(0, 15), sticky="ew") - - # 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=4, 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=5, 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.vault_dropdown.set_items(vaults) - - def _on_vault_selected(self, vault: Dict): - """Handle vault selection.""" - # Selection is stored in dropdown - no additional action needed - pass - - 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.vault_dropdown.get_selected() - - 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") - self.vault_dropdown.set_enabled(True) - self.remove_old_checkbox.configure(state="normal") - self.generate_button.configure(state="normal") - else: - self.description_entry.configure(state="disabled") - self.vault_dropdown.set_enabled(False) - 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 - """ - self.vault_dropdown.set_loading(loading) - - 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 deleted file mode 100644 index 361be69..0000000 --- a/ui/subscription_selection_frame.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -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 deleted file mode 100644 index e69de29..0000000 diff --git a/utils/async_worker.py b/utils/async_worker.py deleted file mode 100644 index d8d6b73..0000000 --- a/utils/async_worker.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -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 deleted file mode 100644 index d4a87cc..0000000 --- a/utils/logger.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -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 deleted file mode 100644 index 87e8057..0000000 --- a/utils/sanitizer.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -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