From 4f6268f7608dbf785d017ced013eb7f43c5f598a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 16 Aug 2026 09:13:49 +0700 Subject: [PATCH 1/3] feat: redesign OTP verification email --- ...ses-email-verification-delivery.adapter.ts | 29 +-- .../iam/adapter/aws-ses-v2-sender.adapter.ts | 5 +- .../email-verification-message-content.ts | 169 ++++++++++++++++++ ...mtp-email-verification-delivery.adapter.ts | 47 ++--- ...mail-verification-delivery.adapter.test.ts | 26 ++- ...mail-verification-delivery.adapter.test.ts | 2 + ...mail-verification-delivery.adapter.test.ts | 21 +++ 7 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 services/api/src/features/iam/adapter/email-verification-message-content.ts diff --git a/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts b/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts index 8ccea418..e5216ca1 100644 --- a/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts +++ b/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts @@ -1,4 +1,8 @@ import type { EmailVerificationDeliveryPortV1 } from '../application/email-verification-repository.port.js'; +import { + createEmailVerificationMessageContentV1, + type EmailVerificationMessageContentV1, +} from './email-verification-message-content.js'; const EMAIL_ADDRESS_PATTERN_V1 = /^[^\s@]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/u; @@ -7,6 +11,7 @@ export interface SesEmailMessageV1 { readonly toAddress: string; readonly subject: string; readonly textBody: string; + readonly htmlBody: string; } /** Provider-neutral transactional email boundary implemented by the AWS SES runtime adapter. */ @@ -25,26 +30,7 @@ function validAddress(value: string): boolean { ); } -function content( - locale: string, - code: string, -): Pick | undefined { - if (locale === 'vi-VN') { - return Object.freeze({ - subject: 'Mã xác minh DataBreeze', - textBody: `Mã xác minh DataBreeze của bạn là ${code}. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.`, - }); - } - if (locale === 'en') { - return Object.freeze({ - subject: 'Your DataBreeze verification code', - textBody: `Your DataBreeze verification code is ${code}. It expires in 10 minutes. If you did not request this code, ignore this email.`, - }); - } - return undefined; -} - -/** IAM-022: minimal localized OTP delivery with no account or correlation metadata. */ +/** IAM-022: localized OTP delivery with no account or correlation metadata. */ export class AwsSesEmailVerificationDeliveryAdapter implements EmailVerificationDeliveryPortV1 { public constructor( private readonly sender: SesEmailSenderPortV1, @@ -61,7 +47,8 @@ export class AwsSesEmailVerificationDeliveryAdapter implements EmailVerification readonly locale: string; readonly correlationId?: string; }): Promise { - const messageContent = content(input.locale, input.code); + const messageContent: EmailVerificationMessageContentV1 | undefined = + createEmailVerificationMessageContentV1(input.locale, input.code); if (!validAddress(input.email) || !/^\d{6}$/u.test(input.code) || !messageContent) { throw new Error('IAM_EMAIL_DELIVERY_INPUT_INVALID'); } diff --git a/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts b/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts index ed1320cc..7815ed3a 100644 --- a/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts +++ b/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts @@ -21,7 +21,10 @@ export class AwsSesV2SenderAdapter implements SesEmailSenderPortV1 { Content: { Simple: { Subject: { Data: message.subject, Charset: 'UTF-8' }, - Body: { Text: { Data: message.textBody, Charset: 'UTF-8' } }, + Body: { + Text: { Data: message.textBody, Charset: 'UTF-8' }, + Html: { Data: message.htmlBody, Charset: 'UTF-8' }, + }, }, }, }), diff --git a/services/api/src/features/iam/adapter/email-verification-message-content.ts b/services/api/src/features/iam/adapter/email-verification-message-content.ts new file mode 100644 index 00000000..6da45312 --- /dev/null +++ b/services/api/src/features/iam/adapter/email-verification-message-content.ts @@ -0,0 +1,169 @@ +export interface EmailVerificationMessageContentV1 { + readonly subject: string; + readonly textBody: string; + readonly htmlBody: string; +} + +const HTML_ESCAPES: Readonly> = Object.freeze({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}); + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/gu, (character) => HTML_ESCAPES[character] ?? character); +} + +function renderHtml(input: { + readonly language: 'en' | 'vi'; + readonly preheader: string; + readonly eyebrow: string; + readonly title: string; + readonly introduction: string; + readonly codeLabel: string; + readonly expires: string; + readonly security: string; + readonly notRequested: string; + readonly footer: string; + readonly code: string; +}): string { + const code = escapeHtml(input.code); + const language = input.language; + return ` + + + + + + ${escapeHtml(input.title)} + + +
+ ${escapeHtml(input.preheader)} +
+ + + + +
+ + + + + + + + + + +
+ + + + + +
+ DB + + DataBreeze +
+
+ + + + + + + +
 
+

+ ${escapeHtml(input.eyebrow)} +

+

+ ${escapeHtml(input.title)} +

+

+ ${escapeHtml(input.introduction)} +

+ + + + +
+

+ ${escapeHtml(input.codeLabel)} +

+

+ ${code} +

+
+

+ ${escapeHtml(input.expires)} +

+ + + + +
+ ${escapeHtml(input.security)} +
+

+ ${escapeHtml(input.notRequested)} +

+
+
+ ${escapeHtml(input.footer)}
+ © DataBreeze +
+
+ +`; +} + +/** IAM-022: localized OTP content with a safe text fallback and branded HTML presentation. */ +export function createEmailVerificationMessageContentV1( + locale: string, + code: string, +): EmailVerificationMessageContentV1 | undefined { + if (locale === 'vi-VN') { + return Object.freeze({ + subject: 'Mã xác minh DataBreeze', + textBody: `Mã xác minh DataBreeze của bạn là ${code}. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.`, + htmlBody: renderHtml({ + language: 'vi', + preheader: 'Mã xác minh DataBreeze của bạn có hiệu lực trong 10 phút.', + eyebrow: 'XÁC MINH EMAIL', + title: 'Xác minh email của bạn', + introduction: 'Sử dụng mã bên dưới để hoàn tất việc tạo tài khoản DataBreeze.', + codeLabel: 'Mã xác minh', + expires: 'Mã này hết hạn sau 10 phút.', + security: 'Để bảo vệ tài khoản, không chia sẻ mã này với bất kỳ ai.', + notRequested: 'Nếu bạn không yêu cầu mã này, bạn có thể bỏ qua email này.', + footer: 'Không gian làm việc an toàn cho doanh nghiệp', + code, + }), + }); + } + if (locale === 'en') { + return Object.freeze({ + subject: 'Your DataBreeze verification code', + textBody: `Your DataBreeze verification code is ${code}. It expires in 10 minutes. If you did not request this code, ignore this email.`, + htmlBody: renderHtml({ + language: 'en', + preheader: 'Your DataBreeze verification code is valid for 10 minutes.', + eyebrow: 'EMAIL VERIFICATION', + title: 'Verify your email', + introduction: 'Use the code below to finish creating your DataBreeze account.', + codeLabel: 'Verification code', + expires: 'This code expires in 10 minutes.', + security: 'For your security, never share this code with anyone.', + notRequested: 'If you did not request this code, you can safely ignore this email.', + footer: 'Secure workspaces for better decisions', + code, + }), + }); + } + return undefined; +} diff --git a/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts b/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts index ca8ea7de..208390ea 100644 --- a/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts +++ b/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts @@ -1,6 +1,10 @@ import { createConnection, type Socket } from 'node:net'; import type { EmailVerificationDeliveryPortV1 } from '../application/email-verification-repository.port.js'; +import { + createEmailVerificationMessageContentV1, + type EmailVerificationMessageContentV1, +} from './email-verification-message-content.js'; const EMAIL_ADDRESS_PATTERN_V1 = /^[^\s@]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/u; const MAX_SMTP_MESSAGE_BYTES_V1 = 16 * 1024; @@ -10,6 +14,7 @@ export interface SmtpMessageV1 { readonly toAddresses: readonly [string]; readonly subject: string; readonly textBody: string; + readonly htmlBody: string; } export interface SmtpSenderPortV1 { @@ -35,23 +40,10 @@ export function validSmtpAddressV1(value: string): boolean { ); } -function content( - locale: string, - code: string, -): Pick | undefined { - if (locale === 'vi-VN') { - return Object.freeze({ - subject: 'Mã xác minh DataBreeze', - textBody: `Mã xác minh DataBreeze của bạn là ${code}. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.`, - }); - } - if (locale === 'en') { - return Object.freeze({ - subject: 'Your DataBreeze verification code', - textBody: `Your DataBreeze verification code is ${code}. It expires in 10 minutes. If you did not request this code, ignore this email.`, - }); - } - return undefined; +const SMTP_BODY_LIMIT_BYTES_V1 = 12 * 1024; + +function normalizeSmtpBody(value: string): string { + return value.replace(/\r?\n/gu, '\r\n').replace(/^\./gmu, '..'); } export function renderSmtpMessageV1(message: SmtpMessageV1): string { @@ -65,21 +57,33 @@ export function renderSmtpMessageV1(message: SmtpMessageV1): string { message.subject.includes('\r') || message.subject.includes('\n') || message.textBody.length < 1 || - message.textBody.length > 4_096 + message.textBody.length > 4_096 || + message.htmlBody.length < 1 || + message.htmlBody.length > SMTP_BODY_LIMIT_BYTES_V1 ) { throw new Error('IAM_LOCAL_SMTP_MESSAGE_INVALID'); } const subject = Buffer.from(message.subject, 'utf8').toString('base64'); - const normalizedBody = message.textBody.replace(/\r?\n/gu, '\r\n').replace(/^\./gmu, '..'); + const boundary = '=_DataBreeze_Email_Verification_v1'; const payload = [ `From: <${message.fromAddress}>`, `To: <${message.toAddresses[0]}>`, `Subject: =?UTF-8?B?${subject}?=`, 'MIME-Version: 1.0', + `Content-Type: multipart/alternative; boundary="${boundary}"`, + '', + `--${boundary}`, 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: 8bit', '', - normalizedBody, + normalizeSmtpBody(message.textBody), + `--${boundary}`, + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + '', + normalizeSmtpBody(message.htmlBody), + `--${boundary}--`, + '', ].join('\r\n'); if (Buffer.byteLength(payload, 'utf8') > MAX_SMTP_MESSAGE_BYTES_V1) { throw new Error('IAM_LOCAL_SMTP_MESSAGE_INVALID'); @@ -202,7 +206,8 @@ export class MailpitSmtpEmailVerificationDeliveryAdapter readonly locale: string; readonly correlationId?: string; }): Promise { - const messageContent = content(input.locale, input.code); + const messageContent: EmailVerificationMessageContentV1 | undefined = + createEmailVerificationMessageContentV1(input.locale, input.code); if (!validSmtpAddressV1(input.email) || !/^\d{6}$/u.test(input.code) || !messageContent) { throw new Error('IAM_LOCAL_EMAIL_INPUT_INVALID'); } diff --git a/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts b/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts index 77a94bf9..6332b150 100644 --- a/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts +++ b/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts @@ -23,15 +23,16 @@ void test('[IAM-022] SES delivery emits one bounded localized transactional mess correlationId: 'internal-correlation-id', }); - assert.deepEqual(messages, [ - { - fromAddress: 'verify@databreeze.example', - toAddress: 'customer@example.com', - subject: 'Mã xác minh DataBreeze', - textBody: - 'Mã xác minh DataBreeze của bạn là 042917. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.', - }, - ]); + assert.equal(messages.length, 1); + assert.equal(messages[0]?.fromAddress, 'verify@databreeze.example'); + assert.equal(messages[0]?.toAddress, 'customer@example.com'); + assert.equal(messages[0]?.subject, 'Mã xác minh DataBreeze'); + assert.equal( + messages[0]?.textBody, + 'Mã xác minh DataBreeze của bạn là 042917. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.', + ); + assert.match(messages[0]?.htmlBody ?? '', /042917/u); + assert.match(messages[0]?.htmlBody ?? '', /Xác minh email của bạn/u); assert.equal(JSON.stringify(messages).includes('internal-correlation-id'), false); }); @@ -49,6 +50,8 @@ void test('[IAM-022] SES delivery supports the complete English locale', async ( messages[0]?.textBody, 'Your DataBreeze verification code is 123456. It expires in 10 minutes. If you did not request this code, ignore this email.', ); + assert.match(messages[0]?.htmlBody ?? '', /123456/u); + assert.match(messages[0]?.htmlBody ?? '', /Verify your email/u); }); void test('[IAM-022] SES delivery fails closed with stable content-safe errors', async () => { @@ -99,6 +102,7 @@ void test('[IAM-022] AWS SES v2 sender maps only the bounded simple-message fiel toAddress: 'customer@example.com', subject: 'Your DataBreeze verification code', textBody: 'Your DataBreeze verification code is 123456.', + htmlBody: '

Your DataBreeze verification code is 123456.

', }); assert.equal(commands.length, 1); @@ -114,6 +118,10 @@ void test('[IAM-022] AWS SES v2 sender maps only the bounded simple-message fiel Data: 'Your DataBreeze verification code is 123456.', Charset: 'UTF-8', }, + Html: { + Data: '

Your DataBreeze verification code is 123456.

', + Charset: 'UTF-8', + }, }, }, }, diff --git a/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts b/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts index 4fe2ea21..ee1025a9 100644 --- a/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts +++ b/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts @@ -48,6 +48,8 @@ void test('[IAM-022] Gmail delivery uses the authenticated Gmail identity as the assert.deepEqual(messages[0]?.toAddresses, ['owner@example.com']); assert.equal(messages[0]?.subject, 'Your DataBreeze verification code'); assert.match(messages[0]?.textBody ?? '', /042917/u); + assert.match(messages[0]?.htmlBody ?? '', /042917/u); + assert.match(messages[0]?.htmlBody ?? '', /Verify your email/u); }); void test('[IAM-022] Gmail delivery hides SMTP provider details', async () => { diff --git a/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts b/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts index c3774354..983ca4bc 100644 --- a/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts +++ b/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts @@ -6,6 +6,7 @@ import test from 'node:test'; import { MailpitSmtpEmailVerificationDeliveryAdapter, NodeLoopbackSmtpSenderAdapter, + renderSmtpMessageV1, type SmtpMessageV1, } from '../../../src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.js'; @@ -29,9 +30,29 @@ void test('[IAM-022] Mailpit delivery emits one bounded localized SMTP message w assert.equal(messages[0]?.subject, 'Mã xác minh DataBreeze'); assert.match(messages[0]?.textBody ?? '', /042917/u); assert.match(messages[0]?.textBody ?? '', /10 phút/u); + assert.match(messages[0]?.htmlBody ?? '', /042917/u); + assert.match(messages[0]?.htmlBody ?? '', /XÁC MINH EMAIL/u); assert.equal(JSON.stringify(messages).includes('must-not-enter-email'), false); }); +void test('[IAM-022] SMTP rendering keeps the text fallback and branded HTML alternative', async () => { + const message: SmtpMessageV1 = { + fromAddress: 'verify@databreeze.local', + toAddresses: ['owner@example.com'], + subject: 'Your DataBreeze verification code', + textBody: 'Your DataBreeze verification code is 042917.', + htmlBody: '\n

042917

', + }; + + const payload = renderSmtpMessageV1(message); + + assert.match(payload, /multipart\/alternative/u); + assert.match(payload, /Content-Type: text\/plain; charset=UTF-8/u); + assert.match(payload, /Content-Type: text\/html; charset=UTF-8/u); + assert.match(payload, /Your DataBreeze verification code is 042917\./u); + assert.match(payload, //u); +}); + void test('[IAM-022] Mailpit delivery rejects header injection and hides provider details', async () => { assert.throws( () => From c7653e0150c24306f0bcd0a02c7faec8ebbbc260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Sun, 16 Aug 2026 09:48:15 +0700 Subject: [PATCH 2/3] feat: add password recovery flow --- apps/web/src/app/router.tsx | 6 + apps/web/src/features/auth/auth-api.ts | 45 +++++ .../src/features/auth/auth-route-pages.tsx | 39 +++- .../features/auth/forgot-password-page.tsx | 131 +++++++++++++ .../src/features/auth/reset-password-page.tsx | 179 ++++++++++++++++++ apps/web/src/features/auth/sign-in-page.tsx | 21 +- apps/web/src/styles.css | 80 ++++++++ apps/web/test/auth-api.test.ts | 71 ++++++- apps/web/test/auth-pages.test.tsx | 69 +++++++ apps/web/test/auth-routing.test.tsx | 12 ++ docs/operations/iam-recovery-2026-08-03.md | 8 +- infrastructure/aws/environments/alpha/main.tf | 1 + .../alpha/tests/alpha-plan.tofutest.hcl | 10 +- .../aws/environments/production/main.tf | 1 + .../tests/production-plan.tofutest.hcl | 16 +- .../aws/environments/staging/main.tf | 1 + .../staging/tests/staging-plan.tofutest.hcl | 16 +- infrastructure/aws/modules/compute/main.tf | 7 +- .../aws/modules/compute/variables.tf | 14 ++ infrastructure/aws/modules/security/main.tf | 8 + .../aws/modules/security/outputs.tf | 4 + .../tests/platform-key-policy.tofutest.hcl | 19 ++ infrastructure/lightsail/.env.example | 1 + infrastructure/lightsail/README.md | 19 +- infrastructure/lightsail/compose.pilot.yml | 2 + infrastructure/local/.env.example | 1 + infrastructure/local/README.md | 6 +- infrastructure/local/compose.yml | 1 + services/api/openapi/v1.json | 5 +- ...-ses-password-recovery-delivery.adapter.ts | 52 +++++ .../password-recovery-delivery.utils.ts | 79 ++++++++ .../password-recovery-message-content.ts | 125 ++++++++++++ ...smtp-password-recovery-delivery.adapter.ts | 57 ++++++ .../features/iam/api/recovery.controller.ts | 2 +- .../api/src/features/iam/api/recovery.dto.ts | 9 +- .../application/recovery-repository.port.ts | 3 + .../iam/application/recovery.service.ts | 15 +- .../platform/local-database.composition.ts | 10 + .../production-database.composition.ts | 26 ++- ...password-recovery-delivery.adapter.test.ts | 177 +++++++++++++++++ .../test/features/iam/recovery-http.test.ts | 9 +- .../local-database-composition.test.ts | 4 + .../production-database-composition.test.ts | 8 +- 43 files changed, 1330 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/features/auth/forgot-password-page.tsx create mode 100644 apps/web/src/features/auth/reset-password-page.tsx create mode 100644 services/api/src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.ts create mode 100644 services/api/src/features/iam/adapter/password-recovery-delivery.utils.ts create mode 100644 services/api/src/features/iam/adapter/password-recovery-message-content.ts create mode 100644 services/api/src/features/iam/adapter/smtp-password-recovery-delivery.adapter.ts create mode 100644 services/api/test/features/iam/password-recovery-delivery.adapter.test.ts diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 5eb86b94..ffedff76 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -21,8 +21,10 @@ import { DashboardPage } from '../features/dashboards/dashboard-page.tsx'; import { AnalysisRoutePage } from '../features/analysis/analysis-route-page.tsx'; import { DataRoutePage } from '../features/data/data-route-page.tsx'; import { + ForgotPasswordRoutePage, SignInRoutePage, RegisterRoutePage, + ResetPasswordRoutePage, VerifyEmailRoutePage, } from '../features/auth/auth-route-pages.tsx'; import { LandingRoutePage } from '../features/landing/landing-page.tsx'; @@ -74,6 +76,8 @@ const logicalRoots = new Set([ 'sign-in', 'register', 'verify-email', + 'forgot-password', + 'reset-password', ]); function canonicalPathname(pathname: string): string | undefined { @@ -133,6 +137,8 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] { { path: 'sign-in', element: }, { path: 'register', element: }, { path: 'verify-email', element: }, + { path: 'forgot-password', element: }, + { path: 'reset-password', element: }, ], }, { diff --git a/apps/web/src/features/auth/auth-api.ts b/apps/web/src/features/auth/auth-api.ts index 565664c5..02bb5f58 100644 --- a/apps/web/src/features/auth/auth-api.ts +++ b/apps/web/src/features/auth/auth-api.ts @@ -29,6 +29,14 @@ export interface AuthApiOptionsV1 { export type AuthFailureV1 = { readonly accepted: false; readonly code: 'AUTH_FAILED' }; +export type PasswordResetRequestResultV1 = { readonly accepted: true } | AuthFailureV1; +export type PasswordResetCompleteResultV1 = + | { + readonly accepted: true; + readonly value: { readonly userId: string; readonly mfaReenrollmentRequired: true }; + } + | AuthFailureV1; + export interface AuthApiV1 { readonly register: ( input: Omit, @@ -42,6 +50,14 @@ export interface AuthApiV1 { readonly email: string; readonly password: string; }) => Promise<{ readonly accepted: true; readonly value: IamAuthSession } | AuthFailureV1>; + readonly requestPasswordReset: (input: { + readonly email: string; + readonly locale: 'en' | 'vi-VN'; + }) => Promise; + readonly completePasswordReset: (input: { + readonly token: string; + readonly newPassword: string; + }) => Promise; readonly recoverWebSession: () => Promise<{ readonly accepted: true } | AuthFailureV1>; readonly loadBootstrap: () => Promise< { readonly accepted: true; readonly value: IamBootstrapValue } | AuthFailureV1 @@ -53,6 +69,25 @@ function failure(): AuthFailureV1 { return Object.freeze({ accepted: false, code: 'AUTH_FAILED' }); } +function passwordResetRequested(raw: unknown): raw is { readonly requested: true } { + return typeof raw === 'object' && raw !== null && 'requested' in raw && raw.requested === true; +} + +function passwordResetCompleted( + raw: unknown, +): raw is { readonly userId: string; readonly mfaReenrollmentRequired: true } { + return ( + typeof raw === 'object' && + raw !== null && + 'userId' in raw && + typeof raw.userId === 'string' && + raw.userId.length > 0 && + raw.userId.length <= 128 && + 'mfaReenrollmentRequired' in raw && + raw.mfaReenrollmentRequired === true + ); +} + async function request(fetcher: typeof fetch, url: string, body: unknown): Promise { try { const response = await fetcher(url, { @@ -114,6 +149,16 @@ export function createAuthApiV1(options: AuthApiOptionsV1 = {}): AuthApiV1 { ? Object.freeze({ accepted: true as const, value: parsed.value as IamAuthSession }) : failure(); }, + async requestPasswordReset(input: { readonly email: string; readonly locale: 'en' | 'vi-VN' }) { + const raw = await request(fetcher, `${baseUrl}/v1/auth/recovery`, input); + return passwordResetRequested(raw) ? Object.freeze({ accepted: true as const }) : failure(); + }, + async completePasswordReset(input: { readonly token: string; readonly newPassword: string }) { + const raw = await request(fetcher, `${baseUrl}/v1/auth/recovery/complete`, input); + return passwordResetCompleted(raw) + ? Object.freeze({ accepted: true as const, value: raw }) + : failure(); + }, async recoverWebSession() { clearAuthSessionV1(); if (currentCsrfTokenV1() === undefined) return failure(); diff --git a/apps/web/src/features/auth/auth-route-pages.tsx b/apps/web/src/features/auth/auth-route-pages.tsx index 3c02410e..581ea83e 100644 --- a/apps/web/src/features/auth/auth-route-pages.tsx +++ b/apps/web/src/features/auth/auth-route-pages.tsx @@ -9,11 +9,18 @@ import { rememberAuthSessionV1, } from './auth-session.ts'; import { RegisterPage } from './register-page.tsx'; +import { ForgotPasswordPage } from './forgot-password-page.tsx'; +import { ResetPasswordPage } from './reset-password-page.tsx'; import { SignInPage } from './sign-in-page.tsx'; import { VerifyEmailPage } from './verify-email-page.tsx'; function authApi() { - return createAuthApiV1({ baseUrl: import.meta.env['VITE_DATABREEZE_API_BASE_URL'] ?? '' }); + const configuredBaseUrl = (import.meta.env as Record)[ + 'VITE_DATABREEZE_API_BASE_URL' + ]; + return createAuthApiV1({ + baseUrl: typeof configuredBaseUrl === 'string' ? configuredBaseUrl : '', + }); } async function establishProductSession( @@ -41,7 +48,7 @@ export function SignInRoutePage() { if (result.accepted) { const established = await establishProductSession(api, result.value); if (!established.accepted) return established; - navigate(`/${locale}/data`, { replace: true }); + void navigate(`/${locale}/data`, { replace: true }); } return result; }} @@ -60,7 +67,7 @@ export function RegisterRoutePage() { onRegistered={async (input) => { const result = await api.register(input); if (result.accepted) - navigate(`/${locale}/verify-email`, { + void navigate(`/${locale}/verify-email`, { state: { challengeId: result.value.challengeId, email: input.email }, }); return result; @@ -105,10 +112,34 @@ export function VerifyEmailRoutePage() { if (result.accepted) { const established = await establishProductSession(api, result.value); if (!established.accepted) return established; - navigate(`/${locale}/data`, { replace: true }); + void navigate(`/${locale}/data`, { replace: true }); } return result; }} /> ); } + +export function ForgotPasswordRoutePage() { + const { locale: routeLocale } = useParams(); + const locale = normalizeRouteLocale(routeLocale); + const api = useMemo(authApi, []); + return ( + api.requestPasswordReset(input)} /> + ); +} + +export function ResetPasswordRoutePage() { + const { locale: routeLocale } = useParams(); + const locale = normalizeRouteLocale(routeLocale); + const location = useLocation(); + const api = useMemo(authApi, []); + const token = new URLSearchParams(location.search).get('token') ?? ''; + return ( + api.completePasswordReset(input)} + /> + ); +} diff --git a/apps/web/src/features/auth/forgot-password-page.tsx b/apps/web/src/features/auth/forgot-password-page.tsx new file mode 100644 index 00000000..bed11f34 --- /dev/null +++ b/apps/web/src/features/auth/forgot-password-page.tsx @@ -0,0 +1,131 @@ +import { useState, type FormEvent } from 'react'; + +import { AuthPageShell } from './auth-page-shell.tsx'; + +export interface PasswordResetRequestInputV1 { + readonly email: string; + readonly locale: 'en' | 'vi-VN'; +} + +type PasswordResetRequestResultV1 = { readonly accepted: boolean }; + +function rejected(result: PasswordResetRequestResultV1 | undefined): boolean { + return result?.accepted === false; +} + +export function ForgotPasswordPage({ + locale, + onRequested, +}: { + readonly locale: 'en' | 'vi-VN'; + readonly onRequested: ( + input: PasswordResetRequestInputV1, + ) => Promise | PasswordResetRequestResultV1; +}) { + const [email, setEmail] = useState(''); + const [pending, setPending] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(false); + const isVi = locale === 'vi-VN'; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (pending || email.trim().length < 3) { + setError(true); + return; + } + setPending(true); + setError(false); + try { + const result = await onRequested({ email: email.trim(), locale }); + if (rejected(result)) setError(true); + else setSubmitted(true); + } catch { + setError(true); + } finally { + setPending(false); + } + }; + + return ( + + {isVi ? 'Nhớ lại mật khẩu?' : 'Remember your password?'}{' '} + + {isVi ? 'Đăng nhập' : 'Sign in'} + +

+ } + > + {submitted ? ( +
+ +
+ {isVi ? 'Hãy kiểm tra hộp thư' : 'Check your inbox'} +

+ {isVi + ? 'Nếu email này thuộc DataBreeze, chúng tôi đã gửi liên kết đặt lại mật khẩu. Hãy kiểm tra cả thư mục spam.' + : 'If this email belongs to DataBreeze, we sent a password reset link. Check your spam folder too.'} +

+
+
+ ) : ( + <> +
void submit(event)}> + +

+ {isVi + ? 'Để bảo vệ tài khoản, chúng tôi luôn hiển thị cùng một thông báo.' + : 'For account privacy, we show the same message for every email address.'} +

+ +
+ {error ? ( +
+ + + {isVi + ? 'Không thể gửi yêu cầu. Hãy thử lại.' + : 'Could not send the request. Try again.'} + +
+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/src/features/auth/reset-password-page.tsx b/apps/web/src/features/auth/reset-password-page.tsx new file mode 100644 index 00000000..1ef6a707 --- /dev/null +++ b/apps/web/src/features/auth/reset-password-page.tsx @@ -0,0 +1,179 @@ +import { useState, type FormEvent } from 'react'; + +import { AuthPageShell } from './auth-page-shell.tsx'; + +export interface PasswordResetCompletionInputV1 { + readonly token: string; + readonly newPassword: string; +} + +type PasswordResetCompletionResultV1 = { readonly accepted: boolean }; + +function rejected(result: PasswordResetCompletionResultV1 | undefined): boolean { + return result?.accepted === false; +} + +export function ResetPasswordPage({ + locale, + token, + onReset, +}: { + readonly locale: 'en' | 'vi-VN'; + readonly token: string; + readonly onReset: ( + input: PasswordResetCompletionInputV1, + ) => Promise | PasswordResetCompletionResultV1; +}) { + const [password, setPassword] = useState(''); + const [confirmation, setConfirmation] = useState(''); + const [pending, setPending] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(false); + const isVi = locale === 'vi-VN'; + const invalidToken = token.trim().length === 0; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if ( + pending || + invalidToken || + password.length < 12 || + password.length > 128 || + password !== confirmation + ) { + setError(true); + return; + } + setPending(true); + setError(false); + try { + const result = await onReset({ token, newPassword: password }); + if (rejected(result)) setError(true); + else setSuccess(true); + } catch { + setError(true); + } finally { + setPending(false); + } + }; + + return ( + + {isVi ? 'Cần bắt đầu lại?' : 'Need to start over?'}{' '} + + {isVi ? 'Yêu cầu liên kết mới' : 'Request a new link'} + +

+ } + > + {invalidToken ? ( +
+ + + {isVi + ? 'Liên kết đặt lại không hợp lệ hoặc đã hết hạn.' + : 'This reset link is invalid or has expired.'} + +
+ ) : success ? ( +
+ +
+ {isVi ? 'Mật khẩu đã được cập nhật' : 'Password updated'} +

+ {isVi + ? 'Hãy đăng nhập lại bằng mật khẩu mới. Bạn có thể cần đăng ký lại MFA trước khi dùng các thao tác nhạy cảm.' + : 'Sign in again with your new password. You may need to re-enroll MFA before using sensitive actions.'} +

+ + {isVi ? 'Đi tới đăng nhập' : 'Go to sign in'} → + +
+
+ ) : ( + <> +
void submit(event)}> + + +

+ {isVi + ? 'Mật khẩu cần từ 12 đến 128 ký tự và hai ô phải giống nhau.' + : 'Use 12–128 characters, and make sure both fields match.'} +

+ +
+ {error ? ( +
+ + + {password !== confirmation + ? isVi + ? 'Hai mật khẩu chưa giống nhau.' + : 'The passwords do not match.' + : isVi + ? 'Không thể cập nhật mật khẩu. Liên kết có thể đã hết hạn.' + : 'Could not update the password. The link may have expired.'} + +
+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/src/features/auth/sign-in-page.tsx b/apps/web/src/features/auth/sign-in-page.tsx index bce18d42..a99b091d 100644 --- a/apps/web/src/features/auth/sign-in-page.tsx +++ b/apps/web/src/features/auth/sign-in-page.tsx @@ -1,6 +1,8 @@ import { useState, type FormEvent } from 'react'; import { AuthPageShell } from './auth-page-shell.tsx'; +type SignInActionResultV1 = { readonly accepted: boolean }; + export function SignInPage({ locale, onSignedIn, @@ -9,7 +11,7 @@ export function SignInPage({ readonly onSignedIn: (input: { readonly email: string; readonly password: string; - }) => Promise | unknown; + }) => Promise | SignInActionResultV1 | undefined; }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -23,14 +25,7 @@ export function SignInPage({ setError(false); try { const result = await onSignedIn({ email, password }); - if ( - typeof result === 'object' && - result !== null && - 'accepted' in result && - result.accepted === false - ) { - setError(true); - } + if (result?.accepted === false) setError(true); } catch { setError(true); } finally { @@ -69,9 +64,15 @@ export function SignInPage({ onChange={(event) => setEmail(event.currentTarget.value)} /> -