Skip to content

Commit 56c8315

Browse files
author
Ravi Singh
committed
feat: client-side QR claim — phone verifies receiver, not server
The cloud server can't reach the receiver (different network). Moved the verification + device discovery to the phone (which IS on LAN): 1. Phone verifies token with receiver directly 2. Phone discovers tanks from receiver's /api/data 3. Phone sends verified data to cloud server 4. Server creates site + MQTT credentials 5. Phone pushes MQTT config to receiver directly This works because QR scanning is always done locally (user is standing next to the receiver). After linking, everything is remote via MQTT over TLS.
1 parent b667bcb commit 56c8315

2 files changed

Lines changed: 106 additions & 76 deletions

File tree

pwa/client/src/pages/LinkDevice.jsx

Lines changed: 75 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@ import { api } from '../utils/api.js';
99
/**
1010
* QR Code Claim Page — handles /link?id=...&token=...&ip=...
1111
*
12-
* Flow:
13-
* 1. User scans QR from receiver's web UI → opens this URL
14-
* 2. If not logged in → redirect to /login with return URL
15-
* 3. If logged in → auto-claim the device via POST /api/link/claim
16-
* 4. On success → redirect to dashboard
12+
* Architecture: The phone (on LAN) talks directly to the receiver to verify
13+
* the token and discover devices. The cloud server only creates DB records
14+
* and MQTT credentials — it never needs LAN access to the receiver.
1715
*/
1816
export default function LinkDevice() {
1917
const [searchParams] = useSearchParams();
@@ -24,58 +22,107 @@ export default function LinkDevice() {
2422
const token = searchParams.get('token');
2523
const receiverIp = searchParams.get('ip');
2624

27-
const [status, setStatus] = useState('linking'); // linking | success | error | missing_params
25+
const [status, setStatus] = useState('linking');
2826
const [message, setMessage] = useState('');
29-
const [siteId, setSiteId] = useState(null);
27+
const [step, setStep] = useState('');
3028

31-
// Validate params
3229
useEffect(() => {
33-
if (!deviceId || !token || !receiverIp) {
34-
setStatus('missing_params');
35-
}
30+
if (!deviceId || !token || !receiverIp) setStatus('missing_params');
3631
}, [deviceId, token, receiverIp]);
3732

38-
// Auto-claim once authenticated
3933
useEffect(() => {
4034
if (authLoading) return;
4135
if (!user) {
42-
// Redirect to login, preserving the link URL for after login
4336
const returnUrl = `/link?id=${deviceId}&token=${token}&ip=${receiverIp}`;
4437
navigate(`/login?redirect=${encodeURIComponent(returnUrl)}`);
4538
return;
4639
}
4740
if (status !== 'linking' || !deviceId || !token || !receiverIp) return;
48-
4941
claimDevice();
5042
}, [user, authLoading, status]);
5143

5244
const claimDevice = async () => {
5345
try {
46+
// Step 1: Verify token directly with receiver (phone is on LAN)
47+
setStep('Verifying receiver...');
48+
let receiverData;
49+
try {
50+
const resp = await fetch(`http://${receiverIp}/api/link`, { signal: AbortSignal.timeout(5000) });
51+
receiverData = await resp.json();
52+
} catch {
53+
throw new Error('Could not reach receiver. Make sure your phone is on the same WiFi network as the receiver.');
54+
}
55+
56+
if (receiverData.device_id !== deviceId || receiverData.token !== token) {
57+
throw new Error('Invalid or expired link token. Try scanning the QR code again.');
58+
}
59+
60+
// Step 2: Discover tanks from receiver
61+
setStep('Discovering tanks...');
62+
let tanks = [];
63+
try {
64+
const dataResp = await fetch(`http://${receiverIp}/api/data`, { signal: AbortSignal.timeout(5000) });
65+
const data = await dataResp.json();
66+
tanks = data.tanks || [];
67+
} catch {}
68+
69+
// Step 3: Get transmitter details
70+
let transmitters = [];
71+
try {
72+
const txResp = await fetch(`http://${receiverIp}/api/transmitters`, { signal: AbortSignal.timeout(5000) });
73+
const txData = await txResp.json();
74+
transmitters = txData.transmitters || [];
75+
} catch {}
76+
77+
// Step 4: Send everything to cloud server (server creates site + MQTT creds)
78+
setStep('Setting up cloud connection...');
5479
const result = await api.post('/api/link/claim', {
5580
device_id: deviceId,
56-
token,
57-
receiver_ip: receiverIp
81+
receiver_ip: receiverIp,
82+
verified: true,
83+
tanks,
84+
transmitters,
5885
});
5986

60-
setSiteId(result.site_id);
87+
// Step 5: Push MQTT config to receiver (phone is on LAN)
88+
if (result.mqtt) {
89+
setStep('Configuring MQTT on receiver...');
90+
try {
91+
await fetch(`http://${receiverIp}/api/mqtt`, {
92+
method: 'POST',
93+
headers: { 'Content-Type': 'application/json' },
94+
body: JSON.stringify({
95+
host: result.mqtt.mqtt_host,
96+
port: result.mqtt.mqtt_port,
97+
user: result.mqtt.mqtt_username,
98+
pass: result.mqtt.mqtt_password,
99+
enabled: true,
100+
ha_discovery: false,
101+
use_tls: true,
102+
}),
103+
signal: AbortSignal.timeout(5000),
104+
});
105+
} catch {
106+
// Non-fatal — user can configure MQTT manually
107+
}
108+
}
61109

110+
setStatus('success');
111+
const mqttMsg = result.mqtt ? ' MQTT auto-configured.' : '';
62112
if (result.already_linked) {
63-
setStatus('success');
64-
setMessage('This device is already linked to your account.');
113+
setMessage('This device is already linked to your account.' + mqttMsg);
65114
} else {
66-
setStatus('success');
67-
setMessage(`Linked successfully! ${result.device_count || 0} tank${result.device_count !== 1 ? 's' : ''} discovered.`);
115+
setMessage(`Linked successfully! ${result.device_count || 0} tank${result.device_count !== 1 ? 's' : ''} discovered.${mqttMsg}`);
68116
}
69117
} catch (err) {
70118
setStatus('error');
71119
setMessage(err.message || 'Failed to link device');
72120
}
73121
};
74122

75-
// Missing params
76123
if (status === 'missing_params') {
77124
return (
78-
<div className="min-h-screen bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
125+
<div className="min-h-[100dvh] bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
79126
<ErrorIcon />
80127
<h2 className="text-xl font-bold text-white mt-4 mb-2">Invalid Link</h2>
81128
<p className="text-slate-400 text-sm mb-6">This link is missing required parameters. Please scan the QR code from your receiver's web UI again.</p>
@@ -87,22 +134,20 @@ export default function LinkDevice() {
87134
);
88135
}
89136

90-
// Linking in progress
91137
if (status === 'linking') {
92138
return (
93-
<div className="min-h-screen bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
139+
<div className="min-h-[100dvh] bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
94140
<div className="w-16 h-16 border-4 border-water/30 border-t-water rounded-full animate-spin mb-6" />
95141
<h2 className="text-xl font-bold text-white mb-2">Linking Device</h2>
96-
<p className="text-slate-400 text-sm">Verifying and connecting your TankSync receiver...</p>
142+
<p className="text-slate-400 text-sm">{step || 'Connecting...'}</p>
97143
<p className="text-slate-500 text-xs mt-2 font-mono">{deviceId}</p>
98144
</div>
99145
);
100146
}
101147

102-
// Success
103148
if (status === 'success') {
104149
return (
105-
<div className="min-h-screen bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
150+
<div className="min-h-[100dvh] bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
106151
<div className="w-20 h-20 rounded-full bg-success/10 flex items-center justify-center mb-6">
107152
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#22C55E" strokeWidth="2.5" strokeLinecap="round">
108153
<path d="M20 6L9 17l-5-5" />
@@ -119,14 +164,13 @@ export default function LinkDevice() {
119164
);
120165
}
121166

122-
// Error
123167
return (
124-
<div className="min-h-screen bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
168+
<div className="min-h-[100dvh] bg-slate-950 flex flex-col items-center justify-center px-6 text-center">
125169
<ErrorIcon />
126170
<h2 className="text-xl font-bold text-white mt-4 mb-2">Linking Failed</h2>
127171
<p className="text-danger text-sm mb-6">{message}</p>
128172
<div className="flex gap-3 w-full max-w-xs">
129-
<button onClick={() => { setStatus('linking'); claimDevice(); }}
173+
<button onClick={() => { setStatus('linking'); setStep(''); claimDevice(); }}
130174
className="flex-1 py-3 rounded-xl bg-water text-white font-semibold active:scale-[0.98] transition-all">
131175
Try Again
132176
</button>

pwa/server-cloud/index.js

Lines changed: 31 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -279,75 +279,61 @@ app.delete('/api/devices/:id', { preHandler: [app.authenticate] }, async (req, r
279279
});
280280

281281
// ─── QR CODE DEVICE LINKING ────────────────────────────────────────────────────
282+
// The phone (on LAN) verifies the receiver token and discovers devices,
283+
// then sends the pre-verified data here. The server never contacts the receiver.
282284

283285
app.post('/api/link/claim', { preHandler: [app.authenticate] }, async (req, reply) => {
284-
const { device_id, token, receiver_ip } = req.body || {};
285-
if (!device_id || !token || !receiver_ip) return reply.code(400).send({ error: 'Missing device_id, token, or receiver_ip' });
286-
287-
let receiverData;
288-
try {
289-
const resp = await fetch(`http://${receiver_ip}/api/link`, { signal: AbortSignal.timeout(5000) });
290-
receiverData = await resp.json();
291-
} catch {
292-
return reply.code(502).send({ error: 'Could not reach receiver. Make sure the server and receiver are on the same network.' });
293-
}
294-
295-
if (receiverData.device_id !== device_id || receiverData.token !== token)
296-
return reply.code(403).send({ error: 'Invalid or expired link token.' });
286+
const { device_id, receiver_ip, verified, tanks, transmitters } = req.body || {};
287+
if (!device_id || !receiver_ip) return reply.code(400).send({ error: 'Missing device_id or receiver_ip' });
288+
if (!verified) return reply.code(400).send({ error: 'Token not verified by client' });
297289

290+
// Check if already linked
298291
const existing = await db.get('SELECT id FROM sites WHERE user_id = $1 AND mqtt_device_id = $2', req.user.id, device_id);
299-
if (existing) return { site_id: existing.id, message: 'Device already linked', already_linked: true };
292+
if (existing) {
293+
// Still generate MQTT creds if missing
294+
let mqttCreds = null;
295+
try { mqttCreds = await generateMqttCredentials(req.user.id, existing.id, device_id); } catch {}
296+
return { site_id: existing.id, message: 'Device already linked', already_linked: true, mqtt: mqttCreds };
297+
}
300298

299+
// Create site
301300
const site = await db.run('INSERT INTO sites (user_id, name, receiver_ip, mqtt_device_id) VALUES ($1, $2, $3, $4)',
302301
req.user.id, 'My Tank', receiver_ip, device_id);
303302
const siteId = site.lastInsertRowid;
304303

304+
// Add discovered tanks (sent by the phone)
305305
let deviceCount = 0;
306-
try {
307-
const dataResp = await fetch(`http://${receiver_ip}/api/data`, { signal: AbortSignal.timeout(5000) });
308-
const data = await dataResp.json();
309-
if (data.tanks?.length > 0) {
310-
for (const tank of data.tanks) {
311-
try {
312-
await db.run('INSERT INTO devices (site_id, lora_address, name) VALUES ($1, $2, $3)', siteId, tank.address, tank.name || `Tank ${tank.address}`);
313-
deviceCount++;
314-
} catch {}
315-
}
306+
if (tanks?.length > 0) {
307+
for (const tank of tanks) {
308+
try {
309+
await db.run('INSERT INTO devices (site_id, lora_address, name) VALUES ($1, $2, $3)',
310+
siteId, tank.address, tank.name || `Tank ${tank.address}`);
311+
deviceCount++;
312+
} catch {}
316313
}
317-
} catch {}
314+
}
318315

319-
try {
320-
const txResp = await fetch(`http://${receiver_ip}/api/transmitters`, { signal: AbortSignal.timeout(5000) });
321-
const txData = await txResp.json();
322-
if (txData.transmitters) {
323-
for (const tx of txData.transmitters) {
324-
await db.run('UPDATE devices SET tank_capacity_l=$1, min_distance_cm=$2, max_distance_cm=$3, fw_version=$4 WHERE site_id=$5 AND lora_address=$6',
325-
tx.capacity, tx.min_dist, tx.max_dist, tx.fw_version || null, siteId, tx.address);
326-
}
316+
// Update with transmitter details
317+
if (transmitters?.length > 0) {
318+
for (const tx of transmitters) {
319+
await db.run('UPDATE devices SET tank_capacity_l=$1, min_distance_cm=$2, max_distance_cm=$3, fw_version=$4 WHERE site_id=$5 AND lora_address=$6',
320+
tx.capacity, tx.min_dist, tx.max_dist, tx.fw_version || null, siteId, tx.address);
327321
}
328-
} catch {}
322+
}
329323

330-
// 6. Generate MQTT credentials and push to receiver
331-
let mqtt_configured = false;
324+
// Generate MQTT credentials (server-side only — phone pushes to receiver)
332325
let mqttCreds = null;
333326
try {
334327
mqttCreds = await generateMqttCredentials(req.user.id, siteId, device_id);
335-
mqtt_configured = await pushMqttToReceiver(receiver_ip, mqttCreds);
336-
if (mqtt_configured) {
337-
app.log.info(`MQTT auto-configured on receiver ${receiver_ip} — user ${mqttCreds.mqtt_username}`);
338-
}
339328
} catch (err) {
340329
app.log.warn(`MQTT credential setup failed: ${err.message}`);
341330
}
342331

343332
return {
344333
site_id: siteId,
345334
device_count: deviceCount,
346-
mqtt_configured,
347-
mqtt_host: mqttCreds?.mqtt_host,
348-
message: mqtt_configured
349-
? 'Device linked and MQTT configured automatically'
350-
: 'Device linked. Configure MQTT manually in the receiver web UI.',
335+
mqtt: mqttCreds,
336+
message: 'Device linked successfully',
351337
};
352338
});
353339

0 commit comments

Comments
 (0)