feat(web): auth screens (login/register/forgot/2FA)

This commit is contained in:
Munkherdene 2026-08-22 20:42:29 +08:00
parent 071b5bdb6f
commit ef0fe234b8
8 changed files with 700 additions and 0 deletions

View file

@ -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 (
<>
<button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
background: "none",
border: "none",
alignSelf: "flex-start",
marginTop: 24,
fontSize: 20,
cursor: "pointer",
}}
>
</button>
<div style={{ height: 72 }} />
<h1
style={{
textAlign: "center",
fontWeight: 700,
letterSpacing: 1,
fontSize: 24,
}}
>
{authStrings.wordmark}
</h1>
<div style={{ flex: 1, minHeight: 40 }} />
<AuthForm mode="forgot" />
<div style={{ paddingBottom: 60 }} />
</>
);
}

27
src/app/(auth)/layout.tsx Normal file
View file

@ -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 (
<div
style={{
minHeight: "100dvh",
background: "var(--seed-color-bg-layer-default, #fff)",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
width: "100%",
maxWidth: 430,
display: "flex",
flexDirection: "column",
padding: "0 20px",
}}
>
{children}
</div>
</div>
);
}

View file

@ -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 (
<>
<button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
background: "none",
border: "none",
alignSelf: "flex-start",
marginTop: 24,
fontSize: 20,
cursor: "pointer",
}}
>
</button>
<div style={{ height: 72 }} />
<h1
style={{
textAlign: "center",
fontWeight: 700,
letterSpacing: 1,
fontSize: 24,
}}
>
{authStrings.wordmark}
</h1>
<div style={{ flex: 1, minHeight: 40 }} />
<AuthForm
mode="login"
onNavigateRegister={() => router.replace("/register")}
onNavigateForgot={() => router.push("/forgot")}
/>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -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 (
<>
<button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
background: "none",
border: "none",
alignSelf: "flex-start",
marginTop: 24,
fontSize: 20,
cursor: "pointer",
}}
>
</button>
<div style={{ height: 72 }} />
<h1
style={{
textAlign: "center",
fontWeight: 700,
letterSpacing: 1,
fontSize: 24,
}}
>
{authStrings.wordmark}
</h1>
<div style={{ flex: 1, minHeight: 40 }} />
<AuthForm mode="register" onNavigateLogin={() => router.replace("/login")} />
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -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(<AuthForm mode="login" />);
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(<AuthForm mode="login" />);
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(<AuthForm mode="login" />);
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(<AuthForm mode="register" />);
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(<AuthForm mode="forgot" />);
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();
});
});

View file

@ -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<Step>("form");
const [challenge, setChallenge] = React.useState<string | null>(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<string | undefined>();
const [infoMessage, setInfoMessage] = React.useState<string | undefined>();
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 (
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-3">
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
{authStrings.twoFactor.subtitle}
</p>
<TextFieldRoot value={code} onValueChange={setCode} name="code">
<TextFieldInput
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder={authStrings.twoFactor.code}
aria-label={authStrings.twoFactor.code}
/>
</TextFieldRoot>
<FormError message={errorMessage} />
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
{authStrings.twoFactor.submit}
</MercuryButton>
</form>
);
}
return (
<form onSubmit={handleFormSubmit} className="flex flex-col gap-3">
{mode === "forgot" && (
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
{authStrings.forgot.subtitle}
</p>
)}
<TextFieldRoot value={email} onValueChange={setEmail} name="email">
<TextFieldInput
type="email"
autoComplete="email"
placeholder={authStrings.login.email}
aria-label={authStrings.login.email}
/>
</TextFieldRoot>
{mode !== "forgot" && (
<TextFieldRoot value={password} onValueChange={setPassword} name="password">
<TextFieldInput
type="password"
autoComplete={mode === "register" ? "new-password" : "current-password"}
placeholder={authStrings.login.password}
aria-label={authStrings.login.password}
/>
</TextFieldRoot>
)}
{mode === "register" && (
<TextFieldRoot
value={confirmPassword}
onValueChange={setConfirmPassword}
name="confirmPassword"
>
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.register.confirmPassword}
aria-label={authStrings.register.confirmPassword}
/>
</TextFieldRoot>
)}
{mode === "login" && (
<button
type="button"
onClick={onNavigateForgot}
className="self-end text-sm"
style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none" }}
>
{authStrings.login.forgot}
</button>
)}
<FormError message={errorMessage} />
{infoMessage && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
{infoMessage}
</p>
)}
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
{mode === "login"
? authStrings.login.submit
: mode === "register"
? authStrings.register.submit
: authStrings.forgot.submit}
</MercuryButton>
{mode === "login" && (
<MercuryButton type="button" variant="ghost" onClick={onNavigateRegister}>
{authStrings.login.registerLink}
</MercuryButton>
)}
{mode === "register" && (
<MercuryButton type="button" variant="ghost" onClick={onNavigateLogin}>
{authStrings.register.loginLink}
</MercuryButton>
)}
</form>
);
}
function FormError({ message }: { message?: string }) {
if (!message) return null;
return (
<p role="alert" className="text-sm" style={{ color: "#d92626" }}>
{message}
</p>
);
}

View file

@ -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<string, string> = {
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<AuthActionResult> {
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<AuthActionResult> {
return postAuth("login", { email, password });
}
export function submitRegister(
email: string,
password: string,
): Promise<AuthActionResult> {
return postAuth("register", { email, password });
}
export function submit2FA(
challenge: string,
code: string,
): Promise<AuthActionResult> {
return postAuth("2fa", { challenge, code });
}
export function submitForgot(email: string): Promise<AuthActionResult> {
return postAuth("forgot", { email });
}

View file

@ -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;