@@ -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 */
1816export 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 >
0 commit comments