4
4
*/
5
5
6
6
import { Alert , Button , Checkbox , Divider , Input } from "antd" ;
7
- import { CSSProperties , useEffect , useRef , useState } from "react" ;
7
+ import { CSSProperties , useCallback , useEffect , useRef , useState } from "react" ;
8
8
import {
9
9
GoogleReCaptchaProvider ,
10
10
useGoogleReCaptcha ,
11
11
} from "react-google-recaptcha-v3" ;
12
+ import { debounce } from "lodash" ;
13
+
14
+ import { reuseInFlight } from "@cocalc/util/reuse-in-flight" ;
12
15
13
16
import Markdown from "@cocalc/frontend/editors/slate/static-markdown" ;
14
- import { MAX_PASSWORD_LENGTH , MIN_PASSWORD_LENGTH } from "@cocalc/util/auth" ;
17
+ import {
18
+ MAX_PASSWORD_LENGTH ,
19
+ MIN_PASSWORD_LENGTH ,
20
+ MIN_PASSWORD_STRENGTH ,
21
+ } from "@cocalc/util/auth" ;
15
22
import {
16
23
CONTACT_TAG ,
17
24
CONTACT_THESE_TAGS ,
@@ -88,6 +95,11 @@ function SignUp0({
88
95
const [ firstName , setFirstName ] = useState < string > ( "" ) ;
89
96
const [ lastName , setLastName ] = useState < string > ( "" ) ;
90
97
const [ signingUp , setSigningUp ] = useState < boolean > ( false ) ;
98
+ const [ passwordStrength , setPasswordStrength ] = useState < {
99
+ score : number ;
100
+ help ?: string ;
101
+ } > ( { score : 0 } ) ;
102
+ const [ checkingPassword , setCheckingPassword ] = useState < boolean > ( false ) ;
91
103
const [ issues , setIssues ] = useState < {
92
104
email ?: string ;
93
105
password ?: string ;
@@ -120,6 +132,23 @@ function SignUp0({
120
132
}
121
133
} , [ ] ) ;
122
134
135
+ // Debounced password strength checking with reuse-in-flight protection
136
+ const debouncedCheckPassword = useCallback (
137
+ debounce ( ( password : string ) => {
138
+ checkPasswordStrengthReuseInFlight ( password ) ;
139
+ } , 100 ) ,
140
+ [ ] ,
141
+ ) ;
142
+
143
+ useEffect ( ( ) => {
144
+ if ( ! password ) {
145
+ setPasswordStrength ( { score : 0 } ) ;
146
+ return ;
147
+ }
148
+
149
+ debouncedCheckPassword ( password ) ;
150
+ } , [ password , debouncedCheckPassword ] ) ;
151
+
123
152
// based on email: if user has to sign up via SSO, this will tell which strategy to use.
124
153
const requiredSSO = useRequiredSSO ( strategies , email ) ;
125
154
@@ -139,6 +168,7 @@ function SignUp0({
139
168
isValidEmailAddress ( email ) &&
140
169
password &&
141
170
password . length >= MIN_PASSWORD_LENGTH &&
171
+ passwordStrength . score > MIN_PASSWORD_STRENGTH &&
142
172
firstName ?. trim ( ) &&
143
173
lastName ?. trim ( ) &&
144
174
! needsTags
@@ -182,6 +212,31 @@ function SignUp0({
182
212
}
183
213
}
184
214
215
+ async function checkPasswordStrength ( password : string ) {
216
+ if ( ! password || password . length < MIN_PASSWORD_LENGTH ) {
217
+ setPasswordStrength ( { score : 0 } ) ;
218
+ return ;
219
+ }
220
+
221
+ setCheckingPassword ( true ) ;
222
+ try {
223
+ const result = await apiPost ( "/auth/password-strength" , { password } ) ;
224
+ setPasswordStrength ( result ) ;
225
+ } catch ( err ) {
226
+ // If the API fails, fall back to basic length check
227
+ setPasswordStrength ( {
228
+ score : password . length >= MIN_PASSWORD_LENGTH ? 1 : 0 ,
229
+ } ) ;
230
+ } finally {
231
+ setCheckingPassword ( false ) ;
232
+ }
233
+ }
234
+
235
+ // Wrap the function to prevent concurrent calls
236
+ const checkPasswordStrengthReuseInFlight = reuseInFlight (
237
+ checkPasswordStrength ,
238
+ ) ;
239
+
185
240
if ( ! emailSignup && strategies . length == 0 ) {
186
241
return (
187
242
< Alert
@@ -364,6 +419,15 @@ function SignUp0({
364
419
onPressEnter = { signUp }
365
420
maxLength = { MAX_PASSWORD_LENGTH }
366
421
/>
422
+ { password && password . length >= MIN_PASSWORD_LENGTH && (
423
+ < div style = { { marginTop : "8px" } } >
424
+ < PasswordStrengthIndicator
425
+ score = { passwordStrength . score }
426
+ help = { passwordStrength . help }
427
+ checking = { checkingPassword }
428
+ />
429
+ </ div >
430
+ ) }
367
431
</ div >
368
432
) }
369
433
{ issues . password && (
@@ -425,6 +489,10 @@ function SignUp0({
425
489
? "You must sign up via SSO"
426
490
: ! password || password . length < MIN_PASSWORD_LENGTH
427
491
? `Choose password with at least ${ MIN_PASSWORD_LENGTH } characters`
492
+ : password &&
493
+ password . length >= MIN_PASSWORD_LENGTH &&
494
+ passwordStrength . score <= MIN_PASSWORD_STRENGTH
495
+ ? "Make your password more complex"
428
496
: ! firstName ?. trim ( )
429
497
? "Enter your first name above"
430
498
: ! lastName ?. trim ( )
@@ -528,3 +596,115 @@ export function TermsCheckbox({
528
596
</ Checkbox >
529
597
) ;
530
598
}
599
+
600
+ interface PasswordStrengthIndicatorProps {
601
+ score : number ;
602
+ help ?: string ;
603
+ checking : boolean ;
604
+ }
605
+
606
+ function PasswordStrengthIndicator ( {
607
+ score,
608
+ help,
609
+ checking,
610
+ } : PasswordStrengthIndicatorProps ) {
611
+ if ( checking ) {
612
+ return (
613
+ < div style = { { fontSize : "12px" , color : COLORS . GRAY_M } } >
614
+ Checking password strength...
615
+ </ div >
616
+ ) ;
617
+ }
618
+
619
+ const getStrengthColor = ( score : number ) : string => {
620
+ switch ( score ) {
621
+ case 0 :
622
+ case 1 :
623
+ return COLORS . ANTD_RED_WARN ;
624
+ case 2 :
625
+ return COLORS . ORANGE_WARN ;
626
+ case 3 :
627
+ return COLORS . ANTD_YELL_M ;
628
+ case 4 :
629
+ return COLORS . BS_GREEN ;
630
+ default :
631
+ return COLORS . GRAY_M ;
632
+ }
633
+ } ;
634
+
635
+ const getStrengthLabel = ( score : number ) : string => {
636
+ switch ( score ) {
637
+ case 0 :
638
+ return "Very weak" ;
639
+ case 1 :
640
+ return "Weak" ;
641
+ case 2 :
642
+ return "Fair" ;
643
+ case 3 :
644
+ return "Good" ;
645
+ case 4 :
646
+ return "Strong" ;
647
+ default :
648
+ return "Unknown" ;
649
+ }
650
+ } ;
651
+
652
+ const getStrengthWidth = ( score : number ) : string => {
653
+ return `${ Math . max ( 10 , ( score + 1 ) * 20 ) } %` ;
654
+ } ;
655
+
656
+ return (
657
+ < div style = { { fontSize : "12px" } } >
658
+ < div
659
+ style = { {
660
+ display : "flex" ,
661
+ alignItems : "center" ,
662
+ marginBottom : "4px" ,
663
+ } }
664
+ >
665
+ < span style = { { marginRight : "8px" , minWidth : "80px" } } >
666
+ Password strength:{ " " }
667
+ </ span >
668
+ < div
669
+ style = { {
670
+ flex : 1 ,
671
+ height : "6px" ,
672
+ backgroundColor : COLORS . GRAY_LL ,
673
+ borderRadius : "3px" ,
674
+ overflow : "hidden" ,
675
+ } }
676
+ >
677
+ < div
678
+ style = { {
679
+ height : "100%" ,
680
+ width : getStrengthWidth ( score ) ,
681
+ backgroundColor : getStrengthColor ( score ) ,
682
+ transition : "width 0.3s ease, background-color 0.3s ease" ,
683
+ } }
684
+ />
685
+ </ div >
686
+ < span
687
+ style = { {
688
+ marginLeft : "8px" ,
689
+ color : getStrengthColor ( score ) ,
690
+ fontWeight : "500" ,
691
+ minWidth : "60px" ,
692
+ } }
693
+ >
694
+ { getStrengthLabel ( score ) }
695
+ </ span >
696
+ </ div >
697
+ { help && (
698
+ < div
699
+ style = { {
700
+ color : COLORS . GRAY_D ,
701
+ fontSize : "11px" ,
702
+ marginTop : "2px" ,
703
+ } }
704
+ >
705
+ { help }
706
+ </ div >
707
+ ) }
708
+ </ div >
709
+ ) ;
710
+ }
0 commit comments