mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-07 01:03:15 +02:00
feat: add contact management MVP (manual approval + pending)
Implements Contact Management UI with manual contact approval and pending contacts list. **Backend changes (meshcore-bridge/bridge.py):** - Remove forced manual_add_contacts on session init (was for testing only) - Add _load_webui_settings() to read .webui_settings.json from MC_CONFIG_DIR - Add /set_manual_add_contacts endpoint for persistent settings management - Settings now persist across container restarts via .webui_settings.json **Backend changes (app/meshcore/cli.py):** - Add get_pending_contacts() - proxy to bridge /pending_contacts - Add approve_pending_contact() - proxy to bridge /add_pending (always uses full public_key) - Add get_device_settings() - read .webui_settings.json - Add set_manual_add_contacts() - proxy to bridge /set_manual_add_contacts **API changes (app/routes/api.py):** - Add GET /api/contacts/pending - list pending contacts - Add POST /api/contacts/pending/approve - approve contact by public_key - Add GET /api/device/settings - get persistent settings - Add POST /api/device/settings - update manual_add_contacts setting **Frontend (app/routes/views.py, templates, js):** - Add /contacts/manage route rendering contacts.html - Add contacts.html template with mobile-first design - Add contacts.js with settings toggle and pending list UI - Add "Contact Management" menu item in base.html - Features: manual approval toggle, pending list, approve/copy actions, toast notifications **Documentation (README.md):** - Add Contact Management section in Usage - Add to Key Features list - Add debugging instructions **Key features:** - Manual approval toggle (persists across restarts) - Pending contacts list with name and public_key - Approve button (always sends full public_key for compatibility) - Copy full key button (clipboard API) - Auto-refresh on page load - Mobile-first responsive design - Info badge when manual approval is disabled - Toast notifications for user feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to
|
||||
- 🔐 **Channel sharing** - Share channels via QR code or encrypted keys
|
||||
- 🔓 **Public channels** - Join public channels (starting with #) without encryption keys
|
||||
- 🎯 **Reply to users** - Quick reply with `@[UserName]` format
|
||||
- 👥 **Contact management** - Manual contact approval mode with pending contacts list (persistent settings)
|
||||
- 🧹 **Clean contacts** - Remove inactive contacts with configurable threshold
|
||||
- 📦 **Message archiving** - Automatic daily archiving with browse-by-date selector
|
||||
- ⚡ **Efficient polling** - Lightweight update checks every 10s, UI refreshes only when needed
|
||||
@@ -307,7 +308,64 @@ Access the Direct Messages feature:
|
||||
- Each conversation shows unread indicator (*) in the dropdown
|
||||
- DM badge in the menu shows total unread DM count
|
||||
|
||||
### Managing Contacts
|
||||
### Contact Management
|
||||
|
||||
Access the Contact Management feature to control who can connect to your node:
|
||||
|
||||
**From the menu:**
|
||||
1. Click the menu icon (☰) in the navbar
|
||||
2. Select "Contact Management" from the menu
|
||||
3. Opens the contact management page
|
||||
|
||||
#### Manual Contact Approval
|
||||
|
||||
By default, new contacts attempting to connect are automatically added to your contacts list. You can enable manual approval to control who can communicate with your node.
|
||||
|
||||
**Enable manual approval:**
|
||||
1. On the Contact Management page, toggle the "Manual Contact Approval" switch
|
||||
2. When enabled, new contact requests will appear in the Pending Contacts list
|
||||
3. This setting persists across container restarts
|
||||
|
||||
**Security benefits:**
|
||||
- **Control over network access** - Only approved contacts can communicate with your node
|
||||
- **Prevention of spam/unwanted contacts** - Filter out random nodes attempting connection
|
||||
- **Explicit trust model** - You decide who to trust on the mesh network
|
||||
|
||||
#### Pending Contacts
|
||||
|
||||
When manual approval is enabled, new contacts appear in the Pending Contacts list for review:
|
||||
|
||||
**Approve a contact:**
|
||||
1. View the contact name and truncated public key
|
||||
2. Click "Copy Full Key" to copy the complete public key (useful for verification)
|
||||
3. Click "Approve" to add the contact to your contacts list
|
||||
4. The contact is moved from pending to regular contacts
|
||||
|
||||
**Note:** Always use the full public key for approval (not name or prefix). This ensures compatibility with all contact types (CLI, ROOM, REP, SENS).
|
||||
|
||||
**Refresh pending list:**
|
||||
- Click the "Refresh" button to check for new pending contacts
|
||||
- The page automatically loads pending contacts when first opened
|
||||
|
||||
#### Debugging
|
||||
|
||||
If you encounter issues with contact management:
|
||||
|
||||
**Check logs:**
|
||||
```bash
|
||||
# mc-webui container logs
|
||||
docker compose logs -f mc-webui
|
||||
|
||||
# meshcore-bridge container logs (where settings are applied)
|
||||
docker compose logs -f meshcore-bridge
|
||||
```
|
||||
|
||||
**Look for:**
|
||||
- "Loaded webui settings" - confirms settings file is being read
|
||||
- "manual_add_contacts set to on/off" - confirms setting is applied to meshcli session
|
||||
- "Saved manual_add_contacts=..." - confirms setting is persisted to file
|
||||
|
||||
### Managing Contacts (Cleanup)
|
||||
|
||||
Access the settings panel to clean up inactive contacts:
|
||||
1. Click the settings icon
|
||||
|
||||
@@ -4,7 +4,9 @@ MeshCore CLI wrapper - executes meshcli commands via HTTP bridge
|
||||
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List, Dict
|
||||
from app.config import config
|
||||
|
||||
@@ -391,3 +393,159 @@ def send_dm(recipient: str, text: str) -> Tuple[bool, str]:
|
||||
|
||||
success, stdout, stderr = _run_command(['msg', recipient.strip(), text.strip()])
|
||||
return success, stdout or stderr
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Contact Management (Pending Contacts)
|
||||
# =============================================================================
|
||||
|
||||
def get_pending_contacts() -> Tuple[bool, List[Dict], str]:
|
||||
"""
|
||||
Get list of contacts awaiting manual approval.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, pending_contacts_list, error_message)
|
||||
Each contact dict: {
|
||||
'name': str,
|
||||
'public_key': str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{config.MC_BRIDGE_URL.replace('/cli', '/pending_contacts')}",
|
||||
timeout=DEFAULT_TIMEOUT + 5
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False, [], f'Bridge HTTP error: {response.status_code}'
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success', False):
|
||||
error = data.get('error', 'Failed to get pending contacts')
|
||||
return False, [], error
|
||||
|
||||
pending = data.get('pending', [])
|
||||
return True, pending, ""
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return False, [], 'Bridge timeout'
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, [], 'Cannot connect to meshcore-bridge service'
|
||||
except Exception as e:
|
||||
return False, [], str(e)
|
||||
|
||||
|
||||
def approve_pending_contact(public_key: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Approve and add a pending contact by public key.
|
||||
|
||||
Args:
|
||||
public_key: Full public key of the contact to approve (REQUIRED - full key works for all contact types)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not public_key or not public_key.strip():
|
||||
return False, "Public key is required"
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.MC_BRIDGE_URL.replace('/cli', '/add_pending')}",
|
||||
json={'selector': public_key.strip()},
|
||||
timeout=DEFAULT_TIMEOUT + 5
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False, f'Bridge HTTP error: {response.status_code}'
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success', False):
|
||||
error = data.get('stderr', 'Failed to approve contact')
|
||||
return False, error
|
||||
|
||||
stdout = data.get('stdout', 'Contact approved successfully')
|
||||
return True, stdout
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return False, 'Bridge timeout'
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, 'Cannot connect to meshcore-bridge service'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Settings (Persistent Configuration)
|
||||
# =============================================================================
|
||||
|
||||
def get_device_settings() -> Tuple[bool, Dict]:
|
||||
"""
|
||||
Get persistent device settings from .webui_settings.json.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, settings_dict)
|
||||
Settings dict currently contains:
|
||||
{
|
||||
'manual_add_contacts': bool
|
||||
}
|
||||
"""
|
||||
settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json"
|
||||
|
||||
try:
|
||||
if not settings_path.exists():
|
||||
# Return defaults if file doesn't exist
|
||||
return True, {'manual_add_contacts': False}
|
||||
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
# Ensure manual_add_contacts exists
|
||||
if 'manual_add_contacts' not in settings:
|
||||
settings['manual_add_contacts'] = False
|
||||
return True, settings
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read device settings: {e}")
|
||||
return False, {'manual_add_contacts': False}
|
||||
|
||||
|
||||
def set_manual_add_contacts(enabled: bool) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable or disable manual contact approval mode.
|
||||
|
||||
This setting is:
|
||||
1. Saved to .webui_settings.json for persistence across container restarts
|
||||
2. Applied immediately to the running meshcli session via bridge
|
||||
|
||||
Args:
|
||||
enabled: True to enable manual approval, False for automatic
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.MC_BRIDGE_URL.replace('/cli', '/set_manual_add_contacts')}",
|
||||
json={'enabled': enabled},
|
||||
timeout=DEFAULT_TIMEOUT + 5
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return False, f'Bridge HTTP error: {response.status_code}'
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success', False):
|
||||
error = data.get('error', 'Failed to set manual_add_contacts')
|
||||
return False, error
|
||||
|
||||
message = data.get('message', f"manual_add_contacts set to {'on' if enabled else 'off'}")
|
||||
return True, message
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return False, 'Bridge timeout'
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, 'Cannot connect to meshcore-bridge service'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@@ -1198,3 +1198,207 @@ def get_dm_updates():
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Contact Management (Pending Contacts & Settings)
|
||||
# =============================================================================
|
||||
|
||||
@api_bp.route('/contacts/pending', methods=['GET'])
|
||||
def get_pending_contacts_api():
|
||||
"""
|
||||
Get list of contacts awaiting manual approval.
|
||||
|
||||
Returns:
|
||||
JSON with pending contacts list:
|
||||
{
|
||||
"success": true,
|
||||
"pending": [
|
||||
{
|
||||
"name": "Skyllancer",
|
||||
"public_key": "f9ef123abc..."
|
||||
},
|
||||
...
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
"""
|
||||
try:
|
||||
success, pending, error = cli.get_pending_contacts()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'pending': pending,
|
||||
'count': len(pending)
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': error or 'Failed to get pending contacts',
|
||||
'pending': []
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pending contacts: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'pending': []
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/pending/approve', methods=['POST'])
|
||||
def approve_pending_contact_api():
|
||||
"""
|
||||
Approve and add a pending contact.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"public_key": "<full_public_key>"
|
||||
}
|
||||
|
||||
IMPORTANT: Always send the full public_key (not name or prefix).
|
||||
Full public key works for all contact types (CLI, ROOM, REP, SENS).
|
||||
|
||||
Returns:
|
||||
JSON with approval result:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Contact approved successfully"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'public_key' not in data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing required field: public_key'
|
||||
}), 400
|
||||
|
||||
public_key = data['public_key']
|
||||
|
||||
if not isinstance(public_key, str) or not public_key.strip():
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'public_key must be a non-empty string'
|
||||
}), 400
|
||||
|
||||
success, message = cli.approve_pending_contact(public_key)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message or 'Contact approved successfully'
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': message
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error approving pending contact: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/device/settings', methods=['GET'])
|
||||
def get_device_settings_api():
|
||||
"""
|
||||
Get persistent device settings.
|
||||
|
||||
Returns:
|
||||
JSON with settings:
|
||||
{
|
||||
"success": true,
|
||||
"settings": {
|
||||
"manual_add_contacts": false
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
success, settings = cli.get_device_settings()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'settings': settings
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to get device settings',
|
||||
'settings': {'manual_add_contacts': False}
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting device settings: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'settings': {'manual_add_contacts': False}
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/device/settings', methods=['POST'])
|
||||
def update_device_settings_api():
|
||||
"""
|
||||
Update persistent device settings.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"manual_add_contacts": true/false
|
||||
}
|
||||
|
||||
This setting is:
|
||||
1. Saved to .webui_settings.json for persistence across container restarts
|
||||
2. Applied immediately to the running meshcli session
|
||||
|
||||
Returns:
|
||||
JSON with update result:
|
||||
{
|
||||
"success": true,
|
||||
"message": "manual_add_contacts set to on"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'manual_add_contacts' not in data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing required field: manual_add_contacts'
|
||||
}), 400
|
||||
|
||||
manual_add_contacts = data['manual_add_contacts']
|
||||
|
||||
if not isinstance(manual_add_contacts, bool):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'manual_add_contacts must be a boolean'
|
||||
}), 400
|
||||
|
||||
success, message = cli.set_manual_add_contacts(manual_add_contacts)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message,
|
||||
'settings': {'manual_add_contacts': manual_add_contacts}
|
||||
}), 200
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': message
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating device settings: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -41,6 +41,18 @@ def direct_messages():
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/contacts/manage')
|
||||
def contact_management():
|
||||
"""
|
||||
Contact Management view - manual approval settings and pending contacts list.
|
||||
"""
|
||||
return render_template(
|
||||
'contacts.html',
|
||||
device_name=config.MC_DEVICE_NAME,
|
||||
refresh_interval=config.MC_REFRESH_INTERVAL
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/health')
|
||||
def health():
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Contact Management UI
|
||||
*
|
||||
* Features:
|
||||
* - Manual contact approval toggle (persistent across restarts)
|
||||
* - Pending contacts list with approve/copy actions
|
||||
* - Auto-refresh on page load
|
||||
* - Mobile-first design
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// State Management
|
||||
// =============================================================================
|
||||
|
||||
let manualApprovalEnabled = false;
|
||||
let pendingContacts = [];
|
||||
|
||||
// =============================================================================
|
||||
// Initialization
|
||||
// =============================================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('Contact Management UI initialized');
|
||||
|
||||
// Attach event listeners
|
||||
attachEventListeners();
|
||||
|
||||
// Load initial state
|
||||
loadSettings();
|
||||
loadPendingContacts();
|
||||
});
|
||||
|
||||
function attachEventListeners() {
|
||||
// Manual approval toggle
|
||||
const approvalSwitch = document.getElementById('manualApprovalSwitch');
|
||||
if (approvalSwitch) {
|
||||
approvalSwitch.addEventListener('change', handleApprovalToggle);
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = document.getElementById('refreshPendingBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadPendingContacts();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Settings Management
|
||||
// =============================================================================
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/device/settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
manualApprovalEnabled = data.settings.manual_add_contacts || false;
|
||||
updateApprovalUI(manualApprovalEnabled);
|
||||
} else {
|
||||
console.error('Failed to load settings:', data.error);
|
||||
showToast('Failed to load settings', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
showToast('Network error loading settings', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprovalToggle(event) {
|
||||
const enabled = event.target.checked;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/device/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
manual_add_contacts: enabled
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
manualApprovalEnabled = enabled;
|
||||
updateApprovalUI(enabled);
|
||||
showToast(
|
||||
enabled ? 'Manual approval enabled' : 'Manual approval disabled',
|
||||
'success'
|
||||
);
|
||||
|
||||
// Reload pending contacts after toggle
|
||||
setTimeout(() => loadPendingContacts(), 500);
|
||||
} else {
|
||||
console.error('Failed to update setting:', data.error);
|
||||
showToast('Failed to update setting: ' + data.error, 'danger');
|
||||
|
||||
// Revert toggle on failure
|
||||
event.target.checked = !enabled;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error);
|
||||
showToast('Network error updating setting', 'danger');
|
||||
|
||||
// Revert toggle on failure
|
||||
event.target.checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
function updateApprovalUI(enabled) {
|
||||
const switchEl = document.getElementById('manualApprovalSwitch');
|
||||
const labelEl = document.getElementById('switchLabel');
|
||||
const infoEl = document.getElementById('approvalInfo');
|
||||
|
||||
if (switchEl) {
|
||||
switchEl.checked = enabled;
|
||||
}
|
||||
|
||||
if (labelEl) {
|
||||
labelEl.textContent = enabled
|
||||
? 'Manual approval enabled'
|
||||
: 'Automatic approval (default)';
|
||||
}
|
||||
|
||||
if (infoEl) {
|
||||
infoEl.style.display = enabled ? 'none' : 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pending Contacts Management
|
||||
// =============================================================================
|
||||
|
||||
async function loadPendingContacts() {
|
||||
const loadingEl = document.getElementById('pendingLoading');
|
||||
const emptyEl = document.getElementById('pendingEmpty');
|
||||
const listEl = document.getElementById('pendingList');
|
||||
const errorEl = document.getElementById('pendingError');
|
||||
const countBadge = document.getElementById('pendingCount');
|
||||
|
||||
// Show loading state
|
||||
if (loadingEl) loadingEl.style.display = 'block';
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
if (countBadge) countBadge.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/pending');
|
||||
const data = await response.json();
|
||||
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
|
||||
if (data.success) {
|
||||
pendingContacts = data.pending || [];
|
||||
|
||||
if (pendingContacts.length === 0) {
|
||||
// Show empty state
|
||||
if (emptyEl) emptyEl.style.display = 'block';
|
||||
} else {
|
||||
// Render pending contacts list
|
||||
renderPendingList(pendingContacts);
|
||||
|
||||
// Update count badge
|
||||
if (countBadge) {
|
||||
countBadge.textContent = pendingContacts.length;
|
||||
countBadge.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load pending contacts:', data.error);
|
||||
if (errorEl) {
|
||||
const errorMsg = document.getElementById('errorMessage');
|
||||
if (errorMsg) errorMsg.textContent = data.error || 'Failed to load pending contacts';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading pending contacts:', error);
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (errorEl) {
|
||||
const errorMsg = document.getElementById('errorMessage');
|
||||
if (errorMsg) errorMsg.textContent = 'Network error: ' + error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPendingList(contacts) {
|
||||
const listEl = document.getElementById('pendingList');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '';
|
||||
|
||||
contacts.forEach((contact, index) => {
|
||||
const card = createContactCard(contact, index);
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createContactCard(contact, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pending-contact-card';
|
||||
card.id = `contact-${index}`;
|
||||
|
||||
// Contact name
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'contact-name';
|
||||
nameDiv.textContent = contact.name;
|
||||
|
||||
// Public key (truncated)
|
||||
const keyDiv = document.createElement('div');
|
||||
keyDiv.className = 'contact-key';
|
||||
const truncatedKey = contact.public_key.substring(0, 16) + '...';
|
||||
keyDiv.textContent = truncatedKey;
|
||||
keyDiv.title = contact.public_key; // Full key on hover
|
||||
|
||||
// Action buttons
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'd-flex gap-2 flex-wrap';
|
||||
|
||||
// Approve button
|
||||
const approveBtn = document.createElement('button');
|
||||
approveBtn.className = 'btn btn-success btn-action flex-grow-1';
|
||||
approveBtn.innerHTML = '<i class="bi bi-check-circle"></i> Approve';
|
||||
approveBtn.onclick = () => approveContact(contact, index);
|
||||
|
||||
// Copy key button
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'btn btn-outline-secondary btn-action';
|
||||
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy Full Key';
|
||||
copyBtn.onclick = () => copyPublicKey(contact.public_key, copyBtn);
|
||||
|
||||
actionsDiv.appendChild(approveBtn);
|
||||
actionsDiv.appendChild(copyBtn);
|
||||
|
||||
card.appendChild(nameDiv);
|
||||
card.appendChild(keyDiv);
|
||||
card.appendChild(actionsDiv);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function approveContact(contact, index) {
|
||||
const cardEl = document.getElementById(`contact-${index}`);
|
||||
|
||||
// Disable buttons during approval
|
||||
if (cardEl) {
|
||||
const buttons = cardEl.querySelectorAll('button');
|
||||
buttons.forEach(btn => btn.disabled = true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/pending/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
public_key: contact.public_key // ALWAYS use full public_key (works for CLI, ROOM, etc.)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(`Approved: ${contact.name}`, 'success');
|
||||
|
||||
// Remove from list with animation
|
||||
if (cardEl) {
|
||||
cardEl.style.opacity = '0';
|
||||
cardEl.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => {
|
||||
cardEl.remove();
|
||||
|
||||
// Reload pending list to update count
|
||||
loadPendingContacts();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to approve contact:', data.error);
|
||||
showToast('Failed to approve: ' + data.error, 'danger');
|
||||
|
||||
// Re-enable buttons
|
||||
if (cardEl) {
|
||||
const buttons = cardEl.querySelectorAll('button');
|
||||
buttons.forEach(btn => btn.disabled = false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving contact:', error);
|
||||
showToast('Network error: ' + error.message, 'danger');
|
||||
|
||||
// Re-enable buttons
|
||||
if (cardEl) {
|
||||
const buttons = cardEl.querySelectorAll('button');
|
||||
buttons.forEach(btn => btn.disabled = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyPublicKey(publicKey, buttonEl) {
|
||||
navigator.clipboard.writeText(publicKey).then(() => {
|
||||
// Visual feedback
|
||||
const originalHTML = buttonEl.innerHTML;
|
||||
buttonEl.innerHTML = '<i class="bi bi-check"></i> Copied!';
|
||||
buttonEl.classList.remove('btn-outline-secondary');
|
||||
buttonEl.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
buttonEl.innerHTML = originalHTML;
|
||||
buttonEl.classList.remove('btn-success');
|
||||
buttonEl.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
|
||||
showToast('Public key copied to clipboard', 'info');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
showToast('Failed to copy to clipboard', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Toast Notifications
|
||||
// =============================================================================
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toastEl = document.getElementById('contactToast');
|
||||
if (!toastEl) return;
|
||||
|
||||
const bodyEl = toastEl.querySelector('.toast-body');
|
||||
if (!bodyEl) return;
|
||||
|
||||
// Set message and style
|
||||
bodyEl.textContent = message;
|
||||
|
||||
// Apply color based on type
|
||||
toastEl.classList.remove('bg-success', 'bg-danger', 'bg-info', 'bg-warning');
|
||||
toastEl.classList.remove('text-white');
|
||||
|
||||
if (type === 'success' || type === 'danger' || type === 'warning') {
|
||||
toastEl.classList.add(`bg-${type}`, 'text-white');
|
||||
} else if (type === 'info') {
|
||||
toastEl.classList.add('bg-info', 'text-white');
|
||||
}
|
||||
|
||||
// Show toast
|
||||
const toast = new bootstrap.Toast(toastEl, {
|
||||
autohide: true,
|
||||
delay: 3000
|
||||
});
|
||||
toast.show();
|
||||
}
|
||||
@@ -70,6 +70,10 @@
|
||||
<span id="dmMenuBadge" class="badge bg-success rounded-pill" style="display: none;">0</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" onclick="window.location.href='/contacts/manage';">
|
||||
<i class="bi bi-person-check" style="font-size: 1.5rem;"></i>
|
||||
<span>Contact Management</span>
|
||||
</button>
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-center gap-3 mb-2">
|
||||
<i class="bi bi-calendar3" style="font-size: 1.5rem;"></i>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Contact Management - mc-webui{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Mobile-first custom styles for Contact Management */
|
||||
.settings-section {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pending-contact-card {
|
||||
background-color: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 0.5rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.contact-key {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
min-height: 44px; /* Touch-friendly size */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.info-badge {
|
||||
display: inline-block;
|
||||
background-color: #e7f3ff;
|
||||
color: #0c5460;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3 py-4">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 mx-auto">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">
|
||||
<i class="bi bi-person-check"></i> Contact Management
|
||||
</h2>
|
||||
<button class="btn btn-outline-secondary" onclick="window.history.back();" title="Go back">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Manual Approval Settings Section -->
|
||||
<div class="settings-section">
|
||||
<h5 class="mb-3">
|
||||
<i class="bi bi-shield-check"></i> Manual Contact Approval
|
||||
</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
When enabled, new contacts must be manually approved before they can communicate with your node.
|
||||
</p>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="manualApprovalSwitch" style="cursor: pointer; min-width: 3rem; min-height: 1.5rem;">
|
||||
<label class="form-check-label" for="manualApprovalSwitch" style="cursor: pointer; font-weight: 500;">
|
||||
<span id="switchLabel">Loading...</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="info-badge" id="approvalInfo" style="display: none;">
|
||||
<i class="bi bi-info-circle"></i> Pending contacts will only appear when manual approval is enabled.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Contacts Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-hourglass-split"></i> Pending Contacts
|
||||
<span class="badge bg-primary rounded-pill" id="pendingCount" style="display: none;">0</span>
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refreshPendingBtn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="pendingLoading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
<span class="ms-2 text-muted">Loading pending contacts...</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="pendingEmpty" class="empty-state" style="display: none;">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
<p class="mb-0">No pending contact requests</p>
|
||||
<small class="text-muted">New contacts will appear here for approval</small>
|
||||
</div>
|
||||
|
||||
<!-- Pending Contacts List -->
|
||||
<div id="pendingList"></div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="pendingError" class="alert alert-danger" style="display: none;" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span id="errorMessage">Failed to load pending contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="contactToast" class="toast" role="alert">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">Contact Management</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', filename='js/contacts.js') }}"></script>
|
||||
{% endblock %}
|
||||
+130
-3
@@ -119,19 +119,51 @@ class MeshCLISession:
|
||||
logger.error(f"Failed to start meshcli session: {e}")
|
||||
raise
|
||||
|
||||
def _load_webui_settings(self):
|
||||
"""
|
||||
Load webui settings from .webui_settings.json file.
|
||||
|
||||
Returns:
|
||||
dict: Settings dictionary or empty dict if file doesn't exist
|
||||
"""
|
||||
settings_path = self.config_dir / ".webui_settings.json"
|
||||
|
||||
if not settings_path.exists():
|
||||
logger.info("No webui settings file found, using defaults")
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
logger.info(f"Loaded webui settings: {settings}")
|
||||
return settings
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load webui settings: {e}")
|
||||
return {}
|
||||
|
||||
def _init_session_settings(self):
|
||||
"""Configure meshcli session for advert logging, message subscription, and manual contact approval"""
|
||||
"""Configure meshcli session for advert logging, message subscription, and user-configured settings"""
|
||||
logger.info("Configuring meshcli session settings")
|
||||
|
||||
# Send configuration commands directly to stdin (bypass queue for init)
|
||||
if self.process and self.process.stdin:
|
||||
try:
|
||||
# Core settings (always enabled)
|
||||
self.process.stdin.write('set json_log_rx on\n')
|
||||
self.process.stdin.write('set print_adverts on\n')
|
||||
self.process.stdin.write('set manual_add_contacts on\n')
|
||||
self.process.stdin.write('msgs_subscribe\n')
|
||||
|
||||
# User-configurable settings from .webui_settings.json
|
||||
webui_settings = self._load_webui_settings()
|
||||
manual_add_contacts = webui_settings.get('manual_add_contacts', False)
|
||||
|
||||
if manual_add_contacts:
|
||||
self.process.stdin.write('set manual_add_contacts on\n')
|
||||
logger.info("Session settings applied: json_log_rx=on, print_adverts=on, manual_add_contacts=on, msgs_subscribe")
|
||||
else:
|
||||
logger.info("Session settings applied: json_log_rx=on, print_adverts=on, manual_add_contacts=off (default), msgs_subscribe")
|
||||
|
||||
self.process.stdin.flush()
|
||||
logger.info("Session settings applied: json_log_rx=on, print_adverts=on, manual_add_contacts=on, msgs_subscribe")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply session settings: {e}")
|
||||
|
||||
@@ -645,6 +677,101 @@ def add_pending_contact():
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/set_manual_add_contacts', methods=['POST'])
|
||||
def set_manual_add_contacts():
|
||||
"""
|
||||
Enable or disable manual contact approval mode.
|
||||
|
||||
This setting is:
|
||||
1. Saved to .webui_settings.json for persistence across container restarts
|
||||
2. Applied immediately to the running meshcli session
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"enabled": true/false
|
||||
}
|
||||
|
||||
Response JSON:
|
||||
{
|
||||
"success": true,
|
||||
"message": "manual_add_contacts set to on"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'enabled' not in data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Missing required field: enabled'
|
||||
}), 400
|
||||
|
||||
enabled = data['enabled']
|
||||
|
||||
if not isinstance(enabled, bool):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'enabled must be a boolean'
|
||||
}), 400
|
||||
|
||||
# Save to persistent settings file
|
||||
settings_path = meshcli_session.config_dir / ".webui_settings.json"
|
||||
|
||||
try:
|
||||
# Read existing settings or create new
|
||||
if settings_path.exists():
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
# Update manual_add_contacts setting
|
||||
settings['manual_add_contacts'] = enabled
|
||||
|
||||
# Write back to file
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Saved manual_add_contacts={enabled} to {settings_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save settings file: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to save settings: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# Apply setting immediately to running session
|
||||
if not meshcli_session or not meshcli_session.process:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'meshcli session not initialized'
|
||||
}), 503
|
||||
|
||||
# Execute set manual_add_contacts on|off command
|
||||
command_value = 'on' if enabled else 'off'
|
||||
result = meshcli_session.execute_command(['set', 'manual_add_contacts', command_value], timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
if not result['success']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Failed to apply setting: {result.get('stderr', 'Unknown error')}"
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"manual_add_contacts set to {command_value}",
|
||||
'enabled': enabled
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"API error in /set_manual_add_contacts: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info(f"Starting MeshCore Bridge on port 5001")
|
||||
logger.info(f"Serial port: {MC_SERIAL_PORT}")
|
||||
|
||||
Reference in New Issue
Block a user