feat(web): password-reset + email-verify pages + planner spend-breakdown

This commit is contained in:
Munkherdene 2026-08-22 22:59:15 +08:00
parent 5eff0f661f
commit 7753168b11
9 changed files with 462 additions and 26 deletions

View file

@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { ResetForm } from "@/features/auth/ResetForm";
import { AuthHeader } from "@/features/auth/AuthHeader";
// The password-reset landing: reached from the emailed link (?token=...).
// Wrapped in Suspense because ResetForm reads useSearchParams, which Next.js
// requires to sit below a Suspense boundary.
export default function ResetPasswordPage() {
return (
<>
<AuthHeader />
<div style={{ flex: 1, minHeight: 40 }} />
<Suspense fallback={null}>
<ResetForm />
</Suspense>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { VerifyStatus } from "@/features/auth/VerifyStatus";
import { AuthHeader } from "@/features/auth/AuthHeader";
// The email-verify landing: reached from the emailed link (?token=...).
// Wrapped in Suspense because VerifyStatus reads useSearchParams, which
// Next.js requires to sit below a Suspense boundary.
export default function VerifyEmailPage() {
return (
<>
<AuthHeader />
<div style={{ flex: 1, minHeight: 40 }} />
<Suspense fallback={null}>
<VerifyStatus />
</Suspense>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
import { MercuryButton } from "../../ds/MercuryButton";
import { authStrings } from "./strings";
import { submitReset } from "./actions";
/** Set-new-password form for the emailed reset link (`/reset?token=...`).
* Ported in spirit from ForgotPasswordView.swift's field/CTA styling there is
* no iOS screen for this step since the reset itself only happens on web (see
* that file's header comment). Same 8-char minimum as AuthForm's register step. */
export function ResetForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const [done, setDone] = React.useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isSubmitting) return;
setErrorMessage(undefined);
if (!token) {
setErrorMessage(authStrings.reset.missingToken);
return;
}
if (password.length < 8) {
setErrorMessage(authStrings.errors.passwordTooShort);
return;
}
if (password !== confirmPassword) {
setErrorMessage(authStrings.errors.passwordMismatch);
return;
}
setIsSubmitting(true);
try {
const result = await submitReset(token, password);
if (result.ok) {
setDone(true);
} else {
setErrorMessage(result.error ?? authStrings.errors.unknown);
}
} finally {
setIsSubmitting(false);
}
}
if (done) {
return (
<div className="flex flex-col gap-5" style={{ textAlign: "center" }}>
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.reset.success}
</p>
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
{authStrings.reset.loginLink}
</MercuryButton>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.reset.subtitle}
</p>
<div className="flex flex-col gap-3">
<TextFieldRoot value={password} onValueChange={setPassword} name="password">
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.reset.password}
aria-label={authStrings.reset.password}
/>
</TextFieldRoot>
<TextFieldRoot value={confirmPassword} onValueChange={setConfirmPassword} name="confirmPassword">
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.reset.confirmPassword}
aria-label={authStrings.reset.confirmPassword}
/>
</TextFieldRoot>
</div>
{errorMessage && (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
{errorMessage}
</p>
)}
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
{authStrings.reset.submit}
</MercuryButton>
</form>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { MercuryButton } from "../../ds/MercuryButton";
import { authStrings } from "./strings";
import { submitVerify } from "./actions";
type State = "verifying" | "success" | "error";
/** Email-verify landing (`/verify?token=...`): posts the token once on mount
* and shows a verifying/success/failure state. The backend's email-verification
* feature may be disabled server-side (Config.EmailVerificationEnabled), in
* which case the route 404s and this renders the generic error message
* still a graceful, non-broken screen rather than a blocked build. */
export function VerifyStatus() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [state, setState] = React.useState<State>("verifying");
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const attempted = React.useRef(false);
React.useEffect(() => {
if (attempted.current) return;
attempted.current = true;
if (!token) {
setErrorMessage(authStrings.verify.missingToken);
setState("error");
return;
}
submitVerify(token).then((result) => {
if (result.ok) {
setState("success");
} else {
setErrorMessage(result.error ?? authStrings.errors.unknown);
setState("error");
}
});
}, [token]);
return (
<div className="flex flex-col items-center gap-5" style={{ textAlign: "center" }}>
{state === "verifying" && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.verify.verifying}
</p>
)}
{state === "success" && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.verify.success}
</p>
)}
{state === "error" && (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
{errorMessage}
</p>
)}
{state !== "verifying" && (
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
{authStrings.verify.loginLink}
</MercuryButton>
)}
</div>
);
}

