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