From ef0fe234b82f636927c99be7c31b2592b2ba328c Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sat, 22 Aug 2026 20:42:29 +0800 Subject: [PATCH] feat(web): auth screens (login/register/forgot/2FA) --- src/app/(auth)/forgot/page.tsx | 51 ++++++ src/app/(auth)/layout.tsx | 27 +++ src/app/(auth)/login/page.tsx | 54 ++++++ src/app/(auth)/register/page.tsx | 50 ++++++ src/features/auth/AuthForm.test.tsx | 143 ++++++++++++++++ src/features/auth/AuthForm.tsx | 254 ++++++++++++++++++++++++++++ src/features/auth/actions.ts | 75 ++++++++ src/features/auth/strings.ts | 46 +++++ 8 files changed, 700 insertions(+) create mode 100644 src/app/(auth)/forgot/page.tsx create mode 100644 src/app/(auth)/layout.tsx create mode 100644 src/app/(auth)/login/page.tsx create mode 100644 src/app/(auth)/register/page.tsx create mode 100644 src/features/auth/AuthForm.test.tsx create mode 100644 src/features/auth/AuthForm.tsx create mode 100644 src/features/auth/actions.ts create mode 100644 src/features/auth/strings.ts diff --git a/src/app/(auth)/forgot/page.tsx b/src/app/(auth)/forgot/page.tsx new file mode 100644 index 0000000..a839da1 --- /dev/null +++ b/src/app/(auth)/forgot/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AuthForm } from "@/features/auth/AuthForm"; +import { authStrings } from "@/features/auth/strings"; + +// Ports ios/Mercury/Features/Auth/ForgotPasswordView.swift: back chevron, +// MERCURY wordmark, explanatory subtitle (rendered by AuthForm), the email +// field, and the "Сэргээх холбоос авах" CTA. +export default function ForgotPasswordPage() { + const router = useRouter(); + + return ( + <> + + +
+ +

+ {authStrings.wordmark} +

+ +
+ + + +
+ + ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..1304e03 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,27 @@ +// Centers the auth flow in a phone-width column, matching +// ios/Mercury/DesignSystem/AdaptiveLayout.swift's `.phoneWidthColumn()` used by +// every Auth screen (WelcomeView, LoginView, RegisterView, ForgotPasswordView). +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..0278dc7 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AuthForm } from "@/features/auth/AuthForm"; +import { authStrings } from "@/features/auth/strings"; + +// Ports ios/Mercury/Features/Auth/LoginView.swift: back chevron, MERCURY +// wordmark, then the login form (email + password + forgot link + CTAs). +export default function LoginPage() { + const router = useRouter(); + + return ( + <> + + +
+ +

+ {authStrings.wordmark} +

+ +
+ + router.replace("/register")} + onNavigateForgot={() => router.push("/forgot")} + /> + +
+ + ); +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..c5f6429 --- /dev/null +++ b/src/app/(auth)/register/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AuthForm } from "@/features/auth/AuthForm"; +import { authStrings } from "@/features/auth/strings"; + +// Ports ios/Mercury/Features/Auth/RegisterView.swift: back chevron, MERCURY +// wordmark, then the registration form (email + password + confirm + CTAs). +export default function RegisterPage() { + const router = useRouter(); + + return ( + <> + + +
+ +

+ {authStrings.wordmark} +

