1- // SPDX-License-Identifier: AGPL-3.0-or-later
2- // Copyright (c) 2025-2026 Ravi Singh (Techposts)
3-
4- import { useState } from 'react' ;
1+ import { useState , useEffect , useRef } from 'react' ;
52import { useNavigate , useSearchParams } from 'react-router-dom' ;
63import { useAuth } from '../hooks/useAuth.jsx' ;
74
5+ const TURNSTILE_SITE_KEY = import . meta. env . VITE_TURNSTILE_SITE_KEY || '' ;
6+
87export default function Login ( ) {
9- const [ isRegister , setIsRegister ] = useState ( false ) ;
8+ const [ mode , setMode ] = useState ( 'login' ) ; // 'login' | 'register' | 'verify'
109 const [ email , setEmail ] = useState ( '' ) ;
1110 const [ password , setPassword ] = useState ( '' ) ;
1211 const [ name , setName ] = useState ( '' ) ;
12+ const [ code , setCode ] = useState ( '' ) ;
1313 const [ error , setError ] = useState ( '' ) ;
14+ const [ info , setInfo ] = useState ( '' ) ;
1415 const [ loading , setLoading ] = useState ( false ) ;
15- const { login, register } = useAuth ( ) ;
16+ const [ resendCooldown , setResendCooldown ] = useState ( 0 ) ;
17+ const turnstileRef = useRef ( null ) ;
18+ const turnstileWidgetId = useRef ( null ) ;
19+ const { login, register, verifyEmail, resendCode, needsVerification, user } = useAuth ( ) ;
1620 const navigate = useNavigate ( ) ;
1721 const [ searchParams ] = useSearchParams ( ) ;
1822 const redirectTo = searchParams . get ( 'redirect' ) || '/' ;
1923
24+ // If logged in and verified, redirect (run once, not on every render)
25+ useEffect ( ( ) => {
26+ if ( user && ! needsVerification ) {
27+ navigate ( redirectTo , { replace : true } ) ;
28+ } else if ( user && needsVerification && mode !== 'verify' ) {
29+ setEmail ( user . email ) ;
30+ setMode ( 'verify' ) ;
31+ }
32+ // eslint-disable-next-line react-hooks/exhaustive-deps
33+ } , [ user ?. id , needsVerification ] ) ;
34+
35+ // Load Turnstile script for registration
36+ useEffect ( ( ) => {
37+ if ( ! TURNSTILE_SITE_KEY || mode !== 'register' ) return ;
38+ if ( document . getElementById ( 'cf-turnstile-script' ) ) return ;
39+
40+ const script = document . createElement ( 'script' ) ;
41+ script . id = 'cf-turnstile-script' ;
42+ script . src = 'https://challenges.cloudflare.com/turnstile/v0/api.js' ;
43+ script . async = true ;
44+ script . onload = ( ) => renderTurnstile ( ) ;
45+ document . head . appendChild ( script ) ;
46+ return ( ) => { } ;
47+ } , [ mode ] ) ;
48+
49+ useEffect ( ( ) => {
50+ if ( mode === 'register' && TURNSTILE_SITE_KEY && window . turnstile && turnstileRef . current ) {
51+ renderTurnstile ( ) ;
52+ }
53+ } , [ mode ] ) ;
54+
55+ function renderTurnstile ( ) {
56+ if ( ! window . turnstile || ! turnstileRef . current ) return ;
57+ if ( turnstileWidgetId . current ) window . turnstile . remove ( turnstileWidgetId . current ) ;
58+ turnstileWidgetId . current = window . turnstile . render ( turnstileRef . current , {
59+ sitekey : TURNSTILE_SITE_KEY ,
60+ theme : 'dark' ,
61+ callback : ( ) => { } ,
62+ } ) ;
63+ }
64+
65+ function getTurnstileToken ( ) {
66+ if ( ! TURNSTILE_SITE_KEY || ! window . turnstile || turnstileWidgetId . current == null ) return '' ;
67+ return window . turnstile . getResponse ( turnstileWidgetId . current ) || '' ;
68+ }
69+
70+ // Resend cooldown timer
71+ useEffect ( ( ) => {
72+ if ( resendCooldown <= 0 ) return ;
73+ const t = setTimeout ( ( ) => setResendCooldown ( c => c - 1 ) , 1000 ) ;
74+ return ( ) => clearTimeout ( t ) ;
75+ } , [ resendCooldown ] ) ;
76+
2077 const handleSubmit = async ( e ) => {
2178 e . preventDefault ( ) ;
2279 setError ( '' ) ;
80+ setInfo ( '' ) ;
2381 setLoading ( true ) ;
2482 try {
25- if ( isRegister ) {
26- await register ( email , password , name ) ;
83+ if ( mode === 'register' ) {
84+ const turnstileToken = getTurnstileToken ( ) ;
85+ if ( TURNSTILE_SITE_KEY && ! turnstileToken ) {
86+ setError ( 'Please complete the captcha' ) ;
87+ setLoading ( false ) ;
88+ return ;
89+ }
90+ await register ( email , password , name , turnstileToken ) ;
91+ setMode ( 'verify' ) ;
92+ setInfo ( 'Verification code sent to your email' ) ;
93+ } else if ( mode === 'verify' ) {
94+ await verifyEmail ( email , code ) ;
95+ navigate ( redirectTo ) ;
2796 } else {
28- await login ( email , password ) ;
97+ const data = await login ( email , password ) ;
98+ if ( data . needsVerification ) {
99+ setMode ( 'verify' ) ;
100+ setInfo ( 'Please verify your email to continue' ) ;
101+ } else {
102+ navigate ( redirectTo ) ;
103+ }
29104 }
30- navigate ( redirectTo ) ;
31105 } catch ( err ) {
32106 setError ( err . message ) ;
33107 } finally {
34108 setLoading ( false ) ;
35109 }
36110 } ;
37111
112+ const handleResend = async ( ) => {
113+ setError ( '' ) ;
114+ try {
115+ await resendCode ( email ) ;
116+ setInfo ( 'New code sent to your email' ) ;
117+ setResendCooldown ( 60 ) ;
118+ } catch ( err ) {
119+ setError ( err . message ) ;
120+ }
121+ } ;
122+
38123 return (
39- < div className = "min-h-screen flex flex-col items-center justify-center px-6 bg-slate-950" >
124+ < div className = "fixed inset-0 flex flex-col items-center justify-center px-6 bg-slate-950 overflow-auto " >
40125 { /* Logo */ }
41126 < div className = "mb-10 text-center" >
42127 < div className = "inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-water/10 mb-4" >
@@ -48,88 +133,106 @@ export default function Login() {
48133 </ svg >
49134 </ div >
50135 < h1 className = "text-3xl font-bold text-white tracking-tight" > TankSync</ h1 >
51- < p className = "text-slate-400 mt-1 text-sm" > Smart Water Monitoring</ p >
136+ < p className = "text-slate-400 mt-1 text-sm" >
137+ { mode === 'verify' ? 'Verify your email' : 'Smart Water Monitoring' }
138+ </ p >
52139 </ div >
53140
54141 { /* Form card */ }
55142 < div className = "w-full max-w-sm" >
56143 < form onSubmit = { handleSubmit } className = "space-y-4" >
57- { isRegister && (
58- < div >
59- < label className = "block text-sm font-medium text-slate-300 mb-1.5" > Name</ label >
144+ { mode === 'verify' ? (
145+ < >
146+ < p className = "text-sm text-slate-400 text-center" >
147+ Enter the 6-digit code sent to < span className = "text-white font-medium" > { email } </ span >
148+ </ p >
60149 < input
61150 type = "text"
62- value = { name }
63- onChange = { e => setName ( e . target . value ) }
64- className = "w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
65- focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
66- placeholder = "Your name"
151+ inputMode = "numeric"
152+ maxLength = { 6 }
153+ value = { code }
154+ onChange = { e => setCode ( e . target . value . replace ( / \D / g, '' ) ) }
155+ required
156+ autoFocus
157+ className = "w-full px-4 py-4 rounded-xl bg-surface border border-slate-700 text-white text-center
158+ text-2xl tracking-[0.5em] font-mono focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
159+ placeholder = "000000"
67160 />
68- </ div >
161+ </ >
162+ ) : (
163+ < >
164+ { mode === 'register' && (
165+ < div >
166+ < label className = "block text-sm font-medium text-slate-300 mb-1.5" > Name</ label >
167+ < input type = "text" value = { name } onChange = { e => setName ( e . target . value ) }
168+ className = "w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
169+ focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
170+ placeholder = "Your name" />
171+ </ div >
172+ ) }
173+ < div >
174+ < label className = "block text-sm font-medium text-slate-300 mb-1.5" > Email</ label >
175+ < input type = "email" value = { email } onChange = { e => setEmail ( e . target . value ) } required
176+ className = "w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
177+ focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
178+ placeholder = "you@example.com" />
179+ </ div >
180+ < div >
181+ < label className = "block text-sm font-medium text-slate-300 mb-1.5" > Password</ label >
182+ < input type = "password" value = { password } onChange = { e => setPassword ( e . target . value ) } required minLength = { 6 }
183+ className = "w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
184+ focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
185+ placeholder = "At least 6 characters" />
186+ </ div >
187+ { mode === 'register' && TURNSTILE_SITE_KEY && (
188+ < div ref = { turnstileRef } className = "flex justify-center" />
189+ ) }
190+ </ >
69191 ) }
70192
71- < div >
72- < label className = "block text-sm font-medium text-slate-300 mb-1.5" > Email</ label >
73- < input
74- type = "email"
75- value = { email }
76- onChange = { e => setEmail ( e . target . value ) }
77- required
78- className = "w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
79- focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
80- placeholder = "you@example.com"
81- />
82- </ div >
83-
84- < div >
85- < label className = "block text-sm font-medium text-slate-300 mb-1.5" > Password</ label >
86- < input
87- type = "password"
88- value = { password }
89- onChange = { e => setPassword ( e . target . value ) }
90- required
91- minLength = { 6 }
92- className = "w-full px-4 py-3 rounded-xl bg-surface border border-slate-700 text-white
93- focus:border-water focus:ring-1 focus:ring-water/50 outline-none transition-all"
94- placeholder = "At least 6 characters"
95- />
96- </ div >
97-
98193 { error && (
99- < div className = "text-danger text-sm bg-danger/10 px-4 py-2.5 rounded-xl" >
100- { error }
101- </ div >
194+ < div className = "text-danger text-sm bg-danger/10 px-4 py-2.5 rounded-xl" > { error } </ div >
195+ ) }
196+ { info && (
197+ < div className = "text-success text-sm bg-success/10 px-4 py-2.5 rounded-xl" > { info } </ div >
102198 ) }
103199
104- < button
105- type = "submit"
106- disabled = { loading }
200+ < button type = "submit" disabled = { loading }
107201 className = "w-full py-3.5 rounded-xl bg-water text-white font-semibold text-base
108202 hover:bg-water-dark active:scale-[0.98] transition-all duration-150
109- disabled:opacity-50 disabled:cursor-not-allowed"
110- >
203+ disabled:opacity-50 disabled:cursor-not-allowed" >
111204 { loading ? (
112205 < span className = "inline-flex items-center gap-2" >
113206 < svg className = "animate-spin h-4 w-4" viewBox = "0 0 24 24" >
114207 < circle cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "3" fill = "none" opacity = "0.3" />
115208 < path d = "M12 2a10 10 0 0 1 10 10" stroke = "currentColor" strokeWidth = "3" fill = "none" strokeLinecap = "round" />
116209 </ svg >
117- { isRegister ? 'Creating account...' : 'Signing in...' }
210+ { mode === 'verify' ? 'Verifying...' : mode === 'register' ? 'Creating account...' : 'Signing in...' }
118211 </ span >
119212 ) : (
120- isRegister ? 'Create Account' : 'Sign In'
213+ mode === 'verify' ? 'Verify Email' : mode === 'register' ? 'Create Account' : 'Sign In'
121214 ) }
122215 </ button >
123216 </ form >
124217
125- < div className = "mt-6 text-center" >
126- < button
127- onClick = { ( ) => { setIsRegister ( ! isRegister ) ; setError ( '' ) ; } }
128- className = "text-sm text-slate-400 hover:text-water transition-colors"
129- >
130- { isRegister ? 'Already have an account? Sign in' : "Don't have an account? Create one" }
131- </ button >
132- </ div >
218+ { mode === 'verify' && (
219+ < div className = "mt-4 text-center" >
220+ < button onClick = { handleResend } disabled = { resendCooldown > 0 }
221+ className = "text-sm text-slate-400 hover:text-water transition-colors disabled:opacity-40" >
222+ { resendCooldown > 0 ? `Resend code in ${ resendCooldown } s` : 'Resend verification code' }
223+ </ button >
224+ </ div >
225+ ) }
226+
227+ { mode !== 'verify' && (
228+ < div className = "mt-6 text-center" >
229+ < button
230+ onClick = { ( ) => { setMode ( mode === 'register' ? 'login' : 'register' ) ; setError ( '' ) ; setInfo ( '' ) ; } }
231+ className = "text-sm text-slate-400 hover:text-water transition-colors" >
232+ { mode === 'register' ? 'Already have an account? Sign in' : "Don't have an account? Create one" }
233+ </ button >
234+ </ div >
235+ ) }
133236 </ div >
134237 </ div >
135238 ) ;
0 commit comments