View file

@ -15,6 +15,7 @@ const ERROR_MESSAGE_BY_CODE: Record<string, string> = {
email_taken: authStrings.errors.emailTaken, email_taken: authStrings.errors.emailTaken,
invalid_code: authStrings.errors.invalidCode, invalid_code: authStrings.errors.invalidCode,
invalid_challenge: authStrings.errors.invalidCode, invalid_challenge: authStrings.errors.invalidCode,
invalid_token: authStrings.errors.invalidToken,
}; };
function messageForCode(code: string | undefined): string { function messageForCode(code: string | undefined): string {
@ -23,7 +24,7 @@ function messageForCode(code: string | undefined): string {
} }
async function postAuth( async function postAuth(
path: "login" | "register" | "forgot" | "2fa", path: "login" | "register" | "forgot" | "2fa" | "reset" | "verify",
body: unknown, body: unknown,
): Promise<AuthActionResult> { ): Promise<AuthActionResult> {
let res: Response; let res: Response;
@ -73,3 +74,20 @@ export function submit2FA(
export function submitForgot(email: string): Promise<AuthActionResult> { export function submitForgot(email: string): Promise<AuthActionResult> {
return postAuth("forgot", { email }); return postAuth("forgot", { email });
} }
// Backend contract (internal/transport/api/password_handlers.go
// handleResetPassword): { token, newPassword } — the response body carries no
// user/session, just { status: "ok" }, so `postAuth`'s ok/error handling is
// all callers need.
export function submitReset(
token: string,
password: string,
): Promise<AuthActionResult> {
return postAuth("reset", { token, newPassword: password });
}
// Backend contract (internal/transport/api/verify_handlers.go
// handleVerifyEmail): { token } → { status: "verified" } on success.
export function submitVerify(token: string): Promise<AuthActionResult> {
return postAuth("verify", { token });
}

View file

@ -28,6 +28,26 @@ export const authStrings = {
submit: "Сэргээх холбоос авах", submit: "Сэргээх холбоос авах",
sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.", sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.",
}, },
// /reset — reached from the emailed link (?token=...). No iOS counterpart:
// ForgotPasswordView.swift is request-only, the actual change happens here
// on web (see that file's header comment).
reset: {
subtitle: "Шинэ нууц үгээ хоёр удаа оруулна уу.",
password: "Шинэ нууц үг",
confirmPassword: "Нууц үг давтах",
submit: "Нууц үг шинэчлэх",
success: "Нууц үг амжилттай шинэчлэгдлээ. Шинэ нууц үгээрээ нэвтэрнэ үү.",
loginLink: "Нэвтрэх хуудас руу очих",
missingToken: "Холбоос буруу байна. Имэйлээр ирсэн холбоосоор дахин орно уу.",
},
// /verify — reached from the emailed link (?token=...). Also no iOS
// counterpart; verification is a web-only flow today.
verify: {
verifying: "Имэйл хаягийг баталгаажуулж байна…",
success: "Имэйл хаяг амжилттай баталгаажлаа.",
loginLink: "Нэвтрэх хуудас руу очих",
missingToken: "Баталгаажуулах холбоос буруу байна.",
},
twoFactor: { twoFactor: {
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.", subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
code: "Баталгаажуулах код", code: "Баталгаажуулах код",
@ -40,6 +60,7 @@ export const authStrings = {
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.", invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
emailTaken: "Энэ имэйл бүртгэлтэй байна.", emailTaken: "Энэ имэйл бүртгэлтэй байна.",
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.", invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
invalidToken: "Холбоосны хугацаа дууссан эсвэл буруу байна.",
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.", network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
unknown: "Алдаа гарлаа. Дахин оролдоно уу.", unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
}, },

View file

@ -28,6 +28,7 @@ import { tugrik, tugrikShort } from "@/ds/money";
import { useBudget, useNetWorth } from "@/api/hooks/reads"; import { useBudget, useNetWorth } from "@/api/hooks/reads";
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations"; import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
import type { Budget, SavingsGoal, Account } from "@/api/schemas"; import type { Budget, SavingsGoal, Account } from "@/api/schemas";
import { SpendBreakdown } from "./SpendBreakdown";
import { plannerStrings as s } from "./strings"; import { plannerStrings as s } from "./strings";
type Horizon = "day" | "week" | "month"; type Horizon = "day" | "week" | "month";
@ -82,6 +83,7 @@ export function PlannerView() {
const goalMutations = useSavingsGoalMutations(); const goalMutations = useSavingsGoalMutations();
const [horizon, setHorizon] = React.useState<Horizon>("day"); const [horizon, setHorizon] = React.useState<Horizon>("day");
const [breakdownOpen, setBreakdownOpen] = React.useState(false);
const accounts: Account[] = netWorth?.accounts ?? []; const accounts: Account[] = netWorth?.accounts ?? [];
@ -167,6 +169,15 @@ export function PlannerView() {
limit={overallLimit} limit={overallLimit}
loading={isLoading} loading={isLoading}
onSave={(v) => saveOverall(horizon, v)} onSave={(v) => saveOverall(horizon, v)}
onOpenBreakdown={() => setBreakdownOpen(true)}
/>
<SpendBreakdown
open={breakdownOpen}
onOpenChange={setBreakdownOpen}
horizonLabel={OVERALL_LABEL[horizon]}
total={overallSpent}
rows={rows}
/> />
<CategoryList <CategoryList
@ -306,12 +317,14 @@ function OverallCard({
limit, limit,
loading, loading,
onSave, onSave,
onOpenBreakdown,
}: { }: {
horizon: Horizon; horizon: Horizon;
spent: number; spent: number;
limit: number; limit: number;
loading: boolean; loading: boolean;
onSave: (value: number) => void; onSave: (value: number) => void;
onOpenBreakdown: () => void;
}) { }) {
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(""); const [draft, setDraft] = React.useState("");
@ -352,31 +365,54 @@ function OverallCard({
</div> </div>
</div> </div>
) : ( ) : (
<button <div className="flex flex-col gap-2">
type="button" <div className="flex items-center justify-end">
onClick={() => { <button
setDraft(limit > 0 ? String(limit) : ""); type="button"
setEditing(true); onClick={onOpenBreakdown}
}} className="flex items-center gap-1"
className="flex w-full items-center gap-4 text-left" style={{
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }} background: "none",
> border: "none",
<div className="flex items-center gap-4" style={{ minWidth: 0 }}> padding: "2px 0",
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}> cursor: "pointer",
<ProgressCircleTrack style={{ opacity: 0.3 }} /> color: "#fff",
<ProgressCircleRange /> opacity: 0.85,
</ProgressCircleRoot> fontSize: 12,
<div className="flex flex-col gap-1"> fontWeight: 600,
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span> }}
<span style={{ fontSize: 26, fontWeight: 700 }}> >
{tugrikShort(spent)} <Icon name="trending" size={13} />
{limitSet ? ` / ${tugrikShort(limit)}` : ""} {s.overall.breakdown}
</span> </button>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div>
</div> </div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button> <button
type="button"
onClick={() => {
setDraft(limit > 0 ? String(limit) : "");
setEditing(true);
}}
className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
>
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} />
<ProgressCircleRange />
</ProgressCircleRoot>
<div className="flex flex-col gap-1">
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
</span>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div>
</div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button>
</div>
)} )}
</div> </div>
); );
@ -590,7 +626,7 @@ function AmountField({ label, value, onChange }: { label: string; value: string;
); );
} }
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) { export function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
return ( return (
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}> <div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
<div <div

View file

@ -0,0 +1,130 @@
"use client";
import Link from "next/link";
import {
ContentDialogRoot,
ContentDialogBackdrop,
ContentDialogPositioner,
ContentDialogContent,
ContentDialogHeader,
ContentDialogTitle,
ContentDialogBody,
ContentDialogFooter,
} from "@seed-design/react";
import { IconChip, EmptyState, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { tugrik } from "@/ds/money";
import { ProgressBar } from "./PlannerView";
import { plannerStrings as s } from "./strings";
export interface SpendBreakdownRow {
category: string;
spent: string;
limit: string;
}
function dec(v: string | undefined | null): number {
return parseFloat(v ?? "0") || 0;
}
export interface SpendBreakdownProps {
open: boolean;
onOpenChange: (open: boolean) => void;
horizonLabel: string;
total: number;
rows: SpendBreakdownRow[];
}
/** Opened from the overall-spend card's "Задаргаа" affordance: how the
* horizon's total spend is composed, category by category, biggest first
* "how did this number get so big?". Ports SpendBreakdownView.swift; each row
* links to the same `/planner/[category]` transactions page the category-limit
* list already uses. */
export function SpendBreakdown({ open, onOpenChange, horizonLabel, total, rows }: SpendBreakdownProps) {
const spent = rows
.filter((r) => dec(r.spent) > 0)
.slice()
.sort((a, b) => dec(b.spent) - dec(a.spent));
return (
<ContentDialogRoot open={open} onOpenChange={onOpenChange}>
<ContentDialogBackdrop />
<ContentDialogPositioner>
<ContentDialogContent style={{ maxWidth: 420, width: "100%" }}>
<ContentDialogHeader>
<ContentDialogTitle>{s.spendBreakdown.title(horizonLabel)}</ContentDialogTitle>
</ContentDialogHeader>
<ContentDialogBody>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--seed-color-fg-placeholder)" }}>
{tugrik(total)}
</span>
{spent.length === 0 ? (
<EmptyState icon="layers" title={s.spendBreakdown.empty} compact />
) : (
<ul className="flex flex-col gap-3">
{spent.map((row) => {
const style = categoryStyle(row.category);
const value = dec(row.spent);
const percent = total > 0 ? Math.min(100, (value / total) * 100) : 0;
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<li key={row.category}>
<Link
href={`/planner/${encodeURIComponent(row.category)}`}
onClick={() => onOpenChange(false)}
className="flex items-center gap-3"
style={{ color: "inherit", textDecoration: "none" }}
>
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
<div className="flex items-center justify-between gap-2">
<span
style={{
fontWeight: 700,
fontSize: 14,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{style.name}
</span>
<span style={{ fontWeight: 700, fontSize: 13, flexShrink: 0 }}>{tugrik(value)}</span>
</div>
<div className="flex items-center gap-2">
<div style={{ flex: 1 }}>
<ProgressBar percent={percent} tone="brand" />
</div>
<span
style={{
fontSize: 11,
width: 32,
textAlign: "right",
flexShrink: 0,
color: "var(--seed-color-fg-placeholder)",
}}
>
{pct}%
</span>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</ContentDialogBody>
<ContentDialogFooter>
<MercuryButton variant="secondary" onClick={() => onOpenChange(false)}>
{s.spendBreakdown.close}
</MercuryButton>
</ContentDialogFooter>
</ContentDialogContent>
</ContentDialogPositioner>
</ContentDialogRoot>
);
}

View file

@ -25,6 +25,14 @@ export const plannerStrings = {
}, },
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`, editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
unsetHint: "Лимит тохируулаагүй", unsetHint: "Лимит тохируулаагүй",
breakdown: "Задаргаа",
},
// Spend-breakdown dialog, ported from SpendBreakdownView.swift: how a
// horizon's spend total is composed, category by category.
spendBreakdown: {
title: (horizonLabel: string) => `${horizonLabel} — задаргаа`,
empty: "Гүйлгээ алга",
close: "Хаах",
}, },
categories: { categories: {
title: "Ангиллын лимит", title: "Ангиллын лимит",