Rust
This commit is contained in:
+10
-66
@@ -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
|
||||
|
||||
+46
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
## 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
|
||||
|
||||
<!-- Add your screenshots here -->
|
||||
```
|
||||
[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 <your-repo-url>
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
@@ -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
|
||||
+75
@@ -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]
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)}")
|
||||
@@ -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)}")
|
||||
@@ -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)}")
|
||||
+519
@@ -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<AzureAuthenticator>,
|
||||
graph_client: GraphApiClient,
|
||||
keyvault_client: KeyVaultClient,
|
||||
vault_discovery: VaultDiscovery,
|
||||
config: Config,
|
||||
position_applied: bool,
|
||||
}
|
||||
|
||||
impl AzureAppManager {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, auth: Arc<AzureAuthenticator>, 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::<String>();
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
error: Option<String>,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AzureAuthenticator {
|
||||
token_cache: TokenCache,
|
||||
}
|
||||
|
||||
impl AzureAuthenticator {
|
||||
pub fn new() -> AppResult<Self> {
|
||||
let token_cache = TokenCache::new()?;
|
||||
Ok(Self { token_cache })
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self) -> AppResult<String> {
|
||||
// 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<String> {
|
||||
// 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<AuthCallback>| 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("<html><body><h1>Authentication Failed</h1><p>Error: See application for details.</p><p>You can close this window.</p></body></html>");
|
||||
}
|
||||
|
||||
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("<html><body><h1>✓ Authentication Successful!</h1><p>You can close this window and return to the application.</p></body></html>")
|
||||
} else {
|
||||
// Signal server shutdown after response
|
||||
if let Some(shutdown) = shutdown_for_callback.lock().unwrap().take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
Html("<html><body><h1>Authentication Failed</h1><p>No authorization code received.</p></body></html>")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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<String> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<AuthCallback>| 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("<html><body><h1>Key Vault Authentication Failed</h1><p>Error: See application for details.</p><p>You can close this window.</p></body></html>");
|
||||
}
|
||||
|
||||
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("<html><body><h1>✓ Key Vault Authentication Successful!</h1><p>You can close this window and return to the application.</p></body></html>")
|
||||
} else {
|
||||
Html("<html><body><h1>Authentication Failed</h1><p>No authorization code received.</p></body></html>")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod azure_auth;
|
||||
pub mod token_cache;
|
||||
|
||||
pub use azure_auth::AzureAuthenticator;
|
||||
@@ -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<String>,
|
||||
pub expires_at: i64, // Unix timestamp
|
||||
}
|
||||
|
||||
pub struct TokenCache {
|
||||
entry: Entry,
|
||||
}
|
||||
|
||||
impl TokenCache {
|
||||
pub fn new() -> AppResult<Self> {
|
||||
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<Option<CachedToken>> {
|
||||
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<bool> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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<AzureAuthenticator>,
|
||||
}
|
||||
|
||||
impl GraphApiClient {
|
||||
pub fn new(auth: Arc<AzureAuthenticator>) -> Self {
|
||||
Self { auth }
|
||||
}
|
||||
|
||||
async fn get_client(&self) -> AppResult<GraphClient> {
|
||||
let token = self.auth.get_access_token().await?;
|
||||
let client = Graph::new(&token);
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub async fn list_applications(&self) -> AppResult<Vec<Application>> {
|
||||
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<CreatedSecret> {
|
||||
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<UserInfo> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use crate::auth::AzureAuthenticator;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct KeyVaultClient {
|
||||
auth: Arc<AzureAuthenticator>,
|
||||
}
|
||||
|
||||
impl KeyVaultClient {
|
||||
pub fn new(auth: Arc<AzureAuthenticator>) -> 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<i64>,
|
||||
) -> 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<String> {
|
||||
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<String> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PasswordCredential {
|
||||
#[serde(rename = "customKeyIdentifier")]
|
||||
pub custom_key_identifier: Option<String>,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(rename = "endDateTime")]
|
||||
pub end_date_time: Option<String>,
|
||||
pub hint: Option<String>,
|
||||
#[serde(rename = "keyId")]
|
||||
pub key_id: Option<String>,
|
||||
#[serde(rename = "secretText")]
|
||||
pub secret_text: Option<String>,
|
||||
#[serde(rename = "startDateTime")]
|
||||
pub start_date_time: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String> 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<String>,
|
||||
}
|
||||
@@ -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<AzureAuthenticator>,
|
||||
}
|
||||
|
||||
impl VaultDiscovery {
|
||||
pub fn new(auth: Arc<AzureAuthenticator>) -> Self {
|
||||
Self { auth }
|
||||
}
|
||||
|
||||
pub async fn list_accessible_vaults(&self) -> AppResult<Vec<KeyVault>> {
|
||||
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<Vec<String>> {
|
||||
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<String> = 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<Vec<KeyVault>> {
|
||||
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<KeyVault> = 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod window_config;
|
||||
pub use window_config::{Config, load_config};
|
||||
@@ -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<ColorConfig>,
|
||||
#[serde(default)]
|
||||
pub text_sizing: Option<TextSizing>,
|
||||
#[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::<Config>(&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()
|
||||
}
|
||||
@@ -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<anyhow::Error> for AppError {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
AppError::Unknown(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
AppError::SerializationError(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<keyring::Error> for AppError {
|
||||
fn from(error: keyring::Error) -> Self {
|
||||
AppError::TokenCacheError(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
+80
@@ -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<dyn std::error::Error>> {
|
||||
// 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(())
|
||||
}
|
||||
@@ -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<UserInfo>,
|
||||
|
||||
// Data
|
||||
pub applications: Vec<Application>,
|
||||
pub selected_app: Option<Application>,
|
||||
pub key_vaults: Vec<KeyVault>,
|
||||
pub selected_keyvault: Option<KeyVault>,
|
||||
|
||||
// Created secret (temporary, should be saved to vault ASAP)
|
||||
pub created_secret: Option<CreatedSecret>,
|
||||
|
||||
// Async operations
|
||||
pub operations: AsyncOperations,
|
||||
|
||||
// UI State
|
||||
pub current_view: ViewState,
|
||||
pub error_message: Option<String>,
|
||||
pub success_message: Option<String>,
|
||||
|
||||
// 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<Arc<BackgroundRenderer>>,
|
||||
}
|
||||
|
||||
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<Application> {
|
||||
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<KeyVault> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Promise<AppResult<String>>>,
|
||||
pub load_user: Option<Promise<AppResult<UserInfo>>>,
|
||||
pub load_applications: Option<Promise<AppResult<Vec<Application>>>>,
|
||||
pub create_secret: Option<Promise<AppResult<CreatedSecret>>>,
|
||||
pub load_vaults: Option<Promise<AppResult<Vec<KeyVault>>>>,
|
||||
pub save_to_vault: Option<Promise<AppResult<()>>>,
|
||||
pub sign_out: Option<Promise<AppResult<()>>>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod app_state;
|
||||
pub mod async_operations;
|
||||
|
||||
pub use app_state::{AppState, AuthStatus, ViewState};
|
||||
@@ -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<AppListAction> {
|
||||
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<Application>,
|
||||
action: &mut Option<AppListAction>,
|
||||
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,
|
||||
}
|
||||
@@ -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<AuthAction> {
|
||||
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,
|
||||
}
|
||||
@@ -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<TextureHandle>,
|
||||
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<TextureHandle> {
|
||||
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<TextureHandle> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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<KeyVaultSelectAction> {
|
||||
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<KeyVaultSelectAction>,
|
||||
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<KeyVault>,
|
||||
action: &mut Option<KeyVaultSelectAction>,
|
||||
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,
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<SecretCreateAction> {
|
||||
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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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("<Enter>", self._on_enter, add="+")
|
||||
self.widget.bind("<Leave>", self._on_leave, add="+")
|
||||
self.widget.bind("<ButtonPress>", self._on_leave, add="+")
|
||||
|
||||
def _on_enter(self, event=None):
|
||||
"""Handle mouse enter event."""
|
||||
# Schedule tooltip to appear after delay
|
||||
self._cancel_scheduled()
|
||||
self.after_id = self.widget.after(self.delay, self._show_tooltip)
|
||||
|
||||
def _on_leave(self, event=None):
|
||||
"""Handle mouse leave event."""
|
||||
self._cancel_scheduled()
|
||||
self._hide_tooltip()
|
||||
|
||||
def _cancel_scheduled(self):
|
||||
"""Cancel scheduled tooltip appearance."""
|
||||
if self.after_id:
|
||||
self.widget.after_cancel(self.after_id)
|
||||
self.after_id = None
|
||||
|
||||
def _show_tooltip(self):
|
||||
"""Display the tooltip window."""
|
||||
if self.tooltip_window or not self.text:
|
||||
return
|
||||
|
||||
# Get widget position
|
||||
x = self.widget.winfo_rootx() + 20
|
||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
|
||||
|
||||
# Create tooltip window
|
||||
self.tooltip_window = tk.Toplevel(self.widget)
|
||||
self.tooltip_window.wm_overrideredirect(True) # Remove window decorations
|
||||
|
||||
# Handle screen edge cases
|
||||
screen_width = self.widget.winfo_screenwidth()
|
||||
screen_height = self.widget.winfo_screenheight()
|
||||
|
||||
# Create label with text
|
||||
label = tk.Label(
|
||||
self.tooltip_window,
|
||||
text=self.text,
|
||||
justify=tk.LEFT,
|
||||
background="#ffffe0", # Light yellow background
|
||||
foreground="#000000", # Black text
|
||||
relief=tk.SOLID,
|
||||
borderwidth=1,
|
||||
wraplength=self.wrap_length,
|
||||
font=("TkDefaultFont", 9),
|
||||
padx=8,
|
||||
pady=6
|
||||
)
|
||||
label.pack()
|
||||
|
||||
# Update to get actual size
|
||||
self.tooltip_window.update_idletasks()
|
||||
tooltip_width = self.tooltip_window.winfo_width()
|
||||
tooltip_height = self.tooltip_window.winfo_height()
|
||||
|
||||
# Adjust position if near screen edges
|
||||
if x + tooltip_width > screen_width:
|
||||
x = screen_width - tooltip_width - 10
|
||||
if y + tooltip_height > screen_height:
|
||||
y = self.widget.winfo_rooty() - tooltip_height - 5
|
||||
|
||||
# Position the tooltip
|
||||
self.tooltip_window.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
def _hide_tooltip(self):
|
||||
"""Hide the tooltip window."""
|
||||
if self.tooltip_window:
|
||||
self.tooltip_window.destroy()
|
||||
self.tooltip_window = None
|
||||
|
||||
def update_text(self, text: str):
|
||||
"""
|
||||
Update the tooltip text.
|
||||
|
||||
Args:
|
||||
text: New text to display
|
||||
"""
|
||||
self.text = text
|
||||
if self.tooltip_window:
|
||||
self._hide_tooltip()
|
||||
|
||||
def destroy(self):
|
||||
"""Clean up the tooltip."""
|
||||
self._cancel_scheduled()
|
||||
self._hide_tooltip()
|
||||
|
||||
# Unbind events
|
||||
try:
|
||||
self.widget.unbind("<Enter>")
|
||||
self.widget.unbind("<Leave>")
|
||||
self.widget.unbind("<ButtonPress>")
|
||||
except:
|
||||
pass
|
||||
@@ -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("<Down>", lambda e: self._open_dropdown())
|
||||
self.dropdown_button.bind("<Up>", lambda e: self._open_dropdown())
|
||||
self.dropdown_button.bind("<space>", lambda e: self._toggle_dropdown())
|
||||
|
||||
# Bind button click to ensure toggle works even when popup has focus
|
||||
self.dropdown_button.bind("<Button-1>", 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("<KeyRelease>", self._on_search_changed)
|
||||
self.search_entry.bind("<Escape>", 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("<MouseWheel>", popup_scroll, add="+")
|
||||
self.popup_frame.bind("<MouseWheel>", popup_scroll, add="+")
|
||||
self.popup_frame._parent_canvas.bind("<MouseWheel>", popup_scroll, add="+")
|
||||
|
||||
# Bind to all item buttons so scrolling works when hovering over them
|
||||
for btn in self.item_buttons:
|
||||
btn.bind("<MouseWheel>", popup_scroll, add="+")
|
||||
|
||||
# Highlight currently selected item
|
||||
if self.current_index >= 0:
|
||||
self._highlight_item(self.current_index)
|
||||
|
||||
# Position popup
|
||||
self._position_popup()
|
||||
|
||||
# Bind keyboard events to search entry for navigation
|
||||
self.search_entry.bind("<Down>", lambda e: self._focus_first_item())
|
||||
self.search_entry.bind("<Up>", lambda e: self._navigate_end())
|
||||
self.search_entry.bind("<Next>", self._navigate_page_down) # PageDown in Tkinter
|
||||
self.search_entry.bind("<Prior>", self._navigate_page_up) # PageUp in Tkinter
|
||||
self.search_entry.bind("<Home>", self._navigate_home)
|
||||
self.search_entry.bind("<End>", self._navigate_end)
|
||||
self.search_entry.bind("<Return>", self._confirm_selection)
|
||||
|
||||
# Bind keyboard events to popup window as well (for when focus moves)
|
||||
self.popup_window.bind("<Escape>", lambda e: self._close_dropdown())
|
||||
self.popup_window.bind("<Down>", self._navigate_down)
|
||||
self.popup_window.bind("<Up>", self._navigate_up)
|
||||
self.popup_window.bind("<Next>", self._navigate_page_down)
|
||||
self.popup_window.bind("<Prior>", self._navigate_page_up)
|
||||
self.popup_window.bind("<Home>", self._navigate_home)
|
||||
self.popup_window.bind("<End>", self._navigate_end)
|
||||
self.popup_window.bind("<Return>", self._confirm_selection)
|
||||
|
||||
# Bind any printable character to redirect focus to search entry
|
||||
self.popup_window.bind("<Key>", 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("<Button-1>", 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("<MouseWheel>")
|
||||
if self.popup_frame:
|
||||
self.popup_frame.unbind("<MouseWheel>")
|
||||
self.popup_frame._parent_canvas.unbind("<MouseWheel>")
|
||||
# Unbind from buttons
|
||||
for btn in self.item_buttons:
|
||||
btn.unbind("<MouseWheel>")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Destroy popup
|
||||
self.popup_window.destroy()
|
||||
self.popup_window = None
|
||||
self.popup_frame = None
|
||||
self.item_buttons.clear()
|
||||
|
||||
# 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("<MouseWheel>", 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)
|
||||
@@ -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")
|
||||
@@ -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("<MouseWheel>", smooth_scroll, add="+")
|
||||
|
||||
# Login Frame
|
||||
self.login_frame = LoginFrame(self.scroll_frame, on_connect=on_connect)
|
||||
self.login_frame.grid(row=0, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
|
||||
# Subscription Selection Frame
|
||||
self.subscription_selection_frame = SubscriptionSelectionFrame(
|
||||
self.scroll_frame,
|
||||
on_subscription_selected=on_subscription_selected
|
||||
)
|
||||
self.subscription_selection_frame.grid(row=1, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.subscription_selection_frame.set_enabled(False)
|
||||
|
||||
# App Selection Frame
|
||||
self.app_selection_frame = AppSelectionFrame(
|
||||
self.scroll_frame,
|
||||
on_app_selected=on_app_selected
|
||||
)
|
||||
self.app_selection_frame.grid(row=2, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.app_selection_frame.set_enabled(False)
|
||||
|
||||
# Secret Generation Frame
|
||||
self.secret_generation_frame = SecretGenerationFrame(
|
||||
self.scroll_frame,
|
||||
on_generate=on_generate_secret
|
||||
)
|
||||
self.secret_generation_frame.grid(row=3, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.secret_generation_frame.set_enabled(False)
|
||||
|
||||
# Result Frame (initially hidden)
|
||||
self.result_frame = ResultFrame(
|
||||
self.scroll_frame,
|
||||
on_generate_another=on_generate_another
|
||||
)
|
||||
self.result_frame.grid(row=4, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.result_frame.hide()
|
||||
|
||||
def set_authenticated(self, authenticated: bool):
|
||||
"""
|
||||
Update UI after authentication.
|
||||
|
||||
Args:
|
||||
authenticated: Whether authentication succeeded
|
||||
"""
|
||||
self.login_frame.set_authenticated(authenticated)
|
||||
|
||||
if authenticated:
|
||||
self.subscription_selection_frame.set_enabled(True)
|
||||
|
||||
def set_connecting(self):
|
||||
"""Set UI to connecting state."""
|
||||
self.login_frame.set_connecting()
|
||||
|
||||
def enable_connect_button(self):
|
||||
"""Enable connect button for retry."""
|
||||
self.login_frame.enable_button()
|
||||
|
||||
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of subscriptions.
|
||||
|
||||
Args:
|
||||
subscriptions: List of subscription dictionaries
|
||||
"""
|
||||
self.subscription_selection_frame.set_subscriptions(subscriptions)
|
||||
|
||||
def set_subscription_selected(self):
|
||||
"""Enable UI after subscription is selected."""
|
||||
self.app_selection_frame.set_enabled(True)
|
||||
self.secret_generation_frame.set_enabled(True)
|
||||
|
||||
def set_apps(self, apps: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of applications.
|
||||
|
||||
Args:
|
||||
apps: List of app dictionaries
|
||||
"""
|
||||
self.app_selection_frame.set_apps(apps)
|
||||
|
||||
def set_vaults(self, vaults: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of Key Vaults.
|
||||
|
||||
Args:
|
||||
vaults: List of vault dictionaries
|
||||
"""
|
||||
self.secret_generation_frame.set_vaults(vaults)
|
||||
|
||||
def set_loading_subscriptions(self, loading: bool):
|
||||
"""
|
||||
Set loading subscriptions state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.subscription_selection_frame.set_loading(loading)
|
||||
|
||||
def set_loading_apps(self, loading: bool):
|
||||
"""
|
||||
Set loading apps state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.app_selection_frame.set_loading(loading)
|
||||
|
||||
def set_loading_vaults(self, loading: bool):
|
||||
"""
|
||||
Set loading vaults state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.secret_generation_frame.set_loading_vaults(loading)
|
||||
|
||||
def set_generating(self, generating: bool):
|
||||
"""
|
||||
Set generating secret state.
|
||||
|
||||
Args:
|
||||
generating: Whether currently generating
|
||||
"""
|
||||
self.secret_generation_frame.set_generating(generating)
|
||||
|
||||
def show_result(
|
||||
self,
|
||||
secret_name: str,
|
||||
vault_name: str,
|
||||
secret_value: str,
|
||||
removed_count: int = 0
|
||||
):
|
||||
"""
|
||||
Show secret generation result.
|
||||
|
||||
Args:
|
||||
secret_name: The sanitized secret name
|
||||
vault_name: The Key Vault name
|
||||
secret_value: The secret value
|
||||
removed_count: Number of old secrets removed
|
||||
"""
|
||||
self.result_frame.show_result(secret_name, vault_name, secret_value, removed_count)
|
||||
|
||||
def reset_form(self):
|
||||
"""Reset the secret generation form."""
|
||||
self.secret_generation_frame.reset()
|
||||
self.result_frame.hide()
|
||||
|
||||
def get_selected_app(self) -> Dict[str, str]:
|
||||
"""Get the currently selected app."""
|
||||
return self.app_selection_frame.get_selected_app()
|
||||
|
||||
def get_description(self) -> str:
|
||||
"""Get the secret description."""
|
||||
return self.secret_generation_frame.get_description()
|
||||
|
||||
def get_selected_vault(self) -> Dict[str, str]:
|
||||
"""Get the currently selected vault."""
|
||||
return self.secret_generation_frame.get_selected_vault()
|
||||
|
||||
def get_remove_old_secrets(self) -> bool:
|
||||
"""Get whether to remove old secrets."""
|
||||
return self.secret_generation_frame.get_remove_old_secrets()
|
||||
|
||||
def _set_icon(self):
|
||||
"""Set the application icon if available."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Get the directory where the script is located
|
||||
script_dir = Path(__file__).parent.parent
|
||||
|
||||
# Try common icon file names and locations
|
||||
icon_paths = [
|
||||
script_dir / "icon.ico",
|
||||
script_dir / "assets" / "icon.ico",
|
||||
script_dir / "icon.png",
|
||||
script_dir / "assets" / "icon.png",
|
||||
]
|
||||
|
||||
for icon_path in icon_paths:
|
||||
if icon_path.exists():
|
||||
try:
|
||||
if icon_path.suffix == '.ico':
|
||||
# Use .ico file directly (Windows)
|
||||
self.iconbitmap(str(icon_path))
|
||||
print(f"Icon loaded: {icon_path}")
|
||||
return
|
||||
elif icon_path.suffix == '.png':
|
||||
# Use .png file with iconphoto (cross-platform)
|
||||
import tkinter as tk
|
||||
from PIL import Image, ImageTk
|
||||
img = Image.open(icon_path)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self.iconphoto(True, photo)
|
||||
print(f"Icon loaded: {icon_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Failed to load icon from {icon_path}: {str(e)}")
|
||||
|
||||
# No icon found - use default
|
||||
print("No custom icon found. Using default application icon.")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user