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.
 """
 
+import asyncio
 import copy
 import json
 import secrets
+import subprocess
 from base64 import b64encode
 from datetime import datetime, timezone
 from pathlib import Path
@@ -15,7 +17,7 @@ from pydantic import BaseModel
 from sqlalchemy import select, update
 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.settings import Settings
 
@@ -52,6 +54,43 @@ def _generate_password() -> str:
     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)
 async def register_device(
     data: RegistrationRequest,
@@ -126,6 +165,10 @@ async def register_device(
 
     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(
         device_token=device.device_token,
         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 fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, Request
 from sqlalchemy.ext.asyncio import AsyncSession
 from pydantic import BaseModel
 from sqlalchemy import select
+from starlette.responses import StreamingResponse, Response
+import httpx
+import asyncio
 
 from app.api.deps import get_current_superadmin
 from app.core.database import get_db
 from app.models.device import Device
 from app.models.user import User
 from app.services.tunnel_service import tunnel_service
+import socket
 
 
 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):
     """Response when enabling tunnel"""
     session_uuid: str
@@ -72,6 +117,17 @@ async def enable_tunnel(
             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
     session = tunnel_service.create_session(
         device_id=device.mac_address,
@@ -79,7 +135,7 @@ async def enable_tunnel(
         tunnel_type=tunnel_type
     )
 
-    # Update device config to enable tunnel
+    # Update device config to enable tunnel with allocated port
     if device.config is None:
         device.config = {}
 
@@ -88,6 +144,14 @@ async def enable_tunnel(
         device.config[tunnel_key] = {}
 
     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)
     from sqlalchemy.orm import attributes
@@ -95,6 +159,8 @@ async def enable_tunnel(
 
     await db.commit()
 
+    print(f"[tunnel] Enabled {tunnel_type} tunnel for {device.mac_address} on port {allocated_port}")
+
     return TunnelEnableResponse(
         session_uuid=session.uuid,
         device_id=device.mac_address,
@@ -132,23 +198,12 @@ async def get_tunnel_status(
         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
 
@@ -170,3 +225,334 @@ async def session_heartbeat(
         )
 
     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)
     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_phone: Mapped[str | None] = mapped_column(String(50))
 
     # Product access (modular)
     wifi_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_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))
     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)
 
     # Status: pending, active, suspended, deleted
@@ -40,11 +40,14 @@ class User(Base):
         String(20), default="pending", nullable=False
     )
 
-    # Organization (NULL for superadmin)
+    # Organization (NULL for superadmin/admin)
     organization_id: Mapped[int | None] = mapped_column(
         ForeignKey("organizations.id", ondelete="CASCADE")
     )
 
+    # Admin notes (visible only to superadmin)
+    notes: Mapped[str | None] = mapped_column(String)
+
     # Email verification
     email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
     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."""
 
     name: str
+    contact_name: str | None = None
     contact_email: EmailStr
     contact_phone: str | None = None
 
@@ -20,16 +21,19 @@ class OrganizationCreate(OrganizationBase):
 
     wifi_enabled: bool = False
     ble_enabled: bool = False
+    android_enabled: bool = False
 
 
 class OrganizationUpdate(BaseModel):
     """Schema for updating an organization."""
 
     name: str | None = None
+    contact_name: str | None = None
     contact_email: EmailStr | None = None
     contact_phone: str | None = None
     wifi_enabled: bool | None = None
     ble_enabled: bool | None = None
+    android_enabled: bool | None = None
     status: str | None = None
     notes: str | None = None
 
@@ -40,6 +44,7 @@ class OrganizationResponse(OrganizationBase):
     id: int
     wifi_enabled: bool
     ble_enabled: bool
+    android_enabled: bool
     status: str
     notes: str | None
     created_at: datetime

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

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

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

@@ -68,6 +68,7 @@ class TunnelService:
         now = datetime.now()
         inactive_threshold = now - timedelta(minutes=60)
         grace_period = now - timedelta(seconds=60)
