Browse Source

Add SSH and Dashboard reverse tunnel support with WebSocket proxying

Implement complete reverse tunnel infrastructure for remote device access:

Backend:
- SSH tunnel with ttyd web terminal (auto-spawns on device connection)
- Dashboard tunnel with full HTTP/WebSocket proxying
- Smart path rewriting in HTML/JS/CSS (handles /api/* paths)
- WebSocket proxy for real-time updates (ttyd and device dashboard)
- Session management with UUID-based security and heartbeat
- Auto-cleanup of inactive sessions (60min timeout, 2min grace period)
- Dedicated port ranges (50000-59999 SSH, 60000-65535 Dashboard, 45000-49999 ttyd)

Frontend:
- TunnelTerminal.vue - ttyd web terminal interface
- TunnelDashboard.vue - proxied device dashboard
- Tunnel controls in DevicesView (SSH/Dashboard buttons)
- Polling-based status updates with auto-window opening
- Heartbeat every 30s to keep sessions alive

Database:
- Add contact_name to organizations
- Add android_enabled flag to organizations
- Add notes field to users

Other:
- Add websockets dependency for WS proxying
- Add sync_ssh_keys.py utility
- I18n updates for new features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 4 weeks ago
parent
commit
7ca759305b

+ 32 - 0
backend/alembic/versions/20251229_0254_3681c8aada86_add_contact_name_and_android_enabled_to_.py

@@ -0,0 +1,32 @@
+"""add_contact_name_and_android_enabled_to_organizations
+
+Revision ID: 3681c8aada86
+Revises: 652fb7324044
+Create Date: 2025-12-29 02:54:30.686663+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '3681c8aada86'
+down_revision: Union[str, None] = '652fb7324044'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('organizations', sa.Column('contact_name', sa.String(length=255), nullable=True))
+    op.add_column('organizations', sa.Column('android_enabled', sa.Boolean(), server_default='false', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('organizations', 'android_enabled')
+    op.drop_column('organizations', 'contact_name')
+    # ### end Alembic commands ###

+ 30 - 0
backend/alembic/versions/20251229_0325_1e3b17c9184b_add_notes_to_users.py

@@ -0,0 +1,30 @@
+"""add_notes_to_users
+
+Revision ID: 1e3b17c9184b
+Revises: 3681c8aada86
+Create Date: 2025-12-29 03:25:16.704621+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '1e3b17c9184b'
+down_revision: Union[str, None] = '3681c8aada86'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('notes', sa.String(), nullable=True))
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'notes')
+    # ### end Alembic commands ###

+ 44 - 1
backend/app/api/v1/registration.py

@@ -2,9 +2,11 @@
 Device registration endpoint.
 Device registration endpoint.
 """
 """
 
 
+import asyncio
 import copy
 import copy
 import json
 import json
 import secrets
 import secrets
+import subprocess
 from base64 import b64encode
 from base64 import b64encode
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
@@ -15,7 +17,7 @@ from pydantic import BaseModel
 from sqlalchemy import select, update
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from app.core.database import get_db
+from app.core.database import async_session_maker, get_db
 from app.models.device import Device
 from app.models.device import Device
 from app.models.settings import Settings
 from app.models.settings import Settings
 
 
@@ -52,6 +54,43 @@ def _generate_password() -> str:
     return f"{n:08d}"
     return f"{n:08d}"
 
 
 
 
+async def _sync_authorized_keys():
+    """Sync all device SSH keys to /home/tunnel/.ssh/authorized_keys"""
+    try:
+        async with async_session_maker() as session:
+            result = await session.execute(select(Device))
+            devices = result.scalars().all()
+
+            keys = []
+            for device in devices:
+                if device.config and 'ssh_public_key' in device.config:
+                    ssh_key = device.config['ssh_public_key'].strip()
+                    if ssh_key:
+                        keys.append(f"{ssh_key} # {device.mac_address}")
+
+            authorized_keys_content = "\n".join(keys) + "\n" if keys else ""
+
+            # Write using sudo
+            subprocess.run(
+                ["sudo", "tee", "/home/tunnel/.ssh/authorized_keys"],
+                input=authorized_keys_content.encode(),
+                stdout=subprocess.DEVNULL,
+                check=True
+            )
+            subprocess.run(
+                ["sudo", "chmod", "600", "/home/tunnel/.ssh/authorized_keys"],
+                check=True
+            )
+            subprocess.run(
+                ["sudo", "chown", "tunnel:tunnel", "/home/tunnel/.ssh/authorized_keys"],
+                check=True
+            )
+
+            print(f"[SSH] Synced {len(keys)} keys to authorized_keys")
+    except Exception as e:
+        print(f"[SSH] Failed to sync authorized_keys: {e}")
+
+
 @router.post("/registration", response_model=RegistrationResponse, status_code=201)
 @router.post("/registration", response_model=RegistrationResponse, status_code=201)
 async def register_device(
 async def register_device(
     data: RegistrationRequest,
     data: RegistrationRequest,
@@ -126,6 +165,10 @@ async def register_device(
 
 
     print(f"[REGISTRATION] device={mac_address} simple_id={device.simple_id}")
     print(f"[REGISTRATION] device={mac_address} simple_id={device.simple_id}")
 
 
+    # Sync SSH keys to authorized_keys (background task)
+    if data.ssh_public_key:
+        asyncio.create_task(_sync_authorized_keys())
+
     return RegistrationResponse(
     return RegistrationResponse(
         device_token=device.device_token,
         device_token=device.device_token,
         device_password=device.device_password,
         device_password=device.device_password,

+ 405 - 19
backend/app/api/v1/superadmin/tunnels.py

@@ -4,21 +4,66 @@ Superadmin tunnel management API endpoints.
 
 
 from typing import Annotated, Optional
 from typing import Annotated, Optional
 
 
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, Request
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from pydantic import BaseModel
 from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy import select
+from starlette.responses import StreamingResponse, Response
+import httpx
+import asyncio
 
 
 from app.api.deps import get_current_superadmin
 from app.api.deps import get_current_superadmin
 from app.core.database import get_db
 from app.core.database import get_db
 from app.models.device import Device
 from app.models.device import Device
 from app.models.user import User
 from app.models.user import User
 from app.services.tunnel_service import tunnel_service
 from app.services.tunnel_service import tunnel_service
+import socket
 
 
 
 
 router = APIRouter(prefix="/tunnels", tags=["superadmin-tunnels"])
 router = APIRouter(prefix="/tunnels", tags=["superadmin-tunnels"])
 
 
 
 
+async def _allocate_tunnel_port(
+    db: AsyncSession,
+    device: Device,
+    tunnel_type: str,
+    port_range: tuple[int, int]
+) -> int:
+    """
+    Allocate a free port for tunnel.
+    Strategy: Use device's simple_id to calculate deterministic port offset.
+    """
+    # Calculate port based on device simple_id for deterministic allocation
+    start_port, end_port = port_range
+    port_offset = device.simple_id % (end_port - start_port + 1)
+    allocated_port = start_port + port_offset
+
+    # Verify port is free on the system
+    def is_port_free(port: int) -> bool:
+        try:
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('', port))
+                return True
+        except OSError:
+            return False
+
+    # If calculated port is taken, find next free port
+    attempts = 0
+    while not is_port_free(allocated_port) and attempts < 1000:
+        allocated_port += 1
+        if allocated_port > end_port:
+            allocated_port = start_port
+        attempts += 1
+
+    if attempts >= 1000:
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail="No free ports available for tunnel"
+        )
+
+    return allocated_port
+
+
 class TunnelEnableResponse(BaseModel):
 class TunnelEnableResponse(BaseModel):
     """Response when enabling tunnel"""
     """Response when enabling tunnel"""
     session_uuid: str
     session_uuid: str
@@ -72,6 +117,17 @@ async def enable_tunnel(
             detail="Device not found"
             detail="Device not found"
         )
         )
 
 
+    # Allocate port for tunnel
+    if tunnel_type == "ssh":
+        # SSH tunnel ports: 50000-59999
+        port_range = (50000, 59999)
+    else:
+        # Dashboard tunnel ports: 60000-65535
+        port_range = (60000, 65535)
+
+    # Find free port (simple sequential allocation)
+    allocated_port = await _allocate_tunnel_port(db, device, tunnel_type, port_range)
+
     # Create tunnel session
     # Create tunnel session
     session = tunnel_service.create_session(
     session = tunnel_service.create_session(
         device_id=device.mac_address,
         device_id=device.mac_address,
@@ -79,7 +135,7 @@ async def enable_tunnel(
         tunnel_type=tunnel_type
         tunnel_type=tunnel_type
     )
     )
 
 
-    # Update device config to enable tunnel
+    # Update device config to enable tunnel with allocated port
     if device.config is None:
     if device.config is None:
         device.config = {}
         device.config = {}
 
 
@@ -88,6 +144,14 @@ async def enable_tunnel(
         device.config[tunnel_key] = {}
         device.config[tunnel_key] = {}
 
 
     device.config[tunnel_key]["enabled"] = True
     device.config[tunnel_key]["enabled"] = True
+    device.config[tunnel_key]["remote_port"] = allocated_port
+
+    # Copy other tunnel settings from default
+    if "server" not in device.config[tunnel_key]:
+        device.config[tunnel_key]["server"] = "192.168.5.4"
+        device.config[tunnel_key]["port"] = 22
+        device.config[tunnel_key]["user"] = "tunnel"
+        device.config[tunnel_key]["keepalive_interval"] = 30
 
 
     # Mark as modified (SQLAlchemy JSON field)
     # Mark as modified (SQLAlchemy JSON field)
     from sqlalchemy.orm import attributes
     from sqlalchemy.orm import attributes
@@ -95,6 +159,8 @@ async def enable_tunnel(
 
 
     await db.commit()
     await db.commit()
 
 
+    print(f"[tunnel] Enabled {tunnel_type} tunnel for {device.mac_address} on port {allocated_port}")
+
     return TunnelEnableResponse(
     return TunnelEnableResponse(
         session_uuid=session.uuid,
         session_uuid=session.uuid,
         device_id=device.mac_address,
         device_id=device.mac_address,
@@ -132,23 +198,12 @@ async def get_tunnel_status(
         ttyd_port=session.ttyd_port
         ttyd_port=session.ttyd_port
     )
     )
 
 
-    # If ready, spawn ttyd if not already spawned
-    if session.status == "ready" and session.device_tunnel_port:
-        if not session.ttyd_port:
-            try:
-                # Spawn ttyd
-                ttyd_port = tunnel_service.spawn_ttyd(
-                    session_uuid=session.uuid,
-                    device_tunnel_port=session.device_tunnel_port
-                )
-                response.ttyd_port = ttyd_port
-                response.tunnel_url = f"/admin/{session.tunnel_type}/{session.uuid}"
-            except Exception as e:
-                print(f"[tunnel] Failed to spawn ttyd: {e}")
-                session.status = "failed"
-                response.status = "failed"
-        else:
-            response.tunnel_url = f"/admin/{session.tunnel_type}/{session.uuid}"
+    # If ready, return appropriate URL based on tunnel type
+    if session.status == "ready":
+        if session.tunnel_type == "ssh" and session.ttyd_port:
+            response.tunnel_url = f"/admin/ssh/{session.uuid}"
+        elif session.tunnel_type == "dashboard" and session.device_tunnel_port:
+            response.tunnel_url = f"/admin/dashboard/{session.uuid}"
 
 
     return response
     return response
 
 
@@ -170,3 +225,334 @@ async def session_heartbeat(
         )
         )
 
 
     return {"success": True}
     return {"success": True}
+
+
+@router.websocket("/sessions/{session_uuid}/terminal/ws")
+async def tunnel_terminal_websocket(
+    websocket: WebSocket,
+    session_uuid: str
+):
+    """
+    WebSocket proxy to ttyd on localhost.
+    """
+    session = tunnel_service.get_session(session_uuid)
+    if not session or not session.ttyd_port:
+        print(f"[tunnel] WS: Session not found or no ttyd port: {session_uuid}")
+        await websocket.close(code=1008, reason="Session not found")
+        return
+
+    print(f"[tunnel] WS: Accepting connection for session {session_uuid}, ttyd port {session.ttyd_port}")
+    # ttyd uses "tty" WebSocket subprotocol
+    await websocket.accept(subprotocol="tty")
+
+    # Connect to ttyd WebSocket
+    import websockets
+    try:
+        ttyd_url = f'ws://localhost:{session.ttyd_port}/ws'
+        print(f"[tunnel] WS: Connecting to ttyd at {ttyd_url}")
+
+        # Connect to ttyd with "tty" subprotocol
+        async with websockets.connect(ttyd_url, subprotocols=["tty"]) as ttyd_ws:
+            print(f"[tunnel] WS: Connected to ttyd successfully")
+
+            # Proxy messages both ways
+            async def forward_to_ttyd():
+                try:
+                    while True:
+                        message = await websocket.receive()
+                        print(f"[tunnel] WS: Received from browser: {message.keys()}")
+
+                        if 'bytes' in message:
+                            data = message['bytes']
+                            print(f"[tunnel] WS: Browser -> ttyd ({len(data)} bytes)")
+                            await ttyd_ws.send(data)
+                        elif 'text' in message:
+                            data = message['text']
+                            print(f"[tunnel] WS: Browser -> ttyd ({len(data)} chars text)")
+                            await ttyd_ws.send(data)
+                        elif 'websocket.disconnect' in message:
+                            print(f"[tunnel] WS: Browser sent disconnect")
+                            break
+                except WebSocketDisconnect:
+                    print(f"[tunnel] WS: Browser disconnected (exception)")
+                except Exception as e:
+                    print(f"[tunnel] WS: Error forwarding to ttyd: {e}")
+                    import traceback
+                    traceback.print_exc()
+
+            async def forward_from_ttyd():
+                try:
+                    async for message in ttyd_ws:
+                        if isinstance(message, bytes):
+                            print(f"[tunnel] WS: ttyd -> Browser ({len(message)} bytes)")
+                            await websocket.send_bytes(message)
+                        else:
+                            print(f"[tunnel] WS: ttyd -> Browser ({len(message)} chars text)")
+                            await websocket.send_text(message)
+                except Exception as e:
+                    print(f"[tunnel] WS: Error forwarding from ttyd: {e}")
+                    import traceback
+                    traceback.print_exc()
+
+            await asyncio.gather(forward_to_ttyd(), forward_from_ttyd(), return_exceptions=True)
+            print(f"[tunnel] WS: Proxy loop ended")
+
+    except Exception as e:
+        print(f"[tunnel] WS: Proxy error: {e}")
+        import traceback
+        traceback.print_exc()
+        try:
+            await websocket.close(code=1011, reason="Proxy error")
+        except:
+            pass
+
+
+@router.websocket("/sessions/{session_uuid}/dashboard/api/ws")
+async def tunnel_dashboard_websocket(
+    websocket: WebSocket,
+    session_uuid: str
+):
+    """
+    WebSocket proxy to device dashboard WebSocket.
+    """
+    session = tunnel_service.get_session(session_uuid)
+    if not session or not session.device_tunnel_port:
+        print(f"[tunnel] Dashboard WS: Session not found: {session_uuid}")
+        await websocket.close(code=1008, reason="Session not found")
+        return
+
+    print(f"[tunnel] Dashboard WS: Accepting connection for session {session_uuid}")
+    await websocket.accept()
+
+    # Connect to device dashboard WebSocket
+    import websockets
+    try:
+        device_ws_url = f'ws://localhost:{session.device_tunnel_port}/api/ws'
+        print(f"[tunnel] Dashboard WS: Connecting to {device_ws_url}")
+
+        async with websockets.connect(device_ws_url) as device_ws:
+            print(f"[tunnel] Dashboard WS: Connected successfully")
+
+            # Proxy messages both ways
+            async def forward_to_device():
+                try:
+                    while True:
+                        message = await websocket.receive()
+                        if 'bytes' in message:
+                            await device_ws.send(message['bytes'])
+                        elif 'text' in message:
+                            await device_ws.send(message['text'])
+                        elif 'websocket.disconnect' in message:
+                            break
+                except WebSocketDisconnect:
+                    print(f"[tunnel] Dashboard WS: Browser disconnected")
+                except Exception as e:
+                    print(f"[tunnel] Dashboard WS: Error forwarding to device: {e}")
+
+            async def forward_from_device():
+                try:
+                    async for message in device_ws:
+                        if isinstance(message, bytes):
+                            await websocket.send_bytes(message)
+                        else:
+                            await websocket.send_text(message)
+                except Exception as e:
+                    print(f"[tunnel] Dashboard WS: Error forwarding from device: {e}")
+
+            await asyncio.gather(forward_to_device(), forward_from_device(), return_exceptions=True)
+            print(f"[tunnel] Dashboard WS: Proxy ended")
+
+    except Exception as e:
+        print(f"[tunnel] Dashboard WS: Proxy error: {e}")
+        try:
+            await websocket.close(code=1011, reason="Proxy error")
+        except:
+            pass
+
+
+@router.api_route("/sessions/{session_uuid}/terminal/{path:path}", methods=["GET", "POST"])
+async def tunnel_terminal_proxy(
+    session_uuid: str,
+    path: str,
+    request: Request
+):
+    """
+    Full HTTP proxy to ttyd (including token, ws, etc).
+    Security: Session was created by superadmin, UUID is secret.
+    Proxies /terminal/{path} to http://localhost:ttyd_port/{path}
+    """
+    session = tunnel_service.get_session(session_uuid)
+    if not session or not session.ttyd_port:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Terminal session not found"
+        )
+
+    # Proxy request to ttyd
+    url = f'http://localhost:{session.ttyd_port}/{path}'
+    print(f"[tunnel] HTTP proxy: {request.url.path} -> {url}")
+
+    async with httpx.AsyncClient() as client:
+        resp = await client.request(
+            method=request.method,
+            url=url,
+            headers=dict(request.headers),
+            params=request.query_params,
+        )
+
+        return Response(
+            content=resp.content,
+            status_code=resp.status_code,
+            headers=dict(resp.headers)
+        )
+
+
+@router.api_route("/sessions/{session_uuid}/dashboard/{path:path}", methods=["GET", "POST"])
+async def tunnel_dashboard_proxy(
+    session_uuid: str,
+    path: str,
+    request: Request
+):
+    """
+    Proxy Dashboard HTTP/WebSocket to device via tunnel.
+    Security: Session was created by superadmin, UUID is secret.
+    """
+    session = tunnel_service.get_session(session_uuid)
+    if not session or not session.device_tunnel_port:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Dashboard session not found"
+        )
+
+    # Proxy to device dashboard through tunnel
+    # Normalize path - avoid double slashes
+    clean_path = path if path and path != '/' else ''
+    url = f'http://localhost:{session.device_tunnel_port}/{clean_path}'
+    print(f"[tunnel] Dashboard proxy: {request.url.path} -> {url}")
+
+    async with httpx.AsyncClient() as client:
+        resp = await client.request(
+            method=request.method,
+            url=url,
+            headers={k: v for k, v in request.headers.items() if k.lower() not in ('host',)},
+            params=request.query_params,
+            content=await request.body()
+        )
+
+        # Replace paths in HTML/JS/CSS files
+        content_type = resp.headers.get('content-type', '')
+        should_replace = (
+            (not path or path == '/') and 'text/html' in content_type
+        ) or (
+            'javascript' in content_type or 'css' in content_type
+        )
+
+        if should_replace:
+            try:
+                content = resp.text
+                base_url = f'/api/v1/superadmin/tunnels/sessions/{session_uuid}/dashboard/'
+
+                # Replace absolute paths with proxied paths
+                import re
+
+                # First: Replace API paths (in JS/HTML)
+                # Replace /api/* paths but avoid double-replacing
+                # Use a unique marker to avoid replacing already-replaced paths
+                marker = f'__DASHBOARD__{session_uuid}__'
+
+                # Replace all /api/ with marker first
+                content = re.sub(r'(?<!/v1/superadmin/tunnels)/api/', rf'{marker}/api/', content)
+
+                # Now replace marker with full path
+                content = content.replace(marker, base_url.rstrip('/'))
+
+                # Second: Replace href/src="/..." -> href/src="/api/v1/.../dashboard/..." (HTML only)
+                if 'text/html' in content_type:
+                    content = re.sub(r'(href|src)="/', rf'\1="{base_url}', content)
+
+                headers = {
+                    k: v for k, v in resp.headers.items()
+                    if k.lower() not in ('content-length', 'content-encoding')
+                }
+
+                if 'text/html' in content_type:
+                    from starlette.responses import HTMLResponse
+                    return HTMLResponse(content=content, headers=headers)
+                else:
+                    return Response(content=content.encode('utf-8'), headers=headers)
+            except Exception as e:
+                print(f"[tunnel] Dashboard path replacement error: {e}")
+                # Fall through to return original response
+
+        return Response(
+            content=resp.content,
+            status_code=resp.status_code,
+            headers={k: v for k, v in resp.headers.items() if k.lower() not in ('content-length', 'content-encoding')}
+        )
+
+
+@router.get("/sessions/{session_uuid}/terminal")
+async def tunnel_terminal_html(
+    session_uuid: str
+):
+    """
+    Return ttyd HTML with modified URLs.
+    Security: Session was created by superadmin, UUID is secret.
+    """
+    session = tunnel_service.get_session(session_uuid)
+    if not session or not session.ttyd_port:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Terminal session not found"
+        )
+
+    # Fetch ttyd HTML and modify URLs to work through our proxy
+    async with httpx.AsyncClient() as client:
+        try:
+            resp = await client.get(f'http://localhost:{session.ttyd_port}/')
+            print(f"[tunnel] HTML: ttyd returned status {resp.status_code}")
+
+            if resp.status_code != 200:
+                print(f"[tunnel] HTML: ttyd error: {resp.text[:200]}")
+                raise HTTPException(
+                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+                    detail="Terminal not ready"
+                )
+
+            html = resp.text
+
+            # Prefix for all ttyd resources
+            prefix = f'/api/v1/superadmin/tunnels/sessions/{session_uuid}/terminal'
+
+            # Replace relative URLs with proxied URLs
+            html = html.replace('src="/', f'src="{prefix}/')
+            html = html.replace('href="/', f'href="{prefix}/')
+
+            # Replace WebSocket URL
+            # ttyd uses: new WebSocket(url) where url is like "ws://host/ws"
+            # We need to replace it to go through our proxy
+            import re
+            # Find WebSocket URL construction in JavaScript
+            ws_before = html.count('new WebSocket')
+            html = re.sub(
+                r'(new WebSocket\([\'"])(ws[s]?://[^/]+)(/ws[\'"])',
+                rf'\1ws://192.168.5.4:8000{prefix}\3',
+                html
+            )
+            ws_after = html.count('ws://192.168.5.4:8000')
+            print(f"[tunnel] HTML: replaced {ws_after} WebSocket URLs (found {ws_before} total)")
+
+            from starlette.responses import HTMLResponse
+            # Don't copy Content-Length or Content-Encoding since we modified the HTML
+            # (ttyd returns compressed, we return uncompressed modified HTML)
+            headers = {
+                k: v for k, v in resp.headers.items()
+                if k.lower() not in ('content-length', 'content-encoding')
+            }
+            return HTMLResponse(content=html, headers=headers)
+        except Exception as e:
+            print(f"[tunnel] HTML: exception: {e}")
+            raise HTTPException(
+                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+                detail=f"Failed to fetch terminal: {str(e)}"
+            )

+ 2 - 0
backend/app/models/organization.py

@@ -21,12 +21,14 @@ class Organization(Base):
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(255), nullable=False)
     name: Mapped[str] = mapped_column(String(255), nullable=False)
+    contact_name: Mapped[str | None] = mapped_column(String(255))
     contact_email: Mapped[str] = mapped_column(String(255), nullable=False)
     contact_email: Mapped[str] = mapped_column(String(255), nullable=False)
     contact_phone: Mapped[str | None] = mapped_column(String(50))
     contact_phone: Mapped[str | None] = mapped_column(String(50))
 
 
     # Product access (modular)
     # Product access (modular)
     wifi_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
     wifi_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
     ble_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
     ble_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+    android_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
 
 
     # WiFi credentials (encrypted)
     # WiFi credentials (encrypted)
     wifi_ssid: Mapped[str | None] = mapped_column(String(100))
     wifi_ssid: Mapped[str | None] = mapped_column(String(100))

+ 5 - 2
backend/app/models/user.py

@@ -32,7 +32,7 @@ class User(Base):
     full_name: Mapped[str | None] = mapped_column(String(255))
     full_name: Mapped[str | None] = mapped_column(String(255))
     phone: Mapped[str | None] = mapped_column(String(50))
     phone: Mapped[str | None] = mapped_column(String(50))
 
 
-    # Role: superadmin, owner, admin, manager, operator, viewer
+    # Role: superadmin (cloud), admin (cloud), owner (org), user (org)
     role: Mapped[str] = mapped_column(String(20), nullable=False)
     role: Mapped[str] = mapped_column(String(20), nullable=False)
 
 
     # Status: pending, active, suspended, deleted
     # Status: pending, active, suspended, deleted
@@ -40,11 +40,14 @@ class User(Base):
         String(20), default="pending", nullable=False
         String(20), default="pending", nullable=False
     )
     )
 
 
-    # Organization (NULL for superadmin)
+    # Organization (NULL for superadmin/admin)
     organization_id: Mapped[int | None] = mapped_column(
     organization_id: Mapped[int | None] = mapped_column(
         ForeignKey("organizations.id", ondelete="CASCADE")
         ForeignKey("organizations.id", ondelete="CASCADE")
     )
     )
 
 
+    # Admin notes (visible only to superadmin)
+    notes: Mapped[str | None] = mapped_column(String)
+
     # Email verification
     # Email verification
     email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
     email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
     email_verification_token: Mapped[str | None] = mapped_column(String(255))
     email_verification_token: Mapped[str | None] = mapped_column(String(255))

+ 5 - 0
backend/app/schemas/organization.py

@@ -11,6 +11,7 @@ class OrganizationBase(BaseModel):
     """Base organization schema."""
     """Base organization schema."""
 
 
     name: str
     name: str
+    contact_name: str | None = None
     contact_email: EmailStr
     contact_email: EmailStr
     contact_phone: str | None = None
     contact_phone: str | None = None
 
 
@@ -20,16 +21,19 @@ class OrganizationCreate(OrganizationBase):
 
 
     wifi_enabled: bool = False
     wifi_enabled: bool = False
     ble_enabled: bool = False
     ble_enabled: bool = False
+    android_enabled: bool = False
 
 
 
 
 class OrganizationUpdate(BaseModel):
 class OrganizationUpdate(BaseModel):
     """Schema for updating an organization."""
     """Schema for updating an organization."""
 
 
     name: str | None = None
     name: str | None = None
+    contact_name: str | None = None
     contact_email: EmailStr | None = None
     contact_email: EmailStr | None = None
     contact_phone: str | None = None
     contact_phone: str | None = None
     wifi_enabled: bool | None = None
     wifi_enabled: bool | None = None
     ble_enabled: bool | None = None
     ble_enabled: bool | None = None
+    android_enabled: bool | None = None
     status: str | None = None
     status: str | None = None
     notes: str | None = None
     notes: str | None = None
 
 
@@ -40,6 +44,7 @@ class OrganizationResponse(OrganizationBase):
     id: int
     id: int
     wifi_enabled: bool
     wifi_enabled: bool
     ble_enabled: bool
     ble_enabled: bool
+    android_enabled: bool
     status: str
     status: str
     notes: str | None
     notes: str | None
     created_at: datetime
     created_at: datetime

+ 2 - 0
backend/app/schemas/user.py

@@ -30,6 +30,7 @@ class UserUpdate(BaseModel):
     phone: str | None = None
     phone: str | None = None
     role: str | None = None
     role: str | None = None
     status: str | None = None
     status: str | None = None
+    notes: str | None = None
 
 
 
 
 class UserResponse(UserBase):
 class UserResponse(UserBase):
@@ -42,6 +43,7 @@ class UserResponse(UserBase):
     email_verified: bool
     email_verified: bool
     last_login_at: datetime | None
     last_login_at: datetime | None
     created_at: datetime
     created_at: datetime
+    notes: str | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

+ 40 - 3
backend/app/services/tunnel_service.py

@@ -68,6 +68,7 @@ class TunnelService:
         now = datetime.now()
         now = datetime.now()
         inactive_threshold = now - timedelta(minutes=60)
         inactive_threshold = now - timedelta(minutes=60)
         grace_period = now - timedelta(seconds=60)
         grace_period = now - timedelta(seconds=60)
+        initial_grace = now - timedelta(minutes=2)
 
 
         for session_uuid, session in list(self.sessions.items()):
         for session_uuid, session in list(self.sessions.items()):
             # Check expiration (hard limit: 1 hour)
             # Check expiration (hard limit: 1 hour)
@@ -77,6 +78,14 @@ class TunnelService:
                 del self.sessions[session_uuid]
                 del self.sessions[session_uuid]
                 continue
                 continue
 
 
+            # Check if tab was never opened (ttyd spawned but no heartbeat after 2 min)
+            if (session.ttyd_pid and not session.last_heartbeat and
+                session.created_at < initial_grace):
+                print(f"[tunnel] Session never opened (no heartbeat): {session_uuid}")
+                self._kill_ttyd(session.ttyd_pid)
+                del self.sessions[session_uuid]
+                continue
+
             # Check inactivity (60 minutes without heartbeat)
             # Check inactivity (60 minutes without heartbeat)
             if session.last_heartbeat and session.last_heartbeat < inactive_threshold:
             if session.last_heartbeat and session.last_heartbeat < inactive_threshold:
                 print(f"[tunnel] Session inactive for 60 min: {session_uuid}")
                 print(f"[tunnel] Session inactive for 60 min: {session_uuid}")
@@ -164,6 +173,27 @@ class TunnelService:
                     session.device_tunnel_port = port
                     session.device_tunnel_port = port
                     session.status = "ready"
                     session.status = "ready"
 
 
+                    # Spawn ttyd only for SSH tunnels (dashboard doesn't need ttyd)
+                    if session.tunnel_type == "ssh" and not session.ttyd_port:
+                        try:
+                            # Wait a moment for SSH to be fully ready
+                            import time
+                            time.sleep(2)
+
+                            ttyd_port = self.spawn_ttyd(
+                                session_uuid=session.uuid,
+                                device_tunnel_port=port
+                            )
+                            print(f"[tunnel] Auto-spawned ttyd for session {session.uuid} on port {ttyd_port}")
+                        except Exception as e:
+                            print(f"[tunnel] Failed to auto-spawn ttyd: {e}")
+                            session.status = "failed"
+                    elif session.tunnel_type == "dashboard":
+                        # Wait for dashboard to be fully ready
+                        import time
+                        time.sleep(3)
+                        print(f"[tunnel] Dashboard tunnel ready for session {session.uuid} on port {port}")
+
         elif status == "disconnected":
         elif status == "disconnected":
             if status_key in self.tunnel_status:
             if status_key in self.tunnel_status:
                 self.tunnel_status[status_key].status = "disconnected"
                 self.tunnel_status[status_key].status = "disconnected"
@@ -200,19 +230,26 @@ class TunnelService:
         cmd = [
         cmd = [
             "ttyd",
             "ttyd",
             "--port", str(ttyd_port),
             "--port", str(ttyd_port),
-            "--once",  # Single session
             "--writable",  # Allow input
             "--writable",  # Allow input
             "ssh",
             "ssh",
             "-p", str(device_tunnel_port),
             "-p", str(device_tunnel_port),
             "-o", "StrictHostKeyChecking=no",
             "-o", "StrictHostKeyChecking=no",
             "-o", "UserKnownHostsFile=/dev/null",
             "-o", "UserKnownHostsFile=/dev/null",
+            "-o", "ServerAliveInterval=30",
+            "-o", "ServerAliveCountMax=3",
             f"root@{server_host}"
             f"root@{server_host}"
         ]
         ]
 
 
+        # Log ttyd output for debugging
+        log_file = f"/tmp/ttyd_{ttyd_port}.log"
+        with open(log_file, 'w') as f:
+            f.write(f"Starting ttyd for session {session_uuid}\n")
+            f.write(f"Command: {' '.join(cmd)}\n")
+
         process = subprocess.Popen(
         process = subprocess.Popen(
             cmd,
             cmd,
-            stdout=subprocess.DEVNULL,
-            stderr=subprocess.DEVNULL
+            stdout=open(log_file, 'a'),
+            stderr=subprocess.STDOUT
         )
         )
 
 
         session.ttyd_port = ttyd_port
         session.ttyd_port = ttyd_port

+ 1 - 1
backend/poetry.lock

@@ -2118,4 +2118,4 @@ files = [
 [metadata]
 [metadata]
 lock-version = "2.1"
 lock-version = "2.1"
 python-versions = "^3.11"
 python-versions = "^3.11"
-content-hash = "10d4c44b35fb4ed08e4d1a03c9bab41ce128011d9c67bc494089d4e3e9d4e3c6"
+content-hash = "f2aa6d4a3397a9b388aba5bd5776a9984b84b90f6a76ec841a1cd79e44314042"

+ 1 - 0
backend/pyproject.toml

@@ -25,6 +25,7 @@ aiofiles = "^23.2.1"
 email-validator = "^2.3.0"
 email-validator = "^2.3.0"
 bcrypt = "<4.0.0"
 bcrypt = "<4.0.0"
 psutil = "^7.2.0"
 psutil = "^7.2.0"
+websockets = "^15.0.1"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 pytest = "^7.4.4"
 pytest = "^7.4.4"

+ 65 - 0
backend/sync_ssh_keys.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""
+Sync SSH public keys from database to /home/tunnel/.ssh/authorized_keys
+Run this script when devices register or keys change.
+"""
+
+import asyncio
+from pathlib import Path
+
+from sqlalchemy import select
+
+from app.core.database import async_session_maker
+from app.models.device import Device
+
+
+AUTHORIZED_KEYS_PATH = Path("/home/tunnel/.ssh/authorized_keys")
+
+
+async def sync_ssh_keys():
+    """Sync all device SSH keys to authorized_keys file."""
+
+    async with async_session_maker() as session:
+        # Get all devices with SSH public keys
+        result = await session.execute(select(Device))
+        devices = result.scalars().all()
+
+        keys = []
+        for device in devices:
+            if device.config and 'ssh_public_key' in device.config:
+                ssh_key = device.config['ssh_public_key'].strip()
+                if ssh_key:
+                    # Add comment with device MAC
+                    keys.append(f"{ssh_key} # {device.mac_address}")
+
+        print(f"Found {len(keys)} SSH keys in database")
+
+        # Write to authorized_keys
+        authorized_keys_content = "\n".join(keys) + "\n" if keys else ""
+
+        # Need sudo to write
+        import subprocess
+        subprocess.run(
+            ["sudo", "tee", str(AUTHORIZED_KEYS_PATH)],
+            input=authorized_keys_content.encode(),
+            stdout=subprocess.DEVNULL,
+            check=True
+        )
+
+        # Set permissions
+        subprocess.run(
+            ["sudo", "chmod", "600", str(AUTHORIZED_KEYS_PATH)],
+            check=True
+        )
+        subprocess.run(
+            ["sudo", "chown", "tunnel:tunnel", str(AUTHORIZED_KEYS_PATH)],
+            check=True
+        )
+
+        print(f"Synced {len(keys)} keys to {AUTHORIZED_KEYS_PATH}")
+
+        return len(keys)
+
+
+if __name__ == "__main__":
+    asyncio.run(sync_ssh_keys())

+ 22 - 14
frontend/src/i18n/index.js

@@ -46,13 +46,17 @@ const messages = {
       manage: 'Manage all organizations',
       manage: 'Manage all organizations',
       add: 'Add Organization',
       add: 'Add Organization',
       name: 'Name',
       name: 'Name',
+      contactName: 'Contact Name',
       contactEmail: 'Contact Email',
       contactEmail: 'Contact Email',
       contactPhone: 'Contact Phone',
       contactPhone: 'Contact Phone',
       wifiEnabled: 'WiFi Enabled',
       wifiEnabled: 'WiFi Enabled',
       bleEnabled: 'BLE Enabled',
       bleEnabled: 'BLE Enabled',
+      androidEnabled: 'Android + BLE Enabled',
       createdAt: 'Created',
       createdAt: 'Created',
       manageAction: 'Manage Organizations',
       manageAction: 'Manage Organizations',
-      manageDesc: 'Create and configure organizations'
+      manageDesc: 'Create and configure organizations',
+      searchPlaceholder: 'Search organizations...',
+      showUsers: 'Show users'
     },
     },
     devices: {
     devices: {
       title: 'Devices',
       title: 'Devices',
@@ -96,15 +100,15 @@ const messages = {
       add: 'Add User',
       add: 'Add User',
       fullName: 'Full Name',
       fullName: 'Full Name',
       role: 'Role',
       role: 'Role',
+      notes: 'Notes',
+      notesHint: 'Internal notes (visible only to superadmin)',
       manageAction: 'Manage Users',
       manageAction: 'Manage Users',
       manageDesc: 'View all system users',
       manageDesc: 'View all system users',
       roles: {
       roles: {
-        superadmin: 'Superadmin',
-        owner: 'Owner',
-        admin: 'Admin',
-        manager: 'Manager',
-        operator: 'Operator',
-        viewer: 'Viewer'
+        superadmin: 'Superadmin (Cloud)',
+        admin: 'Admin (Cloud)',
+        owner: 'Owner (Organization)',
+        user: 'User (Organization)'
       }
       }
     },
     },
     host: {
     host: {
@@ -213,13 +217,17 @@ const messages = {
       manage: 'Управление всеми организациями',
       manage: 'Управление всеми организациями',
       add: 'Добавить организацию',
       add: 'Добавить организацию',
       name: 'Название',
       name: 'Название',
+      contactName: 'Имя контакта',
       contactEmail: 'Email контакта',
       contactEmail: 'Email контакта',
       contactPhone: 'Телефон контакта',
       contactPhone: 'Телефон контакта',
       wifiEnabled: 'WiFi включен',
       wifiEnabled: 'WiFi включен',
       bleEnabled: 'BLE включен',
       bleEnabled: 'BLE включен',
+      androidEnabled: 'Android + BLE включен',
       createdAt: 'Создано',
       createdAt: 'Создано',
       manageAction: 'Управление организациями',
       manageAction: 'Управление организациями',
-      manageDesc: 'Создание и настройка организаций'
+      manageDesc: 'Создание и настройка организаций',
+      searchPlaceholder: 'Поиск организаций...',
+      showUsers: 'Показать пользователей'
     },
     },
     devices: {
     devices: {
       title: 'Устройства',
       title: 'Устройства',
@@ -263,15 +271,15 @@ const messages = {
       add: 'Добавить пользователя',
       add: 'Добавить пользователя',
       fullName: 'Полное имя',
       fullName: 'Полное имя',
       role: 'Роль',
       role: 'Роль',
+      notes: 'Заметки',
+      notesHint: 'Внутренние заметки (видны только superadmin)',
       manageAction: 'Управление пользователями',
       manageAction: 'Управление пользователями',
       manageDesc: 'Просмотр всех пользователей системы',
       manageDesc: 'Просмотр всех пользователей системы',
       roles: {
       roles: {
-        superadmin: 'Суперадмин',
-        owner: 'Владелец',
-        admin: 'Администратор',
-        manager: 'Менеджер',
-        operator: 'Оператор',
-        viewer: 'Наблюдатель'
+        superadmin: 'Суперадмин (Cloud)',
+        admin: 'Админ (Cloud)',
+        owner: 'Владелец (Организация)',
+        user: 'Пользователь (Организация)'
       }
       }
     },
     },
     host: {
     host: {

+ 12 - 0
frontend/src/router/index.js

@@ -71,6 +71,18 @@ const routes = [
         component: () => import('@/views/client/UsersView.vue')
         component: () => import('@/views/client/UsersView.vue')
       }
       }
     ]
     ]
+  },
+  {
+    path: '/admin/ssh/:uuid',
+    name: 'SSHTunnel',
+    component: () => import('@/views/admin/TunnelTerminal.vue'),
+    meta: { requiresAuth: true, requiresSuperadmin: true }
+  },
+  {
+    path: '/admin/dashboard/:uuid',
+    name: 'DashboardTunnel',
+    component: () => import('@/views/admin/TunnelDashboard.vue'),
+    meta: { requiresAuth: true, requiresSuperadmin: true }
   }
   }
 ]
 ]
 
 

+ 109 - 0
frontend/src/views/admin/TunnelDashboard.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="dashboard-page">
+    <div v-if="loading" class="loading">Loading dashboard...</div>
+    <div v-else-if="error" class="error">{{ error }}</div>
+    <iframe
+      v-else-if="dashboardUrl"
+      :src="dashboardUrl"
+      class="dashboard-iframe"
+      @load="onIframeLoad"
+    />
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { useRoute } from 'vue-router'
+import tunnelsApi from '@/api/tunnels'
+
+const route = useRoute()
+const sessionUuid = route.params.uuid
+const dashboardUrl = ref(null)
+const loading = ref(true)
+const error = ref(null)
+let heartbeatInterval = null
+
+async function loadDashboard() {
+  loading.value = true
+  error.value = null
+
+  try {
+    const status = await tunnelsApi.getSessionStatus(sessionUuid)
+
+    if (status.status === 'ready' && status.device_tunnel_port) {
+      // Backend proxies dashboard through tunnel
+      dashboardUrl.value = `http://192.168.5.4:8000/api/v1/superadmin/tunnels/sessions/${sessionUuid}/dashboard/`
+      loading.value = false
+
+      // Start heartbeat
+      startHeartbeat()
+    } else if (status.status === 'failed') {
+      error.value = 'Tunnel connection failed'
+      loading.value = false
+    } else {
+      error.value = 'Dashboard not ready yet'
+      loading.value = false
+    }
+  } catch (err) {
+    console.error('Failed to load dashboard:', err)
+    error.value = err.response?.data?.detail || 'Failed to load dashboard'
+    loading.value = false
+  }
+}
+
+function startHeartbeat() {
+  // Send heartbeat every 30 seconds
+  heartbeatInterval = setInterval(async () => {
+    try {
+      await tunnelsApi.sendHeartbeat(sessionUuid)
+      console.log('Heartbeat sent')
+    } catch (err) {
+      console.error('Heartbeat failed:', err)
+    }
+  }, 30000)
+}
+
+function onIframeLoad() {
+  console.log('Dashboard loaded')
+}
+
+onMounted(() => {
+  loadDashboard()
+})
+
+onBeforeUnmount(() => {
+  if (heartbeatInterval) {
+    clearInterval(heartbeatInterval)
+  }
+})
+</script>
+
+<style scoped>
+.dashboard-page {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.loading, .error {
+  font-size: 18px;
+  padding: 40px;
+  text-align: center;
+}
+
+.error {
+  color: #ff6b6b;
+}
+
+.dashboard-iframe {
+  width: 100%;
+  height: 100%;
+  border: none;
+}
+</style>

+ 110 - 0
frontend/src/views/admin/TunnelTerminal.vue

@@ -0,0 +1,110 @@
+<template>
+  <div class="terminal-page">
+    <div v-if="loading" class="loading">Loading terminal...</div>
+    <div v-else-if="error" class="error">{{ error }}</div>
+    <iframe
+      v-else-if="ttydUrl"
+      :src="ttydUrl"
+      class="terminal-iframe"
+      @load="onIframeLoad"
+    />
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { useRoute } from 'vue-router'
+import tunnelsApi from '@/api/tunnels'
+
+const route = useRoute()
+const sessionUuid = route.params.uuid
+const ttydUrl = ref(null)
+const loading = ref(true)
+const error = ref(null)
+let heartbeatInterval = null
+
+async function loadTerminal() {
+  loading.value = true
+  error.value = null
+
+  try {
+    const status = await tunnelsApi.getSessionStatus(sessionUuid)
+
+    if (status.status === 'ready' && status.ttyd_port) {
+      // Backend проксирует WebSocket на localhost ttyd
+      ttydUrl.value = `http://192.168.5.4:8000/api/v1/superadmin/tunnels/sessions/${sessionUuid}/terminal`
+      loading.value = false
+
+      // Start heartbeat
+      startHeartbeat()
+    } else if (status.status === 'failed') {
+      error.value = 'Tunnel connection failed'
+      loading.value = false
+    } else {
+      error.value = 'Terminal not ready yet'
+      loading.value = false
+    }
+  } catch (err) {
+    console.error('Failed to load terminal:', err)
+    error.value = err.response?.data?.detail || 'Failed to load terminal'
+    loading.value = false
+  }
+}
+
+function startHeartbeat() {
+  // Send heartbeat every 30 seconds
+  heartbeatInterval = setInterval(async () => {
+    try {
+      await tunnelsApi.sendHeartbeat(sessionUuid)
+      console.log('Heartbeat sent')
+    } catch (err) {
+      console.error('Heartbeat failed:', err)
+    }
+  }, 30000)
+}
+
+function onIframeLoad() {
+  console.log('Terminal loaded')
+}
+
+onMounted(() => {
+  loadTerminal()
+})
+
+onBeforeUnmount(() => {
+  if (heartbeatInterval) {
+    clearInterval(heartbeatInterval)
+  }
+})
+</script>
+
+<style scoped>
+.terminal-page {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: #1e1e1e;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.loading, .error {
+  color: #ffffff;
+  font-size: 18px;
+  padding: 40px;
+  text-align: center;
+}
+
+.error {
+  color: #ff6b6b;
+}
+
+.terminal-iframe {
+  width: 100%;
+  height: 100%;
+  border: none;
+}
+</style>

+ 3 - 1
frontend/src/views/superadmin/DevicesView.vue

@@ -746,6 +746,7 @@ async function openTunnel(device, tunnelType) {
     // Step 2: Poll for tunnel status
     // Step 2: Poll for tunnel status
     const maxAttempts = 60 // 60 seconds max wait
     const maxAttempts = 60 // 60 seconds max wait
     let attempts = 0
     let attempts = 0
+    let opened = false // Prevent multiple window.open()
 
 
     const pollInterval = setInterval(async () => {
     const pollInterval = setInterval(async () => {
       attempts++
       attempts++
@@ -753,10 +754,11 @@ async function openTunnel(device, tunnelType) {
       try {
       try {
         const status = await tunnelsApi.getSessionStatus(session_uuid)
         const status = await tunnelsApi.getSessionStatus(session_uuid)
 
 
-        if (status.status === 'ready' && status.tunnel_url) {
+        if (status.status === 'ready' && status.tunnel_url && !opened) {
           // Clear polling
           // Clear polling
           clearInterval(pollInterval)
           clearInterval(pollInterval)
           tunnelLoading.value[loadingKey] = false
           tunnelLoading.value[loadingKey] = false
+          opened = true
 
 
           // Open tunnel URL in new tab
           // Open tunnel URL in new tab
           window.open(status.tunnel_url, '_blank')
           window.open(status.tunnel_url, '_blank')

+ 269 - 28
frontend/src/views/superadmin/OrganizationsView.vue

@@ -11,39 +11,76 @@
     </div>
     </div>
 
 
     <div class="content">
     <div class="content">
+      <!-- Search Filter -->
+      <div class="search-box">
+        <input
+          v-model="searchQuery"
+          type="text"
+          :placeholder="$t('organizations.searchPlaceholder')"
+          class="search-input"
+        />
+      </div>
+
       <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
       <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
 
 
       <div v-else-if="error" class="error">{{ error }}</div>
       <div v-else-if="error" class="error">{{ error }}</div>
 
 
-      <table v-else-if="organizations.length > 0" class="data-table">
+      <table v-else-if="filteredOrganizations.length > 0" class="data-table">
         <thead>
         <thead>
           <tr>
           <tr>
+            <th style="width: 40px;"></th>
             <th>ID</th>
             <th>ID</th>
             <th>{{ $t('organizations.name') }}</th>
             <th>{{ $t('organizations.name') }}</th>
             <th>{{ $t('organizations.contactEmail') }}</th>
             <th>{{ $t('organizations.contactEmail') }}</th>
             <th>{{ $t('organizations.contactPhone') }}</th>
             <th>{{ $t('organizations.contactPhone') }}</th>
             <th>WiFi</th>
             <th>WiFi</th>
             <th>BLE</th>
             <th>BLE</th>
+            <th>Android</th>
             <th>{{ $t('common.status') }}</th>
             <th>{{ $t('common.status') }}</th>
             <th>{{ $t('common.actions') }}</th>
             <th>{{ $t('common.actions') }}</th>
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          <tr v-for="org in organizations" :key="org.id">
-            <td>{{ org.id }}</td>
-            <td><strong>{{ org.name }}</strong></td>
-            <td>{{ org.contact_email }}</td>
-            <td>{{ org.contact_phone || '-' }}</td>
-            <td><span class="badge" :class="{ active: org.wifi_enabled }">{{ org.wifi_enabled ? '✓' : '✗' }}</span></td>
-            <td><span class="badge" :class="{ active: org.ble_enabled }">{{ org.ble_enabled ? '✓' : '✗' }}</span></td>
-            <td><span class="badge" :class="`status-${org.status}`">{{ org.status }}</span></td>
-            <td>
-              <div class="actions">
-                <button @click="showEditModal(org)" class="btn-icon" title="Edit">✏️</button>
-                <button @click="confirmDelete(org)" class="btn-icon" title="Delete">🗑️</button>
-              </div>
-            </td>
-          </tr>
+          <template v-for="org in filteredOrganizations" :key="org.id">
+            <tr>
+              <td>
+                <button @click="toggleUsers(org.id)" class="btn-expand" :title="$t('organizations.showUsers')">
+                  {{ expandedOrgs[org.id] ? '−' : '+' }}
+                </button>
+              </td>
+              <td>{{ org.id }}</td>
+              <td><strong>{{ org.name }}</strong></td>
+              <td>{{ org.contact_email }}</td>
+              <td>{{ org.contact_phone || '-' }}</td>
+              <td><span class="badge" :class="{ active: org.wifi_enabled }">{{ org.wifi_enabled ? '✓' : '✗' }}</span></td>
+              <td><span class="badge" :class="{ active: org.ble_enabled }">{{ org.ble_enabled ? '✓' : '✗' }}</span></td>
+              <td><span class="badge" :class="{ active: org.android_enabled }">{{ org.android_enabled ? '✓' : '✗' }}</span></td>
+              <td><span class="badge" :class="`status-${org.status}`">{{ org.status }}</span></td>
+              <td>
+                <div class="actions">
+                  <button @click="showEditModal(org)" class="btn-icon" title="Edit">✏️</button>
+                </div>
+              </td>
+            </tr>
+            <tr v-if="expandedOrgs[org.id]" class="expanded-row">
+              <td colspan="10">
+                <div class="users-list">
+                  <div v-if="loadingUsers[org.id]" class="loading-users">Loading users...</div>
+                  <div v-else-if="orgUsers[org.id] && orgUsers[org.id].length > 0" class="users-content">
+                    <h4>Users ({{ orgUsers[org.id].length }})</h4>
+                    <div class="user-cards">
+                      <div v-for="user in orgUsers[org.id]" :key="user.id" class="user-card" @click="openUserEdit(user.id)">
+                        <div class="user-name">{{ user.full_name || user.email }}</div>
+                        <div class="user-email">{{ user.email }}</div>
+                        <div class="user-role">{{ user.role }}</div>
+                      </div>
+                    </div>
+                  </div>
+                  <div v-else class="no-users">No users in this organization</div>
+                </div>
+              </td>
+            </tr>
+          </template>
         </tbody>
         </tbody>
       </table>
       </table>
 
 
@@ -63,6 +100,11 @@
             <input v-model="form.name" type="text" required />
             <input v-model="form.name" type="text" required />
           </div>
           </div>
 
 
+          <div class="form-group">
+            <label>{{ $t('organizations.contactName') }}</label>
+            <input v-model="form.contact_name" type="text" />
+          </div>
+
           <div class="form-group">
           <div class="form-group">
             <label>{{ $t('organizations.contactEmail') }} *</label>
             <label>{{ $t('organizations.contactEmail') }} *</label>
             <input v-model="form.contact_email" type="email" required />
             <input v-model="form.contact_email" type="email" required />
@@ -87,6 +129,13 @@
                 <span>{{ $t('organizations.bleEnabled') }}</span>
                 <span>{{ $t('organizations.bleEnabled') }}</span>
               </label>
               </label>
             </div>
             </div>
+
+            <div class="form-group">
+              <label class="checkbox">
+                <input v-model="form.android_enabled" type="checkbox" />
+                <span>{{ $t('organizations.androidEnabled') }}</span>
+              </label>
+            </div>
           </div>
           </div>
 
 
           <div class="form-group">
           <div class="form-group">
@@ -98,11 +147,23 @@
             </select>
             </select>
           </div>
           </div>
 
 
+          <div class="form-group">
+            <label>{{ $t('users.notes') }}</label>
+            <textarea v-model="form.notes" rows="3" :placeholder="$t('users.notesHint')"></textarea>
+          </div>
+
           <div class="modal-footer">
           <div class="modal-footer">
-            <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
-            <button type="submit" :disabled="saving" class="btn-primary">
-              {{ saving ? $t('common.loading') : $t('common.save') }}
-            </button>
+            <div class="modal-footer-left">
+              <button v-if="editingOrg" type="button" @click="confirmDelete(editingOrg)" class="btn-danger">
+                {{ $t('common.delete') }}
+              </button>
+            </div>
+            <div class="modal-footer-right">
+              <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
+              <button type="submit" :disabled="saving" class="btn-primary">
+                {{ saving ? $t('common.loading') : $t('common.save') }}
+              </button>
+            </div>
           </div>
           </div>
         </form>
         </form>
       </div>
       </div>
@@ -129,9 +190,13 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import axios from '@/api/client'
 import organizationsApi from '@/api/organizations'
 import organizationsApi from '@/api/organizations'
 
 
+const router = useRouter()
+
 const organizations = ref([])
 const organizations = ref([])
 const loading = ref(false)
 const loading = ref(false)
 const error = ref(null)
 const error = ref(null)
@@ -141,14 +206,32 @@ const editingOrg = ref(null)
 const organizationToDelete = ref(null)
 const organizationToDelete = ref(null)
 const saving = ref(false)
 const saving = ref(false)
 const deleting = ref(false)
 const deleting = ref(false)
+const searchQuery = ref('')
+const expandedOrgs = ref({})
+const orgUsers = ref({})
+const loadingUsers = ref({})
 
 
 const form = ref({
 const form = ref({
   name: '',
   name: '',
+  contact_name: '',
   contact_email: '',
   contact_email: '',
   contact_phone: '',
   contact_phone: '',
   wifi_enabled: false,
   wifi_enabled: false,
   ble_enabled: false,
   ble_enabled: false,
-  status: 'pending'
+  android_enabled: false,
+  status: 'pending',
+  notes: ''
+})
+
+const filteredOrganizations = computed(() => {
+  if (!searchQuery.value) return organizations.value
+  const query = searchQuery.value.toLowerCase()
+  return organizations.value.filter(org =>
+    org.name.toLowerCase().includes(query) ||
+    org.contact_email.toLowerCase().includes(query) ||
+    (org.contact_name && org.contact_name.toLowerCase().includes(query)) ||
+    (org.contact_phone && org.contact_phone.includes(query))
+  )
 })
 })
 
 
 async function loadOrganizations() {
 async function loadOrganizations() {
@@ -163,15 +246,46 @@ async function loadOrganizations() {
   }
   }
 }
 }
 
 
+async function toggleUsers(orgId) {
+  if (expandedOrgs.value[orgId]) {
+    expandedOrgs.value[orgId] = false
+    return
+  }
+
+  expandedOrgs.value[orgId] = true
+
+  if (!orgUsers.value[orgId]) {
+    loadingUsers.value[orgId] = true
+    try {
+      const response = await axios.get('/superadmin/users', {
+        params: { organization_id: orgId, limit: 1000 }
+      })
+      orgUsers.value[orgId] = response.data.users
+    } catch (err) {
+      console.error('Failed to load users:', err)
+      orgUsers.value[orgId] = []
+    } finally {
+      loadingUsers.value[orgId] = false
+    }
+  }
+}
+
+function openUserEdit(userId) {
+  router.push({ name: 'SuperadminUsers', query: { edit: userId } })
+}
+
 function showCreateModal() {
 function showCreateModal() {
   editingOrg.value = null
   editingOrg.value = null
   form.value = {
   form.value = {
     name: '',
     name: '',
+    contact_name: '',
     contact_email: '',
     contact_email: '',
     contact_phone: '',
     contact_phone: '',
     wifi_enabled: false,
     wifi_enabled: false,
     ble_enabled: false,
     ble_enabled: false,
-    status: 'pending'
+    android_enabled: false,
+    status: 'pending',
+    notes: ''
   }
   }
   modalVisible.value = true
   modalVisible.value = true
 }
 }
@@ -180,11 +294,14 @@ function showEditModal(org) {
   editingOrg.value = org
   editingOrg.value = org
   form.value = {
   form.value = {
     name: org.name,
     name: org.name,
+    contact_name: org.contact_name || '',
     contact_email: org.contact_email,
     contact_email: org.contact_email,
     contact_phone: org.contact_phone || '',
     contact_phone: org.contact_phone || '',
     wifi_enabled: org.wifi_enabled,
     wifi_enabled: org.wifi_enabled,
     ble_enabled: org.ble_enabled,
     ble_enabled: org.ble_enabled,
-    status: org.status
+    android_enabled: org.android_enabled,
+    status: org.status,
+    notes: org.notes || ''
   }
   }
   modalVisible.value = true
   modalVisible.value = true
 }
 }
@@ -457,14 +574,130 @@ onMounted(() => {
   padding: 24px;
   padding: 24px;
 }
 }
 
 
+.search-box {
+  margin-bottom: 20px;
+}
+
+.search-input {
+  width: 100%;
+  max-width: 400px;
+  padding: 10px 16px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  font-size: 14px;
+  transition: border-color 0.2s;
+}
+
+.search-input:focus {
+  outline: none;
+  border-color: #667eea;
+}
+
+.btn-expand {
+  width: 28px;
+  height: 28px;
+  border: 1px solid #e2e8f0;
+  background: white;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 18px;
+  font-weight: bold;
+  color: #667eea;
+  transition: all 0.2s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  line-height: 1;
+}
+
+.btn-expand:hover {
+  background: #f7fafc;
+  border-color: #667eea;
+}
+
+.expanded-row {
+  background: #f7fafc;
+}
+
+.users-list {
+  padding: 16px;
+  min-height: 80px;
+}
+
+.loading-users, .no-users {
+  text-align: center;
+  padding: 20px;
+  color: #718096;
+}
+
+.users-content h4 {
+  margin: 0 0 12px 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: #4a5568;
+}
+
+.user-cards {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 12px;
+}
+
+.user-card {
+  background: white;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  padding: 12px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.user-card:hover {
+  border-color: #667eea;
+  box-shadow: 0 2px 4px rgba(102, 126, 234, 0.1);
+  transform: translateY(-1px);
+}
+
+.user-name {
+  font-weight: 600;
+  color: #1a202c;
+  margin-bottom: 4px;
+  font-size: 14px;
+}
+
+.user-email {
+  font-size: 12px;
+  color: #718096;
+  margin-bottom: 6px;
+}
+
+.user-role {
+  font-size: 11px;
+  font-weight: 600;
+  color: #667eea;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
 .modal-footer {
 .modal-footer {
   display: flex;
   display: flex;
-  justify-content: flex-end;
+  justify-content: space-between;
+  align-items: center;
   gap: 12px;
   gap: 12px;
   padding: 24px;
   padding: 24px;
   border-top: 1px solid #e2e8f0;
   border-top: 1px solid #e2e8f0;
 }
 }
 
 
+.modal-footer-left {
+  flex: 1;
+}
+
+.modal-footer-right {
+  display: flex;
+  gap: 12px;
+}
+
 .form-group {
 .form-group {
   margin-bottom: 20px;
   margin-bottom: 20px;
 }
 }
@@ -480,24 +713,32 @@ onMounted(() => {
 .form-group input[type="text"],
 .form-group input[type="text"],
 .form-group input[type="email"],
 .form-group input[type="email"],
 .form-group input[type="tel"],
 .form-group input[type="tel"],
-.form-group select {
+.form-group select,
+.form-group textarea {
   width: 100%;
   width: 100%;
   padding: 10px 12px;
   padding: 10px 12px;
   border: 1px solid #e2e8f0;
   border: 1px solid #e2e8f0;
   border-radius: 8px;
   border-radius: 8px;
   font-size: 14px;
   font-size: 14px;
   transition: border-color 0.2s;
   transition: border-color 0.2s;
+  font-family: inherit;
 }
 }
 
 
 .form-group input:focus,
 .form-group input:focus,
-.form-group select:focus {
+.form-group select:focus,
+.form-group textarea:focus {
   outline: none;
   outline: none;
   border-color: #667eea;
   border-color: #667eea;
 }
 }
 
 
+.form-group textarea {
+  resize: vertical;
+  min-height: 80px;
+}
+
 .form-row {
 .form-row {
   display: grid;
   display: grid;
-  grid-template-columns: 1fr 1fr;
+  grid-template-columns: 1fr 1fr 1fr;
   gap: 16px;
   gap: 16px;
 }
 }
 
 

+ 180 - 17
frontend/src/views/superadmin/UsersView.vue

@@ -9,10 +9,40 @@
     </div>
     </div>
 
 
     <div class="content">
     <div class="content">
+      <!-- Search & Filters -->
+      <div class="filters-bar">
+        <input
+          v-model="searchQuery"
+          type="text"
+          :placeholder="$t('common.search') + '...'"
+          class="search-input"
+        />
+        <select v-model="filterRole" class="filter-select">
+          <option value="">All Roles</option>
+          <option value="superadmin">{{ $t('users.roles.superadmin') }}</option>
+          <option value="admin">{{ $t('users.roles.admin') }}</option>
+          <option value="owner">{{ $t('users.roles.owner') }}</option>
+          <option value="user">{{ $t('users.roles.user') }}</option>
+        </select>
+        <select v-model="filterOrg" class="filter-select">
+          <option value="">All Organizations</option>
+          <option value="null">No Organization (Cloud)</option>
+          <option v-for="org in organizations" :key="org.id" :value="org.id">
+            {{ org.name }}
+          </option>
+        </select>
+        <select v-model="filterStatus" class="filter-select">
+          <option value="">All Statuses</option>
+          <option value="pending">Pending</option>
+          <option value="active">Active</option>
+          <option value="suspended">Suspended</option>
+        </select>
+      </div>
+
       <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
       <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
       <div v-else-if="error" class="error">{{ error }}</div>
       <div v-else-if="error" class="error">{{ error }}</div>
 
 
-      <table v-else-if="users.length > 0" class="data-table">
+      <table v-else-if="filteredUsers.length > 0" class="data-table">
         <thead>
         <thead>
           <tr>
           <tr>
             <th>ID</th>
             <th>ID</th>
@@ -20,17 +50,25 @@
             <th>{{ $t('users.fullName') }}</th>
             <th>{{ $t('users.fullName') }}</th>
             <th>{{ $t('users.role') }}</th>
             <th>{{ $t('users.role') }}</th>
             <th>{{ $t('devices.organization') }}</th>
             <th>{{ $t('devices.organization') }}</th>
+            <th>Email ✓</th>
+            <th>Last Login</th>
             <th>{{ $t('common.status') }}</th>
             <th>{{ $t('common.status') }}</th>
             <th>{{ $t('common.actions') }}</th>
             <th>{{ $t('common.actions') }}</th>
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          <tr v-for="user in users" :key="user.id">
+          <tr v-for="user in filteredUsers" :key="user.id">
             <td>{{ user.id }}</td>
             <td>{{ user.id }}</td>
             <td><strong>{{ user.email }}</strong></td>
             <td><strong>{{ user.email }}</strong></td>
             <td>{{ user.full_name || '-' }}</td>
             <td>{{ user.full_name || '-' }}</td>
             <td><span class="badge role">{{ $t(`users.roles.${user.role}`) }}</span></td>
             <td><span class="badge role">{{ $t(`users.roles.${user.role}`) }}</span></td>
             <td>{{ getOrganizationName(user.organization_id) }}</td>
             <td>{{ getOrganizationName(user.organization_id) }}</td>
+            <td>
+              <span class="badge" :class="user.email_verified ? 'badge-verified' : 'badge-unverified'">
+                {{ user.email_verified ? '✓' : '✗' }}
+              </span>
+            </td>
+            <td>{{ formatLastLogin(user.last_login_at) }}</td>
             <td><span class="badge" :class="`status-${user.status}`">{{ user.status }}</span></td>
             <td><span class="badge" :class="`status-${user.status}`">{{ user.status }}</span></td>
             <td>
             <td>
               <div class="actions">
               <div class="actions">
@@ -74,14 +112,12 @@
             <label>{{ $t('users.role') }} *</label>
             <label>{{ $t('users.role') }} *</label>
             <select v-model="form.role" required>
             <select v-model="form.role" required>
               <option value="superadmin">{{ $t('users.roles.superadmin') }}</option>
               <option value="superadmin">{{ $t('users.roles.superadmin') }}</option>
-              <option value="owner">{{ $t('users.roles.owner') }}</option>
               <option value="admin">{{ $t('users.roles.admin') }}</option>
               <option value="admin">{{ $t('users.roles.admin') }}</option>
-              <option value="manager">{{ $t('users.roles.manager') }}</option>
-              <option value="operator">{{ $t('users.roles.operator') }}</option>
-              <option value="viewer">{{ $t('users.roles.viewer') }}</option>
+              <option value="owner">{{ $t('users.roles.owner') }}</option>
+              <option value="user">{{ $t('users.roles.user') }}</option>
             </select>
             </select>
           </div>
           </div>
-          <div class="form-group" v-if="form.role !== 'superadmin'">
+          <div class="form-group" v-if="form.role !== 'superadmin' && form.role !== 'admin'">
             <label>{{ $t('devices.organization') }} *</label>
             <label>{{ $t('devices.organization') }} *</label>
             <select v-model="form.organization_id" required>
             <select v-model="form.organization_id" required>
               <option :value="null">Select organization...</option>
               <option :value="null">Select organization...</option>
@@ -98,6 +134,10 @@
               <option value="suspended">Suspended</option>
               <option value="suspended">Suspended</option>
             </select>
             </select>
           </div>
           </div>
+          <div class="form-group">
+            <label>{{ $t('users.notes') }}</label>
+            <textarea v-model="form.notes" rows="3" :placeholder="$t('users.notesHint')"></textarea>
+          </div>
           <div class="modal-footer">
           <div class="modal-footer">
             <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
             <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
             <button type="submit" :disabled="saving" class="btn-primary">
             <button type="submit" :disabled="saving" class="btn-primary">
@@ -151,10 +191,14 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, onMounted, watch } from 'vue'
+import { ref, computed, onMounted, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
 import usersApi from '@/api/users'
 import usersApi from '@/api/users'
 import organizationsApi from '@/api/organizations'
 import organizationsApi from '@/api/organizations'
 
 
+const route = useRoute()
+const router = useRouter()
+
 const users = ref([])
 const users = ref([])
 const organizations = ref([])
 const organizations = ref([])
 const loading = ref(false)
 const loading = ref(false)
@@ -169,20 +213,62 @@ const saving = ref(false)
 const changingPassword = ref(false)
 const changingPassword = ref(false)
 const deleting = ref(false)
 const deleting = ref(false)
 
 
+// Filters
+const searchQuery = ref('')
+const filterRole = ref('')
+const filterOrg = ref('')
+const filterStatus = ref('')
+
 const form = ref({
 const form = ref({
   email: '',
   email: '',
   password: '',
   password: '',
   full_name: '',
   full_name: '',
   phone: '',
   phone: '',
-  role: 'viewer',
+  role: 'user',
   organization_id: null,
   organization_id: null,
-  status: 'pending'
+  status: 'pending',
+  notes: ''
 })
 })
 
 
 const passwordForm = ref({
 const passwordForm = ref({
   new_password: ''
   new_password: ''
 })
 })
 
 
+// Filtered users
+const filteredUsers = computed(() => {
+  let result = users.value
+
+  // Search filter
+  if (searchQuery.value) {
+    const query = searchQuery.value.toLowerCase()
+    result = result.filter(user =>
+      user.email.toLowerCase().includes(query) ||
+      (user.full_name && user.full_name.toLowerCase().includes(query))
+    )
+  }
+
+  // Role filter
+  if (filterRole.value) {
+    result = result.filter(user => user.role === filterRole.value)
+  }
+
+  // Organization filter
+  if (filterOrg.value) {
+    if (filterOrg.value === 'null') {
+      result = result.filter(user => user.organization_id === null)
+    } else {
+      result = result.filter(user => user.organization_id === parseInt(filterOrg.value))
+    }
+  }
+
+  // Status filter
+  if (filterStatus.value) {
+    result = result.filter(user => user.status === filterStatus.value)
+  }
+
+  return result
+})
+
 async function loadUsers() {
 async function loadUsers() {
   loading.value = true
   loading.value = true
   error.value = null
   error.value = null
@@ -209,6 +295,21 @@ function getOrganizationName(orgId) {
   return org ? org.name : `Org #${orgId}`
   return org ? org.name : `Org #${orgId}`
 }
 }
 
 
+function formatLastLogin(lastLoginAt) {
+  if (!lastLoginAt) return 'Never'
+  const date = new Date(lastLoginAt)
+  const now = new Date()
+  const diffMs = now - date
+  const diffMins = Math.floor(diffMs / 60000)
+  const diffHours = Math.floor(diffMs / 3600000)
+  const diffDays = Math.floor(diffMs / 86400000)
+
+  if (diffMins < 60) return `${diffMins}m ago`
+  if (diffHours < 24) return `${diffHours}h ago`
+  if (diffDays < 7) return `${diffDays}d ago`
+  return date.toLocaleDateString()
+}
+
 function showCreateModal() {
 function showCreateModal() {
   editingUser.value = null
   editingUser.value = null
   form.value = {
   form.value = {
@@ -216,9 +317,10 @@ function showCreateModal() {
     password: '',
     password: '',
     full_name: '',
     full_name: '',
     phone: '',
     phone: '',
-    role: 'viewer',
+    role: 'user',
     organization_id: null,
     organization_id: null,
-    status: 'pending'
+    status: 'pending',
+    notes: ''
   }
   }
   modalVisible.value = true
   modalVisible.value = true
 }
 }
@@ -231,7 +333,8 @@ function showEditModal(user) {
     phone: user.phone || '',
     phone: user.phone || '',
     role: user.role,
     role: user.role,
     organization_id: user.organization_id,
     organization_id: user.organization_id,
-    status: user.status
+    status: user.status,
+    notes: user.notes || ''
   }
   }
   modalVisible.value = true
   modalVisible.value = true
 }
 }
@@ -295,13 +398,32 @@ async function deleteUser() {
   }
   }
 }
 }
 
 
-// Auto-clear organization if role is superadmin
+// Auto-clear organization if role is cloud-side (superadmin or admin)
 watch(() => form.value.role, (newRole) => {
 watch(() => form.value.role, (newRole) => {
-  if (newRole === 'superadmin') {
+  if (newRole === 'superadmin' || newRole === 'admin') {
     form.value.organization_id = null
     form.value.organization_id = null
   }
   }
 })
 })
 
 
+// Watch for query parameter to auto-open edit modal
+watch(() => route.query.edit, async (userId) => {
+  if (userId) {
+    const user = users.value.find(u => u.id === parseInt(userId))
+    if (user) {
+      showEditModal(user)
+    } else {
+      // User not loaded yet, wait for load
+      await loadUsers()
+      const loadedUser = users.value.find(u => u.id === parseInt(userId))
+      if (loadedUser) {
+        showEditModal(loadedUser)
+      }
+    }
+    // Clear query param
+    router.replace({ query: {} })
+  }
+}, { immediate: true })
+
 onMounted(() => {
 onMounted(() => {
   loadUsers()
   loadUsers()
   loadOrganizations()
   loadOrganizations()
@@ -314,6 +436,44 @@ onMounted(() => {
 .page-header h1 { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 8px; }
 .page-header h1 { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 8px; }
 .page-header p { color: #718096; font-size: 16px; }
 .page-header p { color: #718096; font-size: 16px; }
 .content { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
 .content { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
+
+.filters-bar {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+}
+
+.search-input {
+  flex: 1;
+  min-width: 200px;
+  padding: 10px 16px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  font-size: 14px;
+  transition: border-color 0.2s;
+}
+
+.search-input:focus {
+  outline: none;
+  border-color: #667eea;
+}
+
+.filter-select {
+  min-width: 180px;
+  padding: 10px 16px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  font-size: 14px;
+  background: white;
+  cursor: pointer;
+  transition: border-color 0.2s;
+}
+
+.filter-select:focus {
+  outline: none;
+  border-color: #667eea;
+}
 .loading, .error, .empty { text-align: center; padding: 40px; color: #718096; }
 .loading, .error, .empty { text-align: center; padding: 40px; color: #718096; }
 .error { color: #e53e3e; }
 .error { color: #e53e3e; }
 .data-table { width: 100%; border-collapse: collapse; }
 .data-table { width: 100%; border-collapse: collapse; }
@@ -325,6 +485,8 @@ onMounted(() => {
 .badge.status-active { background: #c6f6d5; color: #22543d; }
 .badge.status-active { background: #c6f6d5; color: #22543d; }
 .badge.status-pending { background: #fef3c7; color: #92400e; }
 .badge.status-pending { background: #fef3c7; color: #92400e; }
 .badge.status-suspended { background: #fed7d7; color: #742a2a; }
 .badge.status-suspended { background: #fed7d7; color: #742a2a; }
+.badge.badge-verified { background: #c6f6d5; color: #22543d; }
+.badge.badge-unverified { background: #e2e8f0; color: #718096; }
 .actions { display: flex; gap: 8px; }
 .actions { display: flex; gap: 8px; }
 .btn-icon { padding: 4px 8px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
 .btn-icon { padding: 4px 8px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
 .btn-icon:hover { opacity: 1; }
 .btn-icon:hover { opacity: 1; }
@@ -346,7 +508,8 @@ onMounted(() => {
 .modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 24px; border-top: 1px solid #e2e8f0; }
 .modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 24px; border-top: 1px solid #e2e8f0; }
 .form-group { margin-bottom: 20px; }
 .form-group { margin-bottom: 20px; }
 .form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #4a5568; font-size: 14px; }
 .form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #4a5568; font-size: 14px; }
-.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
-.form-group input:focus, .form-group select:focus { outline: none; border-color: #667eea; }
+.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; font-family: inherit; }
+.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #667eea; }
 .form-group input:disabled { background: #f7fafc; color: #718096; }
 .form-group input:disabled { background: #f7fafc; color: #718096; }
+.form-group textarea { resize: vertical; min-height: 80px; }
 </style>
 </style>