Files
hope-events/frontend/src/app/dashboard/user/profile/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

366 lines
16 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
const router = useRouter();
useEffect(() => {
if (!user && !token) router.replace("/login");
}, [user, token, router]);
// ── Profile form ─────────────────────────────────────────────────────────
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const hasValidPhone = isValidZAPhone(phone);
useEffect(() => {
if (user) {
setName(user.name || "");
setEmail(user.email || "");
setPhone(user.phoneNumber || "");
setNotifPref(user.notificationPreference || "email");
}
}, [user]);
const saveProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setSavingProfile(true);
setProfileMsg(null);
try {
const res = await apiFetch<any>("/api/users/profile", {
method: "PUT",
authToken: token,
body: {
name,
email,
phoneNumber: phone || null,
notificationPreference: hasValidPhone ? notifPref : "email",
},
});
// Backend returns a refreshed token — save it so the session stays valid
if (res?.token) updateToken(res.token);
setProfileMsg({ type: "ok", text: "Profile updated." });
} catch (e: any) {
setProfileMsg({ type: "err", text: e?.message || "Failed to update profile." });
} finally {
setSavingProfile(false);
}
};
// ── Password change ───────────────────────────────────────────────────────
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pwMsg, setPwMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingPw, setSavingPw] = useState(false);
const changePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
setPwMsg({ type: "err", text: "Passwords do not match." });
return;
}
if (newPassword.length < 8) {
setPwMsg({ type: "err", text: "Password must be at least 8 characters." });
return;
}
if (!token) return;
setSavingPw(true);
setPwMsg(null);
try {
const res = await apiFetch<any>("/api/users/profile", {
method: "PUT",
authToken: token,
body: { currentPassword, password: newPassword },
});
if (res?.token) updateToken(res.token);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPwMsg({ type: "ok", text: "Password changed." });
} catch (e: any) {
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
} finally {
setSavingPw(false);
}
};
// ── Revoke sessions ───────────────────────────────────────────────────────
const [revokeMsg, setRevokeMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [revoking, setRevoking] = useState(false);
const revokeSessions = async () => {
if (!confirm("This will sign you out of all other devices. You will need to log in again everywhere except here. Continue?")) return;
if (!token) return;
setRevoking(true);
setRevokeMsg(null);
try {
const res = await apiFetch<any>("/api/users/revoke-sessions", {
method: "POST",
authToken: token,
});
// Revoking sessions invalidates the token this device was using too —
// save the freshly issued one so this device stays signed in.
if (res?.token) updateToken(res.token);
setRevokeMsg({ type: "ok", text: res?.message || "All other sessions signed out." });
} catch (e: any) {
setRevokeMsg({ type: "err", text: e?.message || "Failed to revoke sessions." });
} finally {
setRevoking(false);
}
};
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
const [closePassword, setClosePassword] = useState("");
const [closeMsg, setCloseMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [closing, setClosing] = useState(false);
const submitAccountClosure = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setClosing(true);
setCloseMsg(null);
try {
const res = await apiFetch<any>("/api/users/close-account", {
method: "POST",
authToken: token,
body: { password: closePassword, deleteData },
});
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
// Slight delay so the user can read the message, then log out
setTimeout(() => logout(), 2500);
} catch (e: any) {
setCloseMsg({ type: "err", text: e?.message || "Failed to close account." });
} finally {
setClosing(false);
}
};
// ── Shared helpers ────────────────────────────────────────────────────────
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{msg.text}
</div>
);
return (
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
<h1 className="text-2xl font-semibold">Profile &amp; Security</h1>
{/* ── Profile info ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="e.g. 082 123 4567"
/>
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={notifPref}
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
<button
type="submit"
disabled={savingProfile}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingProfile ? "Saving…" : "Save changes"}
</button>
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Password ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Change password</h2>
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={savingPw}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingPw ? "Changing…" : "Change password"}
</button>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-1">Security</h2>
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
</div>
);
}