Rust
This commit is contained in:
+10
-66
@@ -1,55 +1,9 @@
|
|||||||
# Virtual Environment
|
# Generated by Cargo
|
||||||
venv/
|
/target/
|
||||||
env/
|
Cargo.lock
|
||||||
ENV/
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# Python
|
# Environment files
|
||||||
__pycache__/
|
.env
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Unit test / coverage
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
# Azure credentials cache
|
|
||||||
.azure/
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -57,20 +11,10 @@ coverage.xml
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Environment variables
|
# Logs
|
||||||
.env
|
*.log
|
||||||
.env.local
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
.python-version
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.bak
|
|
||||||
*.tmp
|
|
||||||
temp/
|
|
||||||
tmp/
|
|
||||||
|
|||||||
+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
|
## Prerequisites
|
||||||
- 🎯 **Auto-Detection**: Automatically detects your Azure tenant ID from logged-in account
|
|
||||||
- 📋 **Subscription Selection**: Choose your subscription from a dropdown (no more config files!)
|
|
||||||
- 🔍 **Smart Dropdowns**: Searchable, scrollable lists with keyboard navigation (Arrow keys, Page Up/Down, Home/End)
|
|
||||||
- 💡 **Tooltips**: Hover over items to see full names if truncated
|
|
||||||
- 🔑 **Secret Management**: Generate 50-year secrets with custom descriptions
|
|
||||||
- 🗑️ **Cleanup**: Optionally remove old secrets when creating new ones
|
|
||||||
- 💾 **Key Vault Integration**: Automatic storage with metadata tags
|
|
||||||
- 📋 **Copy to Clipboard**: One-click secret copying
|
|
||||||
- 🎨 **Modern UI**: Clean interface built with CustomTkinter (supports dark/light themes)
|
|
||||||
- ⚡ **Smooth Performance**: Optimized scrolling and no nested scroll lag
|
|
||||||
|
|
||||||
## 📸 Screenshots
|
- Rust 1.70+ (install from [rustup.rs](https://rustup.rs))
|
||||||
|
- Azure subscription with appropriate permissions
|
||||||
|
|
||||||
<!-- Add your screenshots here -->
|
## Quick Start
|
||||||
```
|
|
||||||
[App Selection] [Secret Generation] [Result View]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Prerequisites
|
### Installation and Run
|
||||||
|
|
||||||
- **Python 3.8+** (Python 3.11 recommended)
|
|
||||||
- **Azure Permissions**:
|
|
||||||
- Application.ReadWrite.All (Microsoft Graph API)
|
|
||||||
- Directory.Read.All (Microsoft Graph API)
|
|
||||||
- Key Vault Secrets Officer role on target Key Vaults
|
|
||||||
- Reader role on subscription/resource groups
|
|
||||||
|
|
||||||
**Note**: No need to create an App Registration! The app uses the Azure CLI public client ID for authentication.
|
|
||||||
|
|
||||||
## 🎨 Customization
|
|
||||||
|
|
||||||
### Adding a Custom Icon
|
|
||||||
|
|
||||||
To replace the default Python icon with your own:
|
|
||||||
|
|
||||||
1. Create an icon file (`.ico` for Windows or `.png` for cross-platform)
|
|
||||||
2. Place it in one of these locations:
|
|
||||||
- `python-app/icon.ico` or `python-app/icon.png`
|
|
||||||
- `python-app/assets/icon.ico` or `python-app/assets/icon.png`
|
|
||||||
3. The application will automatically detect and use it on next launch
|
|
||||||
|
|
||||||
**Recommended icon size**: 256x256 pixels
|
|
||||||
|
|
||||||
## 📦 Installation
|
|
||||||
|
|
||||||
### 1. Clone the Repository
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/azure-keyvault-manager.git
|
git clone <your-repo-url>
|
||||||
cd azure-keyvault-manager/python-app
|
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:**
|
### How It Works
|
||||||
```bash
|
|
||||||
python -m venv venv
|
|
||||||
venv\Scripts\activate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
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.
|
||||||
```bash
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Install Dependencies
|
## Usage
|
||||||
|
|
||||||
|
### Run the Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
cargo run --release
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run the Application
|
Or run the compiled binary:
|
||||||
|
|
||||||
```bash
|
```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**
|
### Technology Stack
|
||||||
- Click **"Connect to Azure"**
|
|
||||||
- Browser opens automatically
|
|
||||||
- Sign in with your Azure account (admin credentials)
|
|
||||||
- ✅ Authentication completes (single login!)
|
|
||||||
|
|
||||||
2. **Select Subscription**
|
- **GUI Framework**: egui/eframe (immediate-mode, cross-platform)
|
||||||
- Choose your Azure subscription from the dropdown
|
- **Azure SDKs**:
|
||||||
- Apps and Key Vaults load automatically
|
- `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**
|
### Project Structure
|
||||||
- Click the App Registration dropdown
|
|
||||||
- Scroll through the list or use keyboard navigation:
|
|
||||||
- `↑` `↓` Arrow keys to navigate
|
|
||||||
- `Page Up` `Page Down` to jump
|
|
||||||
- `Home` `End` for first/last
|
|
||||||
- `Enter` to select
|
|
||||||
- `Esc` to close
|
|
||||||
- Hover for tooltips on long names
|
|
||||||
|
|
||||||
4. **Generate Secret**
|
|
||||||
- Enter a description (e.g., "Production API Key 2025")
|
|
||||||
- Select a Key Vault
|
|
||||||
- *(Optional)* Check "Remove old secrets"
|
|
||||||
- Click **"Generate Secret"**
|
|
||||||
|
|
||||||
5. **Copy & Save**
|
|
||||||
- Secret is displayed once
|
|
||||||
- Click **"Copy to Clipboard"**
|
|
||||||
- Secret is automatically stored in Key Vault with metadata
|
|
||||||
- Click **"Generate Another Secret"** to continue
|
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `↓` `↑` | Navigate dropdown items |
|
|
||||||
| `Page Down` `Page Up` | Jump 5 items |
|
|
||||||
| `Home` `End` | First/Last item |
|
|
||||||
| `Enter` | Select item |
|
|
||||||
| `Escape` | Close dropdown |
|
|
||||||
| `Mouse Wheel` | Scroll in dropdown |
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
python-app/
|
src/
|
||||||
├── main.py # Application entry point
|
├── main.rs # Application entry point
|
||||||
├── config.py # App settings (no secrets!)
|
├── app.rs # Main app logic (eframe::App implementation)
|
||||||
├── requirements.txt # Python dependencies
|
├── error.rs # Error types
|
||||||
├── auth/
|
├── auth/ # Authentication
|
||||||
│ ├── graph_authenticator.py # Microsoft Graph authentication
|
│ ├── azure_auth.rs # OAuth flow
|
||||||
│ └── azure_authenticator.py # Azure Resource Manager authentication
|
│ └── token_cache.rs # Secure token storage
|
||||||
├── services/
|
├── azure/ # Azure API clients
|
||||||
│ ├── app_registration_service.py # App registration operations
|
│ ├── graph_client.rs # Microsoft Graph API
|
||||||
│ ├── secret_service.py # Secret generation/management
|
│ ├── keyvault_client.rs # Key Vault operations
|
||||||
│ └── keyvault_service.py # Key Vault operations
|
│ ├── vault_discovery.rs # Key Vault listing
|
||||||
├── ui/
|
│ └── models.rs # Data models
|
||||||
│ ├── components/
|
├── state/ # Application state
|
||||||
│ │ ├── unified_dropdown.py # Custom dropdown component
|
│ ├── app_state.rs # Central state management
|
||||||
│ │ └── tooltip.py # Tooltip utility
|
│ └── async_operations.rs # Async operation tracking
|
||||||
│ ├── main_window.py # Main application window
|
└── ui/ # UI views
|
||||||
│ ├── login_frame.py # Authentication UI
|
├── auth_view.rs # Login screen
|
||||||
│ ├── subscription_selection_frame.py
|
├── app_list_view.rs # App registration list
|
||||||
│ ├── app_selection_frame.py # App selection UI
|
├── secret_create_view.rs # Secret creation form
|
||||||
│ ├── secret_generation_frame.py # Secret generation form
|
├── keyvault_select_view.rs # Key Vault selection
|
||||||
│ └── result_frame.py # Result display
|
└── components.rs # Reusable UI components
|
||||||
└── utils/
|
|
||||||
├── sanitizer.py # Name sanitization
|
|
||||||
└── logger.py # Logging setup
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## Security Features
|
||||||
|
|
||||||
### Authentication Issues
|
### Token Security
|
||||||
|
|
||||||
**Problem**: "Authentication failed"
|
- Access tokens stored in OS-level secure storage:
|
||||||
- **Solution**: Ensure you have the required permissions in Azure AD
|
- **Windows**: Credential Manager
|
||||||
- Clear cached credentials: Delete `.azure` folder in your home directory
|
- **macOS**: Keychain
|
||||||
- Verify your account has access to the Azure subscription
|
- **Linux**: Secret Service (gnome-keyring/kwallet)
|
||||||
|
- Automatic token refresh before expiration
|
||||||
|
- Secure memory clearing with `zeroize`
|
||||||
|
|
||||||
**Problem**: Double login prompts
|
### Secret Handling
|
||||||
- **Solution**: This has been fixed in the latest version - you should only login once
|
|
||||||
|
|
||||||
### 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"
|
## Platform-Specific Notes
|
||||||
- **Solution**: Request `Application.ReadWrite.All` and `Directory.Read.All` permissions from your Azure AD admin
|
|
||||||
|
|
||||||
**Problem**: "Failed to store secret in Key Vault"
|
### macOS
|
||||||
- **Solution**: Ensure you have **Key Vault Secrets Officer** role on the target vault
|
|
||||||
- Check Key Vault network settings allow your IP address
|
|
||||||
|
|
||||||
### 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
|
1. A code will be displayed in the application
|
||||||
- **Solution**: Updated in latest version - mouse wheel now scrolls the dropdown properly
|
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
|
### Linux
|
||||||
- **Solution**: Use keyboard navigation (arrow keys) or mouse wheel to scroll through large lists
|
|
||||||
|
|
||||||
### General Issues
|
Requires a secret service backend (gnome-keyring or kwallet) for secure token storage:
|
||||||
|
|
||||||
**Problem**: No subscriptions found
|
|
||||||
- **Solution**: Verify your account has at least Reader access to one Azure subscription
|
|
||||||
|
|
||||||
**Problem**: No Key Vaults appear
|
|
||||||
- **Solution**: Create a Key Vault in your subscription or request access to existing ones
|
|
||||||
|
|
||||||
## 📝 Logs
|
|
||||||
|
|
||||||
Application logs are stored in: `logs/app_YYYYMMDD.log`
|
|
||||||
|
|
||||||
Log levels:
|
|
||||||
- **INFO**: Normal operations
|
|
||||||
- **ERROR**: Failed operations with stack traces
|
|
||||||
|
|
||||||
## 🔒 Security Best Practices
|
|
||||||
|
|
||||||
- ✅ Secrets are **only displayed once** in the UI
|
|
||||||
- ✅ Secrets are **never logged** to files
|
|
||||||
- ✅ Authentication uses Azure Identity library (secure token caching)
|
|
||||||
- ✅ Uses Azure CLI public client ID (no app registration needed)
|
|
||||||
- ⚠️ **Always copy secrets immediately** - they cannot be retrieved later
|
|
||||||
- ⚠️ Store secrets in a secure password manager after generation
|
|
||||||
|
|
||||||
## 🏗️ Building Executable (Optional)
|
|
||||||
|
|
||||||
Create a standalone executable:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyinstaller
|
# Ubuntu/Debian
|
||||||
pyinstaller --onefile --windowed --name AzureKeyVaultManager main.py
|
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
|
- Ensure you have appropriate permissions in your Azure AD tenant
|
||||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
- Check your internet connection
|
||||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
- Review logs with `LOG_LEVEL=debug cargo run`
|
||||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
- Some organizations may have conditional access policies that require MFA or compliant devices
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
## 📄 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
|
- On Linux: Install and start gnome-keyring or kwallet
|
||||||
- Uses [Azure SDK for Python](https://github.com/Azure/azure-sdk-for-python)
|
- On macOS: Check Keychain Access permissions
|
||||||
- Uses [Microsoft Graph SDK for Python](https://github.com/microsoftgraph/msgraph-sdk-python)
|
- On Windows: Check Windows Credential Manager
|
||||||
|
|
||||||
## 📮 Support
|
## Development
|
||||||
|
|
||||||
For issues, questions, or suggestions:
|
### Run in Debug Mode
|
||||||
- 🐛 [Open an issue](https://github.com/yourusername/azure-keyvault-manager/issues)
|
|
||||||
- 💬 [Start a discussion](https://github.com/yourusername/azure-keyvault-manager/discussions)
|
|
||||||
|
|
||||||
---
|
```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