account page render fix
This commit is contained in:
parent
543a875169
commit
6c0278c624
2 changed files with 97 additions and 174 deletions
|
|
@ -1,7 +1,8 @@
|
|||
// app/portal/account/AccountClient.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
|
|
@ -10,144 +11,112 @@ type Me = {
|
|||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
location?: string | null;
|
||||
avatar?: { id: string; filename_download?: string } | string | null;
|
||||
avatar?: { id: string; filename_download?: string } | null;
|
||||
};
|
||||
|
||||
export default function AccountClient({ me }: { me: Me }) {
|
||||
// local edit state; no network until you press Save
|
||||
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);
|
||||
export default function AccountClient() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needReauth, setNeedReauth] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const avatarId =
|
||||
typeof me.avatar === "string" ? me.avatar : (me.avatar as any)?.id ?? null;
|
||||
|
||||
async function saveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNeedReauth(false);
|
||||
try {
|
||||
const res = await fetch("/api/account", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
first_name: first || null,
|
||||
last_name: last || null,
|
||||
email: email || null, // email optional per your model
|
||||
location: location || null,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j?.error || "Update failed");
|
||||
setMsg("Saved!");
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setNeedReauth(true);
|
||||
setMe(null);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.error || `Failed: ${res.status}`);
|
||||
}
|
||||
const j = await res.json();
|
||||
setMe(j as Me);
|
||||
} catch (e: any) {
|
||||
const m = String(e?.message || "Update failed");
|
||||
setMsg(m.includes("sign in") ? "Session expired — please sign in again." : m);
|
||||
setError(e?.message || "Failed to load account");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm opacity-70">Loading account…</div>;
|
||||
}
|
||||
|
||||
if (needReauth) {
|
||||
return (
|
||||
<div className="p-6 max-w-xl">
|
||||
<div className="rounded-md border p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Confirm it’s you</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
For security, please sign in again before changing account details.
|
||||
</p>
|
||||
<Link
|
||||
href={`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`}
|
||||
className="inline-block px-3 py-2 rounded-md border bg-accent text-background"
|
||||
>
|
||||
Re-authenticate
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 max-w-xl">
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="text-red-600 mb-2">Error: {error}</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-3 py-2 rounded-md border hover:bg-muted text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded border p-4">
|
||||
<h2 className="font-semibold mb-3">Profile</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Username</label>
|
||||
<input className="w-full border rounded px-2 py-1 bg-muted/50" value={me.username} disabled />
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Usernames can’t be changed.
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
{/* View-only header */}
|
||||
<section className="rounded-md border p-4">
|
||||
<h1 className="text-xl font-semibold mb-2">Account</h1>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Username:</span> {me.username}{" "}
|
||||
<span className="text-muted-foreground">(usernames can’t be changed)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-muted grid place-items-center overflow-hidden">
|
||||
{avatarId ? (
|
||||
// You already serve files via Directus; swap URL builder if needed
|
||||
<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>
|
||||
<form
|
||||
onChange={(e) => {
|
||||
// no-op placeholder so the input is controlled by browser until submit
|
||||
}}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const input = (e.currentTarget.elements.namedItem("avatar") as HTMLInputElement) ?? null;
|
||||
const file = input?.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/account/avatar", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: fd,
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j?.error || "Avatar upload failed");
|
||||
setMsg("Avatar updated!");
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message || "Avatar upload failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
(e.currentTarget as HTMLFormElement).reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input name="avatar" type="file" accept="image/*" />
|
||||
<button className="ml-2 text-sm border rounded px-2 py-1" disabled={busy}>
|
||||
Upload
|
||||
</button>
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded border p-4">
|
||||
<h2 className="font-semibold mb-3">Change Password</h2>
|
||||
{/* render your ConfirmIdentity + password form here; nothing fires until submit */}
|
||||
</section>
|
||||
</div>
|
||||
{/* Your existing edit forms can sit here, wired to:
|
||||
- PATCH /api/account for first_name, last_name, email (optional), location
|
||||
- POST /api/account/password for password change (asks current password in the form)
|
||||
- POST /api/account/avatar for avatar upload (to your folder)
|
||||
None of these fire automatically; only on submit. */}
|
||||
|
||||
{/* Example placeholder showing current profile fields */}
|
||||
<section className="rounded-md border p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Profile</h2>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div><span className="text-muted-foreground">First name:</span> {me.first_name || "—"}</div>
|
||||
<div><span className="text-muted-foreground">Last name:</span> {me.last_name || "—"}</div>
|
||||
<div><span className="text-muted-foreground">Email:</span> {me.email || "—"}</div>
|
||||
<div><span className="text-muted-foreground">Location:</span> {me.location || "—"}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,8 @@
|
|||
// app/portal/account/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { dxGET } from "@/lib/directus";
|
||||
export const dynamic = "force-dynamic"; // don't cache this page
|
||||
|
||||
import AccountClient from "./AccountClient";
|
||||
|
||||
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 } | string | null;
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const jar = await cookies();
|
||||
const token = jar.get("ma_at")?.value;
|
||||
if (!token) {
|
||||
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
|
||||
}
|
||||
const bearer = `Bearer ${token}`;
|
||||
|
||||
const fields =
|
||||
"id,username,first_name,last_name,email,location,avatar.id,avatar.filename_download";
|
||||
|
||||
let me: Me | null = null;
|
||||
let loadError: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await dxGET<any>(`/users/me?fields=${encodeURIComponent(fields)}`, bearer);
|
||||
me = (res?.data ?? res) as Me;
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? 0;
|
||||
const msg = String(e?.message || "");
|
||||
// Only force reauth on auth errors
|
||||
if (status === 401 || status === 403 || /unauth|expired|credential/i.test(msg)) {
|
||||
redirect(`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`);
|
||||
}
|
||||
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>
|
||||
);
|
||||
export default function Page() {
|
||||
return <AccountClient />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue