fix(portal): add password validation and fix Report Cards retry

- PasswordChange: add useState hooks for all 3 password fields, password-
  match validation with inline error, disabled submit when fields are
  empty/mismatched, wired onClick handler with TODO for API integration
- ReportCards: extract fetch into loadReportCards(), replace
  window.location.reload() with loadReportCards() so SPA state (activeSection)
  is preserved on retry

Refs: GRO-287
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Barkley Trimsworth
2026-03-30 11:08:23 +00:00
parent 317915a10d
commit 8cdbb46eb2
2 changed files with 95 additions and 26 deletions
@@ -142,6 +142,47 @@ function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readO
}
function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const passwordsMatch = newPassword === confirmPassword;
const canSubmit =
!readOnly &&
currentPassword.length > 0 &&
newPassword.length > 0 &&
confirmPassword.length > 0 &&
passwordsMatch;
const handleSubmit = async () => {
setValidationError(null);
if (!passwordsMatch) {
setValidationError("Passwords do not match.");
return;
}
if (newPassword.length === 0 || confirmPassword.length === 0) {
setValidationError("All fields are required.");
return;
}
setSubmitting(true);
// TODO: wire up to actual password-change API endpoint (e.g., POST /api/portal/password)
// const response = await fetch("/api/portal/password", { ... });
setTimeout(() => {
setSubmitting(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
// On success, clear error and show confirmation (API integration needed first)
}, 500);
};
if (readOnly) {
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
@@ -156,18 +197,44 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
<div className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Current Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">New Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
<input
type="password"
value={newPassword}
onChange={e => { setNewPassword(e.target.value); setValidationError(null); }}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Confirm New Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
<input
type="password"
value={confirmPassword}
onChange={e => { setConfirmPassword(e.target.value); setValidationError(null); }}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Update Password
{validationError && (
<p className="text-sm text-red-500">{validationError}</p>
)}
<button
onClick={handleSubmit}
disabled={!canSubmit || submitting}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
canSubmit && !submitting
? "bg-(--color-accent) text-white hover:bg-(--color-accent-hover)"
: "bg-stone-100 text-stone-400 cursor-not-allowed"
}`}
>
{submitting ? "Updating..." : "Update Password"}
</button>
</div>
</div>
+23 -21
View File
@@ -30,29 +30,31 @@ export function ReportCards() {
const [error, setError] = useState<string | null>(null);
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
useEffect(() => {
const fetchReportCards = async () => {
try {
const response = await fetch("/api/portal/appointments");
const loadReportCards = async () => {
try {
setError(null);
setIsLoading(true);
const response = await fetch("/api/portal/appointments");
if (response.ok) {
const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter(
(appt) => appt.reportCardId
);
setAppointments(reportCardAppointments);
} else {
setError("Failed to load report cards.");
}
} catch {
setError("Failed to load report cards. Please try again.");
} finally {
setIsLoading(false);
if (response.ok) {
const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter(
(appt) => appt.reportCardId
);
setAppointments(reportCardAppointments);
} else {
setError("Failed to load report cards.");
}
};
} catch {
setError("Failed to load report cards. Please try again.");
} finally {
setIsLoading(false);
}
};
fetchReportCards();
useEffect(() => {
loadReportCards();
}, []);
if (isLoading) {
@@ -69,7 +71,7 @@ export function ReportCards() {
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={() => loadReportCards()}
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
>
Retry