Skill: dr1nnas/approvals-ui (version 1.0.1)

# Approvals UI A web dashboard for managing OpenClaw device pairings, channel approvals, and a live terminal — all from your browser. ## Install Place this folder at: ``` ~/.openclaw/workspace/projects/p1 ``` Your file structure should look like: ``` ~/.openclaw/workspace/projects/p1/ ├── SKILL.md ├── server.py └── templates/ ├── channel_approvals.html ├── dashboard.html ├── device_pairings.html ├── index.html ├── login.html └── terminal.html ``` ## Requirements Install Python dependencies: ```bash pip install flask flask-socketio ``` ## ⚠️ Important — Change These Before Running This skill ships with **placeholder credentials** that you **must** change before using: Open `server.py` and update the following values near the top of the file: | What | Variable | Default | Action | |---|---|---|---| | Dashboard login username | `ADMIN_USERNAME` | `Drinnas` | Change to your own username | | Dashboard login password | `ADMIN_PASSWORD` | `admin` | Change to a strong password | | API auth password | `AUTH_PASSWORD` / env `SERVER_AUTH_PASSWORD` | `Bb7766!server` | Change to a strong password or set the env var | | Flask secret key | env `FLASK_SECRET_KEY` | dev placeholder | Set to a random string in your environment | **Example:** ```bash export FLASK_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" export SERVER_AUTH_PASSWORD="your-strong-api-password-here" ``` > **Do not run with the defaults.** Anyone who knows the defaults can log in and access your terminal and gateway token. ### Credential Explanation There are **two separate auth layers**: 1. **Dashboard login** (`ADMIN_USERNAME` / `ADMIN_PASSWORD`) — protects the web UI pages (dashboard, device pairings, channel approvals, terminal). 2. **API password** (`AUTH_PASSWORD` / env `SERVER_AUTH_PASSWORD`) — protects the backend API endpoints (`/pair`, `/sync`, `/approve`) used for programmatic access. These endpoints are not exposed in the web UI but exist for automation/scripting. Both should be set to strong, unique values. ## Usage Start the server: ```bash cd ~/.openclaw/workspace/projects/p1 python3 server.py ``` Then open **http://127.0.0.1:9100** in your browser. ## Features - **Dashboard** — Landing page with quick navigation to all sections. - **Device Pairings** — View pending and paired browser/device connections. Approve or reject pairing requests. Copy your gateway token to clipboard. - **Channel Approvals** — Review and approve pending channel pairing requests (Telegram, Discord, WhatsApp, etc). Real-time updates via Socket.IO. - **Terminal** — Full interactive terminal session in the browser using xterm.js. ## How It Works - Reads device pairings directly from `~/.openclaw/devices/pending.json` and `~/.openclaw/devices/paired.json`. - Reads channel pairing requests from `~/.openclaw/credentials/*-pairing.json`. - Reads the gateway token from `~/.openclaw/openclaw.json` → `gateway.auth.token`. - Approve/reject actions use the `openclaw devices approve` and `openclaw devices reject` CLI commands. - No external database needed — everything reads from OpenClaw's own state files. ## Security Notes - **Localhost only** — The server binds to `127.0.0.1` by default. Do not change this to `0.0.0.0` unless you put it behind a reverse proxy with TLS and strong auth. - **Terminal access** — The terminal feature gives full shell access to your machine. If you don't need it, you can remove the `/terminal` route and `terminal.html` template. - **Sensitive files** — The app reads your `openclaw.json` (gateway token), device pairing files, and credential pairing files. Anyone who can access the web UI can see this data. - **API endpoints** — `POST /pair`, `POST /sync`, and `POST /approve` accept JSON with a password field. These are protected by `AUTH_PASSWORD` and are intended for scripting/automation, not the web UI. ## Configuration | Setting | Location | Default | |---|---|---| | Server port | `server.py` bottom | `9100` | | Dashboard login | `server.py` `ADMIN_USERNAME` / `ADMIN_PASSWORD` | `Drinnas` / `admin` | | API password | `server.py` `AUTH_PASSWORD` / env `SERVER_AUTH_PASSWORD` | `Bb7766!server` | | Flask secret key | env `FLASK_SECRET_KEY` | dev placeholder | | OpenClaw state dir | env `OPENCLAW_STATE_DIR` | `~/.openclaw` | ## Tags `ui` `dashboard` `pairings` `approvals` `terminal` `web`

path: server.py
content: 
from flask import Flask, render_template, request, jsonify, session, redirect, url_for
from flask_socketio import SocketIO, emit
import os
import json
import subprocess
import time
import pty
import select
import threading
import fcntl
import termios
import struct
from pathlib import Path
from functools import wraps

# Get the directory where this script is located
SCRIPT_DIR = Path(__file__).parent.absolute()

app = Flask(__name__, template_folder=str(SCRIPT_DIR / 'templates'))
# IMPORTANT: In a real application, this should be a strong, unique, and secret key,
# preferably loaded from environment variables or a secure config.
# For this example, we'll use a placeholder and assume it's handled securely.
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'a_very_secret_key_for_dev_only_change_me')

# Use threading mode for Socket.IO (eventlet is deprecated)
# For production, consider using gevent or uWSGI with gevent
async_mode = 'threading'

# Force polling transport only — Werkzeug's dev server doesn't support WebSocket
# with threading async_mode, and the failed upgrade spams ugly 500 errors in the console.
socketio = SocketIO(app, cors_allowed_origins="*", async_mode=async_mode, transports=['polling'])

# In-memory storage for pairings (for simplicity)
# In a real app, use a database or more persistent storage.
pairings = []

# OpenClaw state directory (default location)
OPENCLAW_STATE_DIR = Path(os.environ.get('OPENCLAW_STATE_DIR', os.path.expanduser('~/.openclaw')))

def get_gateway_token():
    """Read the gateway auth token from openclaw.json."""
    config_file = OPENCLAW_STATE_DIR / 'openclaw.json'
    try:
        with open(config_file, 'r') as f:
            config = json.load(f)
        return config.get('gateway', {}).get('auth', {}).get('token', '')
    except Exception:
        return ''

def load_pairings_from_files():
    """Load channel pairings directly from OpenClaw's pairing JSON files."""
    global pairings
    loaded_count = 0
    
    # Scan all *-pairing.json files in the credentials directory
    credentials_dir = OPENCLAW_STATE_DIR / 'credentials'
    if credentials_dir.exists():
        for pairing_file in credentials_dir.glob('*-pairing.json'):
            try:
                with open(pairing_file, 'r') as f:
                    file_data = json.load(f)
                    channel_name = pairing_file.stem.replace('-pairing', '')
                    
                    requests = file_data.get('requests', [])
                    for req in requests:
                        pairing_data = {
                            'channel_id': channel_name,
                            'code': req.get('code', ''),
                            'user_id': req.get('id', ''),
                            'device_name': f"{req.get('meta', {}).get('firstName', '')} {req.get('meta', {}).get('lastName', '')}".strip() or req.get('meta', {}).get('username', 'Unknown'),
                            'timestamp': req.get('createdAt', ''),
                            'meta': req.get('meta', {})
                        }
                        # Avoid duplicates
                        if not any(p.get('code') == pairing_data['code'] for p in pairings):
                            pairings.append(pairing_data)
                            loaded_count += 1
                            print(f"Loaded channel pairing: {pairing_data['code']} from {channel_name}")
            except Exception as e:
                print(f"Error loading {pairing_file}: {e}")
    
    if loaded_count > 0:
        print(f"Loaded {loaded_count} channel pairings from files")
    else:
        print("No pending channel pairing requests found (all channels already paired)")
    
    return loaded_count


def load_device_pairings_from_files():
    """Load device pairings directly from OpenClaw's device JSON files."""
    devices_dir = OPENCLAW_STATE_DIR / 'devices'
    pending = []
    paired = []
    
    pending_file = devices_dir / 'pending.json'
    paired_file = devices_dir / 'paired.json'
    
    if pending_file.exists():
        try:
            with open(pending_file, 'r') as f:
                data = json.load(f)
                pending = list(data.values()) if isinstance(data, dict) else data
                print(f"Loaded {len(pending)} pending device pairing(s) from file")
        except Exception as e:
            print(f"Error loading {pending_file}: {e}")
    
    if paired_file.exists():
        try:
            with open(paired_file, 'r') as f:
                data = json.load(f)
                paired = list(data.values()) if isinstance(data, dict) else data
                print(f"Loaded {len(paired)} paired device(s) from file")
        except Exception as e:
            print(f"Error loading {paired_file}: {e}")
    
    return {
        "pending": sorted(pending, key=lambda x: x.get('ts', 0), reverse=True),
        "paired": sorted(paired, key=lambda x: x.get('approvedAtMs', 0), reverse=True)
    }


# --- Authentication ---
# Login credentials
ADMIN_USERNAME = 'Drinnas'
ADMIN_PASSWORD = 'admin'
AUTH_PASSWORD = os.environ.get('SERVER_AUTH_PASSWORD', 'Bb7766!server') # Defaulting to user's provided password

def authenticate(password):
    # In a real app, you'd do more than just compare a string password.
    # This could involve hashing, tokens, etc.
    return password == AUTH_PASSWORD

def login_required(f):
    """Decorator to require login for routes."""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'logged_in' not in session or not session['logged_in']:
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

# --- Routes ---

@app.route('/')
def index():
    """Redirect to login if not authenticated, otherwise to dashboard."""
    if 'logged_in' not in session or not session['logged_in']:
        return redirect(url_for('login'))
    return redirect(url_for('dashboard'))

@app.route('/login', methods=['GET', 'POST'])
def login():
    """Login page."""
    if request.method == 'POST':
        username = request.form.get('username')
        [REDACTED_SECRET]'password')
        
        if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
            session['logged_in'] = True
            session['username'] = username
            return redirect(url_for('dashboard'))
        else:
            return render_template('login.html', error='Invalid username or password')
    
    # If already logged in, redirect to dashboard
    if 'logged_in' in session and session['logged_in']:
        return redirect(url_for('dashboard'))
    
    return render_template('login.html')

@app.route('/logout')
def logout():
    """Logout and clear session."""
    session.clear()
    return redirect(url_for('login'))

@app.route('/dashboard')
@login_required
def dashboard():
    """Main dashboard page."""
    return render_template('dashboard.html')

@app.route('/channel-approvals')
@login_required
def channel_approvals():
    """Channel approvals tab."""
    return render_template('channel_approvals.html')

@app.route('/terminal')
@login_required
def terminal():
    """Terminal tab."""
    return render_template('terminal.html')

@app.route('/pair', methods=['POST'])
def add_pairing():
    # Endpoint to register a new pairing.
    # Expects a JSON body with pairing details and a password for auth.
    # Example payload:
    # {
    #   "password": "Bb7766!server",
    #   "pairing": {
    #     "channel_id": "some_channel_id",
    #     "device_name": "My Device",
    #     "timestamp": "2023-10-27T10:30:00Z"
    #   }
    # }
    data = request.get_json()
    if not data:
        return jsonify({"error": "Invalid JSON payload"}), 400

    [REDACTED_SECRET]'password')
    if not authenticate(password):
        return jsonify({"error": "Authentication failed"}), 401

    pairing_data = data.get('pairing')
    if not pairing_data:
        return jsonify({"error": "Missing 'pairing' data in payload"}), 400

    # Avoid duplicates (check by code if available)
    pairing_code = pairing_data.get('code')
    if pairing_code and any(p.get('code') == pairing_code for p in pairings):
        return jsonify({"message": "Pairing already exists", "pairing": pairing_data}), 200

    # Add pairing to our list
    pairings.append(pairing_data)
    print(f"New pairing registered: {pairing_data}")

    # Broadcast the new pairing to all connected clients
    socketio.emit('new_pairing', pairing_data)
    
    return jsonify({"message": "Pairing registered successfully", "pairing": pairing_data}), 201

@app.route('/sync', methods=['POST'])
def sync_pairings():
    """Sync pairings from OpenClaw to the server."""
    data = request.get_json() or {}
    [REDACTED_SECRET]'password')
    
    if not authenticate(password):
        return jsonify({"error": "Authentication failed"}), 401
    
    loaded_count = load_pairings_from_files()
    
    # Broadcast all pairings to connected clients
    for pairing in pairings:
        socketio.emit('new_pairing', pairing)
    
    return jsonify({"message": f"Synced {loaded_count} pairings from OpenClaw", "total": len(pairings)}), 200

@app.route('/approve', methods=['POST'])
@login_required
def approve_pairing():
    """Approve a pairing in OpenClaw."""
    global pairings
    
    data = request.get_json() or {}
    [REDACTED_SECRET]'password')
    code = data.get('code')
    channel = data.get('channel')
    notify = data.get('notify', False)
    
    if not authenticate(password):
        return jsonify({"error": "Authentication failed"}), 401
    
    if not code:
        return jsonify({"error": "Missing 'code' in payload"}), 400
    
    if not channel:
        return jsonify({"error": "Missing 'channel' in payload"}), 400
    
    # Helper function to check if pairing was actually approved
    def check_if_approved():
        """Check if the pairing code no longer exists in the pairing file."""
        pairing_file = OPENCLAW_STATE_DIR / 'credentials' / f'{channel}-pairing.json'
        if pairing_file.exists():
            try:
                with open(pairing_file, 'r') as f:
                    file_data = json.load(f)
                    requests = file_data.get('requests', [])
                    # Check if the code is no longer in the requests (meaning it was approved)
                    return not any(req.get('code') == code for req in requests)
            except Exception as e:
                print(f"Error checking pairing file: {e}")
        return False
    
    # Helper function to remove pairing and notify clients
    def remove_pairing_and_notify():
        """Remove pairing from list and notify clients."""
        global pairings
        pairings = [p for p in pairings if p.get('code') != code]
        socketio.emit('pairing_approved', {'code': code})
        print(f"Removed pairing {code} from list and notified clients")
    
    try:
        # Call OpenClaw approve command
        cmd = ['openclaw', 'pairing', 'approve', '--channel', channel, code]
        if notify:
            cmd.append('--notify')
        
        print(f"Running command: {' '.join(cmd)}")
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=30  # Increased timeout to 30 seconds
        )
        
        print(f"Command exit code: {result.returncode}")
        print(f"Command stdout: {result.stdout}")
        print(f"Command stderr: {result.stderr}")
        
        # Check if pairing was actually approved (even if command failed)
        if check_if_approved():
            remove_pairing_and_notify()
            if result.returncode == 0:
                return jsonify({
                    "message": f"Pairing {code} approved successfully",
                    "output": result.stdout
                }), 200
            else:
                # Command failed but pairing was approved (maybe manually or by another process)
                return jsonify({
                    "message": f"Pairing {code} was approved (command had errors but pairing is gone)",
                    "warning": result.stderr or "Command returned non-zero exit code",
                    "output": result.stdout
                }), 200
        
        if result.returncode == 0:
            # Command succeeded but pairing still exists? Remove it anyway
            remove_pairing_and_notify()
            return jsonify({
                "message": f"Pairing {code} approved successfully",
                "output": result.stdout
            }), 200
        else:
            # Command failed and pairing still exists
            return jsonify({
                "error": f"Failed to approve pairing: {result.stderr or 'Unknown error'}",
                "output": result.stdout
            }), 400
            
    except subprocess.TimeoutExpired as e:
        print(f"Command timed out: {e}")
        # Even if timeout, check if the pairing was actually approved
        time.sleep(1)  # Give it a moment to complete
        
        if check_if_approved():
            remove_pairing_and_notify()
            return jsonify({
                "message": f"Pairing {code} approved successfully (timeout but completed)",
                "warning": "Command timed out but pairing was approved"
            }), 200
        else:
            return jsonify({
                "error": "OpenClaw CLI timeout - pairing may still be pending",
                "message": "The approval command timed out. Please check manually or try again."
            }), 500
    except FileNotFoundError:
        print("OpenClaw CLI not found")
        # Check if pairing was approved anyway (maybe manually)
        if check_if_approved():
            remove_pairing_and_notify()
            return jsonify({
                "message": f"Pairing {code} was approved (CLI not found but pairing is gone)",
                "warning": "OpenClaw CLI not found"
            }), 200
        return jsonify({"error": "OpenClaw CLI not found"}), 500
    except Exception as e:
        print(f"Unexpected error approving pairing: {e}")
        import traceback
        traceback.print_exc()
        # Check if pairing was approved anyway
        if check_if_approved():
            remove_pairing_and_notify()
            return jsonify({
                "message": f"Pairing {code} was approved (error occurred but pairing is gone)",
                "warning": f"Error: {str(e)}"
            }), 200
        return jsonify({"error": f"Error approving pairing: {str(e)}"}), 500


# --- Device Pairing (Browser Connections) ---

@app.route('/device-pairings')
@login_required
def device_pairings_page():
    """Device pairings management page."""
    return render_template('device_pairings.html', gateway_token=get_gateway_token())

@app.route('/api/device-pairings', methods=['GET'])
@login_required
def list_device_pairings():
    """List pending and paired device pairings by reading files directly."""
    try:
        data = load_device_pairings_from_files()
        return jsonify(data), 200
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route('/api/device-pair/approve', methods=['POST'])
@login_required
def approve_device_pairing():
    """Approve a pending device pairing request."""
    data = request.get_json() or {}
    request_id = data.get('requestId')
    use_latest = data.get('latest', False)

    if not request_id and not use_latest:
        return jsonify({"error": "Missing 'requestId' or set 'latest' to true"}), 400

    try:
        cmd = ['openclaw', 'devices', 'approve', '--json']
        if use_latest:
            cmd.append('--latest')
        else:
            cmd.append(request_id)

        print(f"Running device approve command: {' '.join(cmd)}")
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=15
        )

        print(f"Device approve exit code: {result.returncode}")
        print(f"Device approve stdout: {result.stdout}")
        print(f"Device approve stderr: {result.stderr}")

        if result.returncode == 0:
            try:
                response_data = json.loads(result.stdout)
            except json.JSONDecodeError:
                response_data = {"output": result.stdout}

            # Notify connected clients
            socketio.emit('device_pairing_approved', {
                'requestId': request_id or 'latest',
                'data': response_data
            })

            return jsonify({
                "message": "Device pairing approved successfully",
                "data": response_data
            }), 200
        else:
            return jsonify({
                "error": f"Failed to approve device pairing: {result.stderr or result.stdout or 'Unknown error'}"
            }), 400

    except subprocess.TimeoutExpired:
        return jsonify({"error": "OpenClaw CLI timeout during approval"}), 500
    except FileNotFoundError:
        return jsonify({"error": "OpenClaw CLI not found"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route('/api/device-pair/reject', methods=['POST'])
@login_required
def reject_device_pairing():
    """Reject a pending device pairing request."""
    data = request.get_json() or {}
    request_id = data.get('requestId')

    if not request_id:
        return jsonify({"error": "Missing 'requestId'"}), 400

    try:
        cmd = ['openclaw', 'devices', 'reject', '--json', request_id]

        print(f"Running device reject command: {' '.join(cmd)}")
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=15
        )

        if result.returncode == 0:
            try:
                response_data = json.loads(result.stdout)
            except json.JSONDecodeError:
                response_data = {"output": result.stdout}

            socketio.emit('device_pairing_rejected', {
                'requestId': request_id,
                'data': response_data
            })

            return jsonify({
                "message": "Device pairing rejected",
                "data": response_data
            }), 200
        else:
            return jsonify({
                "error": f"Failed to reject device pairing: {result.stderr or result.stdout or 'Unknown error'}"
            }), 400

    except subprocess.TimeoutExpired:
        return jsonify({"error": "OpenClaw CLI timeout during rejection"}), 500
    except FileNotFoundError:
        return jsonify({"error": "OpenClaw CLI not found"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route('/api/device-pair/approve-latest', methods=['POST'])
@login_required
def approve_latest_device_pairing():
    """Quick endpoint to approve the most recent pending device pairing."""
    try:
        result = subprocess.run(
            ['openclaw', 'devices', 'approve', '--latest', '--json'],
            capture_output=True,
            text=True,
            timeout=15
        )

        if result.returncode == 0:
            try:
                response_data = json.loads(result.stdout)
            except json.JSONDecodeError:
                response_data = {"output": result.stdout}

            socketio.emit('device_pairing_approved', {
                'requestId': 'latest',
                'data': response_data
            })

            return jsonify({
                "message": "Latest device pairing approved",
                "data": response_data
            }), 200
        else:
            return jsonify({
                "error": f"Failed: {result.stderr or result.stdout or 'No pending requests'}"
            }), 400

    except subprocess.TimeoutExpired:
        return jsonify({"error": "CLI timeout"}), 500
    except FileNotFoundError:
        return jsonify({"error": "OpenClaw CLI not found"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# --- Terminal Connection ---
import paramiko
# Function to connect to a live terminal on your Mac

def connect_terminal():
    # Connect to the terminal using paramiko
    hostname = 'your_mac_ip'
    username = 'your_username'
    [REDACTED_SECRET]'
    port = 22  # Adjust based on your SSH configuration

    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        client.connect(hostname, username=username, [REDACTED_SECRET], port=port)
        # Now you can run commands in the terminal
        return client
    except Exception as e:
        print(f'Failed to connect to terminal: {e}')

@socketio.on('connect')
def handle_connect():
    print('Client connected. Syncing pairings from files...')
    # Load channel pairings from files
    load_pairings_from_files()
    # Load device pairings from files
    device_data = load_device_pairings_from_files()
    print(f'Sending {len(pairings)} channel pairings and {len(device_data.get("pending", []))} pending / {len(device_data.get("paired", []))} paired devices to client.')
    # Send existing pairings to the newly connected client
    emit('initial_pairings', {'pairings': pairings, 'device_pairings': device_data})

@socketio.on('disconnect')
def handle_disconnect():
    print('Client disconnected')
    # Clean up terminal session if it exists
    try:
        _cleanup_terminal(request.sid)
    except Exception:
        pass

# --- Terminal Support ---
# Store terminal processes per session
terminal_processes = {}

def get_shell():
    """Get the user's default shell."""
    shell = os.environ.get('SHELL', '/bin/zsh')
    if not os.path.exists(shell):
        shell = '/bin/bash'
    return shell

@socketio.on('terminal_connect')
def handle_terminal_connect():
    """Initialize a new terminal session using subprocess (avoids fork warnings)."""
    sid = request.sid
    shell = get_shell()
    
    print(f'Terminal connect requested for {sid}, shell: {shell}')
    
    # Check if terminal already exists for this session
    if sid in terminal_processes:
        print(f'Terminal already exists for {sid}, cleaning up first')
        _cleanup_terminal(sid)
    
    try:
        # Create a pseudo-terminal
        master_fd, slave_fd = pty.openpty()
        
        # Use subprocess instead of fork to avoid "multi-threaded fork" warnings
        env = os.environ.copy()
        env['TERM'] = 'xterm-256color'
        env['COLORTERM'] = 'truecolor'
        
        process = subprocess.Popen(
            [shell],
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd,
            start_new_session=True,
            env=env
        )
        os.close(slave_fd)
        
        terminal_processes[sid] = {
            'pid': process.pid,
            'fd': master_fd,
            'process': process,
            'closing': False
        }
        
        # Start thread to read from terminal
        def read_terminal():
            try:
                while sid in terminal_processes and not terminal_processes[sid].get('closing'):
                    try:
                        readable, _, _ = select.select([master_fd], [], [], 0.1)
                    except (OSError, ValueError):
                        break  # fd closed, exit quietly
                    if master_fd in readable:
                        try:
                            data = os.read(master_fd, 1024)
                            if data:
                                socketio.emit('terminal_output', {'data': data.decode('utf-8', errors='replace')}, room=sid)
                            else:
                                break  # EOF
                        except (OSError, ValueError):
                            break  # fd closed, exit quietly
            except Exception:
                pass  # Suppress all terminal read errors on shutdown
            finally:
                # Only clean up if we haven't been cleaned up already
                if sid in terminal_processes and not terminal_processes[sid].get('closing'):
                    _cleanup_terminal(sid)
        
        thread = threading.Thread(target=read_terminal, daemon=True)
        thread.start()
        terminal_processes[sid]['thread'] = thread
        
        emit('terminal_ready')
        print(f'Terminal session started for {sid} (PID {process.pid})')
    except Exception as e:
        print(f'Error starting terminal: {e}')
        emit('terminal_error', {'message': f'Failed to start terminal: {str(e)}'})

@socketio.on('terminal_input')
def handle_terminal_input(data):
    """Handle input from the terminal."""
    sid = request.sid
    if sid in terminal_processes:
        try:
            fd = terminal_processes[sid]['fd']
            input_data = data.get('data', '')
            if isinstance(input_data, str):
                input_data = input_data.encode('utf-8')
            os.write(fd, input_data)
        except Exception as e:
            print(f'Error writing to terminal: {e}')
            emit('terminal_error', {'message': str(e)})

@socketio.on('terminal_resize')
def handle_terminal_resize(data):
    """Handle terminal resize."""
    sid = request.sid
    if sid in terminal_processes:
        try:
            fd = terminal_processes[sid]['fd']
            cols = data.get('cols', 80)
            rows = data.get('rows', 24)
            
            # Set terminal size
            size = struct.pack('HHHH', rows, cols, 0, 0)
            fcntl.ioctl(fd, termios.TIOCSWINSZ, size)
        except Exception as e:
            print(f'Error resizing terminal: {e}')

def _cleanup_terminal(sid):
    """Safely clean up a terminal session. Can be called from any thread."""
    if sid not in terminal_processes:
        return
    info = terminal_processes.pop(sid, None)
    if not info or info.get('closing'):
        return
    info['closing'] = True
    
    # Close the PTY fd
    try:
        os.close(info['fd'])
    except OSError:
        pass
    
    # Terminate the subprocess
    proc = info.get('process')
    if proc:
        try:
            proc.terminate()
            proc.wait(timeout=2)
        except Exception:
            try:
                proc.kill()
            except Exception:
                pass
    
    print(f'Terminal session cleaned up for {sid}')


@socketio.on('terminal_disconnect')
def handle_terminal_disconnect():
    """Clean up terminal session."""
    _cleanup_terminal(request.sid)

# --- Helper for running the server ---
if __name__ == '__main__':
    # Ensure templates directory exists for render_template
    templates_dir = SCRIPT_DIR / 'templates'
    if not templates_dir.exists():
        templates_dir.mkdir(parents=True, exist_ok=True)

    # Templates are now stored as separate files in the templates directory
    # No need to generate index.html dynamically

    # Load pairings from files on startup (will also sync on each client connect)
    print("Loading pairings from files on startup...")
    load_pairings_from_files()
    device_data = load_device_pairings_from_files()
    print(f"Startup: {len(device_data.get('pending', []))} pending device(s), {len(device_data.get('paired', []))} paired device(s)")
    
    print("Starting Flask server on http://127.0.0.1:9100")
    # Use socketio.run for Flask-SocketIO
    # async_mode='threading' works with Werkzeug, but eventlet/gevent is better for production
    # host='0.0.0.0' makes it accessible from other machines on the network
    # debug=True is useful for development, but should be False in production
    socketio.run(app, host='127.0.0.1', port=9100, debug=True, allow_unsafe_werkzeug=True)


path: templates/channel_approvals.html
content: <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Channel Approvals - OpenClaw</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
    <style>
        * {
            box-sizing: border-box;
        }
        
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            background: #000;
            color: #fff;
            font: 16px/1.5 -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }
        
        .wrap {
            min-height: 100vh;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 32px 24px;
            background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
        }
        
        .header {
            width: min(900px, 100%);
            margin-bottom: 32px;
            text-align: center;
        }
        
        h1 {
            margin: 0 0 12px 0;
            font-size: 32px;
            font-weight: 700;
            letter-spacing: -0.5px;
            background: linear-gradient(135deg, #ffffff 0%, #a0a0a0 100%);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }
        
        .subtitle {
            opacity: 0.8;
            font-size: 15px;
            font-weight: 400;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }
        
        #pairings-list {
            width: min(900px, 100%);
            display: flex;
            flex-direction: column;
            gap: 16px;
        }
        
        .pairing-item {
            background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
            border: 1px solid rgba(255, 255, 255, 0.12);
            border-radius: 24px;
            padding: 0;
            display: flex;
            flex-direction: column;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3),
                        0 0 0 0 rgba(36, 224, 138, 0);
            position: relative;
            overflow: hidden;
        }
        
        .pairing-item::before {
            content: '';
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            height: 4px;
            background: linear-gradient(90deg, #24e08a 0%, #1db973 100%);
            opacity: 0;
            transition: opacity 0.3s ease;
        }
        
        .pairing-item:hover {
            background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.06) 100%);
            border-color: rgba(36, 224, 138, 0.4);
            transform: translateY(-4px);
            box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5),
                        0 0 0 1px rgba(36, 224, 138, 0.2);
        }
        
        .pairing-item:hover::before {
            opacity: 1;
        }
        
        .pairing-item.approved {
            opacity: 0.6;
            background: linear-gradient(135deg, rgba(36, 224, 138, 0.1) 0%, rgba(36, 224, 138, 0.05) 100%);
            border-color: rgba(36, 224, 138, 0.3);
        }
        
        .pairing-content {
            flex: 1;
            padding: 28px;
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 20px;
            min-width: 0;
        }
        
        .pairing-row {
            display: contents;
        }
        
        .pairing-field {
            display: flex;
            flex-direction: column;
            gap: 8px;
            padding: 16px;
            background: rgba(255, 255, 255, 0.04);
            border-radius: 12px;
            border: 1px solid rgba(255, 255, 255, 0.08);
            transition: all 0.2s ease;
            position: relative;
        }
        
        .pairing-field:hover {
            background: rgba(255, 255, 255, 0.08);
            border-color: rgba(255, 255, 255, 0.15);
            transform: translateY(-2px);
        }
        
        .pairing-field strong {
            opacity: 0.7;
            font-weight: 600;
            font-size: 10px;
            text-transform: uppercase;
            letter-spacing: 1.2px;
            color: #888;
            margin-bottom: 4px;
        }
        
        .pairing-field span {
            color: #fff;
            word-break: break-word;
            font-weight: 600;
            font-size: 16px;
            line-height: 1.4;
        }
        
        .pairing-field .value {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
            font-size: 15px;
            color: #24e08a;
            font-weight: 700;
            background: rgba(36, 224, 138, 0.15);
            padding: 6px 10px;
            border-radius: 8px;
            border: 1px solid rgba(36, 224, 138, 0.3);
            display: inline-block;
            width: fit-content;
        }
        
        .pairing-field .timestamp {
            opacity: 0.8;
            font-size: 14px;
            font-style: normal;
            color: #ccc;
            font-weight: 500;
        }
        
        .pairing-field.channel-field {
            background: linear-gradient(135deg, rgba(92, 158, 255, 0.15) 0%, rgba(92, 158, 255, 0.08) 100%);
            border-color: rgba(92, 158, 255, 0.3);
        }
        
        .pairing-field.channel-field span {
            color: #5c9eff;
            font-weight: 700;
        }
        
        .pairing-field.channel-field:hover {
            background: linear-gradient(135deg, rgba(92, 158, 255, 0.2) 0%, rgba(92, 158, 255, 0.12) 100%);
            border-color: rgba(92, 158, 255, 0.4);
        }
        
        .pairing-actions {
            padding: 20px 28px;
            background: rgba(0, 0, 0, 0.2);
            border-top: 1px solid rgba(255, 255, 255, 0.1);
            display: flex;
            justify-content: flex-end;
            gap: 12px;
        }
        
        .approve-btn {
            appearance: none;
            border: none;
            background: linear-gradient(135deg, #24e08a 0%, #1db973 100%);
            color: #000;
            padding: 14px 32px;
            border-radius: 12px;
            font-weight: 700;
            font-size: 15px;
            cursor: pointer;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            white-space: nowrap;
            box-shadow: 0 4px 15px rgba(36, 224, 138, 0.4),
                        0 0 0 0 rgba(36, 224, 138, 0.5);
            position: relative;
            overflow: hidden;
            text-transform: uppercase;
            letter-spacing: 1px;
            min-width: 140px;
        }
        
        .approve-btn::before {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 0;
            height: 0;
            border-radius: 50%;
            background: rgba(255, 255, 255, 0.3);
            transform: translate(-50%, -50%);
            transition: width 0.6s, height 0.6s;
        }
        
        .approve-btn:hover:not(:disabled)::before {
            width: 300px;
            height: 300px;
        }
        
        .approve-btn:hover:not(:disabled) {
            background: linear-gradient(135deg, #2ef09a 0%, #24e08a 100%);
            transform: translateY(-2px);
            box-shadow: 0 8px 25px rgba(36, 224, 138, 0.6),
                        0 0 0 4px rgba(36, 224, 138, 0.2);
        }
        
        .approve-btn:active:not(:disabled) {
            transform: translateY(0);
            box-shadow: 0 2px 10px rgba(36, 224, 138, 0.3);
        }
        
        .approve-btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
            background: rgba(255, 255, 255, 0.1);
            color: rgba(255, 255, 255, 0.5);
            box-shadow: none;
        }
        
        .approve-btn.ok {
            color: #000;
        }
        
        .no-pairings {
            text-align: center;
            padding: 64px 24px;
            opacity: 0.5;
            font-size: 16px;
            font-weight: 500;
            color: #888;
        }
        
        .status-indicator {
            display: inline-block;
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background: #24e08a;
            margin-right: 8px;
            animation: pulse 2s ease-in-out infinite;
            box-shadow: 0 0 10px rgba(36, 224, 138, 0.6);
        }
        
        @keyframes pulse {
            0%, 100% { 
                opacity: 1;
                transform: scale(1);
            }
            50% { 
                opacity: 0.7;
                transform: scale(1.2);
            }
        }
        
        .status-indicator.disconnected {
            background: #ff5c5c;
            box-shadow: 0 0 10px rgba(255, 92, 92, 0.6);
        }
        
        .bad {
            color: #ff5c5c;
        }
        
        /* Add special styling for channel field */
        .pairing-field.channel-field span {
            background: linear-gradient(135deg, rgba(92, 158, 255, 0.2) 0%, rgba(92, 158, 255, 0.1) 100%);
            color: #5c9eff;
            padding: 4px 12px;
            border-radius: 8px;
            font-weight: 600;
            border: 1px solid rgba(92, 158, 255, 0.3);
        }
        
        @media (max-width: 768px) {
            .pairing-content {
                grid-template-columns: 1fr;
                gap: 16px;
            }
            
            .pairing-actions {
                padding: 16px 20px;
            }
            
            .approve-btn {
                width: 100%;
            }
        }
        
        @media (max-width: 640px) {
            .wrap {
                padding: 20px 16px;
            }
            
            .pairing-content {
                padding: 20px;
            }
            
            .pairing-field {
                padding: 12px;
            }
            
            .pairing-field span {
                font-size: 14px;
            }
        }
    </style>
</head>
<body>
    <div class="wrap">
        <div class="header">
            <a href="/dashboard" style="display:inline-flex;align-items:center;gap:6px;color:#888;text-decoration:none;font-size:14px;margin-bottom:16px;transition:color .2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#888'">← Back to Dashboard</a>
            <h1>Channel Approvals</h1>
            <div class="subtitle">
                <span class="status-indicator" id="status-indicator"></span>
                <span id="status-text">Connecting...</span>
            </div>
        </div>
        <div id="pairings-list">
            <p class="no-pairings">Loading pairings...</p>
        </div>
    </div>

    <script>
        // Wait for DOM to be ready
        (function() {
            console.log('Channel approvals script starting...');
            
            // Use explicit connection URL and options
            const socketUrl = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port;
            console.log('Connecting to:', socketUrl);
            
            const socket = io(socketUrl, {
                transports: ['polling'],
                reconnection: true,
                reconnectionDelay: 1000,
                reconnectionAttempts: 5
            });

            socket.on('connect', function() {
                console.log('Channel approvals: Connected to server!', socket.id);
                const indicator = document.getElementById('status-indicator');
                const statusText = document.getElementById('status-text');
                if (indicator) {
                    indicator.classList.remove('disconnected');
                }
                if (statusText) {
                    statusText.textContent = 'Connected';
                }
            });

        socket.on('disconnect', function() {
            console.log('Disconnected from server');
            const indicator = document.getElementById('status-indicator');
            const statusText = document.getElementById('status-text');
            if (indicator) {
                indicator.classList.add('disconnected');
            }
            if (statusText) {
                statusText.textContent = 'Disconnected. Reconnecting...';
            }
        });

        socket.on('initial_pairings', function(data) {
            console.log('Received initial pairings:', data.pairings);
            const listDiv = document.getElementById('pairings-list');
            listDiv.innerHTML = ''; // Clear connection/loading message
            if (data.pairings && data.pairings.length > 0) {
                data.pairings.forEach(function(pairing) {
                    addPairingToList(pairing);
                });
            } else {
                listDiv.innerHTML = '<p class="no-pairings">No pairings yet.</p>';
            }
        });

        socket.on('new_pairing', function(pairing_data) {
            console.log('Received new pairing:', pairing_data);
            const listDiv = document.getElementById('pairings-list');
            // If the list was empty, clear the "No pairings yet" message
            if (listDiv.querySelector('.no-pairings')) {
                listDiv.innerHTML = '';
            }
            addPairingToList(pairing_data);
        });

        socket.on('pairing_approved', function(data) {
            console.log('Pairing approved:', data.code);
            // Remove the pairing from the list
            const pairingItem = document.querySelector(`[data-pairing-code="${data.code}"]`);
            if (pairingItem) {
                pairingItem.classList.add('approved');
                const approveBtn = pairingItem.querySelector('.approve-btn');
                if (approveBtn) {
                    approveBtn.disabled = true;
                    approveBtn.textContent = 'Approved';
                }
                // Remove after a short delay
                setTimeout(() => {
                    pairingItem.remove();
                    const listDiv = document.getElementById('pairings-list');
                    if (listDiv.children.length === 0) {
                        listDiv.innerHTML = '<p class="no-pairings">No pairings yet.</p>';
                    }
                }, 2000);
            }
        });

        function addPairingToList(pairing) {
            const listDiv = document.getElementById('pairings-list');
            // Check if pairing already exists
            const existing = document.querySelector(`[data-pairing-code="${pairing.code}"]`);
            if (existing) {
                return; // Already displayed
            }
            
            // Create a new div for the pairing
            const pairingItem = document.createElement('div');
            pairingItem.classList.add('pairing-item');
            pairingItem.setAttribute('data-pairing-code', pairing.code);

            // Construct the content dynamically
            const contentDiv = document.createElement('div');
            contentDiv.className = 'pairing-content';
            
            // Channel field
            if (pairing.channel_id) {
                const field = document.createElement('div');
                field.className = 'pairing-field channel-field';
                field.innerHTML = `<strong>Channel</strong><span>${pairing.channel_id}</span>`;
                contentDiv.appendChild(field);
            }
            
            // Code field
            if (pairing.code) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Code</strong><span class="value">${pairing.code}</span>`;
                contentDiv.appendChild(field);
            }
            
            // User ID field
            if (pairing.user_id) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>User ID</strong><span class="value">${pairing.user_id}</span>`;
                contentDiv.appendChild(field);
            }
            
            // Device field
            if (pairing.device_name) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Device</strong><span>${pairing.device_name}</span>`;
                contentDiv.appendChild(field);
            }
            
            // Timestamp field
            if (pairing.timestamp) {
                const date = new Date(pairing.timestamp);
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Requested</strong><span class="timestamp">${date.toLocaleString()}</span>`;
                contentDiv.appendChild(field);
            }
            
            pairingItem.appendChild(contentDiv);

            // Add approve button
            const actionsDiv = document.createElement('div');
            actionsDiv.className = 'pairing-actions';
            const approveBtn = document.createElement('button');
            approveBtn.className = 'approve-btn ok';
            approveBtn.textContent = 'Approve';
            approveBtn.addEventListener('click', function() {
                approvePairing(pairing.code, pairing.channel_id);
            });
            actionsDiv.appendChild(approveBtn);
            pairingItem.appendChild(actionsDiv);

            listDiv.prepend(pairingItem); // Add to the top of the list
        }

        function approvePairing(code, channel) {
            const pairingItem = document.querySelector(`[data-pairing-code="${code}"]`);
            const approveBtn = pairingItem ? pairingItem.querySelector('.approve-btn') : null;
            
            if (approveBtn) {
                approveBtn.disabled = true;
                approveBtn.textContent = 'Approving...';
            }

            fetch('/approve', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    [REDACTED_SECRET]',
                    code: code,
                    channel: channel,
                    notify: false
                })
            })
            .then(response => response.json())
            .then(data => {
                if (data.error) {
                    alert('Error: ' + data.error);
                    if (approveBtn) {
                        approveBtn.disabled = false;
                        approveBtn.textContent = 'Approve';
                    }
                } else {
                    console.log('Approved:', data.message);
                    // The socket event will handle UI update
                }
            })
            .catch(error => {
                console.error('Error:', error);
                alert('Error approving pairing: ' + error.message);
                if (approveBtn) {
                    approveBtn.disabled = false;
                    approveBtn.textContent = 'Approve';
                }
            });
        }

        // Handle connection errors
        socket.on('connect_error', (err) => {
            console.error('Connection Error:', err);
            const listDiv = document.getElementById('pairings-list');
            if (listDiv) {
                listDiv.innerHTML = `<p class="no-pairings" style="color: red;">Connection failed: ${err.message || 'xhr poll error'}</p>`;
            }
        });
        })(); // End IIFE
    </script>
</body>
</html>



path: templates/dashboard.html
content: <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Dashboard - OpenClaw</title>
    <style>
        * {
            box-sizing: border-box;
        }
        
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            background: #000;
            color: #fff;
            font: 16px/1.5 -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }
        
        .dashboard-container {
            min-height: 100vh;
            display: flex;
            flex-direction: column;
        }
        
        .header {
            background: rgba(255, 255, 255, 0.06);
            border-bottom: 1px solid rgba(255, 255, 255, 0.10);
            padding: 20px 32px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        
        .header h1 {
            margin: 0;
            font-size: 24px;
            font-weight: 600;
            letter-spacing: -0.3px;
        }
        
        .user-info {
            display: flex;
            align-items: center;
            gap: 16px;
        }
        
        .username {
            opacity: 0.8;
            font-size: 14px;
        }
        
        .logout-btn {
            padding: 8px 16px;
            background: rgba(255, 92, 92, 0.15);
            border: 1px solid rgba(255, 92, 92, 0.3);
            border-radius: 8px;
            color: #ff5c5c;
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            transition: all 0.2s ease;
            text-decoration: none;
        }
        
        .logout-btn:hover {
            background: rgba(255, 92, 92, 0.25);
            border-color: rgba(255, 92, 92, 0.4);
        }
        
        .content {
            flex: 1;
            padding: 48px 32px;
            max-width: 900px;
            margin: 0 auto;
            width: 100%;
        }
        
        .welcome {
            margin-bottom: 40px;
        }
        
        .welcome h2 {
            margin: 0 0 8px 0;
            font-size: 28px;
            font-weight: 700;
            letter-spacing: -0.5px;
        }
        
        .welcome p {
            margin: 0;
            color: #888;
            font-size: 15px;
        }
        
        .nav-cards {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
            gap: 16px;
        }
        
        .nav-card {
            display: flex;
            flex-direction: column;
            gap: 12px;
            padding: 28px 24px;
            background: rgba(255, 255, 255, 0.06);
            border: 1px solid rgba(255, 255, 255, 0.10);
            border-radius: 16px;
            text-decoration: none;
            color: #fff;
            transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
        }
        
        .nav-card:hover {
            background: rgba(255, 255, 255, 0.10);
            border-color: rgba(255, 255, 255, 0.20);
            transform: translateY(-4px);
            box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
        }
        
        .nav-card .icon {
            font-size: 32px;
            line-height: 1;
        }
        
        .nav-card .title {
            font-size: 18px;
            font-weight: 600;
        }
        
        .nav-card .desc {
            font-size: 14px;
            color: #888;
            line-height: 1.5;
        }
        
        .nav-card.device-pairings {
            border-color: rgba(46, 204, 113, 0.3);
        }
        .nav-card.device-pairings:hover {
            border-color: rgba(46, 204, 113, 0.5);
            box-shadow: 0 12px 32px rgba(46, 204, 113, 0.1);
        }
        
        .nav-card.channel-approvals {
            border-color: rgba(92, 158, 255, 0.3);
        }
        .nav-card.channel-approvals:hover {
            border-color: rgba(92, 158, 255, 0.5);
            box-shadow: 0 12px 32px rgba(92, 158, 255, 0.1);
        }
        
        .nav-card.terminal {
            border-color: rgba(255, 215, 0, 0.3);
        }
        .nav-card.terminal:hover {
            border-color: rgba(255, 215, 0, 0.5);
            box-shadow: 0 12px 32px rgba(255, 215, 0, 0.1);
        }
        
        @media (max-width: 640px) {
            .content {
                padding: 24px 16px;
            }
            .nav-cards {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>
<body>
    <div class="dashboard-container">
        <div class="header">
            <h1>OpenClaw Dashboard</h1>
            <div class="user-info">
                <span class="username">Welcome, {{ session.username }}</span>
                <a href="/logout" class="logout-btn">Logout</a>
            </div>
        </div>
        
        <div class="content">
            <div class="welcome">
                <h2>Overview</h2>
                <p>Manage your OpenClaw gateway, devices, and channels.</p>
            </div>
            
            <div class="nav-cards">
                <a href="/device-pairings" class="nav-card device-pairings">
                    <span class="icon">🔗</span>
                    <span class="title">Device Pairings</span>
                    <span class="desc">Approve or reject browser and device connections to the gateway.</span>
                </a>
                
                <a href="/channel-approvals" class="nav-card channel-approvals">
                    <span class="icon">💬</span>
                    <span class="title">Channel Approvals</span>
                    <span class="desc">Review and approve pending channel pairing requests (Telegram, Discord, etc).</span>
                </a>
                
                <a href="/terminal" class="nav-card terminal">
                    <span class="icon">🖥️</span>
                    <span class="title">Terminal</span>
                    <span class="desc">Open a live terminal session on the server.</span>
                </a>
            </div>
        </div>
    </div>
</body>
</html>


path: templates/device_pairings.html
content: <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Device Pairings - OpenClaw</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
    <style>
        * {
            box-sizing: border-box;
        }
        
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            background: #000;
            color: #fff;
            font: 16px/1.5 -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }
        
        .container {
            max-width: 900px;
            margin: 0 auto;
            padding: 20px;
        }
        
        h1 {
            color: #ff6b6b;
            margin-bottom: 5px;
        }
        
        .subtitle {
            color: #888;
            margin-bottom: 30px;
        }
        
        .nav {
            display: flex;
            gap: 10px;
            margin-bottom: 30px;
            flex-wrap: wrap;
        }
        
        .nav a {
            color: #aaa;
            text-decoration: none;
            padding: 8px 16px;
            border: 1px solid #333;
            border-radius: 6px;
            transition: all 0.2s;
        }
        
        .nav a:hover {
            color: #fff;
            border-color: #ff6b6b;
        }
        
        .nav a.active {
            color: #ff6b6b;
            border-color: #ff6b6b;
            background: rgba(255, 107, 107, 0.1);
        }
        
        .section {
            margin-bottom: 30px;
        }
        
        .section-title {
            font-size: 18px;
            font-weight: 600;
            margin-bottom: 15px;
            display: flex;
            align-items: center;
            gap: 10px;
        }
        
        .badge {
            background: #ff6b6b;
            color: #fff;
            font-size: 12px;
            padding: 2px 8px;
            border-radius: 10px;
            font-weight: 600;
        }
        
        .badge.green {
            background: #2ecc71;
        }
        
        .device-card {
            background: #111;
            border: 1px solid #222;
            border-radius: 8px;
            padding: 16px;
            margin-bottom: 12px;
            transition: border-color 0.2s;
        }
        
        .device-card:hover {
            border-color: #444;
        }
        
        .device-card.pending {
            border-left: 3px solid #f39c12;
        }
        
        .device-card.paired {
            border-left: 3px solid #2ecc71;
        }
        
        .device-header {
            display: flex;
            justify-content: space-between;
            align-items: flex-start;
            margin-bottom: 10px;
        }
        
        .device-info {
            flex: 1;
        }
        
        .device-name {
            font-weight: 600;
            font-size: 16px;
        }
        
        .device-meta {
            color: #888;
            font-size: 13px;
            margin-top: 4px;
        }
        
        .device-meta span {
            margin-right: 15px;
        }
        
        .device-id {
            color: #666;
            font-size: 11px;
            font-family: monospace;
            margin-top: 6px;
            word-break: break-all;
        }
        
        .device-actions {
            display: flex;
            gap: 8px;
            flex-shrink: 0;
        }
        
        .btn {
            padding: 8px 16px;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
            font-weight: 500;
            transition: all 0.2s;
        }
        
        .btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
        
        .btn-approve {
            background: #2ecc71;
            color: #fff;
        }
        
        .btn-approve:hover:not(:disabled) {
            background: #27ae60;
        }
        
        .btn-reject {
            background: #e74c3c;
            color: #fff;
        }
        
        .btn-reject:hover:not(:disabled) {
            background: #c0392b;
        }
        
        .btn-refresh {
            background: #333;
            color: #fff;
            margin-bottom: 20px;
        }
        
        .btn-refresh:hover {
            background: #444;
        }
        
        .btn-approve-all {
            background: #2ecc71;
            color: #fff;
            margin-bottom: 20px;
            margin-left: 10px;
        }
        
        .btn-approve-all:hover:not(:disabled) {
            background: #27ae60;
        }
        
        .empty {
            color: #666;
            font-style: italic;
            padding: 20px;
            text-align: center;
            background: #111;
            border-radius: 8px;
            border: 1px dashed #333;
        }
        
        .status-bar {
            background: #111;
            border: 1px solid #222;
            border-radius: 6px;
            padding: 10px 16px;
            margin-bottom: 20px;
            font-size: 14px;
            color: #888;
        }
        
        .status-bar.success {
            color: #2ecc71;
            border-color: #2ecc71;
        }
        
        .status-bar.error {
            color: #e74c3c;
            border-color: #e74c3c;
        }
        
        .loading {
            text-align: center;
            padding: 40px;
            color: #666;
        }
        
        .scopes-list {
            display: flex;
            gap: 6px;
            flex-wrap: wrap;
            margin-top: 6px;
        }
        
        .scope-tag {
            background: #1a1a2e;
            color: #7b68ee;
            font-size: 11px;
            padding: 2px 8px;
            border-radius: 4px;
            font-family: monospace;
        }
        
        @media (max-width: 600px) {
            .device-header {
                flex-direction: column;
            }
            .device-actions {
                margin-top: 10px;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <a href="/dashboard" style="display:inline-flex;align-items:center;gap:6px;color:#888;text-decoration:none;font-size:14px;margin-bottom:16px;transition:color .2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#888'">← Back to Dashboard</a>
        <h1>🦞 Device Pairings</h1>
        <p class="subtitle">Manage browser and device connections to the OpenClaw gateway</p>
        
        <div id="status-bar" class="status-bar" style="display:none;"></div>
        
        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
            <button class="btn btn-refresh" onclick="loadDevices()">↻ Refresh</button>
            <button class="btn btn-approve-all" id="btn-approve-latest" onclick="approveLatest()" style="display:none;">✓ Approve Latest</button>
            <button class="btn" id="copy-token-btn" onclick="copyToken()" style="background:#1a1a2e;color:#7b68ee;border:1px solid #333;">🔑 Copy Gateway Token</button>
        </div>
        
        <!-- Pending Section -->
        <div class="section">
            <div class="section-title">
                Pending Requests
                <span class="badge" id="pending-count">0</span>
            </div>
            <div id="pending-list">
                <div class="loading">Loading...</div>
            </div>
        </div>
        
        <!-- Paired Section -->
        <div class="section">
            <div class="section-title">
                Paired Devices
                <span class="badge green" id="paired-count">0</span>
            </div>
            <div id="paired-list">
                <div class="loading">Loading...</div>
            </div>
        </div>
    </div>
    
    <script>
        const socket = io({ transports: ['polling'] });
        
        function showStatus(message, type) {
            const bar = document.getElementById('status-bar');
            bar.textContent = message;
            bar.className = 'status-bar ' + (type || '');
            bar.style.display = 'block';
            if (type === 'success') {
                setTimeout(() => { bar.style.display = 'none'; }, 4000);
            }
        }
        
        function formatTime(ts) {
            if (!ts) return 'Unknown';
            const d = new Date(ts);
            return d.toLocaleString();
        }
        
        function platformIcon(platform) {
            if (!platform) return '💻';
            const p = platform.toLowerCase();
            if (p.includes('iphone') || p.includes('ios')) return '📱';
            if (p.includes('mac') || p.includes('darwin')) return '🍎';
            if (p.includes('win')) return '🪟';
            if (p.includes('linux')) return '🐧';
            if (p.includes('android')) return '🤖';
            return '💻';
        }
        
        function renderPending(items) {
            const container = document.getElementById('pending-list');
            document.getElementById('pending-count').textContent = items.length;
            
            const approveLatestBtn = document.getElementById('btn-approve-latest');
            approveLatestBtn.style.display = items.length > 0 ? 'inline-block' : 'none';
            
            if (items.length === 0) {
                container.innerHTML = '<div class="empty">No pending pairing requests</div>';
                return;
            }
            
            container.innerHTML = items.map(item => `
                <div class="device-card pending" id="pending-${item.requestId}">
                    <div class="device-header">
                        <div class="device-info">
                            <div class="device-name">${platformIcon(item.platform)} ${item.platform || 'Unknown'} — ${item.clientMode || item.clientId || 'device'}</div>
                            <div class="device-meta">
                                <span>⏰ ${formatTime(item.ts)}</span>
                                <span>🌐 ${item.remoteIp || 'local'}</span>
                                <span>👤 ${item.role || 'operator'}</span>
                                ${item.isRepair ? '<span>🔧 Repair</span>' : '<span>🆕 New</span>'}
                            </div>
                            <div class="device-id">ID: ${item.deviceId || 'n/a'}</div>
                            ${item.scopes ? `<div class="scopes-list">${item.scopes.map(s => `<span class="scope-tag">${s}</span>`).join('')}</div>` : ''}
                        </div>
                        <div class="device-actions">
                            <button class="btn btn-approve" onclick="approveDevice('${item.requestId}', this)">✓ Approve</button>
                            <button class="btn btn-reject" onclick="rejectDevice('${item.requestId}', this)">✗ Reject</button>
                        </div>
                    </div>
                </div>
            `).join('');
        }
        
        function renderPaired(items) {
            const container = document.getElementById('paired-list');
            document.getElementById('paired-count').textContent = items.length;
            
            if (items.length === 0) {
                container.innerHTML = '<div class="empty">No paired devices</div>';
                return;
            }
            
            container.innerHTML = items.map(item => `
                <div class="device-card paired">
                    <div class="device-header">
                        <div class="device-info">
                            <div class="device-name">${platformIcon(item.platform)} ${item.platform || 'Unknown'} — ${item.clientMode || item.clientId || 'device'}</div>
                            <div class="device-meta">
                                <span>✅ Paired ${formatTime(item.approvedAtMs)}</span>
                                <span>🌐 ${item.remoteIp || 'local'}</span>
                                <span>👤 ${(item.roles || [item.role]).join(', ') || 'operator'}</span>
                            </div>
                            <div class="device-id">ID: ${item.deviceId || 'n/a'}</div>
                            ${item.tokens ? `<div class="device-meta" style="margin-top:6px">${
                                (Array.isArray(item.tokens) ? item.tokens : Object.values(item.tokens)).map(t =>
                                    `<span>🔑 ${t.role}${t.lastUsedAtMs ? ' (last used: ' + formatTime(t.lastUsedAtMs) + ')' : ' (never used)'}</span>`
                                ).join('')
                            }</div>` : ''}
                        </div>
                    </div>
                </div>
            `).join('');
        }
        
        async function loadDevices() {
            try {
                const resp = await fetch('/api/device-pairings');
                if (!resp.ok) {
                    const err = await resp.json();
                    showStatus('Failed to load: ' + (err.error || 'Unknown error'), 'error');
                    return;
                }
                const data = await resp.json();
                renderPending(data.pending || []);
                renderPaired(data.paired || []);
            } catch (e) {
                showStatus('Error loading devices: ' + e.message, 'error');
            }
        }
        
        async function approveDevice(requestId, btn) {
            if (btn) btn.disabled = true;
            showStatus('Approving device...', '');
            
            try {
                const resp = await fetch('/api/device-pair/approve', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ requestId })
                });
                const data = await resp.json();
                
                if (resp.ok) {
                    showStatus('Device approved successfully!', 'success');
                    // Remove from pending list
                    const card = document.getElementById('pending-' + requestId);
                    if (card) card.style.display = 'none';
                    // Refresh the full list after a short delay
                    setTimeout(loadDevices, 1000);
                } else {
                    showStatus('Approve failed: ' + (data.error || 'Unknown error'), 'error');
                    if (btn) btn.disabled = false;
                }
            } catch (e) {
                showStatus('Error: ' + e.message, 'error');
                if (btn) btn.disabled = false;
            }
        }
        
        async function rejectDevice(requestId, btn) {
            if (!confirm('Reject this device pairing request?')) return;
            if (btn) btn.disabled = true;
            showStatus('Rejecting device...', '');
            
            try {
                const resp = await fetch('/api/device-pair/reject', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ requestId })
                });
                const data = await resp.json();
                
                if (resp.ok) {
                    showStatus('Device rejected.', 'success');
                    const card = document.getElementById('pending-' + requestId);
                    if (card) card.style.display = 'none';
                    setTimeout(loadDevices, 1000);
                } else {
                    showStatus('Reject failed: ' + (data.error || 'Unknown error'), 'error');
                    if (btn) btn.disabled = false;
                }
            } catch (e) {
                showStatus('Error: ' + e.message, 'error');
                if (btn) btn.disabled = false;
            }
        }
        
        async function approveLatest() {
            showStatus('Approving latest pending request...', '');
            try {
                const resp = await fetch('/api/device-pair/approve-latest', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' }
                });
                const data = await resp.json();
                
                if (resp.ok) {
                    showStatus('Latest device approved!', 'success');
                    setTimeout(loadDevices, 1000);
                } else {
                    showStatus('Failed: ' + (data.error || 'Unknown error'), 'error');
                }
            } catch (e) {
                showStatus('Error: ' + e.message, 'error');
            }
        }
        
        // Listen for real-time updates
        socket.on('device_pairing_approved', () => {
            setTimeout(loadDevices, 500);
        });
        
        socket.on('device_pairing_rejected', () => {
            setTimeout(loadDevices, 500);
        });
        
        // Auto-refresh every 30 seconds
        setInterval(loadDevices, 30000);
        
        function copyToken() {
            const token = '{{ gateway_token }}';
            const btn = document.getElementById('copy-token-btn');
            navigator.clipboard.writeText(token).then(() => {
                btn.textContent = '✓ Copied!';
                btn.style.borderColor = '#2ecc71';
                btn.style.color = '#2ecc71';
                setTimeout(() => {
                    btn.textContent = '🔑 Copy Gateway Token';
                    btn.style.borderColor = '#333';
                    btn.style.color = '#7b68ee';
                }, 2000);
            });
        }
        
        // Initial load
        loadDevices();
    </script>
</body>
</html>



path: templates/index.html
content: 
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>OpenClaw Pairing Dashboard</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
    <style>
        * {
            box-sizing: border-box;
        }
        
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            background: #000;
            color: #fff;
            font: 16px/1.5 -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }
        
        .wrap {
            min-height: 100vh;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 32px 24px;
        }
        
        .header {
            width: min(800px, 100%);
            margin-bottom: 24px;
            text-align: center;
        }
        
        h1 {
            margin: 0 0 8px 0;
            font-size: 28px;
            font-weight: 600;
            letter-spacing: -0.3px;
        }
        
        .subtitle {
            opacity: 0.7;
            font-size: 14px;
            font-weight: 400;
        }
        
        #pairings-list {
            width: min(800px, 100%);
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        
        .pairing-item {
            background: rgba(255, 255, 255, 0.06);
            border: 1px solid rgba(255, 255, 255, 0.10);
            border-radius: 16px;
            padding: 18px 18px 14px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 20px;
            transition: all 0.2s ease;
        }
        
        .pairing-item:hover {
            background: rgba(255, 255, 255, 0.08);
            border-color: rgba(255, 255, 255, 0.14);
            transform: translateY(-1px);
        }
        
        .pairing-item.approved {
            opacity: 0.5;
            background: rgba(36, 224, 138, 0.06);
            border-color: rgba(36, 224, 138, 0.10);
        }
        
        .pairing-content {
            flex: 1;
            display: flex;
            flex-direction: column;
            gap: 12px;
            min-width: 0;
        }
        
        .pairing-row {
            display: flex;
            flex-wrap: wrap;
            gap: 16px;
            align-items: baseline;
        }
        
        .pairing-field {
            display: flex;
            align-items: baseline;
            gap: 8px;
            font-size: 14px;
        }
        
        .pairing-field strong {
            opacity: 0.7;
            font-weight: 500;
            font-size: 13px;
            text-transform: uppercase;
            letter-spacing: 0.3px;
            min-width: fit-content;
        }
        
        .pairing-field span {
            color: #fff;
            word-break: break-word;
        }
        
        .pairing-field .value {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
            font-size: 13px;
            opacity: 0.9;
        }
        
        .pairing-field .timestamp {
            opacity: 0.6;
            font-size: 12px;
            font-style: normal;
        }
        
        .pairing-actions {
            display: flex;
            gap: 10px;
            flex-shrink: 0;
        }
        
        .approve-btn {
            appearance: none;
            border: 1px solid rgba(255, 255, 255, 0.14);
            background: rgba(255, 255, 255, 0.10);
            color: #fff;
            padding: 10px 12px;
            border-radius: 12px;
            font-weight: 600;
            font-size: 14px;
            cursor: pointer;
            transition: all 0.2s ease;
            white-space: nowrap;
        }
        
        .approve-btn:hover:not(:disabled) {
            background: rgba(255, 255, 255, 0.15);
            border-color: rgba(255, 255, 255, 0.20);
            transform: translateY(1px);
        }
        
        .approve-btn:active:not(:disabled) {
            transform: translateY(0);
        }
        
        .approve-btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
            border-color: rgba(255, 255, 255, 0.08);
            background: rgba(255, 255, 255, 0.05);
            color: rgba(255, 255, 255, 0.5);
        }
        
        .approve-btn.ok {
            color: #24e08a;
        }
        
        .no-pairings {
            text-align: center;
            padding: 48px 24px;
            opacity: 0.6;
            font-size: 15px;
        }
        
        .status-indicator {
            display: inline-block;
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background: #24e08a;
            margin-right: 8px;
            animation: pulse 2s ease-in-out infinite;
        }
        
        @keyframes pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.5; }
        }
        
        .status-indicator.disconnected {
            background: #ff5c5c;
        }
        
        .bad {
            color: #ff5c5c;
        }
        
        @media (max-width: 640px) {
            .pairing-item {
                flex-direction: column;
                align-items: stretch;
            }
            
            .pairing-actions {
                width: 100%;
            }
            
            .approve-btn {
                flex: 1;
            }
            
            .pairing-row {
                flex-direction: column;
                gap: 8px;
            }
        }
    </style>
