feat: users can change their own password via user portal

This commit is contained in:
2026-06-29 18:09:54 -05:00
parent 765465348e
commit 6b7720d2d8
4 changed files with 141 additions and 16 deletions
-1
View File
@@ -1789,7 +1789,6 @@ function UsersSection({ onToast }: { onToast: (msg: string, type: string) => voi
<div style={{ display: 'grid', gap: 12 }}>
<I label="First Name" value={editing.first_name || ''} onChange={(v: string) => setEditing({ ...editing, first_name: v })} />
<I label="Last Name" value={editing.last_name || ''} onChange={(v: string) => setEditing({ ...editing, last_name: v })} />
<I label="New Password (leave blank to keep current)" type="password" value={editing.password || ''} onChange={(v: string) => setEditing({ ...editing, password: v })} placeholder="Enter new password to change" />
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={editing.comments_disabled || false} onChange={(e) => setEditing({ ...editing, comments_disabled: e.target.checked })} />
<label style={{ fontSize: 13, color: C.text }}>Disable comments</label>
+1 -15
View File
@@ -37,21 +37,7 @@ export async function PUT(request: NextRequest) {
try {
await requireRole('admin');
const body = await request.json();
const { id, first_name, last_name, comments_disabled, password } = body;
// If password provided, hash and update it too
if (password) {
const { hashPassword } = await import('@/lib/auth');
const hash = await hashPassword(password);
const { rows } = await db.query(
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3, password_hash = $4 WHERE id = $5 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
[first_name || null, last_name || null, comments_disabled === true, hash, id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ data: rows[0] });
}
const { id, first_name, last_name, comments_disabled } = body;
const { rows } = await db.query(
'UPDATE users SET first_name = $1, last_name = $2, comments_disabled = $3 WHERE id = $4 RETURNING id, username, role, first_name, last_name, comments_disabled, created_at',
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, hashPassword } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const session = await requireAuth();
const body = await request.json();
const { current_password, new_password } = body;
if (!current_password || !new_password) {
return NextResponse.json({ error: 'Current and new password are required' }, { status: 400 });
}
if (new_password.length < 4) {
return NextResponse.json({ error: 'Password must be at least 4 characters' }, { status: 400 });
}
// Verify current password
const { rows } = await db.query('SELECT password_hash FROM users WHERE id = $1', [session.id]);
if (rows.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const { verifyPassword } = await import('@/lib/auth');
const valid = await verifyPassword(current_password, rows[0].password_hash);
if (!valid) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
}
// Update to new password
const hash = await hashPassword(new_password);
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, session.id]);
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to change password' }, { status: err.message === 'Unauthorized' ? 401 : 500 });
}
}
+101
View File
@@ -215,6 +215,11 @@ export default function UserPage() {
</div>
</Section>
{/* Change Password */}
<Section title="Change Password">
<ChangePassword />
</Section>
{/* Chat with Admins */}
<Section title="Message Staff">
<div style={{ background: cream, borderRadius: '16px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(1,13,30,0.08)' }}>
@@ -268,4 +273,100 @@ function Section({ title, children, highlight }: { title: string; children: Reac
{children}
</section>
);
}
function ChangePassword() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
if (newPassword.length < 4) {
setMessage({ text: 'Password must be at least 4 characters', type: 'error' });
return;
}
if (newPassword !== confirmPassword) {
setMessage({ text: 'Passwords do not match', type: 'error' });
return;
}
setSaving(true);
try {
const res = await fetch('/api/user/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
if (res.ok) {
setMessage({ text: 'Password changed successfully!', type: 'success' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
const err = await res.json();
setMessage({ text: err.error || 'Failed to change password', type: 'error' });
}
} catch {
setMessage({ text: 'Error changing password', type: 'error' });
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} style={{ background: cream, padding: '1.5rem', borderRadius: '16px', boxShadow: '0 2px 8px rgba(1,13,30,0.08)', maxWidth: '400px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
</div>
<div>
<label style={{ fontSize: '13px', fontWeight: 600, color: navy, display: 'block', marginBottom: '0.5rem' }}>Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
style={{ width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', border: '1px solid #ddd', fontSize: '14px', outline: 'none' }}
/>
</div>
{message && (
<div style={{ padding: '0.75rem', borderRadius: '8px', background: message.type === 'success' ? '#d4edda' : '#f8d7da', color: message.type === 'success' ? '#155724' : '#721c24', fontSize: '14px' }}>
{message.text}
</div>
)}
<button
type="submit"
disabled={saving || !currentPassword || !newPassword || !confirmPassword}
style={{ padding: '0.75rem 1.5rem', background: gold, color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 600, cursor: saving ? 'wait' : 'pointer', opacity: saving ? 0.7 : 1 }}
>
{saving ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
);
}