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:
MarekWo
2025-12-29 09:52:09 +01:00
parent 9967980521
commit 77c72ba62e
8 changed files with 1079 additions and 4 deletions
+59 -1
View File
@@ -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
+158
View File
@@ -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)
+204
View File
@@ -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
+12
View File
@@ -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():
"""
+356
View File
@@ -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();
}
+4
View File
@@ -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>
+156
View File
@@ -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
View File
@@ -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}")