</head>
<body>
    <div class="wrap">
        <div class="header">
            <h1>Pairing Dashboard</h1>
            <div class="subtitle">
                <span class="status-indicator" id="status-indicator"></span>
                <span id="status-text">Connecting...</span>
            </div>
        </div>
        <div id="pairings-list">
            <p class="no-pairings">Loading pairings...</p>
        </div>
    </div>

    <script>
        // Use explicit connection URL and options
        const socketUrl = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port;
        const socket = io(socketUrl, {
            transports: ['polling'],
            reconnection: true,
            reconnectionDelay: 1000,
            reconnectionAttempts: 5
        });

        socket.on('connect', function() {
            console.log('Connected to server!');
            const indicator = document.getElementById('status-indicator');
            const statusText = document.getElementById('status-text');
            if (indicator) {
                indicator.classList.remove('disconnected');
            }
            if (statusText) {
                statusText.textContent = 'Connected';
            }
        });

        socket.on('disconnect', function() {
            console.log('Disconnected from server');
            const indicator = document.getElementById('status-indicator');
            const statusText = document.getElementById('status-text');
            if (indicator) {
                indicator.classList.add('disconnected');
            }
            if (statusText) {
                statusText.textContent = 'Disconnected. Reconnecting...';
            }
        });

        socket.on('initial_pairings', function(data) {
            console.log('Received initial pairings:', data.pairings);
            const listDiv = document.getElementById('pairings-list');
            listDiv.innerHTML = ''; // Clear connection/loading message
            if (data.pairings && data.pairings.length > 0) {
                data.pairings.forEach(function(pairing) {
                    addPairingToList(pairing);
                });
            } else {
                listDiv.innerHTML = '<p class="no-pairings">No pairings yet.</p>';
            }
        });

        socket.on('new_pairing', function(pairing_data) {
            console.log('Received new pairing:', pairing_data);
            const listDiv = document.getElementById('pairings-list');
            // If the list was empty, clear the "No pairings yet" message
            if (listDiv.querySelector('.no-pairings')) {
                listDiv.innerHTML = '';
            }
            addPairingToList(pairing_data);
        });

        socket.on('pairing_approved', function(data) {
            console.log('Pairing approved:', data.code);
            // Remove the pairing from the list
            const pairingItem = document.querySelector(`[data-pairing-code="${data.code}"]`);
            if (pairingItem) {
                pairingItem.classList.add('approved');
                const approveBtn = pairingItem.querySelector('.approve-btn');
                if (approveBtn) {
                    approveBtn.disabled = true;
                    approveBtn.textContent = 'Approved';
                }
                // Remove after a short delay
                setTimeout(() => {
                    pairingItem.remove();
                    const listDiv = document.getElementById('pairings-list');
                    if (listDiv.children.length === 0) {
                        listDiv.innerHTML = '<p class="no-pairings">No pairings yet.</p>';
                    }
                }, 2000);
            }
        });

        function addPairingToList(pairing) {
            const listDiv = document.getElementById('pairings-list');
            // Check if pairing already exists
            const existing = document.querySelector(`[data-pairing-code="${pairing.code}"]`);
            if (existing) {
                return; // Already displayed
            }
            
            // Create a new div for the pairing
            const pairingItem = document.createElement('div');
            pairingItem.classList.add('pairing-item');
            pairingItem.setAttribute('data-pairing-code', pairing.code);

            // Construct the content dynamically
            const contentDiv = document.createElement('div');
            contentDiv.className = 'pairing-content';
            
            const row1 = document.createElement('div');
            row1.className = 'pairing-row';
            
            if (pairing.channel_id) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Channel</strong><span>${pairing.channel_id}</span>`;
                row1.appendChild(field);
            }
            
            if (pairing.code) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Code</strong><span class="value">${pairing.code}</span>`;
                row1.appendChild(field);
            }
            
            if (pairing.user_id) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>User ID</strong><span class="value">${pairing.user_id}</span>`;
                row1.appendChild(field);
            }
            
            contentDiv.appendChild(row1);
            
            const row2 = document.createElement('div');
            row2.className = 'pairing-row';
            
            if (pairing.device_name) {
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Device</strong><span>${pairing.device_name}</span>`;
                row2.appendChild(field);
            }
            
            if (pairing.timestamp) {
                const date = new Date(pairing.timestamp);
                const field = document.createElement('div');
                field.className = 'pairing-field';
                field.innerHTML = `<strong>Requested</strong><span class="timestamp">${date.toLocaleString()}</span>`;
                row2.appendChild(field);
            }
            
            contentDiv.appendChild(row2);
            pairingItem.appendChild(contentDiv);

            // Add approve button
            const actionsDiv = document.createElement('div');
            actionsDiv.className = 'pairing-actions';
            const approveBtn = document.createElement('button');
            approveBtn.className = 'approve-btn ok';
            approveBtn.textContent = 'Approve';
            approveBtn.addEventListener('click', function() {
                approvePairing(pairing.code, pairing.channel_id);
            });
            actionsDiv.appendChild(approveBtn);
            pairingItem.appendChild(actionsDiv);

            listDiv.prepend(pairingItem); // Add to the top of the list
        }

        function approvePairing(code, channel) {
            const pairingItem = document.querySelector(`[data-pairing-code="${code}"]`);
            const approveBtn = pairingItem ? pairingItem.querySelector('.approve-btn') : null;
            
            if (approveBtn) {
                approveBtn.disabled = true;
                approveBtn.textContent = 'Approving...';
            }

            fetch('/approve', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    [REDACTED_SECRET]',
                    code: code,
                    channel: channel,
                    notify: false
                })
            })
            .then(response => response.json())
            .then(data => {
                if (data.error) {
                    alert('Error: ' + data.error);
                    if (approveBtn) {
                        approveBtn.disabled = false;
                        approveBtn.textContent = 'Approve';
                    }
                } else {
                    console.log('Approved:', data.message);
                    // The socket event will handle UI update
                }
            })
            .catch(error => {
                console.error('Error:', error);
                alert('Error approving pairing: ' + error.message);
                if (approveBtn) {
                    approveBtn.disabled = false;
                    approveBtn.textContent = 'Approve';
                }
            });
        }

        // Handle connection errors
        socket.on('connect_error', (err) => {
            console.error('Connection Error:', err);
            document.getElementById('pairings-list').innerHTML = `<p class="no-pairings" style="color: red;">Connection failed: ${err.message || 'xhr poll error'}</p>`;
        });
    </script>
</body>
</html>


path: templates/login.html
content: <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Login - OpenClaw Dashboard</title>
    <style>
        * {
            box-sizing: border-box;
        }
        
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            background: #000;
            color: #fff;
            font: 16px/1.5 -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        
        .login-container {
            width: min(400px, 90%);
            background: rgba(255, 255, 255, 0.06);
            border: 1px solid rgba(255, 255, 255, 0.10);
            border-radius: 16px;
            padding: 40px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
        }
        
        h1 {
            margin: 0 0 8px 0;
            font-size: 28px;
            font-weight: 600;
            letter-spacing: -0.3px;
            text-align: center;
        }
        
        .subtitle {
            opacity: 0.7;
            font-size: 14px;
            font-weight: 400;
            text-align: center;
            margin-bottom: 32px;
        }
        
        .form-group {
            margin-bottom: 20px;
        }
        
        label {
            display: block;
            margin-bottom: 8px;
            font-size: 14px;
            font-weight: 500;
            opacity: 0.9;
        }
        
        input[type="text"],
        input[type="password"] {
            width: 100%;
            padding: 12px 16px;
            background: rgba(255, 255, 255, 0.08);
            border: 1px solid rgba(255, 255, 255, 0.14);
            border-radius: 12px;
            color: #fff;
            font-size: 15px;
            transition: all 0.2s ease;
        }
        
        input[type="text"]:focus,
        input[type="password"]:focus {
            outline: none;
            background: rgba(255, 255, 255, 0.12);
            border-color: rgba(255, 255, 255, 0.20);
        }
        
        input::placeholder {
            color: rgba(255, 255, 255, 0.4);
        }
        
        .error {
            background: rgba(255, 92, 92, 0.15);
            border: 1px solid rgba(255, 92, 92, 0.3);
            color: #ff5c5c;
            padding: 12px 16px;
            border-radius: 12px;
            font-size: 14px;
            margin-bottom: 20px;
            text-align: center;
        }
        
        .login-btn {
            width: 100%;
            padding: 14px;
            background: rgba(255, 255, 255, 0.12);
            border: 1px solid rgba(255, 255, 255, 0.14);
            border-radius: 12px;
            color: #fff;
            font-size: 16px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.2s ease;
            margin-top: 8px;
        }
        
        .login-btn:hover {
            background: rgba(255, 255, 255, 0.18);
            border-color: rgba(255, 255, 255, 0.20);
            transform: translateY(-1px);
        }
        
        .login-btn:active {
            transform: translateY(0);
        }
    </style>