+        initial_grace = now - timedelta(minutes=2)
 
         for session_uuid, session in list(self.sessions.items()):
             # Check expiration (hard limit: 1 hour)
@@ -77,6 +78,14 @@ class TunnelService:
                 del self.sessions[session_uuid]
                 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)
             if session.last_heartbeat and session.last_heartbeat < inactive_threshold:
                 print(f"[tunnel] Session inactive for 60 min: {session_uuid}")
@@ -164,6 +173,27 @@ class TunnelService:
                     session.device_tunnel_port = port
                     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":
             if status_key in self.tunnel_status:
                 self.tunnel_status[status_key].status = "disconnected"
@@ -200,19 +230,26 @@ class TunnelService:
         cmd = [
             "ttyd",
             "--port", str(ttyd_port),
-            "--once",  # Single session
             "--writable",  # Allow input
             "ssh",
             "-p", str(device_tunnel_port),
             "-o", "StrictHostKeyChecking=no",
             "-o", "UserKnownHostsFile=/dev/null",
+            "-o", "ServerAliveInterval=30",
+            "-o", "ServerAliveCountMax=3",
             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(
             cmd,
-            stdout=subprocess.DEVNULL,
-            stderr=subprocess.DEVNULL
+            stdout=open(log_file, 'a'),
+            stderr=subprocess.STDOUT
         )
 
         session.ttyd_port = ttyd_port

+ 1 - 1
backend/poetry.lock

@@ -2118,4 +2118,4 @@ files = [
 [metadata]
 lock-version = "2.1"
 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"
 bcrypt = "<4.0.0"
 psutil = "^7.2.0"
+websockets = "^15.0.1"
 
 [tool.poetry.group.dev.dependencies]
 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',
       add: 'Add Organization',
       name: 'Name',
+      contactName: 'Contact Name',
       contactEmail: 'Contact Email',
       contactPhone: 'Contact Phone',
       wifiEnabled: 'WiFi Enabled',
       bleEnabled: 'BLE Enabled',
+      androidEnabled: 'Android + BLE Enabled',
       createdAt: 'Created',
       manageAction: 'Manage Organizations',
-      manageDesc: 'Create and configure organizations'
+      manageDesc: 'Create and configure organizations',
+      searchPlaceholder: 'Search organizations...',
+      showUsers: 'Show users'
     },
     devices: {
       title: 'Devices',
@@ -96,15 +100,15 @@ const messages = {
       add: 'Add User',
       fullName: 'Full Name',
       role: 'Role',
+      notes: 'Notes',
+      notesHint: 'Internal notes (visible only to superadmin)',
       manageAction: 'Manage Users',
       manageDesc: 'View all system users',
       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: {
@@ -213,13 +217,17 @@ const messages = {
       manage: 'Управление всеми организациями',
       add: 'Добавить организацию',
       name: 'Название',
+      contactName: 'Имя контакта',
       contactEmail: 'Email контакта',
       contactPhone: 'Телефон контакта',
       wifiEnabled: 'WiFi включен',
       bleEnabled: 'BLE включен',
+      androidEnabled: 'Android + BLE включен',
       createdAt: 'Создано',
       manageAction: 'Управление организациями',
-      manageDesc: 'Создание и настройка организаций'
+      manageDesc: 'Создание и настройка организаций',
+      searchPlaceholder: 'Поиск организаций...',
+      showUsers: 'Показать пользователей'
     },
     devices: {
       title: 'Устройства',
@@ -263,15 +271,15 @@ const messages = {
       add: 'Добавить пользователя',
       fullName: 'Полное имя',
       role: 'Роль',
+      notes: 'Заметки',
+      notesHint: 'Внутренние заметки (видны только superadmin)',
       manageAction: 'Управление пользователями',
       manageDesc: 'Просмотр всех пользователей системы',
       roles: {
-        superadmin: 'Суперадмин',
-        owner: 'Владелец',
-        admin: 'Администратор',
-        manager: 'Менеджер',
-        operator: 'Оператор',
-        viewer: 'Наблюдатель'
+        superadmin: 'Суперадмин (Cloud)',
+        admin: 'Админ (Cloud)',
+        owner: 'Владелец (Организация)',
+        user: 'Пользователь (Организация)'
       }
     },
     host: {

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

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

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

@@ -11,39 +11,76 @@
     </div>
 
     <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-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>
           <tr>
+            <th style="width: 40px;"></th>
             <th>ID</th>
             <th>{{ $t('organizations.name') }}</th>
             <th>{{ $t('organizations.contactEmail') }}</th>
             <th>{{ $t('organizations.contactPhone') }}</th>
             <th>WiFi</th>
             <th>BLE</th>
+            <th>Android</th>
             <th>{{ $t('common.status') }}</th>
             <th>{{ $t('common.actions') }}</th>
           </tr>
         </thead>
         <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>
       </table>
 
@@ -63,6 +100,11 @@
             <input v-model="form.name" type="text" required />
           </div>
 
+          <div class="form-group">
+            <label>{{ $t('organizations.contactName') }}</label>
+            <input v-model="form.contact_name" type="text" />
+          </div>
+
           <div class="form-group">
             <label>{{ $t('organizations.contactEmail') }} *</label>
             <input v-model="form.contact_email" type="email" required />
@@ -87,6 +129,13 @@
                 <span>{{ $t('organizations.bleEnabled') }}</span>
               </label>
             </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 class="form-group">
@@ -98,11 +147,23 @@
             </select>
           </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">
-            <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>
         </form>
       </div>
@@ -129,9 +190,13 @@
 </template>
 
 <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'
 
+const router = useRouter()
+
 const organizations = ref([])
 const loading = ref(false)
 const error = ref(null)
@@ -141,14 +206,32 @@ const editingOrg = ref(null)
 const organizationToDelete = ref(null)
 const saving = ref(false)
 const deleting = ref(false)
+const searchQuery = ref('')
+const expandedOrgs = ref({})
+const orgUsers = ref({})
+const loadingUsers = ref({})
 
 const form = ref({
   name: '',
+  contact_name: '',
   contact_email: '',
   contact_phone: '',
   wifi_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() {
@@ -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() {
   editingOrg.value = null
   form.value = {
     name: '',
+    contact_name: '',
     contact_email: '',
     contact_phone: '',
     wifi_enabled: false,
     ble_enabled: false,
-    status: 'pending'
+    android_enabled: false,
+    status: 'pending',
+    notes: ''
   }
   modalVisible.value = true
 }
@@ -180,11 +294,14 @@ function showEditModal(org) {
   editingOrg.value = org
   form.value = {
     name: org.name,
+    contact_name: org.contact_name || '',
     contact_email: org.contact_email,
     contact_phone: org.contact_phone || '',
     wifi_enabled: org.wifi_enabled,
     ble_enabled: org.ble_enabled,
-    status: org.status
+    android_enabled: org.android_enabled,
+    status: org.status,
+    notes: org.notes || ''
   }
   modalVisible.value = true
 }
@@ -457,14 +574,130 @@ onMounted(() => {
   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 {
   display: flex;
-  justify-content: flex-end;
+  justify-content: space-between;
+  align-items: center;
   gap: 12px;
   padding: 24px;
   border-top: 1px solid #e2e8f0;
 }
 
+.modal-footer-left {
+  flex: 1;
+}
+
+.modal-footer-right {
+  display: flex;
+  gap: 12px;
+}
+
 .form-group {
   margin-bottom: 20px;
 }
@@ -480,24 +713,32 @@ onMounted(() => {
 .form-group input[type="text"],
 .form-group input[type="email"],
 .form-group input[type="tel"],
-.form-group select {
+.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 select:focus,
+.form-group textarea:focus {
   outline: none;
   border-color: #667eea;
 }
 
+.form-group textarea {
+  resize: vertical;
+  min-height: 80px;
+}
+
 .form-row {
   display: grid;
-  grid-template-columns: 1fr 1fr;
+  grid-template-columns: 1fr 1fr 1fr;
   gap: 16px;
 }
 

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

@@ -9,10 +9,40 @@
     </div>
 
     <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-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>
           <tr>
             <th>ID</th>
@@ -20,17 +50,25 @@
             <th>{{ $t('users.fullName') }}</th>
             <th>{{ $t('users.role') }}</th>
             <th>{{ $t('devices.organization') }}</th>
+            <th>Email ✓</th>
+            <th>Last Login</th>
             <th>{{ $t('common.status') }}</th>
             <th>{{ $t('common.actions') }}</th>
           </tr>
         </thead>
         <tbody>
-          <tr v-for="user in users" :key="user.id">
+          <tr v-for="user in filteredUsers" :key="user.id">
             <td>{{ user.id }}</td>
             <td><strong>{{ user.email }}</strong></td>
             <td>{{ user.full_name || '-' }}</td>
             <td><span class="badge role">{{ $t(`users.roles.${user.role}`) }}</span></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>
               <div class="actions">
@@ -74,14 +112,12 @@
             <label>{{ $t('users.role') }} *</label>
             <select v-model="form.role" required>
               <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="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>
           </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>
             <select v-model="form.organization_id" required>
               <option :value="null">Select organization...</option>
@@ -98,6 +134,10 @@
               <option value="suspended">Suspended</option>
             </select>
           </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">
             <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
             <button type="submit" :disabled="saving" class="btn-primary">
@@ -151,10 +191,14 @@
 </template>
 
 <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 organizationsApi from '@/api/organizations'
 
+const route = useRoute()
+const router = useRouter()
+
 const users = ref([])
 const organizations = ref([])
 const loading = ref(false)
@@ -169,20 +213,62 @@ const saving = ref(false)
 const changingPassword = ref(false)
 const deleting = ref(false)
 
+// Filters
+const searchQuery = ref('')
+const filterRole = ref('')
+const filterOrg = ref('')
+const filterStatus = ref('')
+
 const form = ref({
   email: '',
   password: '',
   full_name: '',
   phone: '',
-  role: 'viewer',
+  role: 'user',
   organization_id: null,
-  status: 'pending'
+  status: 'pending',
+  notes: ''
 })
 
 const passwordForm = ref({
   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() {
   loading.value = true
   error.value = null
@@ -209,6 +295,21 @@ function getOrganizationName(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() {
   editingUser.value = null
   form.value = {
@@ -216,9 +317,10 @@ function showCreateModal() {
     password: '',
     full_name: '',
     phone: '',
-    role: 'viewer',
+    role: 'user',
     organization_id: null,
-    status: 'pending'
+    status: 'pending',
+    notes: ''
   }
   modalVisible.value = true
 }
@@ -231,7 +333,8 @@ function showEditModal(user) {
     phone: user.phone || '',
     role: user.role,
     organization_id: user.organization_id,
-    status: user.status
+    status: user.status,
+    notes: user.notes || ''
   }
   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) => {
-  if (newRole === 'superadmin') {
+  if (newRole === 'superadmin' || newRole === 'admin') {
     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(() => {
   loadUsers()
   loadOrganizations()
@@ -314,6 +436,44 @@ onMounted(() => {
 .page-header h1 { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 8px; }
 .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); }
+
+.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; }
 .error { color: #e53e3e; }
 .data-table { width: 100%; border-collapse: collapse; }
@@ -325,6 +485,8 @@ onMounted(() => {
 .badge.status-active { background: #c6f6d5; color: #22543d; }
 .badge.status-pending { background: #fef3c7; color: #92400e; }
 .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; }
 .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; }
@@ -346,7 +508,8 @@ onMounted(() => {
 .modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 24px; border-top: 1px solid #e2e8f0; }
 .form-group { margin-bottom: 20px; }
 .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 textarea { resize: vertical; min-height: 80px; }
 </style>