This commit is contained in:
2026-01-29 09:57:44 +01:00
parent afa053f006
commit 64d707bd8b
61 changed files with 3811 additions and 3543 deletions
+10 -66
View File
@@ -1,55 +1,9 @@
# Virtual Environment
venv/
env/
ENV/
.venv/
# Generated by Cargo
/target/
Cargo.lock
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Distribution / packaging
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Logs
logs/
*.log
# Unit test / coverage
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Azure credentials cache
.azure/
# Environment files
.env
# IDE
.vscode/
@@ -57,20 +11,10 @@ coverage.xml
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment variables
.env
.env.local
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Temporary files
*.bak
*.tmp
temp/
tmp/
# Logs
*.log
+46
View File
@@ -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
+21
View File
@@ -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.
+168 -206
View File
@@ -1,263 +1,225 @@
# Azure Key Vault Secret Manager
# Azure App Registration Manager
> A modern, user-friendly GUI application for managing Azure App Registration secrets and Key Vault integration.
A cross-platform Rust GUI application for managing Azure App Registrations and Key Vault secrets.
![Python](https://img.shields.io/badge/python-3.8+-blue.svg)
![License](https://img.shields.io/badge/license-MIT-green.svg)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)
## Features
## ✨ Features
- **Interactive Azure Authentication**: Browser-based OAuth 2.0 login flow
- **App Registration Management**: View and manage your Azure App Registrations
- **Client Secret Creation**: Generate new client secrets with automatic expiration
- **Key Vault Integration**: Securely store secrets in Azure Key Vault
- **Cross-Platform**: Works on Windows, Linux, and macOS
- **Secure Token Storage**: Uses OS-level secure storage (Credential Manager/Keychain)
- **Zero Configuration**: No app registration or credential files needed
- 🔐 **Single Sign-On**: Interactive browser authentication - login once for both Microsoft Graph and Azure
- 🎯 **Auto-Detection**: Automatically detects your Azure tenant ID from logged-in account
- 📋 **Subscription Selection**: Choose your subscription from a dropdown (no more config files!)
- 🔍 **Smart Dropdowns**: Searchable, scrollable lists with keyboard navigation (Arrow keys, Page Up/Down, Home/End)
- 💡 **Tooltips**: Hover over items to see full names if truncated
- 🔑 **Secret Management**: Generate 50-year secrets with custom descriptions
- 🗑️ **Cleanup**: Optionally remove old secrets when creating new ones
- 💾 **Key Vault Integration**: Automatic storage with metadata tags
- 📋 **Copy to Clipboard**: One-click secret copying
- 🎨 **Modern UI**: Clean interface built with CustomTkinter (supports dark/light themes)
-**Smooth Performance**: Optimized scrolling and no nested scroll lag
## Prerequisites
## 📸 Screenshots
- Rust 1.70+ (install from [rustup.rs](https://rustup.rs))
- Azure subscription with appropriate permissions
<!-- Add your screenshots here -->
```
[App Selection] [Secret Generation] [Result View]
```
## Quick Start
## 🔧 Prerequisites
- **Python 3.8+** (Python 3.11 recommended)
- **Azure Permissions**:
- Application.ReadWrite.All (Microsoft Graph API)
- Directory.Read.All (Microsoft Graph API)
- Key Vault Secrets Officer role on target Key Vaults
- Reader role on subscription/resource groups
**Note**: No need to create an App Registration! The app uses the Azure CLI public client ID for authentication.
## 🎨 Customization
### Adding a Custom Icon
To replace the default Python icon with your own:
1. Create an icon file (`.ico` for Windows or `.png` for cross-platform)
2. Place it in one of these locations:
- `python-app/icon.ico` or `python-app/icon.png`
- `python-app/assets/icon.ico` or `python-app/assets/icon.png`
3. The application will automatically detect and use it on next launch
**Recommended icon size**: 256x256 pixels
## 📦 Installation
### 1. Clone the Repository
### Installation and Run
```bash
git clone https://github.com/yourusername/azure-keyvault-manager.git
cd azure-keyvault-manager/python-app
git clone <your-repo-url>
cd azure-app-manager
cargo run --release
```
### 2. Create Virtual Environment
That's it! No configuration needed. Click "Sign In with Azure" and authenticate.
**Windows:**
```bash
python -m venv venv
venv\Scripts\activate
```
### How It Works
**Linux/macOS:**
```bash
python3 -m venv venv
source venv/bin/activate
```
This application uses Microsoft's **Azure CLI public client ID**, which is pre-approved for accessing Microsoft Graph and Azure Management APIs. You authenticate with your own Azure AD account and permissions. No app registration or configuration files needed.
### 3. Install Dependencies
## Usage
### Run the Application
```bash
pip install -r requirements.txt
cargo run --release
```
### 4. Run the Application
Or run the compiled binary:
```bash
python main.py
./target/release/azure-app-manager
```
**That's it!** No configuration files to edit - the app auto-detects everything.
### Workflow
## 🚀 Usage
1. **Sign In**: Click "Sign In with Azure" and complete authentication in your browser
2. **Select App**: Browse your app registrations and select one
3. **Create Secret**: Click "Create Secret" and enter a description
4. **Save to Vault**: Select a Key Vault and enter a name for the secret
5. **Done**: The secret is securely stored in your Key Vault
### Quick Start Guide
## Architecture
1. **Connect to Azure**
- Click **"Connect to Azure"**
- Browser opens automatically
- Sign in with your Azure account (admin credentials)
- ✅ Authentication completes (single login!)
### Technology Stack
2. **Select Subscription**
- Choose your Azure subscription from the dropdown
- Apps and Key Vaults load automatically
- **GUI Framework**: egui/eframe (immediate-mode, cross-platform)
- **Azure SDKs**:
- `graph-rs-sdk`: Microsoft Graph API integration
- `azure_security_keyvault_secrets`: Key Vault operations
- `azure_mgmt_keyvault`: Key Vault discovery
- **Async Runtime**: Tokio
- **Async-GUI Bridge**: poll-promise
- **Secure Storage**: keyring (OS-level credential storage)
3. **Select App Registration**
- Click the App Registration dropdown
- Scroll through the list or use keyboard navigation:
- `↑` `↓` Arrow keys to navigate
- `Page Up` `Page Down` to jump
- `Home` `End` for first/last
- `Enter` to select
- `Esc` to close
- Hover for tooltips on long names
4. **Generate Secret**
- Enter a description (e.g., "Production API Key 2025")
- Select a Key Vault
- *(Optional)* Check "Remove old secrets"
- Click **"Generate Secret"**
5. **Copy & Save**
- Secret is displayed once
- Click **"Copy to Clipboard"**
- Secret is automatically stored in Key Vault with metadata
- Click **"Generate Another Secret"** to continue
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `↓` `↑` | Navigate dropdown items |
| `Page Down` `Page Up` | Jump 5 items |
| `Home` `End` | First/Last item |
| `Enter` | Select item |
| `Escape` | Close dropdown |
| `Mouse Wheel` | Scroll in dropdown |
## 📁 Project Structure
### Project Structure
```
python-app/
├── main.py # Application entry point
├── config.py # App settings (no secrets!)
├── requirements.txt # Python dependencies
├── auth/
│ ├── graph_authenticator.py # Microsoft Graph authentication
│ └── azure_authenticator.py # Azure Resource Manager authentication
├── services/
│ ├── app_registration_service.py # App registration operations
│ ├── secret_service.py # Secret generation/management
── keyvault_service.py # Key Vault operations
├── ui/
│ ├── components/
│ ├── unified_dropdown.py # Custom dropdown component
│ └── tooltip.py # Tooltip utility
│ ├── main_window.py # Main application window
├── login_frame.py # Authentication UI
├── subscription_selection_frame.py
├── app_selection_frame.py # App selection UI
├── secret_generation_frame.py # Secret generation form
└── result_frame.py # Result display
└── utils/
├── sanitizer.py # Name sanitization
└── logger.py # Logging setup
src/
├── main.rs # Application entry point
├── app.rs # Main app logic (eframe::App implementation)
├── error.rs # Error types
├── auth/ # Authentication
│ ├── azure_auth.rs # OAuth flow
│ └── token_cache.rs # Secure token storage
├── azure/ # Azure API clients
│ ├── graph_client.rs # Microsoft Graph API
│ ├── keyvault_client.rs # Key Vault operations
── vault_discovery.rs # Key Vault listing
│ └── models.rs # Data models
├── state/ # Application state
│ ├── app_state.rs # Central state management
│ └── async_operations.rs # Async operation tracking
└── ui/ # UI views
├── auth_view.rs # Login screen
├── app_list_view.rs # App registration list
├── secret_create_view.rs # Secret creation form
├── keyvault_select_view.rs # Key Vault selection
└── components.rs # Reusable UI components
```
## 🐛 Troubleshooting
## Security Features
### Authentication Issues
### Token Security
**Problem**: "Authentication failed"
- **Solution**: Ensure you have the required permissions in Azure AD
- Clear cached credentials: Delete `.azure` folder in your home directory
- Verify your account has access to the Azure subscription
- Access tokens stored in OS-level secure storage:
- **Windows**: Credential Manager
- **macOS**: Keychain
- **Linux**: Secret Service (gnome-keyring/kwallet)
- Automatic token refresh before expiration
- Secure memory clearing with `zeroize`
**Problem**: Double login prompts
- **Solution**: This has been fixed in the latest version - you should only login once
### Secret Handling
### Permission Errors
- Secrets wrapped in `SensitiveString` with automatic memory zeroing
- No disk persistence of secrets
- Custom Debug implementation prevents accidental logging
- Immediate prompt to save to Key Vault
**Problem**: "Failed to list applications"
- **Solution**: Request `Application.ReadWrite.All` and `Directory.Read.All` permissions from your Azure AD admin
## Platform-Specific Notes
**Problem**: "Failed to store secret in Key Vault"
- **Solution**: Ensure you have **Key Vault Secrets Officer** role on the target vault
- Check Key Vault network settings allow your IP address
### macOS
### UI Issues
Due to limitations in the graph-rs-sdk, macOS uses **device code flow** instead of interactive browser flow:
**Problem**: Dropdown list won't scroll
- **Solution**: Updated in latest version - mouse wheel now scrolls the dropdown properly
1. A code will be displayed in the application
2. Open the provided URL in your browser
3. Enter the code and complete authentication
4. Return to the application
**Problem**: Can't see all applications
- **Solution**: Use keyboard navigation (arrow keys) or mouse wheel to scroll through large lists
### Linux
### General Issues
**Problem**: No subscriptions found
- **Solution**: Verify your account has at least Reader access to one Azure subscription
**Problem**: No Key Vaults appear
- **Solution**: Create a Key Vault in your subscription or request access to existing ones
## 📝 Logs
Application logs are stored in: `logs/app_YYYYMMDD.log`
Log levels:
- **INFO**: Normal operations
- **ERROR**: Failed operations with stack traces
## 🔒 Security Best Practices
- ✅ Secrets are **only displayed once** in the UI
- ✅ Secrets are **never logged** to files
- ✅ Authentication uses Azure Identity library (secure token caching)
- ✅ Uses Azure CLI public client ID (no app registration needed)
- ⚠️ **Always copy secrets immediately** - they cannot be retrieved later
- ⚠️ Store secrets in a secure password manager after generation
## 🏗️ Building Executable (Optional)
Create a standalone executable:
Requires a secret service backend (gnome-keyring or kwallet) for secure token storage:
```bash
pip install pyinstaller
pyinstaller --onefile --windowed --name AzureKeyVaultManager main.py
# Ubuntu/Debian
sudo apt install gnome-keyring
# Arch Linux
sudo pacman -S gnome-keyring
```
Output: `dist/AzureKeyVaultManager.exe` (Windows) or `dist/AzureKeyVaultManager` (Linux/macOS)
### Windows
**Note**: Executable size will be ~50-100MB due to bundled dependencies.
No additional dependencies required. Uses Windows Credential Manager.
## 🤝 Contributing
## Troubleshooting
Contributions are welcome! Please feel free to submit a Pull Request.
### Authentication Fails
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
- Ensure you have appropriate permissions in your Azure AD tenant
- Check your internet connection
- Review logs with `LOG_LEVEL=debug cargo run`
- Some organizations may have conditional access policies that require MFA or compliant devices
## 📄 License
### No Key Vaults Found
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
- Verify you have Key Vaults in your subscription
- Check that your user has appropriate RBAC permissions
- Ensure the Management API scope was granted
## 🙏 Acknowledgments
### Token Cache Errors
- Built with [CustomTkinter](https://github.com/TomSchimansky/CustomTkinter) by Tom Schimansky
- Uses [Azure SDK for Python](https://github.com/Azure/azure-sdk-for-python)
- Uses [Microsoft Graph SDK for Python](https://github.com/microsoftgraph/msgraph-sdk-python)
- On Linux: Install and start gnome-keyring or kwallet
- On macOS: Check Keychain Access permissions
- On Windows: Check Windows Credential Manager
## 📮 Support
## Development
For issues, questions, or suggestions:
- 🐛 [Open an issue](https://github.com/yourusername/azure-keyvault-manager/issues)
- 💬 [Start a discussion](https://github.com/yourusername/azure-keyvault-manager/discussions)
### Run in Debug Mode
---
```bash
cargo run
```
**Made with ❤️ for Azure administrators**
### Run Tests
```bash
cargo test
```
### Enable Debug Logging
```bash
LOG_LEVEL=debug cargo run
```
## Building for Release
### Current Platform
```bash
cargo build --release
```
### Cross-Platform (requires setup)
```bash
# Windows
cargo build --release --target x86_64-pc-windows-msvc
# Linux
cargo build --release --target x86_64-unknown-linux-gnu
# macOS
cargo build --release --target x86_64-apple-darwin
```
## Contributing
Contributions are welcome. Please ensure:
- Code follows Rust best practices
- All tests pass
- Security considerations are maintained
- Documentation is updated
## License
MIT License - See LICENSE file for details
## Acknowledgments
- Microsoft Graph SDK team for graph-rs-sdk
- Azure SDK for Rust team
- egui framework creators
## Support
For issues and feature requests, please use the GitHub issue tracker.
+50
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

View File
-209
View File
@@ -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
-117
View File
@@ -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
-16
View File
@@ -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
+35
View File
@@ -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");
}
}
-15
View File
@@ -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..."
-21
View File
@@ -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
View File
@@ -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]
-8
View File
@@ -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')
-323
View File
@@ -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()
-19
View File
@@ -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
View File
-99
View File
@@ -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)}")
-187
View File
@@ -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)}")
-155
View File
@@ -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
View File
@@ -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),
}
}
}
+596
View File
@@ -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(&params)
.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(&params)
.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(&params)
.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()
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod azure_auth;
pub mod token_cache;
pub use azure_auth::AzureAuthenticator;
+88
View File
@@ -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")
}
}
+150
View File
@@ -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),
}
}
}
+186
View File
@@ -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),
}
}
}
+10
View File
@@ -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;
+96
View File
@@ -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>,
}
+165
View File
@@ -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),
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod window_config;
pub use window_config::{Config, load_config};
+267
View File
@@ -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()
}
+94
View File
@@ -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>;
+2
View File
@@ -0,0 +1,2 @@
pub mod auth;
pub mod error;
+80
View File
@@ -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(())
}
+136
View File
@@ -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()
}
}
+55
View File
@@ -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()
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod app_state;
pub mod async_operations;
pub use app_state::{AppState, AuthStatus, ViewState};
+214
View File
@@ -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,
}
+66
View File
@@ -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,
}
+170
View File
@@ -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");
}
}
}
+61
View File
@@ -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));
});
}
+323
View File
@@ -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,
}
+12
View File
@@ -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};
+106
View File
@@ -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,
}
View File
-89
View File
@@ -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)
-10
View File
@@ -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']
-139
View File
@@ -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
-781
View File
@@ -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)
-101
View File
@@ -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")
-299
View File
@@ -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.")
-197
View File
@@ -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()
-183
View File
@@ -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()
-89
View File
@@ -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)
View File
-108
View File
@@ -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")
-69
View File
@@ -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()
-37
View File
@@ -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