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>
366 lines
16 KiB
TypeScript
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 & 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 & 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 "Deleted User".
|
|
</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>
|
|
);
|
|
} |