feat(web): auth screens (login/register/forgot/2FA)
This commit is contained in:
parent
071b5bdb6f
commit
ef0fe234b8
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