From fd7d5f847602a0fe11ad2e202cb1ca7b9672793a Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Mon, 29 Dec 2025 16:37:44 +0900 Subject: [PATCH 1/6] o --- backend/ct_dashboard_api/src/email/send.ts | 2 +- .../src/routes/customer_auth.test.ts | 78 +++++++++---------- .../src/routes/customer_auth.ts | 14 ++-- backend/user_dashboard_api/src/email/send.ts | 2 +- .../src/routes/user_auth.test.ts | 2 +- .../src/routes/user_auth.ts | 14 ++-- 6 files changed, 56 insertions(+), 56 deletions(-) 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..ad2aec487 100644 --- a/backend/ct_dashboard_api/src/routes/customer_auth.ts +++ b/backend/ct_dashboard_api/src/routes/customer_auth.ts @@ -79,7 +79,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -182,7 +182,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -244,7 +244,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 +372,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -435,7 +435,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 +562,7 @@ export function setCustomerAuthRoutes(router: Router) { }, }, 404: { - description: "Customer account not found", + description: "Account not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -630,7 +630,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/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; } From 3fa8e901ec8ac35d57a7f6075777cc6ba542e058 Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Wed, 31 Dec 2025 14:11:05 +0900 Subject: [PATCH 2/6] customer_dashboard: Add forgot password flow --- .../users/forgot_password/page.module.scss | 61 +++ .../src/app/users/forgot_password/page.tsx | 221 +++++++++ .../components/sign_in_form/sign_in_form.tsx | 23 +- apps/customer_dashboard/src/fetch/users.ts | 39 ++ .../src/email/password_reset.ts | 69 +++ .../src/routes/customer_auth.ts | 435 ++++++++++++++++++ .../openapi/src/ct_dashboard/customer_auth.ts | 93 ++++ 7 files changed, 924 insertions(+), 17 deletions(-) create mode 100644 apps/customer_dashboard/src/app/users/forgot_password/page.module.scss create mode 100644 apps/customer_dashboard/src/app/users/forgot_password/page.tsx create mode 100644 backend/ct_dashboard_api/src/email/password_reset.ts 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..bc2968f7c --- /dev/null +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.module.scss @@ -0,0 +1,61 @@ +.wrapper { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: var(--bg-primary); + padding: 24px; +} + +.container { + width: 100%; + max-width: 480px; + background: var(--bg-surface); + padding: 32px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + text-align: center; +} + +.infoText { + color: var(--fg-secondary); + font-size: 14px; + line-height: 1.5; + text-align: left; +} + +.linkButton { + background: none; + border: none; + color: var(--fg-tertiary); + cursor: pointer; + font-size: 14px; + text-decoration: underline; + padding: 0; + + &:hover { + color: var(--fg-primary); + } +} + +.successContainer { + display: flex; + flex-direction: column; + align-items: center; +} + +.primaryButton { + width: 100%; + padding: 12px; + background-color: var(--bg-brand-solid); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } +} 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..6e12a8d70 --- /dev/null +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Input } from "@oko-wallet/oko-common-ui/input"; +import { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; +import { EyeIcon } from "@oko-wallet/oko-common-ui/icons/eye"; +import { EyeOffIcon } from "@oko-wallet/oko-common-ui/icons/eye_off"; +import { AccountForm } from "@oko-wallet-ct-dashboard/ui"; +import { + requestForgotPassword, + requestVerifyResetCode, + requestResetPasswordConfirm, +} from "@oko-wallet-ct-dashboard/fetch/users"; +import styles from "./page.module.scss"; + +enum Step { + EMAIL = 0, + CODE = 1, + PASSWORD = 2, + SUCCESS = 3, +} + +export default function ForgotPasswordPage() { + const router = useRouter(); + const [step, setStep] = useState(Step.EMAIL); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + try { + const res = await requestForgotPassword(email); + if (res.success) { + setStep(Step.CODE); + } else { + setError(res.msg || "Failed to send code"); + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handleCodeSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + try { + const res = await requestVerifyResetCode(email, code); + if (res.success) { + setStep(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("Passwords do not match"); + return; + } + setIsLoading(true); + setError(null); + try { + const res = await requestResetPasswordConfirm(email, code, password); + if (res.success) { + setStep(Step.SUCCESS); + } else { + setError(res.msg || "Failed to reset password"); + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + const renderEmailStep = () => ( + + setEmail(e.target.value)} + fullWidth + requiredSymbol + /> + + {error && {error}} + + ); + + const renderCodeStep = () => ( + +
+ We sent a 6-digit code to {email}. +
+ + setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + fullWidth + requiredSymbol + /> + + {error && {error}} + + +
+ ); + + const renderPasswordStep = () => ( + + setPassword(e.target.value)} + fullWidth + requiredSymbol + SideComponent={ + + } + /> + + setConfirmPassword(e.target.value)} + fullWidth + requiredSymbol + SideComponent={ + + } + /> + + {error && {error}} + + ); + + const renderSuccessStep = () => ( +
+ + Password Reset Successful + + + + You can now sign in with your new password. + + + +
+ ); + + return ( +
+
+ + Forgot Password + + + + {step === Step.EMAIL && renderEmailStep()} + {step === Step.CODE && renderCodeStep()} + {step === Step.PASSWORD && renderPasswordStep()} + {step === Step.SUCCESS && renderSuccessStep()} +
+
+ ); +} 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/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..6851be08a --- /dev/null +++ b/backend/ct_dashboard_api/src/email/password_reset.ts @@ -0,0 +1,69 @@ +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}`; + + // temp + const html = ` + + + + + + Reset Password + + + +
+
Reset Your Password
+
+ You have requested to reset your password for ${customer_label}. + Please use the verification code below to proceed. +
+ +
${verification_code}
+ +
+ This code will expire in ${email_verification_expiration_minutes} minutes. +
+ +
+ If you did not request a password reset, please ignore this email. + Your account remains secure. +
+
+ + + `; + + 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/routes/customer_auth.ts b/backend/ct_dashboard_api/src/routes/customer_auth.ts index ad2aec487..a95a15c39 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,437 @@ import { customerJwtMiddleware, type CustomerAuthenticatedRequest, } from "@oko-wallet-ctd-api/middleware/auth"; +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", + 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; + } + + // Rate limit check + const activeVerificationResult = await getLatestPendingVerification( + state.db, + email, + ); + if (activeVerificationResult.success && activeVerificationResult.data) { + const diffTime = Math.abs( + new Date().getTime() - + activeVerificationResult.data.created_at.getTime(), + ); + const diffSeconds = Math.ceil(diffTime / 1000); + if (diffSeconds < 60) { + res.status(429).json({ + success: false, + code: "VERIFICATION_CODE_ALREADY_SENT", + msg: `Please wait ${60 - diffSeconds} seconds before requesting a new code`, + }); + 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", + 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", 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, + }), +); From 7ee5953b8b6b45b133766cbe2aaf96dce039ac30 Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Wed, 31 Dec 2025 14:52:56 +0900 Subject: [PATCH 3/6] ct_dashboard_api: Add rate limiting to customer auth routes --- .../src/routes/customer_auth.ts | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/backend/ct_dashboard_api/src/routes/customer_auth.ts b/backend/ct_dashboard_api/src/routes/customer_auth.ts index a95a15c39..d2a29a18c 100644 --- a/backend/ct_dashboard_api/src/routes/customer_auth.ts +++ b/backend/ct_dashboard_api/src/routes/customer_auth.ts @@ -48,6 +48,7 @@ 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 { @@ -119,6 +120,7 @@ export function setCustomerAuthRoutes(router: Router) { // 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; @@ -166,27 +168,6 @@ export function setCustomerAuthRoutes(router: Router) { return; } - // Rate limit check - const activeVerificationResult = await getLatestPendingVerification( - state.db, - email, - ); - if (activeVerificationResult.success && activeVerificationResult.data) { - const diffTime = Math.abs( - new Date().getTime() - - activeVerificationResult.data.created_at.getTime(), - ); - const diffSeconds = Math.ceil(diffTime / 1000); - if (diffSeconds < 60) { - res.status(429).json({ - success: false, - code: "VERIFICATION_CODE_ALREADY_SENT", - msg: `Please wait ${60 - diffSeconds} seconds before requesting a new code`, - }); - return; - } - } - const verificationCode = generateVerificationCode(); const expiresAt = new Date(); expiresAt.setMinutes( @@ -295,6 +276,7 @@ export function setCustomerAuthRoutes(router: Router) { // 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; From 19b8ea540f0c8c73be09deffe54e207d5b937600 Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Fri, 2 Jan 2026 17:39:40 +0900 Subject: [PATCH 4/6] email_template_2: Add reset code email template --- .../src/app/reset_pw_code/page.tsx | 165 ++++++++++++++++++ .../src/components/EmailCode.tsx | 9 +- .../src/email/password_reset.ts | 38 +--- 3 files changed, 172 insertions(+), 40 deletions(-) create mode 100644 apps/email_template_2/src/app/reset_pw_code/page.tsx 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 index 6851be08a..543911fd1 100644 --- a/backend/ct_dashboard_api/src/email/password_reset.ts +++ b/backend/ct_dashboard_api/src/email/password_reset.ts @@ -11,44 +11,8 @@ export async function sendPasswordResetEmail( ): Promise { const subject = `Reset Password Verification Code for ${customer_label}`; - // temp const html = ` - - - - - - Reset Password - - - -
-
Reset Your Password
-
- You have requested to reset your password for ${customer_label}. - Please use the verification code below to proceed. -
- -
${verification_code}
- -
- This code will expire in ${email_verification_expiration_minutes} minutes. -
- -
- If you did not request a password reset, please ignore this email. - Your account remains secure. -
-
- - + 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( From bf36401277c74711a36999f58123950a36676d89 Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Fri, 2 Jan 2026 20:36:07 +0900 Subject: [PATCH 5/6] o --- .../users/forgot_password/page.module.scss | 226 ++++++++-- .../src/app/users/forgot_password/page.tsx | 412 +++++++++++++----- .../src/otp_input/otp_input.module.scss | 38 +- 3 files changed, 520 insertions(+), 156 deletions(-) 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 index bc2968f7c..6d642e781 100644 --- a/apps/customer_dashboard/src/app/users/forgot_password/page.module.scss +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.module.scss @@ -1,61 +1,219 @@ .wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.header { + height: 64px; display: flex; - justify-content: center; align-items: center; - min-height: 100vh; - background-color: var(--bg-primary); - padding: 24px; + 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; } -.container { +.backButton { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: none; + color: var(--fg-tertiary); + cursor: pointer; + margin-bottom: 24px; +} + +.titleBlock { width: 100%; - max-width: 480px; - background: var(--bg-surface); - padding: 32px; - border-radius: 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + 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; } -.infoText { - color: var(--fg-secondary); - font-size: 14px; - line-height: 1.5; +.codeOtp { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.codeError { text-align: left; } -.linkButton { +.resendRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 20px; +} + +.resendButton { background: none; border: none; - color: var(--fg-tertiary); - cursor: pointer; - font-size: 14px; - text-decoration: underline; padding: 0; + cursor: pointer; - &:hover { - color: var(--fg-primary); + span { + text-decoration: underline; + } + + &:disabled { + cursor: not-allowed; } } -.successContainer { +.eyeButton { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; display: flex; - flex-direction: column; align-items: center; + justify-content: center; + color: var(--fg-tertiary); } -.primaryButton { +.passwordForm { width: 100%; - padding: 12px; - background-color: var(--bg-brand-solid); - color: white; - border: none; - border-radius: 8px; - font-weight: 600; - cursor: pointer; - transition: opacity 0.2s; + 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; + } +} - &:hover { - opacity: 0.9; +@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 index 6e12a8d70..def1c27ab 100644 --- a/apps/customer_dashboard/src/app/users/forgot_password/page.tsx +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx @@ -1,48 +1,78 @@ "use client"; -import React, { useState } from "react"; +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 { Spacing } from "@oko-wallet/oko-common-ui/spacing"; +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 { AccountForm } from "@oko-wallet-ct-dashboard/ui"; + 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, - SUCCESS = 3, } +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 [code, setCode] = 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); - setError(null); + resetError(); try { const res = await requestForgotPassword(email); if (res.success) { - setStep(Step.CODE); + setCodeDigits(EMPTY_CODE); + setVerifiedCode(""); + goToStep(Step.CODE); } else { setError(res.msg || "Failed to send code"); } @@ -53,14 +83,19 @@ export default function ForgotPasswordPage() { } }; - const handleCodeSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleVerifyCode = async (digits: string[]) => { + const code = digits.join(""); + if (!SIX_DIGITS_REGEX.test(code) || isLoading) { + return; + } + setIsLoading(true); - setError(null); + resetError(); try { const res = await requestVerifyResetCode(email, code); if (res.success) { - setStep(Step.PASSWORD); + setVerifiedCode(code); + goToStep(Step.PASSWORD); } else { setError(res.msg || "Invalid verification code"); } @@ -73,16 +108,29 @@ export default function ForgotPasswordPage() { 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); - setError(null); + resetError(); try { - const res = await requestResetPasswordConfirm(email, code, password); + const res = await requestResetPasswordConfirm( + email, + verifiedCode || codeValue, + password, + ); if (res.success) { - setStep(Step.SUCCESS); + router.push(paths.home); } else { setError(res.msg || "Failed to reset password"); } @@ -93,129 +141,265 @@ export default function ForgotPasswordPage() { } }; + 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 = () => ( - - setEmail(e.target.value)} - fullWidth - requiredSymbol - /> - - {error && {error}} - +
+ + +
+ + 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 = () => ( - -
- We sent a 6-digit code to {email}. -
- - setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} - fullWidth - requiredSymbol - /> - - {error && {error}} - +
- + +
+
+ + 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 = () => ( - - setPassword(e.target.value)} - fullWidth - requiredSymbol - SideComponent={ - - } - /> - - setConfirmPassword(e.target.value)} - fullWidth - requiredSymbol - SideComponent={ - - } - /> - - {error && {error}} - - ); +
+
+ + Change Password + + + Time for a fresh, secure password + +
- const renderSuccessStep = () => ( -
- - Password Reset Successful - - - - You can now sign in with your new 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 (
-
- - Forgot Password - - +
+ +
+
+
{step === Step.EMAIL && renderEmailStep()} {step === Step.CODE && renderCodeStep()} {step === Step.PASSWORD && renderPasswordStep()} - {step === Step.SUCCESS && renderSuccessStep()} -
+
); } 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); + } + } +} From 9aa7683bb083808f8083e5dad6fb73b9c080de88 Mon Sep 17 00:00:00 2001 From: Elden Park Date: Sun, 4 Jan 2026 23:01:04 -0800 Subject: [PATCH 6/6] o --- apps/customer_dashboard/src/app/users/reset_password/page.tsx | 2 +- ui/oko_common_ui/src/otp_input/index.ts | 1 - ui/oko_common_ui/src/otp_input/otp_input.tsx | 2 ++ 3 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 ui/oko_common_ui/src/otp_input/index.ts diff --git a/apps/customer_dashboard/src/app/users/reset_password/page.tsx b/apps/customer_dashboard/src/app/users/reset_password/page.tsx index df1ec99a5..d8b4c0d2c 100644 --- a/apps/customer_dashboard/src/app/users/reset_password/page.tsx +++ b/apps/customer_dashboard/src/app/users/reset_password/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import cn from "classnames"; import { useRouter } from "next/navigation"; import { Typography } from "@oko-wallet/oko-common-ui/typography"; diff --git a/ui/oko_common_ui/src/otp_input/index.ts b/ui/oko_common_ui/src/otp_input/index.ts deleted file mode 100644 index 874d25a97..000000000 --- a/ui/oko_common_ui/src/otp_input/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OtpInput } from "./otp_input"; diff --git a/ui/oko_common_ui/src/otp_input/otp_input.tsx b/ui/oko_common_ui/src/otp_input/otp_input.tsx index 118d823e4..38c6020e8 100644 --- a/ui/oko_common_ui/src/otp_input/otp_input.tsx +++ b/ui/oko_common_ui/src/otp_input/otp_input.tsx @@ -4,6 +4,7 @@ import React, { type KeyboardEvent, type ClipboardEvent, } from "react"; + import styles from "./otp_input.module.scss"; interface OtpInputProps { @@ -18,6 +19,7 @@ interface OtpInputProps { function isSingleDigit(value: string): boolean { return /^\d$/.test(value); } + function isComplete(digits: string[], length: number): boolean { return ( digits.length === length &&