feat: email verification + delete confirmation + smtp/cors/prod hardening

This commit is contained in:
2026-02-09 14:46:49 -06:00
parent 27cc7d159b
commit 9856317641
22 changed files with 896 additions and 58 deletions

View File

@@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:8080/api
VITE_API_URL=http://localhost:8081
VITE_APP_NAME=SkyMoney

View File

@@ -1,9 +1,41 @@
import { Suspense } from "react";
import { Outlet } from "react-router-dom";
import { Suspense, useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { SessionTimeoutWarning } from "./components/SessionTimeoutWarning";
import NavBar from "./components/NavBar";
import { useAuthSession } from "./hooks/useAuthSession";
import { http } from "./api/http";
import UpdateNoticeModal from "./components/UpdateNoticeModal";
export default function App() {
const location = useLocation();
const qc = useQueryClient();
const session = useAuthSession({ retry: false });
const [dismissedVersion, setDismissedVersion] = useState<number | null>(null);
const notice = session.data?.updateNotice;
const isPublicRoute =
location.pathname.startsWith("/login") ||
location.pathname.startsWith("/register") ||
location.pathname.startsWith("/verify") ||
location.pathname.startsWith("/beta");
const showUpdateModal = !isPublicRoute && !!notice && dismissedVersion !== notice.version;
useEffect(() => {
setDismissedVersion(null);
}, [session.data?.userId]);
const acknowledgeNotice = async () => {
if (!notice) return;
await http("/app/update-notice/ack", {
method: "POST",
body: { version: notice.version },
});
setDismissedVersion(notice.version);
await qc.invalidateQueries({ queryKey: ["auth", "session"] });
};
return (
<>
<SessionTimeoutWarning />
@@ -13,6 +45,13 @@ export default function App() {
<Outlet />
</Suspense>
</main>
{showUpdateModal && notice ? (
<UpdateNoticeModal
title={notice.title}
body={notice.body}
onAcknowledge={acknowledgeNotice}
/>
) : null}
</>
);
}

View File

@@ -31,5 +31,12 @@ export function RequireAuth({ children }: Props) {
);
}
if (session.data && !session.data.emailVerified) {
const next = encodeURIComponent(
`${location.pathname}${location.search}`.replace(/^$/, "/")
);
return <Navigate to={`/verify?next=${next}`} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,21 @@
type Props = {
title: string;
body: string;
onAcknowledge: () => Promise<void> | void;
};
export default function UpdateNoticeModal({ title, body, onAcknowledge }: Props) {
return (
<div className="fixed inset-0 z-[120] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div className="card max-w-lg w-full p-6">
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<p className="muted whitespace-pre-wrap mb-6">{body}</p>
<div className="flex justify-end">
<button className="btn" onClick={() => void onAcknowledge()}>
Got it
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,18 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { http } from "../api/http";
type SessionResponse = { ok: true; userId: string; email: string | null; displayName: string | null };
type SessionResponse = {
ok: true;
userId: string;
email: string | null;
displayName: string | null;
emailVerified: boolean;
updateNotice: {
version: number;
title: string;
body: string;
} | null;
};
type Options = Omit<UseQueryOptions<SessionResponse, Error>, "queryKey" | "queryFn">;

View File

@@ -54,6 +54,7 @@ const OnboardingPage = lazy(() => import("./pages/OnboardingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage"));
const VerifyPage = lazy(() => import("./pages/VerifyPage"));
const router = createBrowserRouter(
createRoutesFromElements(
@@ -70,6 +71,7 @@ const router = createBrowserRouter(
{/* Public */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/verify" element={<VerifyPage />} />
{/* Protected onboarding */}
<Route

View File

@@ -60,11 +60,19 @@ export default function LoginPage() {
qc.clear();
navigate(next || "/", { replace: true });
} catch (err) {
const status = (err as { status?: number })?.status;
const status = (err as { status?: number; code?: string })?.status;
const code = (err as { code?: string })?.code;
if (status === 403 && code === "EMAIL_NOT_VERIFIED") {
navigate(
`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`,
{ replace: true }
);
return;
}
const message =
status === 401
? "Email or password is incorrect."
: status === 400
: status === 400
? "Enter a valid email and password."
: err instanceof Error
? err.message

View File

@@ -65,13 +65,22 @@ export default function RegisterPage() {
return;
}
try {
await http<{ ok: true }>("/auth/register", {
const result = await http<{ ok: true; needsVerification?: boolean }>(
"/auth/register",
{
method: "POST",
body: { email, password },
skipAuthRedirect: true,
});
}
);
qc.clear();
navigate(next || "/", { replace: true });
if (result.needsVerification) {
navigate(`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`, {
replace: true,
});
} else {
navigate(next || "/", { replace: true });
}
} catch (err) {
const status = (err as { status?: number })?.status;
const message =

View File

@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { http } from "../api/http";
import { useToast } from "../components/Toast";
function useQueryParams() {
const location = useLocation();
return useMemo(() => new URLSearchParams(location.search), [location.search]);
}
export default function VerifyPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const params = useQueryParams();
const { push } = useToast();
const [email, setEmail] = useState(params.get("email") || "");
const [code, setCode] = useState("");
const [pending, setPending] = useState(false);
const [resendPending, setResendPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const next = params.get("next") || "/";
useEffect(() => {
const prefEmail = params.get("email");
if (prefEmail) setEmail(prefEmail);
}, [params]);
const emailError = !email.trim() ? "Email is required." : "";
const codeError = !code.trim() ? "Verification code is required." : "";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
if (emailError || codeError) {
setError(emailError || codeError);
return;
}
setPending(true);
try {
await http("/auth/verify", {
method: "POST",
body: { email, code },
skipAuthRedirect: true,
});
await qc.invalidateQueries({ queryKey: ["auth", "session"] });
navigate(next, { replace: true });
} catch (err: any) {
const message = err?.data?.message || err?.message || "Invalid code.";
setError(message);
} finally {
setPending(false);
}
}
async function handleResend() {
if (!email.trim()) {
setError("Enter your email first.");
return;
}
setResendPending(true);
try {
await http("/auth/verify/resend", {
method: "POST",
body: { email },
skipAuthRedirect: true,
});
push("ok", "Verification code sent.");
} catch (err: any) {
push("err", err?.message ?? "Unable to resend code.");
} finally {
setResendPending(false);
}
}
return (
<div className="flex justify-center py-16 px-4">
<div className="card w-full max-w-md">
<h1 className="section-title mb-2">Verify your email</h1>
<p className="muted mb-6">
Enter the verification code we sent to your inbox to activate your account.
</p>
{error && <div className="alert alert-error mb-4">{error}</div>}
<form className="stack gap-4" onSubmit={handleSubmit}>
<label className="stack gap-1">
<span className="text-sm font-medium">Email</span>
<input
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</label>
<label className="stack gap-1">
<span className="text-sm font-medium">Verification code</span>
<input
className="input"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
/>
</label>
<button className="btn primary" type="submit" disabled={pending}>
{pending ? "Verifying..." : "Verify email"}
</button>
</form>
<button
className="btn btn-outline w-full mt-3"
type="button"
onClick={handleResend}
disabled={resendPending}
>
{resendPending ? "Sending..." : "Resend code"}
</button>
<p className="muted text-sm mt-6 text-center">
Need to log in? <Link className="link" to="/login">Sign in</Link>
</p>
</div>
</div>
);
}

View File

@@ -33,6 +33,10 @@ export default function AccountSettings() {
const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false);
const [fixedExpensePercentage, setFixedExpensePercentage] = useState(40);
const [isUpdatingConservatism, setIsUpdatingConservatism] = useState(false);
const [deletePassword, setDeletePassword] = useState("");
const [deleteCode, setDeleteCode] = useState("");
const [isDeleteRequesting, setIsDeleteRequesting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const browserTimezone = useMemo(() => getBrowserTimezone(), []);
const timezoneOptions = useMemo(() => {
@@ -237,6 +241,46 @@ export default function AccountSettings() {
}
};
const handleRequestDelete = async () => {
if (!deletePassword.trim()) {
push("err", "Enter your password to request a deletion code.");
return;
}
setIsDeleteRequesting(true);
try {
await http("/account/delete-request", {
method: "POST",
body: { password: deletePassword },
});
push("ok", "Delete confirmation code sent to your email.");
} catch (error: any) {
push("err", error?.message ?? "Failed to send delete code");
} finally {
setIsDeleteRequesting(false);
}
};
const handleConfirmDelete = async () => {
if (!deleteCode.trim()) {
push("err", "Enter the delete confirmation code.");
return;
}
setIsDeleting(true);
try {
await http("/account/confirm-delete", {
method: "POST",
body: { email: email.trim(), code: deleteCode.trim(), password: deletePassword },
skipAuthRedirect: true,
});
qc.clear();
window.location.replace("/login");
} catch (error: any) {
push("err", error?.message ?? "Failed to delete account");
} finally {
setIsDeleting(false);
}
};
return (
<div className="space-y-6">
{/* Profile Information */}
@@ -495,18 +539,50 @@ export default function AccountSettings() {
<div className="settings-section settings-danger-section" style={{ marginBottom: 0 }}>
<div className="settings-section-header" style={{ borderBottom: "none", paddingBottom: 0, marginBottom: "0.5rem" }}>
<h3 className="settings-section-title">Delete Account</h3>
<p className="settings-section-desc">Permanently delete your account and all associated data. This action cannot be undone.</p>
<p className="settings-section-desc">
Permanently delete your account and all associated data. This action cannot be undone.
</p>
</div>
<div className="stack gap-3">
<label className="stack gap-1">
<span className="text-sm font-medium">Confirm password</span>
<input
className="input"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password"
/>
</label>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={handleRequestDelete}
disabled={isDeleteRequesting}
>
{isDeleteRequesting ? "Sending code..." : "Send delete code"}
</button>
<label className="stack gap-1">
<span className="text-sm font-medium">Delete confirmation code</span>
<input
className="input"
type="text"
value={deleteCode}
onChange={(e) => setDeleteCode(e.target.value)}
placeholder="Enter the code from your email"
/>
</label>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete Account"}
</button>
</div>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={() => {
push("ok", "Account deletion requires email confirmation");
}}
>
Delete Account
</button>
</div>
</div>
</section>