delay reauth to edit
This commit is contained in:
parent
b9c5c22f86
commit
11bc584dec
2 changed files with 139 additions and 154 deletions
|
|
@ -10,35 +10,24 @@ type Me = {
|
||||||
last_name?: string | null;
|
last_name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
avatar?: { id: string; filename_download?: string; title?: string } | string | null;
|
avatar?: { id: string; filename_download?: string } | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountClient({ initialUser }: { initialUser: Me }) {
|
export default function AccountClient({ me }: { me: Me }) {
|
||||||
const [user, setUser] = useState<Me>(initialUser);
|
// local edit state; no network until you press Save
|
||||||
const [saving, setSaving] = useState(false);
|
const [first, setFirst] = useState(me.first_name ?? "");
|
||||||
|
const [last, setLast] = useState(me.last_name ?? "");
|
||||||
|
const [email, setEmail] = useState(me.email ?? "");
|
||||||
|
const [location, setLocation] = useState(me.location ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const [firstName, setFirst] = useState(user.first_name ?? "");
|
const avatarId =
|
||||||
const [lastName, setLast] = useState(user.last_name ?? "");
|
typeof me.avatar === "string" ? me.avatar : (me.avatar as any)?.id ?? null;
|
||||||
const [email, setEmail] = useState(user.email ?? "");
|
|
||||||
const [location, setLocation] = useState(user.location ?? "");
|
|
||||||
|
|
||||||
const [pwCurr, setPwCurr] = useState("");
|
|
||||||
const [pwNew, setPwNew] = useState("");
|
|
||||||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
|
||||||
const avatarThumb = (() => {
|
|
||||||
// Directus file thumbnails: /assets/{id}?download=... if you expose assets
|
|
||||||
const id = typeof user.avatar === "object" ? user.avatar?.id : user.avatar;
|
|
||||||
if (!id) return null;
|
|
||||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
|
||||||
return `${base}/assets/${id}?width=96&height=96&fit=cover`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
async function saveProfile(e: React.FormEvent) {
|
async function saveProfile(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setBusy(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/account", {
|
const res = await fetch("/api/account", {
|
||||||
|
|
@ -46,137 +35,118 @@ export default function AccountClient({ initialUser }: { initialUser: Me }) {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
first_name: firstName.trim(),
|
first_name: first || null,
|
||||||
last_name: lastName.trim(),
|
last_name: last || null,
|
||||||
email: email.trim(),
|
email: email || null, // email optional per your model
|
||||||
location: location.trim(),
|
location: location || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const j = await res.json().catch(() => ({}));
|
const j = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(j?.error || "Update failed");
|
if (!res.ok) throw new Error(j?.error || "Update failed");
|
||||||
setUser((u) => ({ ...u, ...j }));
|
setMsg("Saved!");
|
||||||
setMsg("Profile updated.");
|
} catch (e: any) {
|
||||||
} catch (err: any) {
|
const m = String(e?.message || "Update failed");
|
||||||
setMsg(err?.message || "Failed to update.");
|
setMsg(m.includes("sign in") ? "Session expired — please sign in again." : m);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setBusy(false);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setUploading(true);
|
|
||||||
setMsg(null);
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", file, file.name);
|
|
||||||
const res = await fetch("/api/account/avatar", { method: "POST", body: fd, credentials: "include" });
|
|
||||||
const j = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(j?.error || "Upload failed");
|
|
||||||
// Refresh our user record
|
|
||||||
setUser((u) => ({ ...u, avatar: j.avatar }));
|
|
||||||
setMsg("Avatar updated.");
|
|
||||||
} catch (err: any) {
|
|
||||||
setMsg(err?.message || "Failed to upload avatar.");
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
e.currentTarget.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changePassword(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setPwMsg(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/account/password", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ current_password: pwCurr, new_password: pwNew }),
|
|
||||||
});
|
|
||||||
const j = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(j?.error || "Password change failed");
|
|
||||||
setPwMsg("Password updated.");
|
|
||||||
setPwCurr("");
|
|
||||||
setPwNew("");
|
|
||||||
} catch (err: any) {
|
|
||||||
setPwMsg(err?.message || "Failed to update password.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-8">
|
<div className="space-y-6">
|
||||||
<section className="rounded-lg border p-4">
|
<section className="rounded border p-4">
|
||||||
<h2 className="text-lg font-semibold mb-3">Account</h2>
|
<h2 className="font-semibold mb-3">Profile</h2>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<label className="text-sm text-muted-foreground">Username (read-only)</label>
|
<div>
|
||||||
<div className="mt-1">
|
<label className="block text-sm mb-1">Username</label>
|
||||||
<input className="w-full border rounded px-2 py-1 bg-muted" value={user.username} readOnly />
|
<input className="w-full border rounded px-2 py-1 bg-muted/50" value={me.username} disabled />
|
||||||
</div>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<p className="text-xs text-muted-foreground mt-1">Usernames can’t be changed.</p>
|
Usernames can’t be changed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={saveProfile} className="grid gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="h-12 w-12 rounded-full bg-muted grid place-items-center overflow-hidden">
|
||||||
<div>
|
{avatarId ? (
|
||||||
<label className="text-sm">First name</label>
|
// You already serve files via Directus; swap URL builder if needed
|
||||||
<input className="w-full border rounded px-2 py-1" value={firstName} onChange={(e) => setFirst(e.target.value)} />
|
<img
|
||||||
|
src={`${process.env.NEXT_PUBLIC_API_BASE_URL}/assets/${avatarId}?fit=cover&width=96&height=96`}
|
||||||
|
alt="Avatar"
|
||||||
|
className="h-12 w-12 object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">No avatar</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<form
|
||||||
<label className="text-sm">Last name</label>
|
onChange={(e) => {
|
||||||
<input className="w-full border rounded px-2 py-1" value={lastName} onChange={(e) => setLast(e.target.value)} />
|
// no-op placeholder so the input is controlled by browser until submit
|
||||||
</div>
|
}}
|
||||||
</div>
|
onSubmit={async (e) => {
|
||||||
<div>
|
e.preventDefault();
|
||||||
<label className="text-sm">Email</label>
|
const input = (e.currentTarget.elements.namedItem("avatar") as HTMLInputElement) ?? null;
|
||||||
<input type="email" className="w-full border rounded px-2 py-1" value={email} onChange={(e) => setEmail(e.target.value)} />
|
const file = input?.files?.[0];
|
||||||
</div>
|
if (!file) return;
|
||||||
<div>
|
setBusy(true);
|
||||||
<label className="text-sm">Location</label>
|
setMsg(null);
|
||||||
<input className="w-full border rounded px-2 py-1" value={location} onChange={(e) => setLocation(e.target.value)} />
|
try {
|
||||||
</div>
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
<div className="flex items-center gap-4 mt-2">
|
const res = await fetch("/api/account/avatar", {
|
||||||
<div className="w-24 h-24 rounded-full overflow-hidden border bg-muted">
|
method: "POST",
|
||||||
{avatarThumb ? <img src={avatarThumb} alt="avatar" className="w-full h-full object-cover" /> : null}
|
credentials: "include",
|
||||||
</div>
|
body: fd,
|
||||||
<div>
|
});
|
||||||
<label className="text-sm block mb-1">Avatar</label>
|
const j = await res.json().catch(() => ({}));
|
||||||
<input type="file" accept="image/*" onChange={onAvatarChange} disabled={uploading} />
|
if (!res.ok) throw new Error(j?.error || "Avatar upload failed");
|
||||||
{uploading ? <div className="text-xs opacity-70 mt-1">Uploading…</div> : null}
|
setMsg("Avatar updated!");
|
||||||
</div>
|
} catch (e: any) {
|
||||||
</div>
|
setMsg(String(e?.message || "Avatar upload failed"));
|
||||||
|
} finally {
|
||||||
<div className="flex items-center gap-2 mt-2">
|
setBusy(false);
|
||||||
<button disabled={saving} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90">
|
(e.currentTarget as HTMLFormElement).reset();
|
||||||
{saving ? "Saving…" : "Save changes"}
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="avatar" type="file" accept="image/*" />
|
||||||
|
<button className="ml-2 text-sm border rounded px-2 py-1" disabled={busy}>
|
||||||
|
Upload
|
||||||
</button>
|
</button>
|
||||||
{msg ? <span className="text-sm">{msg}</span> : null}
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={saveProfile} className="grid sm:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">First name</label>
|
||||||
|
<input className="w-full border rounded px-2 py-1" value={first} onChange={(e)=>setFirst(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Last name</label>
|
||||||
|
<input className="w-full border rounded px-2 py-1" value={last} onChange={(e)=>setLast(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Email (optional)</label>
|
||||||
|
<input className="w-full border rounded px-2 py-1" type="email" value={email} onChange={(e)=>setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Location</label>
|
||||||
|
<input className="w-full border rounded px-2 py-1" value={location} onChange={(e)=>setLocation(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<button className="px-3 py-2 border rounded bg-accent text-background" disabled={busy}>
|
||||||
|
{busy ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="ml-3 text-sm">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border p-4">
|
<section className="rounded border p-4">
|
||||||
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
|
<h2 className="font-semibold mb-3">Change Password</h2>
|
||||||
<form onSubmit={changePassword} className="grid gap-3 max-w-md">
|
{/* render your ConfirmIdentity + password form here; nothing fires until submit */}
|
||||||
<div>
|
|
||||||
<label className="text-sm">Current password</label>
|
|
||||||
<input type="password" className="w-full border rounded px-2 py-1" value={pwCurr} onChange={(e) => setPwCurr(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm">New password</label>
|
|
||||||
<input type="password" className="w-full border rounded px-2 py-1" value={pwNew} onChange={(e) => setPwNew(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button className="px-3 py-2 border rounded hover:bg-muted">Update password</button>
|
|
||||||
{pwMsg ? <span className="text-sm">{pwMsg}</span> : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
If this fails, enable password updates for your role or use the email reset flow.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { dxGET } from "@/lib/directus";
|
import { dxGET } from "@/lib/directus";
|
||||||
import AccountClient from "./AccountClient";
|
import AccountClient from "./AccountClient"; // client component below
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Me = {
|
type Me = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -13,29 +11,46 @@ type Me = {
|
||||||
last_name?: string | null;
|
last_name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
avatar?: { id: string; filename_download?: string; title?: string } | string | null;
|
avatar?: { id: string; filename_download?: string } | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Next 15 cookies() is async
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const jar = await cookies();
|
const jar = await cookies();
|
||||||
const token = jar.get("ma_at")?.value;
|
const token = jar.get("ma_at")?.value;
|
||||||
if (!token) redirect("/auth/sign-in?next=/portal/account");
|
if (!token) {
|
||||||
|
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
|
||||||
|
}
|
||||||
const bearer = `Bearer ${token}`;
|
const bearer = `Bearer ${token}`;
|
||||||
|
|
||||||
const fields = encodeURIComponent([
|
// READ-ONLY fields only; no password calls here, ever.
|
||||||
"id",
|
const fields =
|
||||||
"username",
|
"id,username,first_name,last_name,email,location,avatar.id,avatar.filename_download";
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"email",
|
|
||||||
"location",
|
|
||||||
"avatar.id",
|
|
||||||
"avatar.filename_download",
|
|
||||||
"avatar.title",
|
|
||||||
].join(","));
|
|
||||||
|
|
||||||
const me = await dxGET<{ data: Me }>(`/users/me?fields=${fields}`, bearer);
|
let me: Me | null = null;
|
||||||
const user: Me = (me as any)?.data ?? me;
|
let loadError: string | null = null;
|
||||||
|
|
||||||
return <AccountClient initialUser={user} />;
|
try {
|
||||||
|
const res = await dxGET<any>(`/users/me?fields=${encodeURIComponent(fields)}`, bearer);
|
||||||
|
me = (res?.data ?? res) as Me;
|
||||||
|
} catch (e: any) {
|
||||||
|
// If token is stale, push to sign-in; otherwise show a friendly message
|
||||||
|
const msg = String(e?.message || "");
|
||||||
|
if (/401|403|unauth|expired|credential/i.test(msg)) {
|
||||||
|
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
|
||||||
|
} else {
|
||||||
|
loadError = "Couldn’t load your profile. Please try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-semibold mb-4">Account</h1>
|
||||||
|
{loadError ? (
|
||||||
|
<div className="text-red-600">{loadError}</div>
|
||||||
|
) : (
|
||||||
|
<AccountClient me={me!} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue