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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import {
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 { DownloadsRoutePage } from '../features/downloads/downloads-page.tsx';
import {
ForgotPasswordRoutePage,
SignInRoutePage,
RegisterRoutePage,
ResetPasswordRoutePage,
VerifyEmailRoutePage,
} from '../features/auth/auth-route-pages.tsx';
import { LandingRoutePage } from '../features/landing/landing-page.tsx';
Expand Down Expand Up @@ -74,6 +77,9 @@ const logicalRoots = new Set([
'sign-in',
'register',
'verify-email',
'downloads',
'forgot-password',
'reset-password',
]);

function canonicalPathname(pathname: string): string | undefined {
Expand Down Expand Up @@ -126,13 +132,19 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] {
errorElement: <RouteErrorPage />,
hydrateFallbackElement: <div aria-hidden="true" />,
children: [
{
path: 'downloads',
element: <DownloadsRoutePage />,
},
{
element: <AuthenticationGate publicRoute />,
children: [
{ index: true, element: <LandingRoutePage /> },
{ path: 'sign-in', element: <SignInRoutePage /> },
{ path: 'register', element: <RegisterRoutePage /> },
{ path: 'verify-email', element: <VerifyEmailRoutePage /> },
{ path: 'forgot-password', element: <ForgotPasswordRoutePage /> },
{ path: 'reset-password', element: <ResetPasswordRoutePage /> },
],
},
{
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/features/auth/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IamRegistrationCommand, 'schemaVersion'>,
Expand All @@ -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<PasswordResetRequestResultV1>;
readonly completePasswordReset: (input: {
readonly token: string;
readonly newPassword: string;
}) => Promise<PasswordResetCompleteResultV1>;
readonly recoverWebSession: () => Promise<{ readonly accepted: true } | AuthFailureV1>;
readonly loadBootstrap: () => Promise<
{ readonly accepted: true; readonly value: IamBootstrapValue } | AuthFailureV1
Expand All @@ -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<unknown> {
try {
const response = await fetcher(url, {
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/auth/auth-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type WebAuthenticationStateV1,
} from './auth-session.ts';

const PUBLIC_AUTH_ROUTES_V1 = new Set(['sign-in', 'register', 'verify-email']);
const PUBLIC_AUTH_ROUTES_V1 = new Set(['sign-in', 'register', 'verify-email', 'downloads']);
const PUBLIC_LOCALES_V1 = new Set(['en', 'vi-VN']);

function isPublicPathV1(pathname: string): boolean {
Expand Down
39 changes: 35 additions & 4 deletions apps/web/src/features/auth/auth-route-pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[
'VITE_DATABREEZE_API_BASE_URL'
];
return createAuthApiV1({
baseUrl: typeof configuredBaseUrl === 'string' ? configuredBaseUrl : '',
});
}

async function establishProductSession(
Expand Down Expand Up @@ -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;
}}
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<ForgotPasswordPage locale={locale} onRequested={(input) => 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 (
<ResetPasswordPage
locale={locale}
token={token}
onReset={(input) => api.completePasswordReset(input)}
/>
);
}
131 changes: 131 additions & 0 deletions apps/web/src/features/auth/forgot-password-page.tsx
Original file line number Diff line number Diff line change
@@ -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> | 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 (
<AuthPageShell
locale={locale}
title={isVi ? 'Quên mật khẩu?' : 'Forgot your password?'}
description={
isVi
? 'Nhập email để nhận liên kết đặt lại mật khẩu an toàn.'
: 'Enter your email and we’ll send a secure password reset link.'
}
footer={
<p>
{isVi ? 'Nhớ lại mật khẩu?' : 'Remember your password?'}{' '}
<a href={`/${locale}/sign-in`} className="auth-card__link">
{isVi ? 'Đăng nhập' : 'Sign in'}
</a>
</p>
}
>
{submitted ? (
<div className="auth-form__success" role="status" aria-live="polite">
<div className="auth-form__success-mark" aria-hidden="true">
</div>
<div>
<strong>{isVi ? 'Hãy kiểm tra hộp thư' : 'Check your inbox'}</strong>
<p>
{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.'}
</p>
</div>
</div>
) : (
<>
<form className="auth-form" onSubmit={(event) => void submit(event)}>
<label>
<span className="auth-form__label-text">Email</span>
<input
autoComplete="email"
name="email"
type="email"
placeholder={isVi ? 'ten@congty.com' : 'name@company.com'}
required
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
</label>
<p className="auth-form__hint">
{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.'}
</p>
<button className="auth-form__submit" disabled={pending} type="submit">
{pending ? (
<span className="auth-form__button-content">
<span className="auth-form__spinner" aria-hidden="true" />
<span>{isVi ? 'Đang gửi…' : 'Sending…'}</span>
</span>
) : isVi ? (
'Gửi liên kết đặt lại'
) : (
'Send reset link'
)}
</button>
</form>
{error ? (
<div className="auth-form__error" role="alert">
<span className="auth-form__error-icon" aria-hidden="true">
!
</span>
<span>
{isVi
? 'Không thể gửi yêu cầu. Hãy thử lại.'
: 'Could not send the request. Try again.'}
</span>
</div>
) : null}
</>
)}
</AuthPageShell>
);
}
Loading
Loading