Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 64 additions & 8 deletions apps/customer_dashboard/src/app/users/forgot_password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
EMAIL_REGEX,
EMAIL_VERIFICATION_TIMER_SECONDS,
PASSWORD_MIN_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_CONTAINS_NUMBER_REGEX,
SIX_DIGITS_REGEX,
} from "@oko-wallet-ct-dashboard/constants";
import { ExpiryTimer } from "@oko-wallet-ct-dashboard/components/expiry_timer/expiry_timer";
Expand Down Expand Up @@ -49,6 +51,7 @@ export default function ForgotPasswordPage() {

const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isCodeExpired, setIsCodeExpired] = useState(false);

const codeValue = useMemo(() => codeDigits.join(""), [codeDigits]);

Expand Down Expand Up @@ -116,13 +119,22 @@ export default function ForgotPasswordPage() {
setError(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`);
return;
}
if (password.length > PASSWORD_MAX_LENGTH) {
setError(`Password must be at most ${PASSWORD_MAX_LENGTH} characters`);
return;
}
if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(password)) {
setError("Password must include at least one number");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}

setIsLoading(true);
resetError();
setIsCodeExpired(false);
try {
const res = await requestResetPasswordConfirm(
email,
Expand All @@ -132,7 +144,38 @@ export default function ForgotPasswordPage() {
if (res.success) {
router.push(paths.home);
} else {
setError(res.msg || "Failed to reset password");
if (res.code === "INVALID_VERIFICATION_CODE") {
setIsCodeExpired(true);
setError("Verification code has expired. Please request a new code.");
} else {
setError(res.msg || "Failed to reset password");
}
}
} catch (err) {
setError("An unexpected error occurred");
} finally {
setIsLoading(false);
}
};

const handleRequestNewCode = async () => {
if (!email || isLoading) {
return;
}

setIsLoading(true);
resetError();
setIsCodeExpired(false);
try {
const res = await requestForgotPassword(email);
if (res.success) {
setCodeDigits(EMPTY_CODE);
setVerifiedCode("");
setPassword("");
setConfirmPassword("");
goToStep(Step.CODE);
} else {
setError(res.msg || "Failed to send code");
}
} catch (err) {
setError("An unexpected error occurred");
Expand Down Expand Up @@ -317,6 +360,7 @@ export default function ForgotPasswordPage() {
}}
fullWidth
requiredSymbol
maxLength={16}
helpText={
error
? undefined
Expand Down Expand Up @@ -344,6 +388,7 @@ export default function ForgotPasswordPage() {
}}
fullWidth
requiredSymbol
maxLength={16}
SideComponent={
<button
type="button"
Expand Down Expand Up @@ -373,13 +418,24 @@ export default function ForgotPasswordPage() {
)}

<div className={styles.passwordButton}>
<Button
type="submit"
fullWidth
disabled={!password || !confirmPassword || isLoading}
>
{isLoading ? "Updating..." : "Update"}
</Button>
{isCodeExpired ? (
<Button
type="button"
fullWidth
onClick={handleRequestNewCode}
disabled={isLoading}
>
{isLoading ? "Sending..." : "Request New Code"}
</Button>
) : (
<Button
type="submit"
fullWidth
disabled={!password || !confirmPassword || isLoading}
>
{isLoading ? "Updating..." : "Update"}
</Button>
)}
</div>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { useForm, type SubmitHandler } from "react-hook-form";
import { useRouter } from "next/navigation";

import { paths } from "@oko-wallet-ct-dashboard/paths";
import { PASSWORD_MIN_LENGTH } from "@oko-wallet-ct-dashboard/constants";
import {
PASSWORD_MIN_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_CONTAINS_NUMBER_REGEX,
} from "@oko-wallet-ct-dashboard/constants";
import { requestChangePassword } from "@oko-wallet-ct-dashboard/fetch/users";
import { useAppState } from "@oko-wallet-ct-dashboard/state";

Expand Down Expand Up @@ -91,6 +95,16 @@ function resetPasswordResolver(values: ResetPasswordInputs) {
type: "minLength",
message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters`,
};
} else if (values.newPassword.length > PASSWORD_MAX_LENGTH) {
errors.newPassword = {
type: "maxLength",
message: `Password must be at most ${PASSWORD_MAX_LENGTH} characters`,
};
} else if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(values.newPassword)) {
errors.newPassword = {
type: "pattern",
message: "Password must include at least one number",
};
}

