Skip to content

Commit 22d936a

Browse files
committed
next/sign-up: live feedback for password complexity check
1 parent b434d4d commit 22d936a

File tree

2 files changed

+265
-2
lines changed

2 files changed

+265
-2
lines changed

src/packages/next/components/auth/sign-up.tsx

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,21 @@
44
*/
55

66
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";
88
import {
99
GoogleReCaptchaProvider,
1010
useGoogleReCaptcha,
1111
} from "react-google-recaptcha-v3";
12+
import { debounce } from "lodash";
13+
14+
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
1215

1316
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";
1522
import {
1623
CONTACT_TAG,
1724
CONTACT_THESE_TAGS,
@@ -88,6 +95,11 @@ function SignUp0({
8895
const [firstName, setFirstName] = useState<string>("");
8996
const [lastName, setLastName] = useState<string>("");
9097
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);
91103
const [issues, setIssues] = useState<{
92104
email?: string;
93105
password?: string;
@@ -120,6 +132,23 @@ function SignUp0({
120132
}
121133
}, []);
122134

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+
123152
// based on email: if user has to sign up via SSO, this will tell which strategy to use.
124153
const requiredSSO = useRequiredSSO(strategies, email);
125154

@@ -139,6 +168,7 @@ function SignUp0({
139168
isValidEmailAddress(email) &&
140169
password &&
141170
password.length >= MIN_PASSWORD_LENGTH &&
171+
passwordStrength.score > MIN_PASSWORD_STRENGTH &&
142172
firstName?.trim() &&
143173
lastName?.trim() &&
144174
!needsTags
@@ -182,6 +212,31 @@ function SignUp0({
182212
}
183213
}
184214

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+
185240
if (!emailSignup && strategies.length == 0) {
186241
return (
187242
<Alert
@@ -364,6 +419,15 @@ function SignUp0({
364419
onPressEnter={signUp}
365420
maxLength={MAX_PASSWORD_LENGTH}
366421
/>
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+
)}
367431
</div>
368432
)}
369433
{issues.password && (
@@ -425,6 +489,10 @@ function SignUp0({
425489
? "You must sign up via SSO"
426490
: !password || password.length < MIN_PASSWORD_LENGTH
427491
? `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"
428496
: !firstName?.trim()
429497
? "Enter your first name above"
430498
: !lastName?.trim()
@@ -528,3 +596,115 @@ export function TermsCheckbox({
528596
</Checkbox>
529597
);
530598
}
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+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
/*
7+
API endpoint for checking password strength during sign-up.
8+
Provides real-time feedback without requiring the large zxcvbn library on the client.
9+
10+
Usage:
11+
POST /api/v2/auth/password-strength
12+
Body: { password: "user-password" }
13+
14+
Response:
15+
Success: { score: 0-4, help?: "suggestion text" }
16+
Error: { error: "error message" }
17+
*/
18+
19+
import passwordStrength from "@cocalc/server/auth/password-strength";
20+
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from "@cocalc/util/auth";
21+
import { apiRoute, apiRouteOperation } from "lib/api";
22+
import getParams from "lib/api/get-params";
23+
import { z } from "zod";
24+
25+
const PasswordStrengthInputSchema = z.object({
26+
password: z.string().min(1).max(MAX_PASSWORD_LENGTH),
27+
});
28+
29+
const PasswordStrengthOutputSchema = z.object({
30+
score: z.number().min(0).max(4),
31+
help: z.string().optional(),
32+
});
33+
34+
export async function checkPasswordStrength(req, res) {
35+
try {
36+
const { password } = getParams(req);
37+
38+
if (!password || typeof password !== "string") {
39+
res.status(400).json({ error: "Password is required" });
40+
return;
41+
}
42+
43+
if (password.length < MIN_PASSWORD_LENGTH) {
44+
res.status(400).json({
45+
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long`,
46+
});
47+
return;
48+
}
49+
50+
if (password.length > MAX_PASSWORD_LENGTH) {
51+
res.status(400).json({
52+
error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long`,
53+
});
54+
return;
55+
}
56+
57+
const result = passwordStrength(password);
58+
res.json(result);
59+
} catch (err) {
60+
res.status(500).json({ error: err.message });
61+
}
62+
}
63+
64+
export default apiRoute({
65+
checkPasswordStrength: apiRouteOperation({
66+
method: "POST",
67+
openApiOperation: {
68+
tags: ["Auth"],
69+
},
70+
})
71+
.input({
72+
contentType: "application/json",
73+
body: PasswordStrengthInputSchema,
74+
})
75+
.outputs([
76+
{
77+
status: 200,
78+
contentType: "application/json",
79+
body: PasswordStrengthOutputSchema,
80+
},
81+
])
82+
.handler(checkPasswordStrength),
83+
});

0 commit comments

Comments
 (0)