+ +
+ + router.replace("/login")} /> + +
+ + ); +} diff --git a/src/features/auth/AuthForm.test.tsx b/src/features/auth/AuthForm.test.tsx new file mode 100644 index 0000000..71659c1 --- /dev/null +++ b/src/features/auth/AuthForm.test.tsx @@ -0,0 +1,143 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AuthForm } from "./AuthForm"; +import { authStrings } from "./strings"; + +// jsdom has no CSS.supports(); Seed's TextField calls it via +// @seed-design/react-supports to detect :focus-visible support. +if (typeof (globalThis as any).CSS === "undefined") { + (globalThis as any).CSS = { supports: () => false }; +} else if (typeof (globalThis as any).CSS.supports !== "function") { + (globalThis as any).CSS.supports = () => false; +} + +const push = vi.fn(); +const replace = vi.fn(); +const back = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace, back }), +})); + +function mockFetchOnce(body: unknown, ok = true) { + (global.fetch as unknown) = vi.fn(async () => ({ + ok, + json: async () => body, + })) as unknown as typeof fetch; +} + +beforeEach(() => { + push.mockClear(); + replace.mockClear(); + back.mockClear(); +}); + +describe("AuthForm login", () => { + it("logs in and navigates to /home on success", async () => { + mockFetchOnce({ user: { id: 1, email: "a@b.com" } }); + + render(); + + fireEvent.change(screen.getByLabelText(authStrings.login.email), { + target: { value: "a@b.com" }, + }); + fireEvent.change(screen.getByLabelText(authStrings.login.password), { + target: { value: "password123" }, + }); + fireEvent.click(screen.getByRole("button", { name: authStrings.login.submit })); + + await waitFor(() => expect(push).toHaveBeenCalledWith("/home")); + expect(global.fetch).toHaveBeenCalledWith( + "/api/auth/login", + expect.objectContaining({ credentials: "same-origin" }), + ); + }); + + it("switches to the code step on a twoFactor challenge response", async () => { + mockFetchOnce({ twoFactor: true, challenge: "chal-123" }); + + render(); + + fireEvent.change(screen.getByLabelText(authStrings.login.email), { + target: { value: "a@b.com" }, + }); + fireEvent.change(screen.getByLabelText(authStrings.login.password), { + target: { value: "password123" }, + }); + fireEvent.click(screen.getByRole("button", { name: authStrings.login.submit })); + + expect( + await screen.findByLabelText(authStrings.twoFactor.code), + ).toBeInTheDocument(); + expect(push).not.toHaveBeenCalled(); + + // Completing the code step calls /api/auth/2fa and then navigates home. + mockFetchOnce({ user: { id: 1, email: "a@b.com" } }); + fireEvent.change(screen.getByLabelText(authStrings.twoFactor.code), { + target: { value: "000000" }, + }); + fireEvent.click(screen.getByRole("button", { name: authStrings.twoFactor.submit })); + + await waitFor(() => expect(push).toHaveBeenCalledWith("/home")); + expect(global.fetch).toHaveBeenLastCalledWith( + "/api/auth/2fa", + expect.objectContaining({ credentials: "same-origin" }), + ); + }); + + it("shows a Mongolian error message on invalid credentials", async () => { + mockFetchOnce({ error: { code: "invalid_credentials", message: "nope" } }, false); + + render(); + + fireEvent.change(screen.getByLabelText(authStrings.login.email), { + target: { value: "a@b.com" }, + }); + fireEvent.change(screen.getByLabelText(authStrings.login.password), { + target: { value: "password123" }, + }); + fireEvent.click(screen.getByRole("button", { name: authStrings.login.submit })); + + expect( + await screen.findByText(authStrings.errors.invalidCredentials), + ).toBeInTheDocument(); + expect(push).not.toHaveBeenCalled(); + }); +}); + +describe("AuthForm register", () => { + it("rejects a mismatched confirm-password before calling the API", async () => { + render(); + + fireEvent.change(screen.getByLabelText(authStrings.register.email), { + target: { value: "a@b.com" }, + }); + fireEvent.change(screen.getByLabelText(authStrings.register.password), { + target: { value: "password123" }, + }); + fireEvent.change(screen.getByLabelText(authStrings.register.confirmPassword), { + target: { value: "different" }, + }); + fireEvent.click(screen.getByRole("button", { name: authStrings.register.submit })); + + expect( + await screen.findByText(authStrings.errors.passwordMismatch), + ).toBeInTheDocument(); + }); +}); + +describe("AuthForm forgot", () => { + it("shows a confirmation message instead of navigating", async () => { + mockFetchOnce({ status: "sent" }); + + render(); + + fireEvent.change(screen.getByLabelText(authStrings.forgot.email), { + target: { value: "a@b.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: authStrings.forgot.submit })); + + expect(await screen.findByText(authStrings.forgot.sent)).toBeInTheDocument(); + expect(push).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/auth/AuthForm.tsx b/src/features/auth/AuthForm.tsx new file mode 100644 index 0000000..314fe0c --- /dev/null +++ b/src/features/auth/AuthForm.tsx @@ -0,0 +1,254 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { TextFieldRoot, TextFieldInput } from "@seed-design/react"; +import { MercuryButton } from "../../ds/MercuryButton"; +import { authStrings } from "./strings"; +import { + submit2FA, + submitForgot, + submitLogin, + submitRegister, + type AuthActionResult, +} from "./actions"; + +export type AuthFormMode = "login" | "register" | "forgot"; + +export interface AuthFormProps { + mode: AuthFormMode; + /** Cross-link handlers — wired to router navigation by the page. */ + onNavigateLogin?: () => void; + onNavigateRegister?: () => void; + onNavigateForgot?: () => void; +} + +// Mirrors AuthModel.isValidEmail (ios/Mercury/Features/Auth/AuthModel.swift): +// requires an "@", a non-empty local part, and a domain containing "." that +// doesn't end with it. +function isValidEmail(value: string): boolean { + const at = value.indexOf("@"); + if (at <= 0) return false; + const domain = value.slice(at + 1); + return domain.includes(".") && !domain.endsWith("."); +} + +type Step = "form" | "twoFactor"; + +export function AuthForm({ + mode, + onNavigateLogin, + onNavigateRegister, + onNavigateForgot, +}: AuthFormProps) { + const router = useRouter(); + + const [step, setStep] = React.useState("form"); + const [challenge, setChallenge] = React.useState(null); + + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [confirmPassword, setConfirmPassword] = React.useState(""); + const [code, setCode] = React.useState(""); + + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(); + const [infoMessage, setInfoMessage] = React.useState(); + + function clearMessages() { + setErrorMessage(undefined); + setInfoMessage(undefined); + } + + function applyResult(result: AuthActionResult) { + if (result.twoFactor && result.challenge) { + setChallenge(result.challenge); + setStep("twoFactor"); + return; + } + if (result.ok) { + if (mode === "forgot") { + setInfoMessage(authStrings.forgot.sent); + return; + } + router.push("/home"); + return; + } + setErrorMessage(result.error ?? authStrings.errors.unknown); + } + + async function handleFormSubmit(e: React.FormEvent) { + e.preventDefault(); + if (isSubmitting) return; + clearMessages(); + + const trimmedEmail = email.trim(); + + if (mode === "forgot") { + if (!isValidEmail(trimmedEmail)) { + setErrorMessage(authStrings.errors.invalidEmail); + return; + } + setIsSubmitting(true); + try { + applyResult(await submitForgot(trimmedEmail)); + } finally { + setIsSubmitting(false); + } + return; + } + + if (!isValidEmail(trimmedEmail)) { + setErrorMessage(authStrings.errors.invalidEmail); + return; + } + if (password.length < 8) { + setErrorMessage(authStrings.errors.passwordTooShort); + return; + } + if (mode === "register" && password !== confirmPassword) { + setErrorMessage(authStrings.errors.passwordMismatch); + return; + } + + setIsSubmitting(true); + try { + const result = + mode === "login" + ? await submitLogin(trimmedEmail, password) + : await submitRegister(trimmedEmail, password); + applyResult(result); + } finally { + setIsSubmitting(false); + } + } + + async function handleCodeSubmit(e: React.FormEvent) { + e.preventDefault(); + if (isSubmitting || !challenge) return; + clearMessages(); + setIsSubmitting(true); + try { + applyResult(await submit2FA(challenge, code)); + } finally { + setIsSubmitting(false); + } + } + + if (step === "twoFactor") { + return ( +
+