</head>
<body>
    <div class="login-container">
        <h1>OpenClaw Dashboard</h1>
        <div class="subtitle">Please sign in to continue</div>
        
        {% if error %}
        <div class="error">{{ error }}</div>
        {% endif %}
        
        <form method="POST" action="/login">
            <div class="form-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" placeholder="Enter username" required autofocus>
            </div>
            
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" placeholder="Enter password" required>
            </div>
            
            <button type="submit" class="login-btn">Sign In</button>
        </form>
    </div>
</body>
</html>



path: templates/terminal.html
content: <!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Terminal - OpenClaw</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
    <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
    <style>
        * {
            box-sizing: border-box;
        }
        
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
            background: #000;
            color: #fff;
            font-family: -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            overflow: hidden;
        }
        
        .terminal-container {
            height: 100vh;
            display: flex;
            flex-direction: column;
            padding: 16px;
        }
        
        .terminal-header {
            margin-bottom: 16px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        
        .header-left h1 {
            margin: 0 0 4px 0;
            font-size: 20px;
            font-weight: 600;
            letter-spacing: -0.3px;
        }
        
        .subtitle {
            opacity: 0.7;
            font-size: 12px;
            font-weight: 400;
        }
        
        .status-indicator {
            display: inline-block;
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background: #ff5c5c;
            margin-right: 8px;
        }
        
        .status-indicator.connected {
            background: #24e08a;
            animation: pulse 2s ease-in-out infinite;
        }
        
        @keyframes pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.5; }
        }
        
        #terminal {
            flex: 1;
            width: 100%;
            background: #000;
            border-radius: 8px;
            overflow: hidden;
        }
        
        /* xterm.js custom styling */
        .xterm {
            height: 100%;
            padding: 8px;
        }
        
        .xterm-viewport {
            background-color: #000 !important;
        }
        
        .xterm-screen {
            background-color: #000 !important;
        }
        
        .xterm-cursor-layer {
            z-index: 2;
        }
    </style>
</head>
<body>
    <div class="terminal-container">
        <div class="terminal-header">
            <div class="header-left">
                <a href="/dashboard" style="display:inline-flex;align-items:center;gap:6px;color:#888;text-decoration:none;font-size:13px;margin-bottom:8px;transition:color .2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#888'">← Back to Dashboard</a>
                <h1>Terminal</h1>
                <div class="subtitle">
                    <span class="status-indicator" id="status-indicator"></span>
                    <span id="status-text">Connecting...</span>
                </div>
            </div>
        </div>
        
        <div id="terminal"></div>
    </div>

    <script>
        // Initialize xterm.js terminal
        const term = new Terminal({
            cursorBlink: true,
            fontSize: 14,
            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
            theme: {
                background: '#000000',
                foreground: '#ffffff',
                cursor: '#ffffff',
                black: '#000000',
                red: '#ff5c5c',
                green: '#24e08a',
                yellow: '#ffd700',
                blue: '#5c9eff',
                magenta: '#ff5cff',
                cyan: '#5cffff',
                white: '#ffffff',
                brightBlack: '#808080',
                brightRed: '#ff8080',
                brightGreen: '#80ff80',
                brightYellow: '#ffff80',
                brightBlue: '#8080ff',
                brightMagenta: '#ff80ff',
                brightCyan: '#80ffff',
                brightWhite: '#ffffff'
            }
        });

        // Add fit addon for responsive sizing
        const fitAddon = new FitAddon.FitAddon();
        term.loadAddon(fitAddon);

        // Wait for DOM element to exist (needed when loaded via AJAX)
        function initTerminal() {
            const terminalElement = document.getElementById('terminal');
            if (!terminalElement) {
                console.error('Terminal element not found, retrying...');
                setTimeout(initTerminal, 100);
                return;
            }
            
            console.log('Opening terminal in element');
            term.open(terminalElement);
            fitAddon.fit();
            console.log('Terminal initialized, size:', term.cols, 'x', term.rows);
        }
        
        // Wrap everything in IIFE to avoid conflicts
        (function() {
            console.log('Terminal script starting...');
            
            // Initialize terminal - wait a bit for AJAX content to be inserted
            setTimeout(initTerminal, 300);

            // Handle window resize
            window.addEventListener('resize', () => {
                fitAddon.fit();
                if (socket && socket.connected) {
                    socket.emit('terminal_resize', {
                        cols: term.cols,
                        rows: term.rows
                    });
                }
            });

            // Socket.IO connection
            const socketUrl = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port;
            console.log('Initializing SocketIO connection to:', socketUrl);
            
            const socket = io(socketUrl, {
                transports: ['polling'],
                reconnection: true,
                reconnectionDelay: 1000,
                reconnectionAttempts: 5
            });

            let terminalReady = false;

        socket.on('connect', function() {
            console.log('Socket connected, session ID:', socket.id);
            updateStatus(true, 'Connecting to terminal...');
            
            // Small delay to ensure connection is fully established
            setTimeout(function() {
                // Request terminal connection
                console.log('Requesting terminal connection...');
                socket.emit('terminal_connect');
            }, 100);
        });
        
        socket.on('connect_error', function(error) {
            console.error('Socket connection error:', error);
            updateStatus(false, 'Connection failed: ' + (error.message || 'Unknown error'));
        });

        socket.on('disconnect', function() {
            console.log('Socket disconnected');
            updateStatus(false, 'Disconnected');
            terminalReady = false;
        });

        socket.on('terminal_ready', function() {
            console.log('Terminal ready');
            updateStatus(true, 'Connected');
            terminalReady = true;
            
            // Send initial resize
            setTimeout(function() {
                socket.emit('terminal_resize', {
                    cols: term.cols,
                    rows: term.rows
                });
            }, 100);
        });

        socket.on('terminal_output', function(data) {
            if (data && data.data) {
                term.write(data.data);
            }
        });

        socket.on('terminal_error', function(data) {
            console.error('Terminal error:', data);
            const errorMsg = data && data.message ? data.message : 'Unknown error';
            term.writeln('\r\n\x1b[31mError: ' + errorMsg + '\x1b[0m');
            updateStatus(false, 'Error: ' + errorMsg);
        });

        // Handle terminal input
        term.onData(function(data) {
            if (terminalReady && socket.connected) {
                socket.emit('terminal_input', { data: data });
            }
        });

        // Update status indicator
        function updateStatus(connected, text) {
            const indicator = document.getElementById('status-indicator');
            const statusText = document.getElementById('status-text');
            
            if (indicator) {
                if (connected) {
                    indicator.classList.add('connected');
                } else {
                    indicator.classList.remove('connected');
                }
            }
            
            if (statusText) {
                statusText.textContent = text;
            }
        }

        // Cleanup on page unload
        window.addEventListener('beforeunload', function() {
            if (socket && socket.connected) {
                socket.emit('terminal_disconnect');
            }
        });

        // Handle cleanup when navigating away (for dashboard tabs)
        window.addEventListener('visibilitychange', function() {
            if (document.hidden && socket && socket.connected) {
                // Don't disconnect, just pause output handling if needed
            }
        });
        })(); // End IIFE
    </script>
</body>
</html>


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)