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 0000000..4764a1d Binary files /dev/null and b/assets/background.jpg differ diff --git a/assets/default_background.png b/assets/default_background.png new file mode 100644 index 0000000..4764a1d Binary files /dev/null and b/assets/default_background.png differ diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..5c47cbd Binary files /dev/null and b/assets/icon.ico differ 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