diff --git a/apps/customer_dashboard/src/app/users/forgot_password/page.module.scss b/apps/customer_dashboard/src/app/users/forgot_password/page.module.scss new file mode 100644 index 000000000..6d642e781 --- /dev/null +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.module.scss @@ -0,0 +1,219 @@ +.wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.header { + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 44px; + background: var(--bg-primary); +} + +.headerSpacer { + width: 312px; +} + +.body { + flex: 1; + display: flex; + justify-content: center; + padding: 80px 40px 40px; +} + +.bodyDefault { + padding-top: 80px; +} + +.bodyPassword { + padding-top: 120px; +} + +.formColumn { + width: 100%; + max-width: 512px; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0 40px; +} + +.codeColumn { + width: 100%; + max-width: 472px; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.backButton { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + color: var(--fg-tertiary); + cursor: pointer; + margin-bottom: 24px; +} + +.titleBlock { + width: 100%; + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 40px; +} + +.form { + width: 100%; + display: flex; + flex-direction: column; + gap: 40px; +} + +.codeCard { + width: 100%; + border-radius: 24px; + border: 1px solid var(--border-secondary); + background: var(--bg-secondary); + box-shadow: none; +} + +.codeCardContent { + padding: 48px 24px 36px; + display: flex; + flex-direction: column; + align-items: center; +} + +.codeTitle { + margin-bottom: 32px; +} + +.codeDescription { + margin-bottom: 20px; + text-align: center; +} + +.codeOtp { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.codeError { + text-align: left; +} + +.resendRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 20px; +} + +.resendButton { + background: none; + border: none; + padding: 0; + cursor: pointer; + + span { + text-decoration: underline; + } + + &:disabled { + cursor: not-allowed; + } +} + +.eyeButton { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-tertiary); +} + +.passwordForm { + width: 100%; + display: flex; + flex-direction: column; +} + +.passwordFields { + display: flex; + flex-direction: column; + gap: 28px; + width: 100%; +} + +.passwordError { + margin-top: 16px; +} + +.passwordButton { + margin-top: 40px; + width: 100%; +} + +@media (max-width: 768px) { + .header { + padding: 0 24px; + } + + .headerSpacer { + width: 0; + } + + .body { + padding: 64px 24px 32px; + } + + .bodyDefault { + padding-top: 64px; + } + + .bodyPassword { + padding-top: 96px; + } + + .formColumn, + .codeColumn { + max-width: 100%; + } + + .formColumn { + padding: 0; + } + + .codeCard { + max-width: 100%; + } + + .codeCardContent { + padding: 32px 16px 28px; + } +} + +@media (max-width: 480px) { + .resendRow { + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/apps/customer_dashboard/src/app/users/forgot_password/page.tsx b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx new file mode 100644 index 000000000..def1c27ab --- /dev/null +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx @@ -0,0 +1,405 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Logo } from "@oko-wallet/oko-common-ui/logo"; +import { Input } from "@oko-wallet/oko-common-ui/input"; +import { Button } from "@oko-wallet/oko-common-ui/button"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import { OtpInput } from "@oko-wallet/oko-common-ui/otp_input"; +import { ChevronLeftIcon } from "@oko-wallet/oko-common-ui/icons/chevron_left"; +import { EyeIcon } from "@oko-wallet/oko-common-ui/icons/eye"; +import { EyeOffIcon } from "@oko-wallet/oko-common-ui/icons/eye_off"; + +import { + requestForgotPassword, + requestVerifyResetCode, + requestResetPasswordConfirm, +} from "@oko-wallet-ct-dashboard/fetch/users"; +import { + EMAIL_REGEX, + EMAIL_VERIFICATION_TIMER_SECONDS, + PASSWORD_MIN_LENGTH, + SIX_DIGITS_REGEX, +} from "@oko-wallet-ct-dashboard/constants"; +import { ExpiryTimer } from "@oko-wallet-ct-dashboard/components/expiry_timer/expiry_timer"; +import { paths } from "@oko-wallet-ct-dashboard/paths"; + +import styles from "./page.module.scss"; + +enum Step { + EMAIL = 0, + CODE = 1, + PASSWORD = 2, +} + +const EMPTY_CODE = Array.from({ length: 6 }, () => ""); + +export default function ForgotPasswordPage() { + const router = useRouter(); + const [step, setStep] = useState(Step.EMAIL); + const [email, setEmail] = useState(""); + const [codeDigits, setCodeDigits] = useState(EMPTY_CODE); + const [verifiedCode, setVerifiedCode] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isResending, setIsResending] = useState(false); + const [error, setError] = useState(null); + + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const codeValue = useMemo(() => codeDigits.join(""), [codeDigits]); + + const resetError = () => setError(null); + + const goToStep = (nextStep: Step) => { + resetError(); + setStep(nextStep); + }; + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!EMAIL_REGEX.test(email)) { + setError("Please enter a valid email address."); + return; + } + setIsLoading(true); + resetError(); + try { + const res = await requestForgotPassword(email); + if (res.success) { + setCodeDigits(EMPTY_CODE); + setVerifiedCode(""); + goToStep(Step.CODE); + } else { + setError(res.msg || "Failed to send code"); + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handleVerifyCode = async (digits: string[]) => { + const code = digits.join(""); + if (!SIX_DIGITS_REGEX.test(code) || isLoading) { + return; + } + + setIsLoading(true); + resetError(); + try { + const res = await requestVerifyResetCode(email, code); + if (res.success) { + setVerifiedCode(code); + goToStep(Step.PASSWORD); + } else { + setError(res.msg || "Invalid verification code"); + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password || !confirmPassword) { + setError("Please fill in all fields"); + return; + } + if (password.length < PASSWORD_MIN_LENGTH) { + setError(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + setIsLoading(true); + resetError(); + try { + const res = await requestResetPasswordConfirm( + email, + verifiedCode || codeValue, + password, + ); + if (res.success) { + router.push(paths.home); + } else { + setError(res.msg || "Failed to reset password"); + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handleResend = async (resetTimer: () => void) => { + if (!email || isResending) { + return; + } + + setIsResending(true); + resetError(); + try { + const res = await requestForgotPassword(email); + if (res.success) { + resetTimer(); + } else { + setError(res.msg || "Failed to resend code"); + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsResending(false); + } + }; + + const renderEmailStep = () => ( +
+ + +
+ + Reset Password + + + Please enter your email address you used when you first registered. + +
+ +
+ { + setEmail(e.target.value); + resetError(); + }} + fullWidth + requiredSymbol + error={error ?? undefined} + /> + +
+
+ ); + + const renderCodeStep = () => ( +
+ + +
+
+ + Check your email + + + Enter the 6-digit code sent to {email || "username@email.com"}. + + +
+ { + setCodeDigits(digits); + resetError(); + }} + onComplete={handleVerifyCode} + disabled={isLoading} + isError={!!error} + /> + {error && ( + + {error} + + )} +
+ + + {({ timeDisplay, isExpired, resetTimer }) => ( +
+ + Didn't get the code? + + + + {timeDisplay} + +
+ )} +
+
+
+
+ ); + + const renderPasswordStep = () => ( +
+
+ + Change Password + + + Time for a fresh, secure password + +
+ +
+
+ { + setPassword(e.target.value); + resetError(); + }} + fullWidth + requiredSymbol + helpText={ + error + ? undefined + : "Password must be 8-16 characters and must include numbers." + } + SideComponent={ + + } + /> + { + setConfirmPassword(e.target.value); + resetError(); + }} + fullWidth + requiredSymbol + SideComponent={ + + } + /> +
+ + {error && ( + + {error} + + )} + +
+ +
+
+
+ ); + + const bodyClassName = + step === Step.PASSWORD ? styles.bodyPassword : styles.bodyDefault; + + return ( +
+
+ +
+
+ +
+ {step === Step.EMAIL && renderEmailStep()} + {step === Step.CODE && renderCodeStep()} + {step === Step.PASSWORD && renderPasswordStep()} +
+
+ ); +} diff --git a/apps/customer_dashboard/src/components/sign_in_form/sign_in_form.tsx b/apps/customer_dashboard/src/components/sign_in_form/sign_in_form.tsx index 9d169ba82..f9b45d6ec 100644 --- a/apps/customer_dashboard/src/components/sign_in_form/sign_in_form.tsx +++ b/apps/customer_dashboard/src/components/sign_in_form/sign_in_form.tsx @@ -8,8 +8,7 @@ import { Checkbox } from "@oko-wallet/oko-common-ui/checkbox"; import { AccountForm } from "@oko-wallet-ct-dashboard/ui"; import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; import { Typography } from "@oko-wallet/oko-common-ui/typography"; -import { InfoModal } from "../info_modal/info_modal"; - +import Link from "next/link"; import styles from "./sign_in_form.module.scss"; import { useSignInForm } from "./use_sign_in_form"; import { GET_STARTED_URL } from "@oko-wallet-ct-dashboard/constants"; @@ -83,21 +82,11 @@ export const SignInForm: React.FC = () => { - ( - - )} - /> + + + Forgot password? + +
> { + return errorHandle<{ message: string }>(() => + fetch(`${CUSTOMER_V1_ENDPOINT}/customer/auth/forgot-password`, { + method: "POST", + body: JSON.stringify({ email }), + headers: { "Content-Type": "application/json" }, + }), + ); +} + +export async function requestVerifyResetCode( + email: string, + code: string, +): Promise> { + return errorHandle<{ isValid: boolean }>(() => + fetch(`${CUSTOMER_V1_ENDPOINT}/customer/auth/verify-reset-code`, { + method: "POST", + body: JSON.stringify({ email, code }), + headers: { "Content-Type": "application/json" }, + }), + ); +} + +export async function requestResetPasswordConfirm( + email: string, + code: string, + newPassword: string, +): Promise> { + return errorHandle<{ message: string }>(() => + fetch(`${CUSTOMER_V1_ENDPOINT}/customer/auth/reset-password-confirm`, { + method: "POST", + body: JSON.stringify({ email, code, newPassword }), + headers: { "Content-Type": "application/json" }, + }), + ); +} diff --git a/apps/email_template_2/src/app/reset_pw_code/page.tsx b/apps/email_template_2/src/app/reset_pw_code/page.tsx new file mode 100644 index 000000000..62736b22c --- /dev/null +++ b/apps/email_template_2/src/app/reset_pw_code/page.tsx @@ -0,0 +1,165 @@ +import { type CSSProperties } from "react"; + +import { EmailLayout } from "@oko-wallet-email-template-2/components/EmailLayout"; +import { EmailHeader } from "@oko-wallet-email-template-2/components/EmailHeader"; +import { EmailCard } from "@oko-wallet-email-template-2/components/EmailCard"; +import { EmailText } from "@oko-wallet-email-template-2/components/EmailText"; +import { EmailCode } from "@oko-wallet-email-template-2/components/EmailCode"; + +const containerStyle: CSSProperties = { padding: "2px" }; +const bodyWrapperStyle: CSSProperties = { width: "100%" }; +const fullWidthTableStyle: CSSProperties = { borderSpacing: "0" }; +const outerTdStyle: CSSProperties = { paddingTop: "32px" }; +const contentTableStyle: CSSProperties = { + width: "360px", + maxWidth: "100%", + borderSpacing: "0", +}; +const spacer32Style: CSSProperties = { + height: "32px", + lineHeight: "32px", + fontSize: "0", +}; +const footerTableStyle: CSSProperties = { + width: "360px", + maxWidth: "100%", + borderSpacing: "0", + height: "26px", +}; +const spacer33Style: CSSProperties = { + height: "33.72px", + lineHeight: "33.72px", + fontSize: "0", +}; +const logoStyle: CSSProperties = { + display: "block", + width: "64px", + height: "25px", +}; +const cardPadding = "48px 32px"; + +export default function ResetPwCodePage() { + return ( + +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Enter this code in your Oko Dashboard to reset your + password. +
+ The code is valid for{" "} + {"${email_verification_expiration_minutes}"} minutes + for your security. +
+
+   +
+ + + Your 6-digit code +
+ for changing your password + + } + /> +
+
+   +
+ + If you didn't make this request, you can safely + delete this email. + +
+   +
+ + + + + + +
+ Oko Team +
+
+   +
+ Gray Oko logo +
+
+
+
+
+ ); +} diff --git a/apps/email_template_2/src/components/EmailCode.tsx b/apps/email_template_2/src/components/EmailCode.tsx index ba7c4b6f1..08ac9558c 100644 --- a/apps/email_template_2/src/components/EmailCode.tsx +++ b/apps/email_template_2/src/components/EmailCode.tsx @@ -1,7 +1,8 @@ -import { type CSSProperties, type FC } from "react"; +import { type CSSProperties, type FC, type ReactNode } from "react"; interface EmailCodeProps { code: string; + title?: ReactNode; } const outerTableStyle: CSSProperties = { @@ -49,7 +50,9 @@ const keyIconStyle: CSSProperties = { height: "24px", }; -export const EmailCode: FC = ({ code }) => { +export const EmailCode: FC = ({ code, title }) => { + const displayTitle = title ?? "Your 6-digit code"; + return ( = ({ code }) => { diff --git a/backend/ct_dashboard_api/src/email/password_reset.ts b/backend/ct_dashboard_api/src/email/password_reset.ts new file mode 100644 index 000000000..543911fd1 --- /dev/null +++ b/backend/ct_dashboard_api/src/email/password_reset.ts @@ -0,0 +1,33 @@ +import type { EmailResult, SMTPConfig } from "@oko-wallet/oko-types/admin"; +import { sendEmail } from "@oko-wallet-admin-api/email"; + +export async function sendPasswordResetEmail( + email: string, + verification_code: string, + customer_label: string, + from_email: string, + email_verification_expiration_minutes: number, + smtp_config: SMTPConfig, +): Promise { + const subject = `Reset Password Verification Code for ${customer_label}`; + + const html = ` + Oko Email Template
-

Your 6-digit code

+

{displayTitle}

Oko password reset code header

Enter this code in your Oko Dashboard to reset your password.
The code is valid for ${email_verification_expiration_minutes} minutes for your security.

 

Your 6-digit code
for changing your password

 

${verification_code}

 
 

If you didn't make this request, you can safely delete this email.

 

Oko Team

 
Gray Oko logo
+ `; + + console.info( + "Sending password reset email, email: %s, content len: %s", + email, + html.length, + ); + + return sendEmail( + { + from: from_email, + to: email, + subject, + html, + }, + smtp_config, + ); +} diff --git a/backend/ct_dashboard_api/src/email/send.ts b/backend/ct_dashboard_api/src/email/send.ts index b4cef2506..fa81caa2f 100644 --- a/backend/ct_dashboard_api/src/email/send.ts +++ b/backend/ct_dashboard_api/src/email/send.ts @@ -58,7 +58,7 @@ export async function sendEmailVerificationCode( return { success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }; } diff --git a/backend/ct_dashboard_api/src/routes/customer_auth.test.ts b/backend/ct_dashboard_api/src/routes/customer_auth.test.ts index 714206d39..f0621d96e 100644 --- a/backend/ct_dashboard_api/src/routes/customer_auth.test.ts +++ b/backend/ct_dashboard_api/src/routes/customer_auth.test.ts @@ -176,12 +176,12 @@ describe("Customer Auth API Integration Tests", () => { expect(response.status).toBe(404); expect(response.body.success).toBe(false); expect(response.body.code).toBe("CUSTOMER_ACCOUNT_NOT_FOUND"); - expect(response.body.msg).toBe("Customer account not found"); + expect(response.body.msg).toBe("Account not found"); }); }); /* - + describe('POST /customer_dashboard/v1/customer/auth/verify-login', () => { it('should return error for missing email or verification code', async () => { const response = await request(app) @@ -189,13 +189,13 @@ describe("Customer Auth API Integration Tests", () => { .send({ email: TEST_EMAIL, }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('CUSTOMER_ACCOUNT_NOT_FOUND'); expect(response.body.msg).toBe('email and verification_code are required'); }); - + it('should return error for invalid verification code format', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/verify-login') @@ -203,13 +203,13 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, verification_code: '12345', // only 5 digits }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_VERIFICATION_CODE'); expect(response.body.msg).toBe('Verification code must be 6 digits'); }); - + it('should return error for non-numeric verification code', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/verify-login') @@ -217,13 +217,13 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, verification_code: 'abc123', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_VERIFICATION_CODE'); expect(response.body.msg).toBe('Verification code must be 6 digits'); }); - + it('should return error for wrong verification code', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/verify-login') @@ -231,13 +231,13 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, verification_code: '123456', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); // Error code depends on implementation }); }); - + describe('POST /customer_dashboard/v1/customer/auth/signin', () => { beforeEach(async () => { // Set email as verified for signin tests @@ -251,7 +251,7 @@ describe("Customer Auth API Integration Tests", () => { client.release(); } }); - + it('should sign in successfully with valid credentials', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/signin') @@ -259,26 +259,26 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, password: '0000', // KNOWN_HASH_FROM_0000 corresponds to password '0000' }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); expect(response.body.data.token).toBeDefined(); }); - + it('should return error for missing email or password', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/signin') .send({ email: TEST_EMAIL, }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_EMAIL_OR_PASSWORD'); expect(response.body.msg).toBe('email and password are required'); }); - + it('should return error for invalid email format', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/signin') @@ -286,13 +286,13 @@ describe("Customer Auth API Integration Tests", () => { email: 'invalid-email', password: '0000', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_EMAIL_OR_PASSWORD'); expect(response.body.msg).toBe('Invalid email format'); }); - + it('should return error for wrong password', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/signin') @@ -300,12 +300,12 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, password: 'wrong-password', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_EMAIL_OR_PASSWORD'); }); - + it('should return error for non-existent email', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/signin') @@ -313,13 +313,13 @@ describe("Customer Auth API Integration Tests", () => { email: 'nonexistent@example.com', password: '0000', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('CUSTOMER_ACCOUNT_NOT_FOUND'); }); }); - + describe('POST /customer_dashboard/v1/customer/auth/change-password', () => { it('should change password successfully', async () => { const response = await request(app) @@ -328,25 +328,25 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, new_password: 'newpassword123', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeDefined(); }); - + it('should return error for missing email or new_password', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/change-password') .send({ email: TEST_EMAIL, }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('CUSTOMER_ACCOUNT_NOT_FOUND'); expect(response.body.msg).toBe('email and new_password are required'); }); - + it('should return error for invalid email format', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/change-password') @@ -354,13 +354,13 @@ describe("Customer Auth API Integration Tests", () => { email: 'invalid-email', new_password: 'newpassword123', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_EMAIL_OR_PASSWORD'); expect(response.body.msg).toBe('Invalid email format'); }); - + it('should return error for weak password', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/change-password') @@ -368,13 +368,13 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, new_password: '123', // less than 8 characters }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('INVALID_EMAIL_OR_PASSWORD'); expect(response.body.msg).toBe('Password must be at least 8 characters long'); }); - + it('should return error for non-existent email', async () => { const response = await request(app) .post('/customer_dashboard/v1/customer/auth/change-password') @@ -382,13 +382,13 @@ describe("Customer Auth API Integration Tests", () => { email: 'nonexistent@example.com', new_password: 'newpassword123', }); - + expect(response.status).toBe(200); expect(response.body.success).toBe(false); expect(response.body.code).toBe('CUSTOMER_ACCOUNT_NOT_FOUND'); }); }); - + describe('Customer Auth Flow Integration', () => { it('should complete full auth flow: send code -> verify -> change password', async () => { // Step 1: Send verification code @@ -397,19 +397,19 @@ describe("Customer Auth API Integration Tests", () => { .send({ email: TEST_EMAIL, }); - + expect(sendCodeResponse.status).toBe(200); expect(sendCodeResponse.body.success).toBe(true); - + // Note: In real implementation, we would need to: // 1. Get the actual verification code from database or mock email service // 2. Use that code in verify-login // 3. Then use the returned token to change password - + // For now, we test the flow structure expect(sendCodeResponse.body.data).toBeDefined(); }); - + it('should handle signin flow for verified users', async () => { // Set user as verified const client = await pool.connect(); @@ -421,7 +421,7 @@ describe("Customer Auth API Integration Tests", () => { } finally { client.release(); } - + // Sign in const signinResponse = await request(app) .post('/customer_dashboard/v1/customer/auth/signin') @@ -429,15 +429,15 @@ describe("Customer Auth API Integration Tests", () => { email: TEST_EMAIL, password: '0000', }); - + expect(signinResponse.status).toBe(200); expect(signinResponse.body.success).toBe(true); expect(signinResponse.body.data.token).toBeDefined(); - + // Token should be valid for 1 hour as per requirements expect(signinResponse.body.data.token).toMatch(/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/); }); }); - + */ }); diff --git a/backend/ct_dashboard_api/src/routes/customer_auth.ts b/backend/ct_dashboard_api/src/routes/customer_auth.ts index d96eadb87..d2a29a18c 100644 --- a/backend/ct_dashboard_api/src/routes/customer_auth.ts +++ b/backend/ct_dashboard_api/src/routes/customer_auth.ts @@ -29,6 +29,12 @@ import { SendVerificationSuccessResponseSchema, SignInRequestSchema, VerifyAndLoginRequestSchema, + ForgotPasswordRequestSchema, + ForgotPasswordSuccessResponseSchema, + VerifyResetCodeRequestSchema, + VerifyResetCodeSuccessResponseSchema, + ResetPasswordConfirmRequestSchema, + ResetPasswordConfirmSuccessResponseSchema, } from "@oko-wallet/oko-api-openapi/ct_dashboard"; import { generateCustomerToken } from "@oko-wallet-ctd-api/auth"; @@ -42,8 +48,419 @@ import { customerJwtMiddleware, type CustomerAuthenticatedRequest, } from "@oko-wallet-ctd-api/middleware/auth"; +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"; export function setCustomerAuthRoutes(router: Router) { + registry.registerPath({ + method: "post", + path: "/customer_dashboard/v1/customer/auth/forgot-password", + tags: ["Customer Dashboard"], + summary: "Request password reset", + description: "Sends a password reset verification code to the email", + security: [], + request: { + body: { + required: true, + content: { + "application/json": { + schema: ForgotPasswordRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Reset code sent successfully", + content: { + "application/json": { + schema: ForgotPasswordSuccessResponseSchema, + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 429: { + description: "Too many requests", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: "Server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + }, + }); + // Forgot Password: Send Code + router.post( + "/customer/auth/forgot-password", + rateLimitMiddleware({ windowSeconds: 10 * 60, maxRequests: 20 }), + async (req, res: Response>) => { + try { + const state = req.app.locals as any; + const { email } = req.body; + + if (!email) { + res.status(400).json({ + success: false, + code: "CUSTOMER_ACCOUNT_NOT_FOUND", + msg: "email is required", + }); + return; + } + + if (!EMAIL_REGEX.test(email)) { + res.status(400).json({ + success: false, + code: "INVALID_EMAIL_OR_PASSWORD", + msg: "Invalid email format", + }); + return; + } + + // Check if account exists + const customerAccountResult = await getCTDUserWithCustomerByEmail( + state.db, + email, + ); + + if (!customerAccountResult.success) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: "Failed to check account", + }); + return; + } + + if (customerAccountResult.data === null) { + res.status(404).json({ + success: false, + code: "CUSTOMER_ACCOUNT_NOT_FOUND", + msg: "Account not found", + }); + return; + } + + const verificationCode = generateVerificationCode(); + const expiresAt = new Date(); + expiresAt.setMinutes( + expiresAt.getMinutes() + state.email_verification_expiration_minutes, + ); + + const createRes = await createEmailVerification(state.db, { + email, + verification_code: verificationCode, + expires_at: expiresAt, + }); + + if (!createRes.success) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: "Failed to create verification", + }); + return; + } + + const emailRes = await sendPasswordResetEmail( + email, + verificationCode, + customerAccountResult.data.label, + state.from_email, + state.email_verification_expiration_minutes, + { + smtp_host: state.smtp_host, + smtp_port: state.smtp_port, + smtp_user: state.smtp_user, + smtp_pass: state.smtp_pass, + }, + ); + + if (!emailRes.success) { + res.status(500).json({ + success: false, + code: "FAILED_TO_SEND_EMAIL", + msg: "Failed to send email", + }); + return; + } + + res.status(200).json({ + success: true, + data: { + message: "Reset code sent successfully", + }, + }); + } catch (error) { + console.error("Forgot password route error:", error); + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: "Internal server error", + }); + } + }, + ); + + registry.registerPath({ + method: "post", + path: "/customer_dashboard/v1/customer/auth/verify-reset-code", + tags: ["Customer Dashboard"], + summary: "Verify reset code", + description: "Verifies the password reset code without consuming it", + security: [], + request: { + body: { + required: true, + content: { + "application/json": { + schema: VerifyResetCodeRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Code verified successfully", + content: { + "application/json": { + schema: VerifyResetCodeSuccessResponseSchema, + }, + }, + }, + 400: { + description: "Invalid code", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: "Server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + }, + }); + // Forgot Password: Verify Code + router.post( + "/customer/auth/verify-reset-code", + rateLimitMiddleware({ windowSeconds: 10 * 60, maxRequests: 20 }), + async (req, res: Response>) => { + try { + const state = req.app.locals as any; + const { email, code } = req.body; + + if (!email || !code) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: "Email and code are required", + }); + 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) { + res.status(400).json({ + success: false, + code: "INVALID_VERIFICATION_CODE", + msg: "Invalid or expired verification code", + }); + return; + } + + res.status(200).json({ + success: true, + data: { isValid: true }, + }); + } catch (error) { + console.error("Verify reset code error:", error); + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: "Internal server error", + }); + } + }, + ); + + registry.registerPath({ + method: "post", + path: "/customer_dashboard/v1/customer/auth/reset-password-confirm", + tags: ["Customer Dashboard"], + summary: "Confirm password reset", + description: "Resets the password using a valid verification code", + security: [], + request: { + body: { + required: true, + content: { + "application/json": { + schema: ResetPasswordConfirmRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Password reset successfully", + content: { + "application/json": { + schema: ResetPasswordConfirmSuccessResponseSchema, + }, + }, + }, + 400: { + description: "Invalid request or code", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: "Server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + }, + }); + // Forgot Password: Confirm Reset + router.post( + "/customer/auth/reset-password-confirm", + async (req, res: Response>) => { + try { + const state = req.app.locals as any; + const { email, code, newPassword } = req.body; + + if (!email || !code || !newPassword) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: "Missing fields", + }); + return; + } + + if (newPassword.length < CHANGED_PASSWORD_MIN_LENGTH) { + res.status(400).json({ + success: false, + code: "INVALID_EMAIL_OR_PASSWORD", + msg: "Password too short", + }); + return; + } + + const verificationResult = await verifyEmailCode(state.db, { + email, + verification_code: code, + }); + + if (!verificationResult.success) { + res.status(400).json({ + success: false, + code: "INVALID_VERIFICATION_CODE", + msg: "Invalid or expired verification code", + }); + return; + } + + const customerAccountResult = + await getCTDUserWithCustomerAndPasswordHashByEmail(state.db, email); + + if (!customerAccountResult.success || !customerAccountResult.data) { + res.status(404).json({ + success: false, + code: "CUSTOMER_ACCOUNT_NOT_FOUND", + msg: "User not found", + }); + return; + } + + const hashedNewPassword = await hashPassword(newPassword); + const updateResult = await updateCustomerDashboardUserPassword( + state.db, + { + user_id: customerAccountResult.data.user.user_id, + password_hash: hashedNewPassword, + }, + ); + + if (!updateResult.success) { + res.status(500).json({ + success: false, + code: "FAILED_TO_UPDATE_PASSWORD", + msg: "Failed to update password", + }); + return; + } + + res.status(200).json({ + success: true, + data: { message: "Password reset successfully" }, + }); + } catch (error) { + console.error("Reset password confirm error:", error); + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: "Internal server error", + }); + } + }, + ); + registry.registerPath({ method: "post", path: "/customer_dashboard/v1/customer/auth/send-code", @@ -79,7 +496,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -182,7 +599,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -244,7 +661,7 @@ export function setCustomerAuthRoutes(router: Router) { res.status(404).json({ success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }); return; } @@ -372,7 +789,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -435,7 +852,7 @@ export function setCustomerAuthRoutes(router: Router) { res.status(404).json({ success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }); return; } @@ -562,7 +979,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -630,7 +1047,7 @@ export function setCustomerAuthRoutes(router: Router) { res.status(404).json({ success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }); return; } diff --git a/backend/openapi/src/ct_dashboard/customer_auth.ts b/backend/openapi/src/ct_dashboard/customer_auth.ts index 59ee35917..b37458237 100644 --- a/backend/openapi/src/ct_dashboard/customer_auth.ts +++ b/backend/openapi/src/ct_dashboard/customer_auth.ts @@ -150,3 +150,96 @@ export const ChangePasswordSuccessResponseSchema = registry.register( data: ChangePasswordResponseSchema, }), ); + +export const ForgotPasswordRequestSchema = registry.register( + "CustomerDashboardForgotPasswordRequest", + z.object({ + email: z.email().openapi({ + description: "Email address to send password reset code", + }), + }), +); + +const ForgotPasswordResponseSchema = registry.register( + "CustomerDashboardForgotPasswordResponse", + z.object({ + message: z.string().openapi({ + description: "Success message indicating reset code was sent", + }), + }), +); + +export const ForgotPasswordSuccessResponseSchema = registry.register( + "CustomerDashboardForgotPasswordSuccessResponse", + z.object({ + success: z.literal(true).openapi({ + description: "Indicates the request succeeded", + }), + data: ForgotPasswordResponseSchema, + }), +); + +export const VerifyResetCodeRequestSchema = registry.register( + "CustomerDashboardVerifyResetCodeRequest", + z.object({ + email: z.email().openapi({ + description: "Email address associated with the reset code", + }), + code: z.string().length(6).openapi({ + description: "The 6-digit verification code", + }), + }), +); + +const VerifyResetCodeResponseSchema = registry.register( + "CustomerDashboardVerifyResetCodeResponse", + z.object({ + isValid: z.boolean().openapi({ + description: "Whether the code is valid", + }), + }), +); + +export const VerifyResetCodeSuccessResponseSchema = registry.register( + "CustomerDashboardVerifyResetCodeSuccessResponse", + z.object({ + success: z.literal(true).openapi({ + description: "Indicates the request succeeded", + }), + data: VerifyResetCodeResponseSchema, + }), +); + +export const ResetPasswordConfirmRequestSchema = registry.register( + "CustomerDashboardResetPasswordConfirmRequest", + z.object({ + email: z.email().openapi({ + description: "Email address to reset password for", + }), + code: z.string().length(6).openapi({ + description: "The 6-digit verification code", + }), + newPassword: z.string().min(8).openapi({ + description: "The new password", + }), + }), +); + +const ResetPasswordConfirmResponseSchema = registry.register( + "CustomerDashboardResetPasswordConfirmResponse", + z.object({ + message: z.string().openapi({ + description: "Success message", + }), + }), +); + +export const ResetPasswordConfirmSuccessResponseSchema = registry.register( + "CustomerDashboardResetPasswordConfirmSuccessResponse", + z.object({ + success: z.literal(true).openapi({ + description: "Indicates the request succeeded", + }), + data: ResetPasswordConfirmResponseSchema, + }), +); diff --git a/backend/user_dashboard_api/src/email/send.ts b/backend/user_dashboard_api/src/email/send.ts index 105376652..35f077948 100644 --- a/backend/user_dashboard_api/src/email/send.ts +++ b/backend/user_dashboard_api/src/email/send.ts @@ -58,7 +58,7 @@ export async function sendEmailVerificationCode( return { success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }; } diff --git a/backend/user_dashboard_api/src/routes/user_auth.test.ts b/backend/user_dashboard_api/src/routes/user_auth.test.ts index fd60a54a6..2b1985914 100644 --- a/backend/user_dashboard_api/src/routes/user_auth.test.ts +++ b/backend/user_dashboard_api/src/routes/user_auth.test.ts @@ -175,7 +175,7 @@ describe("Customer Auth API Integration Tests", () => { expect(response.status).toBe(404); expect(response.body.success).toBe(false); expect(response.body.code).toBe("CUSTOMER_ACCOUNT_NOT_FOUND"); - expect(response.body.msg).toBe("Customer account not found"); + expect(response.body.msg).toBe("Account not found"); }); }); }); diff --git a/backend/user_dashboard_api/src/routes/user_auth.ts b/backend/user_dashboard_api/src/routes/user_auth.ts index 8c4e9eadb..b29de8db9 100644 --- a/backend/user_dashboard_api/src/routes/user_auth.ts +++ b/backend/user_dashboard_api/src/routes/user_auth.ts @@ -79,7 +79,7 @@ export function setUserAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -182,7 +182,7 @@ export function setUserAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -244,7 +244,7 @@ export function setUserAuthRoutes(router: Router) { res.status(404).json({ success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }); return; } @@ -372,7 +372,7 @@ export function setUserAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -435,7 +435,7 @@ export function setUserAuthRoutes(router: Router) { res.status(404).json({ success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }); return; } @@ -562,7 +562,7 @@ export function setUserAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -630,7 +630,7 @@ export function setUserAuthRoutes(router: Router) { res.status(404).json({ success: false, code: "CUSTOMER_ACCOUNT_NOT_FOUND", - msg: "Customer account not found", + msg: "Account not found", }); return; } diff --git a/ui/oko_common_ui/src/otp_input/otp_input.module.scss b/ui/oko_common_ui/src/otp_input/otp_input.module.scss index e38aa3748..d32c46ee1 100644 --- a/ui/oko_common_ui/src/otp_input/otp_input.module.scss +++ b/ui/oko_common_ui/src/otp_input/otp_input.module.scss @@ -6,6 +6,7 @@ } .otpInput { + box-sizing: border-box; width: 64px; height: 64px; border: 1px solid var(--border-primary); @@ -22,11 +23,11 @@ font-weight: 500; line-height: var(--font-line-height-display-lg); text-align: center; - color: var(--text-brand-tertiary); + color: var(--text-brand-primary); letter-spacing: -0.96px; outline: none; - transition: border-color 0.2s ease; + transition: border-color 0.2s ease, box-shadow 0.2s ease; &::placeholder { color: var(--text-placeholder-subtle); @@ -39,21 +40,24 @@ letter-spacing: -0.96px; } - &:focus { - border-color: var(--Color-Blue-400, #377bfb); + &:focus, + &.focused { + border: 2px solid var(--border-brand); + box-shadow: + var(--shadow-xs), + 0 0 0 2px var(--bg-primary), + 0 0 0 4px #9e77ed; } &.filled { border: 2px solid var(--border-brand); - } - - &.focused { - border-color: var(--Color-Blue-400, #377bfb); + color: var(--text-brand-primary); } &.error { border: 2px solid var(--border-error, #f04438); color: var(--text-error-primary); + box-shadow: var(--shadow-xs); } &:disabled { @@ -62,3 +66,21 @@ cursor: not-allowed; } } + +@media (max-width: 480px) { + .otpContainer { + gap: 6px; + } + + .otpInput { + width: 40px; + height: 40px; + font-size: var(--font-size-display-xs); + line-height: var(--font-line-height-display-xs); + + &::placeholder { + font-size: var(--font-size-display-xs); + line-height: var(--font-line-height-display-xs); + } + } +}