makearmy-app/app/portal/account/AccountClient.tsx
2025-09-30 00:56:23 -04:00

183 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/portal/account/AccountClient.tsx
"use client";
import { 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; title?: string } | string | null;
};
export default function AccountClient({ initialUser }: { initialUser: Me }) {
const [user, setUser] = useState<Me>(initialUser);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [firstName, setFirst] = useState(user.first_name ?? "");
const [lastName, setLast] = useState(user.last_name ?? "");
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) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await fetch("/api/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
location: location.trim(),
}),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j?.error || "Update failed");
setUser((u) => ({ ...u, ...j }));
setMsg("Profile updated.");
} catch (err: any) {
setMsg(err?.message || "Failed to update.");
} finally {
setSaving(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 (
<div className="max-w-2xl space-y-8">
<section className="rounded-lg border p-4">
<h2 className="text-lg font-semibold mb-3">Account</h2>
<div className="mb-4">
<label className="text-sm text-muted-foreground">Username (read-only)</label>
<div className="mt-1">
<input className="w-full border rounded px-2 py-1 bg-muted" value={user.username} readOnly />
</div>
<p className="text-xs text-muted-foreground mt-1">Usernames cant be changed.</p>
</div>
<form onSubmit={saveProfile} className="grid gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-sm">First name</label>
<input className="w-full border rounded px-2 py-1" value={firstName} onChange={(e) => setFirst(e.target.value)} />
</div>
<div>
<label className="text-sm">Last name</label>
<input className="w-full border rounded px-2 py-1" value={lastName} onChange={(e) => setLast(e.target.value)} />
</div>
</div>
<div>
<label className="text-sm">Email</label>
<input type="email" className="w-full border rounded px-2 py-1" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="text-sm">Location</label>
<input className="w-full border rounded px-2 py-1" value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
<div className="flex items-center gap-4 mt-2">
<div className="w-24 h-24 rounded-full overflow-hidden border bg-muted">
{avatarThumb ? <img src={avatarThumb} alt="avatar" className="w-full h-full object-cover" /> : null}
</div>
<div>
<label className="text-sm block mb-1">Avatar</label>
<input type="file" accept="image/*" onChange={onAvatarChange} disabled={uploading} />
{uploading ? <div className="text-xs opacity-70 mt-1">Uploading</div> : null}
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<button disabled={saving} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90">
{saving ? "Saving…" : "Save changes"}
</button>
{msg ? <span className="text-sm">{msg}</span> : null}
</div>
</form>
</section>
<section className="rounded-lg border p-4">
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
<form onSubmit={changePassword} className="grid gap-3 max-w-md">
<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>
</div>
);
}