325 lines
14 KiB
PHP
325 lines
14 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
|
|
<title>Identia Connect Portal</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="vendors/mdi/css/materialdesignicons.min.css">
|
|
<style>
|
|
:root {
|
|
--primary: #4747a1;
|
|
--primary-light: #e6e6f2;
|
|
--primary-glow: rgba(71,71,161,0.3);
|
|
--bg: #f8fafc;
|
|
--card: #ffffff;
|
|
--text: #0f172a;
|
|
--muted: #64748b;
|
|
--border: #e2e8f0;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body {
|
|
font-family: 'Inter', sans-serif;
|
|
background: linear-gradient(135deg, #f0f4f8, var(--bg));
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.portal-card {
|
|
background: var(--card);
|
|
border-radius: 24px;
|
|
width: 100%; max-width: 400px;
|
|
padding: 40px 30px;
|
|
box-shadow: 0 20px 50px rgba(0,0,0,0.08);
|
|
text-align: center;
|
|
position: relative;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.icon-wrap {
|
|
width: 90px; height: 90px; border-radius: 50%;
|
|
background: var(--primary-light);
|
|
display: flex; align-items: center; justify-content: center;
|
|
margin: 0 auto 24px;
|
|
position: relative;
|
|
}
|
|
.icon-wrap.pulsing::before {
|
|
content: ''; position: absolute; inset: -10px; border-radius: 50%;
|
|
border: 2px solid var(--primary-glow);
|
|
animation: pulse-ring 2s infinite;
|
|
}
|
|
.icon-wrap.active { background: #dcfce7; }
|
|
.icon-wrap.active::before { border-color: #86efac; }
|
|
.icon-wrap.error { background: #fef2f2; }
|
|
.icon-wrap.error::before { border-color: #fecaca; }
|
|
|
|
@keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }
|
|
.icon-wrap i { font-size: 44px; color: var(--primary); transition: color 0.3s; }
|
|
.icon-wrap.active i { color: #10b981; }
|
|
.icon-wrap.error i { color: #ef4444; }
|
|
|
|
.portal-card h1 { font-size: 24px; font-weight: 800; color: var(--text); margin-bottom: 8px; letter-spacing: -0.5px; }
|
|
.portal-card p.desc { font-size: 14px; color: var(--muted); margin-bottom: 32px; line-height: 1.6; }
|
|
|
|
.btn-connect {
|
|
background: var(--primary); color: #fff;
|
|
border: none; border-radius: 16px;
|
|
width: 100%; padding: 16px; font-size: 15px; font-weight: 700;
|
|
cursor: pointer; font-family: 'Inter', sans-serif;
|
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
transition: all 0.3s;
|
|
box-shadow: 0 10px 20px var(--primary-glow);
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.btn-connect:hover { transform: translateY(-3px); box-shadow: 0 15px 25px var(--primary-glow); }
|
|
.btn-connect:active { transform: translateY(0); box-shadow: 0 5px 10px var(--primary-glow); }
|
|
.btn-connect:disabled { opacity: 0.7; cursor: not-allowed; transform: none; box-shadow: none; }
|
|
|
|
.btn-connect .spinner { display: none; font-size: 18px; animation: spin 1s linear infinite; }
|
|
|
|
@keyframes spin { 100% { transform: rotate(360deg); } }
|
|
|
|
/* ── Step Progress Bar ── */
|
|
.steps {
|
|
display: flex; gap: 4px; margin-bottom: 24px;
|
|
}
|
|
.steps .step {
|
|
flex: 1; height: 4px; border-radius: 2px;
|
|
background: var(--border); transition: background 0.5s;
|
|
}
|
|
.steps .step.done { background: #10b981; }
|
|
.steps .step.active { background: var(--primary); animation: stepPulse 1.2s infinite; }
|
|
@keyframes stepPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
|
|
.status-msg { margin-top: 20px; font-size: 13px; font-weight: 600; min-height: 40px; color: var(--muted); line-height: 1.5; }
|
|
.status-msg.error { color: #ef4444; }
|
|
.status-msg.success { color: #10b981; }
|
|
|
|
/* Security badge */
|
|
.sec-badge {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
margin-top: 30px; padding: 8px 16px; border-radius: 20px;
|
|
background: #f1f5f9; font-size: 11px; font-weight: 600; color: #64748b;
|
|
}
|
|
.sec-badge i { color: #10b981; font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="portal-card">
|
|
<!-- Step progress indicator -->
|
|
<div class="steps" id="steps">
|
|
<div class="step" id="step1"></div>
|
|
<div class="step" id="step2"></div>
|
|
<div class="step" id="step3"></div>
|
|
<div class="step" id="step4"></div>
|
|
</div>
|
|
|
|
<div class="icon-wrap pulsing" id="iconWrap">
|
|
<i class="mdi mdi-bluetooth" id="btIcon"></i>
|
|
</div>
|
|
|
|
<h1>Identia Bluetooth</h1>
|
|
<p class="desc">Connect this device to the Bluetooth door module to proceed with the verification process.</p>
|
|
|
|
<button class="btn-connect" id="btnConnect">
|
|
<i class="mdi mdi-loading spinner" id="spinIcon"></i>
|
|
<i class="mdi mdi-bluetooth-connect" id="btnIcon"></i>
|
|
<span id="btnText">Connect Bluetooth</span>
|
|
</button>
|
|
|
|
<div class="status-msg" id="statusMsg"></div>
|
|
|
|
<div class="sec-badge">
|
|
<i class="mdi mdi-shield-check"></i> End-to-End Encryption
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.getElementById('btnConnect').addEventListener('click', async () => {
|
|
const btn = document.getElementById('btnConnect');
|
|
const btnText = document.getElementById('btnText');
|
|
const spinIcon = document.getElementById('spinIcon');
|
|
const btnIcon = document.getElementById('btnIcon');
|
|
const statusMsg = document.getElementById('statusMsg');
|
|
const iconWrap = document.getElementById('iconWrap');
|
|
const btIcon = document.getElementById('btIcon');
|
|
|
|
// Reset status
|
|
statusMsg.className = 'status-msg';
|
|
statusMsg.textContent = '';
|
|
|
|
try {
|
|
if (!navigator.bluetooth) {
|
|
throw new Error("Web Bluetooth is not supported in this browser. Please use Chrome and enable the feature if necessary.");
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// STEP 1: Wake up ESP32 Bluetooth
|
|
// ══════════════════════════════════════════
|
|
btn.disabled = true;
|
|
btnIcon.style.display = 'none';
|
|
spinIcon.style.display = 'inline-block';
|
|
setStep(1);
|
|
btnText.textContent = "Waking up device...";
|
|
statusMsg.textContent = "Sending Bluetooth activation command to the door module...";
|
|
|
|
// Send the command to turn ON Bluetooth on ESP32
|
|
await fetch('api_wifi.php?action=trigger_bt_on');
|
|
|
|
// ══════════════════════════════════════════
|
|
// STEP 2: Wait for ESP32 to confirm BT is active
|
|
// Poll the server until bt_status == 'active'
|
|
// Timeout after 30 seconds (ESP32 polls every ~800ms)
|
|
// ══════════════════════════════════════════
|
|
setStep(2);
|
|
btnText.textContent = "Waiting for device...";
|
|
statusMsg.textContent = "Waiting for the door module to activate Bluetooth. Please wait...";
|
|
|
|
const btReady = await waitForBtActive(30000); // 30 second max wait
|
|
|
|
if (!btReady) {
|
|
throw new Error("The door module did not respond in time. Make sure the ESP32 is powered on and connected to the network.");
|
|
}
|
|
|
|
statusMsg.className = 'status-msg success';
|
|
statusMsg.textContent = "Door module is broadcasting! Connecting...";
|
|
|
|
// ══════════════════════════════════════════
|
|
// STEP 3: Connect to BLE device
|
|
// The browser picker will show identia_bt since
|
|
// ESP32 is now actively advertising
|
|
// ══════════════════════════════════════════
|
|
setStep(3);
|
|
btnText.textContent = "Connecting...";
|
|
statusMsg.textContent = "A device picker will appear. Please select 'identia_bt' and click Pair.";
|
|
|
|
const device = await navigator.bluetooth.requestDevice({
|
|
filters: [{ name: 'identia_bt' }],
|
|
optionalServices: ['12345678-1234-5678-1234-56789abcdef0']
|
|
});
|
|
|
|
statusMsg.textContent = `Establishing connection to ${device.name}...`;
|
|
|
|
const server = await device.gatt.connect();
|
|
|
|
// Connected!
|
|
iconWrap.className = 'icon-wrap pulsing active';
|
|
btIcon.className = "mdi mdi-bluetooth-transfer";
|
|
statusMsg.className = 'status-msg success';
|
|
statusMsg.textContent = "Connected successfully! Generating secure access token...";
|
|
|
|
// ══════════════════════════════════════════
|
|
// STEP 4: Generate token and redirect
|
|
// ══════════════════════════════════════════
|
|
setStep(4);
|
|
btnText.textContent = "Preparing access...";
|
|
|
|
const resp = await fetch('api_generate_door_token.php');
|
|
const data = await resp.json();
|
|
|
|
if (data.status === 'success') {
|
|
statusMsg.textContent = "Redirecting to verification portal...";
|
|
btnText.textContent = "Redirecting...";
|
|
// All 4 steps done
|
|
document.querySelectorAll('.steps .step').forEach(s => s.className = 'step done');
|
|
|
|
setTimeout(() => {
|
|
window.location.href = `192.168.1.20.php?token=${data.token}`;
|
|
}, 600);
|
|
} else {
|
|
throw new Error(data.message || "Failed to generate access token.");
|
|
}
|
|
|
|
} catch(err) {
|
|
// Return to normal UI
|
|
btn.disabled = false;
|
|
btnText.textContent = "Connect Bluetooth";
|
|
btnIcon.style.display = 'inline-block';
|
|
spinIcon.style.display = 'none';
|
|
iconWrap.className = 'icon-wrap pulsing error';
|
|
btIcon.className = "mdi mdi-bluetooth-off";
|
|
resetSteps();
|
|
|
|
statusMsg.className = 'status-msg error';
|
|
|
|
if (err.name === 'NotFoundError') {
|
|
statusMsg.textContent = 'Cancelled or device not found. Please try again.';
|
|
} else if (err.name === 'SecurityError') {
|
|
statusMsg.textContent = 'Permission denied. Ensure HTTPS is enabled or add this site to chrome://flags.';
|
|
} else {
|
|
statusMsg.textContent = err.message || 'Connection failed.';
|
|
}
|
|
|
|
// Reset icon after 3 seconds
|
|
setTimeout(() => {
|
|
iconWrap.className = 'icon-wrap pulsing';
|
|
btIcon.className = "mdi mdi-bluetooth";
|
|
}, 3000);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Poll the server until the ESP32 confirms Bluetooth is active.
|
|
* @param {number} timeoutMs Maximum time to wait in milliseconds
|
|
* @returns {Promise<boolean>} true if BT became active, false if timed out
|
|
*/
|
|
async function waitForBtActive(timeoutMs) {
|
|
const startTime = Date.now();
|
|
const pollInterval = 1000; // Check every 1 second
|
|
|
|
while (Date.now() - startTime < timeoutMs) {
|
|
try {
|
|
const res = await fetch('api_wifi.php?action=get_bt_status');
|
|
const data = await res.json();
|
|
|
|
if (data.bt_status === 'active') {
|
|
// ESP32 has confirmed BT is on and advertising
|
|
// Wait a tiny bit more for the BLE stack to stabilize
|
|
await sleep(500);
|
|
return true;
|
|
}
|
|
|
|
// Update the status message with a visual countdown
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
const remaining = Math.ceil((timeoutMs - (Date.now() - startTime)) / 1000);
|
|
document.getElementById('statusMsg').textContent =
|
|
`Waiting for the door module to activate Bluetooth... (${remaining}s remaining)`;
|
|
|
|
} catch(e) {
|
|
console.error('Poll error:', e);
|
|
}
|
|
|
|
await sleep(pollInterval);
|
|
}
|
|
|
|
return false; // Timed out
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function setStep(num) {
|
|
for (let i = 1; i <= 4; i++) {
|
|
const el = document.getElementById('step' + i);
|
|
if (i < num) el.className = 'step done';
|
|
else if (i === num) el.className = 'step active';
|
|
else el.className = 'step';
|
|
}
|
|
}
|
|
|
|
function resetSteps() {
|
|
for (let i = 1; i <= 4; i++) {
|
|
document.getElementById('step' + i).className = 'step';
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|