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 (
+
+ );
+}
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 (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+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;