feat: email verification + delete confirmation + smtp/cors/prod hardening
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8080/api
|
||||
VITE_API_URL=http://localhost:8081
|
||||
VITE_APP_NAME=SkyMoney
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
21
web/src/components/UpdateNoticeModal.tsx
Normal file
21
web/src/components/UpdateNoticeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
126
web/src/pages/VerifyPage.tsx
Normal file
126
web/src/pages/VerifyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user