Push Tracker
ria-ran--sim-drop/plan/06-captive-portal.md
A ashkan@beigi.net 8672b0de6b Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.

Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.

Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
  3-digit MNC; MSIN length is now 15 - len(mcc) - len(mnc).
- check_rate_limit compared timestamps as strings across mismatched formats
  (SQLite CURRENT_TIMESTAMP vs Python isoformat); both sides now normalized
  via SQLite datetime() so the window check is chronologically correct.

Add .gitignore for venv, caches, runtime config.yaml, and databases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:56:31 +00:00

4.6 KiB

Unit 06 — Captive Portal Frontend

Goal

Mobile-first single-page UI served at /activate. Pure HTML/CSS/JS — no external dependencies.

Files

  • portal/index.html
  • portal/css/style.css
  • portal/js/script.js

UI States

[idle]     → "Activate eSIM" button visible
[loading]  → spinner, button disabled
[success]  → green checkmark, download link, instructions
[error]    → red message, "Try Again" button

portal/index.html

Structure:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>eSIM Activation</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <div class="card">
    <div class="logo">📡</div>
    <h1>Emergency 5G Network</h1>
    <p class="subtitle">Activate your free eSIM to connect</p>

    <!-- Idle state -->
    <div id="state-idle">
      <button id="btn-activate" class="btn-primary">Activate eSIM</button>
      <p class="hint">Tap to generate your personal eSIM profile</p>
    </div>

    <!-- Loading state -->
    <div id="state-loading" hidden>
      <div class="spinner"></div>
      <p>Generating your eSIM profile...</p>
    </div>

    <!-- Success state -->
    <div id="state-success" hidden>
      <div class="checkmark"></div>
      <h2>eSIM Ready!</h2>
      <p>Your profile has been generated.</p>
      <a id="download-link" class="btn-primary" download="esim-profile.bin">
        Download Profile
      </a>
      <div class="instructions">
        <h3>Next Steps:</h3>
        <ol>
          <li>Download the profile above</li>
          <li>Go to <strong>Settings → Mobile Data → Add eSIM</strong></li>
          <li>Select "Import from file"</li>
          <li>Disconnect from this WiFi</li>
          <li>Your device will connect to 5G automatically</li>
        </ol>
      </div>
    </div>

    <!-- Error state -->
    <div id="state-error" hidden>
      <div class="error-icon"></div>
      <h2>Activation Failed</h2>
      <p id="error-message">An error occurred. Please try again.</p>
      <button id="btn-retry" class="btn-secondary">Try Again</button>
    </div>
  </div>
  <script src="/js/script.js"></script>
</body>
</html>

portal/css/style.css

Key rules:

  • body: font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh;
  • .card: background: #16213e; border-radius: 16px; padding: 2rem; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
  • .btn-primary: background: #0f3460; color: white; padding: 1rem 2rem; border-radius: 8px; font-size: 1.2rem; border: none; cursor: pointer; width: 100%; margin: 1rem 0;
  • .btn-primary:hover: background: #e94560;
  • .spinner: CSS animation rotating border
  • .checkmark: color: #4caf50; font-size: 4rem;
  • .error-icon: color: #f44336; font-size: 4rem;
  • .instructions: text-align: left; background: #0f3460; border-radius: 8px; padding: 1rem; margin-top: 1rem;

portal/js/script.js

const states = ['idle', 'loading', 'success', 'error'];
function showState(name) {
    states.forEach(s => {
        document.getElementById(`state-${s}`).hidden = (s !== name);
    });
}

async function activate() {
    showState('loading');
    try {
        const res = await fetch('/api/provision', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ mac_address: '' })  // server resolves from ARP
        });
        if (res.status === 429) {
            showError('You already have an active eSIM for this network.');
            return;
        }
        if (!res.ok) {
            const err = await res.json().catch(() => ({}));
            showError(err.error || 'Server error. Please try again.');
            return;
        }
        const data = await res.json();
        document.getElementById('download-link').href = data.profile_url;
        showState('success');
    } catch (e) {
        showError('Could not reach the server. Are you connected to the WiFi?');
    }
}

function showError(msg) {
    document.getElementById('error-message').textContent = msg;
    showState('error');
}

document.getElementById('btn-activate').addEventListener('click', activate);
document.getElementById('btn-retry').addEventListener('click', () => {
    showState('idle');
});

Verification

# With Flask running
open http://localhost:5000/activate
# Verify page loads, button works, cycling through all states
# Check mobile responsiveness with browser DevTools device emulation