merge task/t7: auth screens (login/register/forgot/2FA)
This commit is contained in:
commit
efd252e8ff
8 changed files with 700 additions and 0 deletions
51
src/app/(auth)/forgot/page.tsx
Normal file
51
src/app/(auth)/forgot/page.tsx
Normal 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
27
src/app/(auth)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/app/(auth)/login/page.tsx
Normal file
54
src/app/(auth)/login/page.tsx
Normal 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 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/(auth)/register/page.tsx
Normal file
50
src/app/(auth)/register/page.tsx
Normal 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 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/features/auth/AuthForm.test.tsx
Normal file
143
src/features/auth/AuthForm.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
254
src/features/auth/AuthForm.tsx
Normal file
254
src/features/auth/AuthForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/features/auth/actions.ts
Normal file
75
src/features/auth/actions.ts
Normal 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 });
|
||||||
|
}
|
||||||
46
src/features/auth/strings.ts
Normal file
46
src/features/auth/strings.ts
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue