feat: email verification + delete confirmation + smtp/cors/prod hardening
This commit is contained in:
@@ -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