A
8672b0de6b
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>
158 lines
4.6 KiB
Markdown
158 lines
4.6 KiB
Markdown
# 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:
|
|
```html
|
|
<!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`
|
|
|
|
```javascript
|
|
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
|
|
```bash
|
|
# 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
|
|
```
|