profile editor bug fixes
This commit is contained in:
parent
0cbeca833f
commit
16ae6d9c1c
4 changed files with 194 additions and 122 deletions
|
|
@ -33,12 +33,16 @@ async function handle(req: Request) {
|
|||
const friendly = /invalid|credential|old_password|incorrect/i.test(reason)
|
||||
? "Current password is incorrect"
|
||||
: reason;
|
||||
// Propagate upstream status (401/403/400…) so the UI can react.
|
||||
return NextResponse.json({ error: friendly }, { status: res.status });
|
||||
// Include upstream reason for debugging on the client
|
||||
return NextResponse.json({ error: friendly, debug: reason }, { status: res.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) { return handle(req); }
|
||||
export async function PATCH(req: Request) { return handle(req); }
|
||||
export async function POST(req: Request) {
|
||||
return handle(req);
|
||||
}
|
||||
export async function PATCH(req: Request) {
|
||||
return handle(req);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
|||
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
|
||||
|
||||
export async function GET() {
|
||||
// show username (read-only) + editable fields
|
||||
// allow missing email
|
||||
try {
|
||||
// caller is already gated by middleware; no token needed for GET here
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Failed to load profile", e?.status || 500);
|
||||
|
|
@ -30,14 +27,11 @@ export async function PATCH(req: Request) {
|
|||
const cookie = req.headers.get("cookie") || "";
|
||||
const hasRecentAuth = /(?:^|;\s*)ma_ra=1(?:;|$)/.test(cookie);
|
||||
if (!hasRecentAuth) {
|
||||
return NextResponse.json(
|
||||
{ error: "Re-authentication required" },
|
||||
{ status: 428 } // Precondition Required
|
||||
);
|
||||
return NextResponse.json({ error: "Re-authentication required" }, { status: 428 });
|
||||
}
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
const payload: Record<string, any> = {};
|
||||
if (typeof (body as any).first_name === "string")
|
||||
payload.first_name = (body as any).first_name.trim();
|
||||
if (typeof (body as any).last_name === "string")
|
||||
|
|
@ -46,9 +40,10 @@ export async function PATCH(req: Request) {
|
|||
payload.location = (body as any).location.trim();
|
||||
if ("email" in body) {
|
||||
const e = String((body as any).email ?? "").trim();
|
||||
payload.email = e ? e : null; // ← optional! blank clears it
|
||||
payload.email = e ? e : null; // optional; blank clears it
|
||||
}
|
||||
// (password handled by /api/account/password)
|
||||
|
||||
if (!Object.keys(payload).length) return bad("No changes");
|
||||
|
||||
const r = await fetch(`${API}/users/me`, {
|
||||
method: "PATCH",
|
||||
|
|
@ -58,6 +53,21 @@ export async function PATCH(req: Request) {
|
|||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) return bad(j?.errors?.[0]?.message || "Update failed", r.status);
|
||||
|
||||
// Single-use recent-auth: clear ma_ra after sensitive success
|
||||
if (wantsSensitive) {
|
||||
const resp = NextResponse.json({ ok: true });
|
||||
resp.cookies.set({
|
||||
name: "ma_ra",
|
||||
value: "",
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Unexpected error", e?.status || 500);
|
||||
|
|
|
|||
|
|
@ -9,107 +9,121 @@ type Props = { nextPath?: string; reauth?: boolean };
|
|||
export default function SignIn({ nextPath = "/portal", reauth = false }: Props) {
|
||||
const router = useRouter();
|
||||
const [identifier, setIdentifier] = useState(""); // email OR username
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShow] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShow] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
const txt = await res.text();
|
||||
let j: any = null; try { j = txt ? JSON.parse(txt) : null; } catch {}
|
||||
if (!res.ok) {
|
||||
throw new Error(j?.error || j?.message || `Sign-in failed (${res.status})`);
|
||||
const onSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
let j: any = null;
|
||||
try {
|
||||
j = txt ? JSON.parse(txt) : null;
|
||||
} catch {}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(j?.error || j?.message || `Sign-in failed (${res.status})`);
|
||||
}
|
||||
|
||||
// If this sign-in is being used as a re-auth step, set a short-lived 'recent auth' marker.
|
||||
if (reauth && typeof document !== "undefined") {
|
||||
document.cookie = `ma_ra=1; Max-Age=300; Path=/; SameSite=Lax${
|
||||
process.env.NODE_ENV === "production" ? "; Secure" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
// Land where caller requested
|
||||
router.replace(nextPath);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign in.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Land where caller requested
|
||||
router.replace(nextPath);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign in.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [identifier, password, nextPath, router]);
|
||||
},
|
||||
[identifier, password, nextPath, router, reauth]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="mb-1 text-2xl font-semibold">
|
||||
{reauth ? "Re-authenticate" : "Sign In"}
|
||||
</h1>
|
||||
<h1 className="mb-1 text-2xl font-semibold">{reauth ? "Re-authenticate" : "Sign In"}</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
{reauth
|
||||
? "Please sign in again to continue."
|
||||
: <>Use your email <em>or</em> username with your password.</>}
|
||||
</p>
|
||||
</div>
|
||||
{reauth ? "Please sign in again to continue." : <>Use your email <em>or</em> username with your password.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com or your-handle"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com or your-handle"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : reauth ? "Re-authenticate" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!reauth && (
|
||||
<div className="mt-2 text-center text-sm">
|
||||
<span className="opacity-70">New here?</span>{" "}
|
||||
<a className="underline" href="/auth/sign-up">Create an account</a>
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : reauth ? "Re-authenticate" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!reauth && (
|
||||
<div className="mt-2 text-center text-sm">
|
||||
<span className="opacity-70">New here?</span>{" "}
|
||||
<a className="underline" href="/auth/sign-up">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// components/account/ProfileEditor.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
|
|
@ -24,33 +24,38 @@ export default function ProfileEditor({
|
|||
const [first_name, setFirst] = useState("");
|
||||
const [last_name, setLast] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [profileLocation, setProfileLocation] = useState(""); // renamed to avoid shadowing window.location
|
||||
const [profileLocation, setProfileLocation] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(!meProp);
|
||||
|
||||
const nextAccount = "/portal/account";
|
||||
|
||||
// Load profile if not provided via props
|
||||
useEffect(() => {
|
||||
if (meProp) {
|
||||
setMe(meProp);
|
||||
setLoading(false);
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const j = await r.json();
|
||||
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
if (!user) throw new Error("Bad response");
|
||||
setMe(user);
|
||||
} catch (e: any) {
|
||||
setMsg(`Failed to load: ${e?.message || e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const j = await r.json();
|
||||
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
if (!user) throw new Error("Bad response");
|
||||
if (alive) setMe(user);
|
||||
} catch (e: any) {
|
||||
if (alive) setMsg(`Failed to load: ${e?.message || e}`);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [meProp]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -61,6 +66,41 @@ export default function ProfileEditor({
|
|||
setProfileLocation(me.location || "");
|
||||
}, [me]);
|
||||
|
||||
// Auto-retry a pending sensitive update after coming back from reauth
|
||||
useEffect(() => {
|
||||
const raw = typeof window !== "undefined" ? sessionStorage.getItem("pendingProfileUpdate") : null;
|
||||
if (!raw) return;
|
||||
let pending: Record<string, any> | null = null;
|
||||
try {
|
||||
pending = JSON.parse(raw);
|
||||
} catch {
|
||||
pending = null;
|
||||
}
|
||||
if (!pending) {
|
||||
sessionStorage.removeItem("pendingProfileUpdate");
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pending),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Update after re-auth failed");
|
||||
} else {
|
||||
setMsg("Saved after re-authentication.");
|
||||
onUpdated?.();
|
||||
}
|
||||
} finally {
|
||||
sessionStorage.removeItem("pendingProfileUpdate");
|
||||
}
|
||||
})();
|
||||
}, [onUpdated]);
|
||||
|
||||
const onSave = async () => {
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
|
|
@ -76,9 +116,12 @@ export default function ProfileEditor({
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (r.status === 428) {
|
||||
// Need reauth for sensitive change (email)
|
||||
if (typeof window !== "undefined") {
|
||||
// Stash the pending payload so we can retry after reauth
|
||||
sessionStorage.setItem("pendingProfileUpdate", JSON.stringify(payload));
|
||||
window.location.assign(
|
||||
`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount + "#security")}`
|
||||
);
|
||||
|
|
@ -91,6 +134,7 @@ export default function ProfileEditor({
|
|||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Update failed");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue