211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
// components/account/ProfileEditor.tsx
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
type Me = {
|
|
id: string;
|
|
username: string;
|
|
first_name?: string | null;
|
|
last_name?: string | null;
|
|
email?: string | null;
|
|
location?: string | null;
|
|
avatar?: { id: string; filename_download?: string } | null;
|
|
};
|
|
|
|
export default function ProfileEditor({
|
|
me: meProp,
|
|
onUpdated,
|
|
}: {
|
|
me?: Me | null;
|
|
onUpdated?: () => void;
|
|
}) {
|
|
const [me, setMe] = useState<Me | null>(meProp ?? null);
|
|
const [first_name, setFirst] = useState("");
|
|
const [last_name, setLast] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
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);
|
|
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(() => {
|
|
if (!me) return;
|
|
setFirst(me.first_name || "");
|
|
setLast(me.last_name || "");
|
|
setEmail(me.email || "");
|
|
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);
|
|
try {
|
|
const payload: Record<string, any> = {
|
|
first_name: first_name.trim(),
|
|
last_name: last_name.trim(),
|
|
email: email.trim() || null, // allow clearing email
|
|
location: profileLocation.trim(),
|
|
};
|
|
const r = await fetch("/api/account/profile", {
|
|
method: "PATCH",
|
|
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")}`
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (r.status === 401) {
|
|
if (typeof window !== "undefined") {
|
|
window.location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount)}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
setMsg(j?.error || "Update failed");
|
|
return;
|
|
}
|
|
setMsg("Saved.");
|
|
onUpdated?.();
|
|
} catch (e: any) {
|
|
setMsg(e?.message || "Update failed");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (loading) return <div className="rounded-md border p-4 text-sm opacity-70">Loading editor…</div>;
|
|
|
|
return (
|
|
<div className="rounded-md border p-4 space-y-3">
|
|
<h3 className="font-semibold">Edit Profile</h3>
|
|
|
|
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
|
<label className="grid gap-1">
|
|
<span className="opacity-60">First Name</span>
|
|
<input
|
|
className="rounded-md border px-2 py-1"
|
|
value={first_name}
|
|
onChange={(e) => setFirst(e.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
<label className="grid gap-1">
|
|
<span className="opacity-60">Last Name</span>
|
|
<input
|
|
className="rounded-md border px-2 py-1"
|
|
value={last_name}
|
|
onChange={(e) => setLast(e.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
<label className="grid gap-1 sm:col-span-2">
|
|
<span className="opacity-60">Email (reauth required)</span>
|
|
<input
|
|
className="rounded-md border px-2 py-1"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@example.com"
|
|
/>
|
|
</label>
|
|
|
|
<label className="grid gap-1 sm:col-span-2">
|
|
<span className="opacity-60">Location</span>
|
|
<input
|
|
className="rounded-md border px-2 py-1"
|
|
value={profileLocation}
|
|
onChange={(e) => setProfileLocation(e.target.value)}
|
|
placeholder="City, Country"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={onSave}
|
|
disabled={busy}
|
|
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
|
>
|
|
{busy ? "Saving…" : "Save Changes"}
|
|
</button>
|
|
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|