First commit
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# 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/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# Temporary files
|
||||
*.bak
|
||||
*.tmp
|
||||
temp/
|
||||
tmp/
|
||||
@@ -0,0 +1,263 @@
|
||||
# Azure Key Vault Secret Manager
|
||||
|
||||
> A modern, user-friendly GUI application for managing Azure App Registration secrets and Key Vault integration.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔐 **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
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Add your screenshots here -->
|
||||
```
|
||||
[App Selection] [Secret Generation] [Result View]
|
||||
```
|
||||
|
||||
## 🔧 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
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/azure-keyvault-manager.git
|
||||
cd azure-keyvault-manager/python-app
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
**That's it!** No configuration files to edit - the app auto-detects everything.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Quick Start Guide
|
||||
|
||||
1. **Connect to Azure**
|
||||
- Click **"Connect to Azure"**
|
||||
- Browser opens automatically
|
||||
- Sign in with your Azure account (admin credentials)
|
||||
- ✅ Authentication completes (single login!)
|
||||
|
||||
2. **Select Subscription**
|
||||
- Choose your Azure subscription from the dropdown
|
||||
- Apps and Key Vaults load automatically
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
**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
|
||||
|
||||
**Problem**: Double login prompts
|
||||
- **Solution**: This has been fixed in the latest version - you should only login once
|
||||
|
||||
### Permission Errors
|
||||
|
||||
**Problem**: "Failed to list applications"
|
||||
- **Solution**: Request `Application.ReadWrite.All` and `Directory.Read.All` permissions from your Azure AD admin
|
||||
|
||||
**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
|
||||
|
||||
### UI Issues
|
||||
|
||||
**Problem**: Dropdown list won't scroll
|
||||
- **Solution**: Updated in latest version - mouse wheel now scrolls the dropdown properly
|
||||
|
||||
**Problem**: Can't see all applications
|
||||
- **Solution**: Use keyboard navigation (arrow keys) or mouse wheel to scroll through large lists
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
pyinstaller --onefile --windowed --name AzureKeyVaultManager main.py
|
||||
```
|
||||
|
||||
Output: `dist/AzureKeyVaultManager.exe` (Windows) or `dist/AzureKeyVaultManager` (Linux/macOS)
|
||||
|
||||
**Note**: Executable size will be ~50-100MB due to bundled dependencies.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
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
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- 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)
|
||||
|
||||
## 📮 Support
|
||||
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for Azure administrators**
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
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 authenticate(self, credential: InteractiveBrowserCredential = None) -> bool:
|
||||
"""
|
||||
Authenticate to Azure. Can reuse credential from Graph authenticator.
|
||||
|
||||
Args:
|
||||
credential: Optional credential to reuse (from GraphAuthenticator)
|
||||
|
||||
Returns:
|
||||
bool: True if authentication succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
Exception: If authentication fails
|
||||
"""
|
||||
try:
|
||||
if credential:
|
||||
# Reuse credential from Graph authentication
|
||||
self.credential = credential
|
||||
else:
|
||||
# Create new interactive browser credential with "organizations" tenant
|
||||
# This allows the user to login with any organizational account
|
||||
# additionally_allowed_tenants="*" allows acquiring tokens for any tenant
|
||||
self.credential = InteractiveBrowserCredential(
|
||||
tenant_id="organizations",
|
||||
additionally_allowed_tenants=["*"]
|
||||
)
|
||||
|
||||
# List all subscriptions (this will use the cached token from Graph auth)
|
||||
sub_client = SubscriptionClient(self.credential)
|
||||
subscriptions_list = list(sub_client.subscriptions.list())
|
||||
|
||||
# Extract tenant ID from the first subscription
|
||||
if subscriptions_list:
|
||||
# Get tenant ID from subscription (format: /subscriptions/{sub-id})
|
||||
# The tenant info is in the subscription object
|
||||
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
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
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) -> bool:
|
||||
"""
|
||||
Authenticate to Microsoft Graph using interactive browser login.
|
||||
|
||||
Returns:
|
||||
bool: True if authentication succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
Exception: If authentication fails
|
||||
"""
|
||||
try:
|
||||
# Create interactive browser credential
|
||||
# Using "organizations" allows login with any organizational account
|
||||
# additionally_allowed_tenants="*" allows acquiring tokens for any tenant (needed for Key Vault access)
|
||||
self.credential = InteractiveBrowserCredential(
|
||||
tenant_id="organizations",
|
||||
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
|
||||
)
|
||||
|
||||
# Authenticate to Graph API FIRST
|
||||
# This triggers the initial browser auth for Graph scope
|
||||
# Then Management API will use SSO (single sign-on) from this auth
|
||||
me = await self.client.me.get()
|
||||
|
||||
if me:
|
||||
print(f"Successfully authenticated as: {me.display_name} ({me.user_principal_name})")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
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."""
|
||||
try:
|
||||
self.logger.info("Starting authentication...")
|
||||
|
||||
# Authenticate to Microsoft Graph
|
||||
await self.graph_auth.authenticate()
|
||||
|
||||
# Authenticate to Azure (reuse credential)
|
||||
credential = self.graph_auth.get_credential()
|
||||
self.azure_auth.authenticate(credential)
|
||||
|
||||
# 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 (should use SSO from Graph auth above)
|
||||
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()
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
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)}")
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
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)}")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
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)}")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
UI Components Package
|
||||
|
||||
Custom reusable UI components for the application.
|
||||
"""
|
||||
|
||||
from .unified_dropdown import UnifiedDropdown
|
||||
from .tooltip import ToolTip
|
||||
|
||||
__all__ = ['UnifiedDropdown', 'ToolTip']
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
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] = []
|
||||
|
||||
# Popup window
|
||||
self.popup_window: Optional[tk.Toplevel] = None
|
||||
self.popup_frame: Optional[ctk.CTkScrollableFrame] = None
|
||||
|
||||
# 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())
|
||||
|
||||
# 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
|
||||
"""
|
||||
self.items = 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 _toggle_dropdown(self):
|
||||
"""Toggle dropdown popup visibility."""
|
||||
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
|
||||
|
||||
# Calculate dynamic height based on number of items
|
||||
# Each item: 40px button + 4px padding = 44px per item
|
||||
item_height = 44
|
||||
calculated_height = len(self.items) * item_height + 10 # +10 for padding
|
||||
|
||||
# Use calculated height, but cap at max_dropdown_height
|
||||
popup_height = min(calculated_height, self.max_dropdown_height)
|
||||
|
||||
# Create scrollable frame for items
|
||||
self.popup_frame = ctk.CTkScrollableFrame(
|
||||
self.popup_window,
|
||||
width=self.button_width - 20,
|
||||
height=popup_height
|
||||
)
|
||||
self.popup_frame.pack(fill="both", expand=True)
|
||||
|
||||
# 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] + "..."
|
||||
|
||||
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"
|
||||
)
|
||||
btn.grid(row=idx, column=0, padx=5, pady=2, 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 (use correct Tkinter key names)
|
||||
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) # PageDown in Tkinter
|
||||
self.popup_window.bind("<Prior>", self._navigate_page_up) # PageUp in Tkinter
|
||||
self.popup_window.bind("<Home>", self._navigate_home)
|
||||
self.popup_window.bind("<End>", self._navigate_end)
|
||||
self.popup_window.bind("<Return>", self._confirm_selection)
|
||||
self.popup_window.bind("<FocusOut>", lambda e: self._close_dropdown())
|
||||
|
||||
# Set focus to popup
|
||||
self.popup_window.focus_set()
|
||||
|
||||
# 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:
|
||||
# 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()
|
||||
|
||||
def _position_popup(self):
|
||||
"""Position the popup window below the button."""
|
||||
# Update to get accurate dimensions
|
||||
self.popup_window.update_idletasks()
|
||||
|
||||
# Get button position
|
||||
x = self.dropdown_button.winfo_rootx()
|
||||
y = self.dropdown_button.winfo_rooty() + self.dropdown_button.winfo_height()
|
||||
|
||||
# Get screen dimensions
|
||||
screen_width = self.winfo_screenwidth()
|
||||
screen_height = self.winfo_screenheight()
|
||||
|
||||
# Get popup dimensions
|
||||
popup_width = self.popup_window.winfo_width()
|
||||
popup_height = self.popup_window.winfo_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 = self.dropdown_button.winfo_rooty() - popup_height
|
||||
|
||||
self.popup_window.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
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()
|
||||
|
||||
if not (popup_x <= x <= popup_x + popup_width and
|
||||
popup_y <= y <= popup_y + popup_height):
|
||||
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=("gray75", "gray25"),
|
||||
border_color="blue"
|
||||
)
|
||||
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 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)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
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")
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
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")
|
||||
self.geometry("950x850")
|
||||
|
||||
# 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.")
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Secret Generation Frame
|
||||
|
||||
UI component for secret generation form.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import List, Dict, Callable, Optional
|
||||
|
||||
|
||||
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 Label
|
||||
self.vault_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Select Key Vault:",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
self.vault_label.grid(row=3, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w")
|
||||
|
||||
# Key Vault Dropdown Button (simplified inline version)
|
||||
self.vault_dropdown_button = ctk.CTkButton(
|
||||
self,
|
||||
text="Please select a subscription first",
|
||||
command=self._open_vault_dropdown,
|
||||
width=600,
|
||||
height=40,
|
||||
font=ctk.CTkFont(size=14),
|
||||
anchor="w",
|
||||
state="disabled"
|
||||
)
|
||||
self.vault_dropdown_button.grid(row=4, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
|
||||
|
||||
# Store vaults and selection
|
||||
self.vaults = []
|
||||
self.selected_vault = None
|
||||
self.vault_popup = None
|
||||
|
||||
# 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=5, 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=6, 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.vaults = vaults
|
||||
|
||||
if vaults:
|
||||
self.vault_dropdown_button.configure(state="normal")
|
||||
# Auto-select first vault
|
||||
self._select_vault(vaults[0])
|
||||
else:
|
||||
self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found")
|
||||
self.selected_vault = None
|
||||
|
||||
def _open_vault_dropdown(self):
|
||||
"""Open the vault selection popup."""
|
||||
if not self.vaults or self.vault_popup:
|
||||
return
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
# Create popup
|
||||
self.vault_popup = tk.Toplevel(self)
|
||||
self.vault_popup.wm_overrideredirect(True)
|
||||
self.vault_popup.wm_attributes("-topmost", True)
|
||||
|
||||
# Calculate height
|
||||
item_height = 44
|
||||
calculated_height = len(self.vaults) * item_height + 10
|
||||
popup_height = min(calculated_height, 300)
|
||||
|
||||
# Create scrollable frame
|
||||
scroll_frame = ctk.CTkScrollableFrame(
|
||||
self.vault_popup,
|
||||
width=580,
|
||||
height=popup_height
|
||||
)
|
||||
scroll_frame.pack(fill="both", expand=True)
|
||||
|
||||
# Configure scroll speed to match main window (40px per scroll unit = 2x faster)
|
||||
scroll_frame._parent_canvas.configure(yscrollincrement=40)
|
||||
|
||||
# Create buttons
|
||||
for idx, vault in enumerate(self.vaults):
|
||||
display_text = f"{vault['name']} (RG: {vault['resource_group']})"
|
||||
max_chars = 60
|
||||
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
|
||||
|
||||
btn = ctk.CTkButton(
|
||||
scroll_frame,
|
||||
text=truncated,
|
||||
command=lambda v=vault: self._select_vault_and_close(v),
|
||||
font=ctk.CTkFont(size=14),
|
||||
height=40,
|
||||
fg_color="transparent",
|
||||
border_width=1,
|
||||
border_color="gray50",
|
||||
hover_color=("gray70", "gray30"),
|
||||
anchor="w"
|
||||
)
|
||||
btn.grid(row=idx, column=0, padx=5, pady=2, sticky="ew")
|
||||
scroll_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Bind mouse wheel
|
||||
def popup_scroll(event):
|
||||
if event.delta > 0:
|
||||
scroll_frame._parent_canvas.yview_scroll(-1, "units")
|
||||
else:
|
||||
scroll_frame._parent_canvas.yview_scroll(1, "units")
|
||||
return "break"
|
||||
|
||||
btn.bind("<MouseWheel>", popup_scroll, add="+")
|
||||
|
||||
# Position popup
|
||||
self.vault_popup.update_idletasks()
|
||||
x = self.vault_dropdown_button.winfo_rootx()
|
||||
y = self.vault_dropdown_button.winfo_rooty() + self.vault_dropdown_button.winfo_height()
|
||||
self.vault_popup.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
# Bind events
|
||||
self.vault_popup.bind("<Escape>", lambda e: self._close_vault_popup())
|
||||
self.vault_popup.bind("<FocusOut>", lambda e: self._close_vault_popup())
|
||||
self.vault_popup.focus_set()
|
||||
|
||||
def _select_vault(self, vault: Dict):
|
||||
"""Select a vault and update button text."""
|
||||
self.selected_vault = vault
|
||||
display_text = f"{vault['name']} (RG: {vault['resource_group']})"
|
||||
max_chars = 60
|
||||
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
|
||||
self.vault_dropdown_button.configure(text=truncated)
|
||||
|
||||
def _select_vault_and_close(self, vault: Dict):
|
||||
"""Select vault and close popup."""
|
||||
self._select_vault(vault)
|
||||
self._close_vault_popup()
|
||||
|
||||
def _close_vault_popup(self):
|
||||
"""Close the vault popup."""
|
||||
if self.vault_popup:
|
||||
self.vault_popup.destroy()
|
||||
self.vault_popup = None
|
||||
|
||||
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.selected_vault
|
||||
|
||||
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")
|
||||
if self.vaults:
|
||||
self.vault_dropdown_button.configure(state="normal")
|
||||
self.remove_old_checkbox.configure(state="normal")
|
||||
self.generate_button.configure(state="normal")
|
||||
else:
|
||||
self.description_entry.configure(state="disabled")
|
||||
self.vault_dropdown_button.configure(state="disabled")
|
||||
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
|
||||
"""
|
||||
if loading:
|
||||
self.vault_dropdown_button.configure(state="disabled", text="Loading Key Vaults...")
|
||||
else:
|
||||
if self.vaults:
|
||||
self.vault_dropdown_button.configure(state="normal")
|
||||
else:
|
||||
self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found")
|
||||
|
||||
def reset(self):
|
||||
"""Reset the form to initial state."""
|
||||
self.description_entry.delete(0, 'end')
|
||||
self.remove_old_checkbox.deselect()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
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")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Name Sanitization Utility
|
||||
|
||||
Sanitizes names for use in Azure Key Vault.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def sanitize_name(name: str) -> str:
|
||||
"""
|
||||
Sanitize a name for use in Azure Key Vault.
|
||||
Key Vault secret names can only contain alphanumeric characters and hyphens.
|
||||
|
||||
Args:
|
||||
name: The name to sanitize
|
||||
|
||||
Returns:
|
||||
str: Sanitized name
|
||||
|
||||
Example:
|
||||
>>> sanitize_name("My App (Production) #2024")
|
||||
'My-App-Production-2024'
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
|
||||
# Replace any non-alphanumeric character (except hyphens) with hyphen
|
||||
sanitized = re.sub(r'[^0-9a-zA-Z-]', '-', name)
|
||||
|
||||
# Remove consecutive hyphens
|
||||
sanitized = re.sub(r'-+', '-', sanitized)
|
||||
|
||||
# Remove leading/trailing hyphens
|
||||
sanitized = sanitized.strip('-')
|
||||
|
||||
return sanitized
|
||||
Reference in New Issue
Block a user