if (!values.confirmPassword) {
Expand Down
4 changes: 3 additions & 1 deletion apps/customer_dashboard/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 16;
export const PASSWORD_CONTAINS_NUMBER_REGEX = /\d/;

export const SIX_DIGITS_REGEX = /^\d{6}$/;

export const EMAIL_VERIFICATION_TIMER_SECONDS = 60 * 3;
export const EMAIL_VERIFICATION_TIMER_SECONDS = 60;

export const GET_STARTED_URL = "https://form.typeform.com/to/MxrBGq9b";
2 changes: 1 addition & 1 deletion apps/email_template_2/src/app/reset_pw_code/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export default function ResetPwCodePage() {
password.
<br />
The code is valid for{" "}
{"${email_verification_expiration_minutes}"} minutes
{"${email_verification_expiration_minutes}"} minute
for your security.
</EmailText>
</td>
Expand Down
2 changes: 2 additions & 0 deletions backend/ct_dashboard_api/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const PASSWORD_MIN_LENGTH = 4;
export const CHANGED_PASSWORD_MIN_LENGTH = 8;
export const CHANGED_PASSWORD_MAX_LENGTH = 16;
export const PASSWORD_CONTAINS_NUMBER_REGEX = /\d/;

export const SIX_DIGITS_REGEX = /^\d{6}$/;

Expand Down
2 changes: 1 addition & 1 deletion backend/ct_dashboard_api/src/email/password_reset.ts

Large diffs are not rendered by default.

60 changes: 45 additions & 15 deletions backend/ct_dashboard_api/src/routes/customer_auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
getCTDUserWithCustomerByEmail,
} from "@oko-wallet/oko-pg-interface/customer_dashboard_users";
import { hashPassword, comparePassword } from "@oko-wallet/crypto-js";
import { verifyEmailCode } from "@oko-wallet/oko-pg-interface/email_verifications";
import {
verifyEmailCode,
markCodeVerified,
} from "@oko-wallet/oko-pg-interface/email_verifications";
import { registry } from "@oko-wallet/oko-api-openapi";
import { ErrorResponseSchema } from "@oko-wallet/oko-api-openapi/common";
import {
Expand All @@ -41,6 +44,8 @@ import { generateCustomerToken } from "@oko-wallet-ctd-api/auth";
import { sendEmailVerificationCode } from "@oko-wallet-ctd-api/email/send";
import {
CHANGED_PASSWORD_MIN_LENGTH,
CHANGED_PASSWORD_MAX_LENGTH,
PASSWORD_CONTAINS_NUMBER_REGEX,
EMAIL_REGEX,
SIX_DIGITS_REGEX,
} from "@oko-wallet-ctd-api/constants";
Expand All @@ -51,10 +56,7 @@ import {
import { rateLimitMiddleware } from "@oko-wallet-ctd-api/middleware/rate_limit";
import { generateVerificationCode } from "@oko-wallet-ctd-api/email/verification";
import { sendPasswordResetEmail } from "@oko-wallet-ctd-api/email/password_reset";
import {
createEmailVerification,
getLatestPendingVerification,
} from "@oko-wallet/oko-pg-interface/email_verifications";
import { createEmailVerification } from "@oko-wallet/oko-pg-interface/email_verifications";

export function setCustomerAuthRoutes(router: Router) {
registry.registerPath({
Expand Down Expand Up @@ -291,16 +293,8 @@ export function setCustomerAuthRoutes(router: Router) {
return;
}

const pendingRes = await getLatestPendingVerification(state.db, email);
if (!pendingRes.success) {
res
.status(500)
.json({ success: false, code: "UNKNOWN_ERROR", msg: "DB Error" });
return;
}

const pending = pendingRes.data;
if (!pending || pending.verification_code !== code) {
const result = await markCodeVerified(state.db, email, code, 5);
if (!result.success) {
res.status(400).json({
success: false,
code: "INVALID_VERIFICATION_CODE",
Expand Down Expand Up @@ -402,6 +396,24 @@ export function setCustomerAuthRoutes(router: Router) {
return;
}

if (newPassword.length > CHANGED_PASSWORD_MAX_LENGTH) {
res.status(400).json({
success: false,
code: "INVALID_EMAIL_OR_PASSWORD",
msg: "Password too long",
});
return;
}

if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(newPassword)) {
res.status(400).json({
success: false,
code: "INVALID_EMAIL_OR_PASSWORD",
msg: "Password must include at least one number",
});
return;
}

const verificationResult = await verifyEmailCode(state.db, {
email,
verification_code: code,
Expand Down Expand Up @@ -1027,6 +1039,24 @@ export function setCustomerAuthRoutes(router: Router) {
return;
}

if (request.new_password.length > CHANGED_PASSWORD_MAX_LENGTH) {
res.status(400).json({
success: false,
code: "INVALID_EMAIL_OR_PASSWORD",
msg: "Password must be at most 16 characters long",
});
return;
}

if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(request.new_password)) {
res.status(400).json({
success: false,
code: "INVALID_EMAIL_OR_PASSWORD",
msg: "Password must include at least one number",
});
return;
}

// Inline changePassword logic
const customerAccountResult =
await getCTDUserWithCustomerAndPasswordHashByEmail(
Expand Down
63 changes: 57 additions & 6 deletions backend/oko_pg_interface/src/email_verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ export async function createEmailVerification(
): Promise<Result<EmailVerification, string>> {
const query = `
INSERT INTO email_verifications (
email_verification_id, email, verification_code,
email_verification_id, email, verification_code,
status, expires_at
)
VALUES (
$1, $2, $3,
$1, $2, $3,
$4, $5
)
RETURNING *
Expand Down Expand Up @@ -61,14 +61,14 @@ export async function verifyEmailCode(
): Promise<Result<VerifyEmailResponse, string>> {
try {
const updateQuery = `
UPDATE email_verifications
UPDATE email_verifications
SET status = '${EmailVerificationStatus.VERIFIED}', updated_at = NOW()
WHERE email_verification_id = (
SELECT email_verification_id
FROM email_verifications
WHERE email = $1
AND verification_code = $2
AND status = '${EmailVerificationStatus.PENDING}'
AND status = '${EmailVerificationStatus.CODE_VERIFIED}'
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
Expand Down Expand Up @@ -110,14 +110,65 @@ RETURNING status
}
}

export async function markCodeVerified(
db: Pool,
email: string,
code: string,
extendMinutes: number = 5,
): Promise<Result<EmailVerification, string>> {
const query = `
UPDATE email_verifications
SET status = '${EmailVerificationStatus.CODE_VERIFIED}',
expires_at = NOW() + INTERVAL '1 minute' * $3,
updated_at = NOW()
WHERE email_verification_id = (
SELECT email_verification_id
FROM email_verifications
WHERE email = $1
AND verification_code = $2
AND status = '${EmailVerificationStatus.PENDING}'
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
)
RETURNING *
`;

try {
const result = await db.query<EmailVerification>(query, [
email,
code,
extendMinutes,
]);

const row = result.rows[0];
if (!row) {
return {
success: false,
err: "Invalid or expired verification code",
};
}

return {
success: true,
data: row,
};
} catch (error) {
return {
success: false,
err: String(error),
};
}
}

export async function getLatestPendingVerification(
db: Pool,
email: string,
): Promise<Result<EmailVerification | null, string>> {
const query = `
SELECT *
SELECT *
FROM email_verifications
WHERE email = $1
WHERE email = $1
AND status = '${EmailVerificationStatus.PENDING}'
AND expires_at > NOW()
ORDER BY created_at DESC
Expand Down
Loading