+ {authStrings.twoFactor.subtitle} +

+ + + + + + + + + {authStrings.twoFactor.submit} + + + ); + } + + return ( +
+ {mode === "forgot" && ( +

+ {authStrings.forgot.subtitle} +

+ )} + + + + + + {mode !== "forgot" && ( + + + + )} + + {mode === "register" && ( + + + + )} + + {mode === "login" && ( + + )} + + + {infoMessage && ( +

+ {infoMessage} +

+ )} + + + {mode === "login" + ? authStrings.login.submit + : mode === "register" + ? authStrings.register.submit + : authStrings.forgot.submit} + + + {mode === "login" && ( + + {authStrings.login.registerLink} + + )} + {mode === "register" && ( + + {authStrings.register.loginLink} + + )} + + ); +} + +function FormError({ message }: { message?: string }) { + if (!message) return null; + return ( +

+ {message} +

+ ); +} diff --git a/src/features/auth/actions.ts b/src/features/auth/actions.ts new file mode 100644 index 0000000..a1bde2c --- /dev/null +++ b/src/features/auth/actions.ts @@ -0,0 +1,75 @@ +import { authStrings } from "./strings"; + +export interface AuthActionResult { + ok: boolean; + twoFactor?: boolean; + challenge?: string; + error?: string; +} + +// Mirrors AuthError.userMessage in ios/Mercury/Features/Auth/AuthRepository.swift, +// keyed by the `{error:{code,message}}` envelope the Go backend writes +// (internal/transport/api/respond.go, auth_handlers.go, twofa_handlers.go). +const ERROR_MESSAGE_BY_CODE: Record = { + invalid_credentials: authStrings.errors.invalidCredentials, + email_taken: authStrings.errors.emailTaken, + invalid_code: authStrings.errors.invalidCode, + invalid_challenge: authStrings.errors.invalidCode, +}; + +function messageForCode(code: string | undefined): string { + if (code && ERROR_MESSAGE_BY_CODE[code]) return ERROR_MESSAGE_BY_CODE[code]; + return authStrings.errors.unknown; +} + +async function postAuth( + path: "login" | "register" | "forgot" | "2fa", + body: unknown, +): Promise { + let res: Response; + try { + res = await fetch(`/api/auth/${path}`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch { + return { ok: false, error: authStrings.errors.network }; + } + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return { ok: false, error: messageForCode(data?.error?.code) }; + } + if (data?.twoFactor) { + return { ok: false, twoFactor: true, challenge: data.challenge }; + } + return { ok: true }; +} + +export function submitLogin( + email: string, + password: string, +): Promise { + return postAuth("login", { email, password }); +} + +export function submitRegister( + email: string, + password: string, +): Promise { + return postAuth("register", { email, password }); +} + +export function submit2FA( + challenge: string, + code: string, +): Promise { + return postAuth("2fa", { challenge, code }); +} + +export function submitForgot(email: string): Promise { + return postAuth("forgot", { email }); +} diff --git a/src/features/auth/strings.ts b/src/features/auth/strings.ts new file mode 100644 index 0000000..445f687 --- /dev/null +++ b/src/features/auth/strings.ts @@ -0,0 +1,46 @@ +// Auth feature copy, ported verbatim from ios/Mercury/Features/Auth/*.swift +// (WelcomeView, LoginView, RegisterView, ForgotPasswordView, AuthModel). +export const authStrings = { + wordmark: "MERCURY", + welcome: { + title: "ONBOARDING", + register: "Бүртгүүлэх", + login: "Нэвтрэх", + }, + login: { + email: "Имэйл хаяг", + password: "Нууц үг", + forgot: "Нууц үг марсан", + submit: "Нэвтрэх", + registerLink: "Бүртгүүлэх", + }, + register: { + email: "Имэйл хаяг", + password: "Нууц үг", + confirmPassword: "Нууц үг давтах", + submit: "Бүртгүүлэх", + loginLink: "Нэвтрэх", + }, + forgot: { + subtitle: + "Бүртгэлтэй имэйл хаягаа оруулбал нууц үг сэргээх холбоосыг илгээнэ.", + email: "Имэйл хаяг", + submit: "Сэргээх холбоос авах", + sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.", + }, + twoFactor: { + subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.", + code: "Баталгаажуулах код", + submit: "Баталгаажуулах", + }, + errors: { + invalidEmail: "Имэйл хаягаа зөв оруулна уу.", + passwordTooShort: "Нууц үг дор хаяж 8 тэмдэгт байх ёстой.", + passwordMismatch: "Нууц үг таарахгүй байна.", + invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.", + emailTaken: "Энэ имэйл бүртгэлтэй байна.", + invalidCode: "Код буруу эсвэл хугацаа дууссан байна.", + network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.", + unknown: "Алдаа гарлаа. Дахин оролдоно уу.", + }, +} as const;