From 16ae6d9c1c3b777d07d83c3c8e17e0f7e7b913fa Mon Sep 17 00:00:00 2001 From: makearmy Date: Tue, 30 Sep 2025 20:36:26 -0400 Subject: [PATCH] profile editor bug fixes --- app/api/account/password/route.ts | 12 +- app/api/account/profile/route.ts | 30 ++-- app/auth/sign-in/sign-in.tsx | 196 ++++++++++++++------------- components/account/ProfileEditor.tsx | 78 ++++++++--- 4 files changed, 194 insertions(+), 122 deletions(-) diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts index bbffcdcd..68783129 100644 --- a/app/api/account/password/route.ts +++ b/app/api/account/password/route.ts @@ -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); +} diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts index 8db06895..f6b47291 100644 --- a/app/api/account/profile/route.ts +++ b/app/api/account/profile/route.ts @@ -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 = {}; 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); diff --git a/app/auth/sign-in/sign-in.tsx b/app/auth/sign-in/sign-in.tsx index 77f05a02..55b24cdc 100644 --- a/app/auth/sign-in/sign-in.tsx +++ b/app/auth/sign-in/sign-in.tsx @@ -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(null); + const [password, setPassword] = useState(""); + const [showPassword, setShow] = useState(false); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); - const onSubmit = useCallback(async (e: React.FormEvent) => { - 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) => { + 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 (
-

- {reauth ? "Re-authenticate" : "Sign In"} -

+

{reauth ? "Re-authenticate" : "Sign In"}

- {reauth - ? "Please sign in again to continue." - : <>Use your email or username with your password.} -

-
+ {reauth ? "Please sign in again to continue." : <>Use your email or username with your password.} +

+
-
-
- - setIdentifier(e.currentTarget.value)} - required - /> -
+ +
+ + setIdentifier(e.currentTarget.value)} + required + /> +
-
-
- - -
- setPassword(e.currentTarget.value)} - required - /> -
+
+
+ + +
+ setPassword(e.currentTarget.value)} + required + /> +
- {err && ( -
- {err} -
- )} - - -
- - {!reauth && ( -
- New here?{" "} - Create an account -
- )} + {err && ( +
+ {err}
+ )} + + + + + {!reauth && ( +
+ New here?{" "} + + Create an account + +
+ )} + ); } diff --git a/components/account/ProfileEditor.tsx b/components/account/ProfileEditor.tsx index 83c56b75..03a0e7ce 100644 --- a/components/account/ProfileEditor.tsx +++ b/components/account/ProfileEditor.tsx @@ -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(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 | 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");