NeonRyan
feat/Added front end support for settings page (#60)
44797d1 unverified
Raw
History Blame Contribute Delete
12.7 kB
import React, { useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { X, User as UserIcon, Lock, Trash2, AlertTriangle } from 'lucide-react';
const overlay = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
};
const modal = {
background: 'var(--bg-primary)', borderRadius: 16, padding: 0, width: 560,
maxWidth: '95vw', maxHeight: '85vh', overflow: 'hidden',
boxShadow: 'var(--shadow-xl)', display: 'flex', flexDirection: 'column',
};
const header = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '20px 24px', borderBottom: '1px solid var(--border-primary)',
};
const tabRow = {
display: 'flex', gap: 4, padding: '12px 16px 0',
borderBottom: '1px solid var(--border-primary)',
};
const tabBtn = (active) => ({
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', background: 'transparent',
border: 'none', borderBottom: active ? '2px solid var(--accent-primary)' : '2px solid transparent',
color: active ? 'var(--accent-primary)' : 'var(--text-secondary)',
cursor: 'pointer', fontSize: 13.5, fontWeight: 500,
marginBottom: -1,
});
const body = { padding: 24, overflowY: 'auto', flex: 1 };
const label = { display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 6 };
const input = {
width: '100%', padding: '10px 12px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontSize: 14, boxSizing: 'border-box',
};
const primaryBtn = {
padding: '10px 16px', background: 'var(--accent-primary)',
color: '#fff', border: 'none', borderRadius: 8,
cursor: 'pointer', fontSize: 14, fontWeight: 500,
};
const dangerBtn = {
padding: '10px 16px', background: '#dc2626',
color: '#fff', border: 'none', borderRadius: 8,
cursor: 'pointer', fontSize: 14, fontWeight: 500,
};
const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => {
const [activeTab, setActiveTab] = useState('profile');
// Track where the mouse went DOWN so we don't close the modal when a user
// drags to select text inside an input and the mouseup happens outside the modal.
// (React's onClick fires on the common ancestor of down+up, which can be the
// overlay itself — causing accidental close on text selection.)
const mouseDownOnOverlay = useRef(false);
const handleOverlayMouseDown = (e) => {
mouseDownOnOverlay.current = e.target === e.currentTarget;
};
const handleOverlayMouseUp = (e) => {
if (mouseDownOnOverlay.current && e.target === e.currentTarget) onClose();
mouseDownOnOverlay.current = false;
};
const [firstName, setFirstName] = useState(user?.firstName || '');
const [lastName, setLastName] = useState(user?.lastName || '');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [deleteConfirmPassword, setDeleteConfirmPassword] = useState('');
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [message, setMessage] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const apiUrl = process.env.REACT_APP_API_URL;
const extractError = (data, fallback) => {
if (!data) return fallback;
if (typeof data.detail === 'string') return data.detail;
if (Array.isArray(data.detail) && data.detail[0]?.msg) return data.detail[0].msg;
return fallback;
};
const handleProfileSubmit = async (e) => {
e.preventDefault();
setMessage(null);
if (!firstName.trim() && !lastName.trim()) {
setMessage({ type: 'error', text: 'Enter a first or last name.' });
return;
}
setIsSubmitting(true);
try {
const response = await fetch(`${apiUrl}/auth/me`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim(),
}),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
setMessage({ type: 'error', text: extractError(data, 'Could not update profile.') });
return;
}
onUserUpdate?.(data);
setFirstName(data.firstName || '');
setLastName(data.lastName || '');
setMessage({ type: 'success', text: 'Profile updated.' });
} catch (err) {
setMessage({ type: 'error', text: 'Network error. Please try again.' });
} finally {
setIsSubmitting(false);
}
};
const handlePasswordSubmit = async (e) => {
e.preventDefault();
setMessage(null);
if (newPassword !== confirmPassword) {
setMessage({ type: 'error', text: 'New passwords do not match.' });
return;
}
if (newPassword.length < 8) {
setMessage({ type: 'error', text: 'New password must be at least 8 characters.' });
return;
}
setIsSubmitting(true);
try {
const response = await fetch(`${apiUrl}/auth/me/password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
setMessage({ type: 'error', text: extractError(data, 'Could not change password.') });
return;
}
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setMessage({ type: 'success', text: 'Password changed.' });
} catch (err) {
setMessage({ type: 'error', text: 'Network error. Please try again.' });
} finally {
setIsSubmitting(false);
}
};
const handleDeleteAccount = async (e) => {
e.preventDefault();
setMessage(null);
if (deleteConfirmText !== 'DELETE') {
setMessage({ type: 'error', text: 'Type DELETE to confirm.' });
return;
}
if (!deleteConfirmPassword) {
setMessage({ type: 'error', text: 'Password required to delete account.' });
return;
}
setIsSubmitting(true);
try {
const response = await fetch(`${apiUrl}/auth/me`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ password: deleteConfirmPassword }),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
setMessage({ type: 'error', text: extractError(data, 'Could not delete account.') });
return;
}
onClose?.();
onSignOut?.();
} catch (err) {
setMessage({ type: 'error', text: 'Network error. Please try again.' });
} finally {
setIsSubmitting(false);
}
};
const messageStyle = (type) => ({
padding: '10px 12px', borderRadius: 8, marginBottom: 16, fontSize: 13,
background: type === 'error'
? 'rgba(220,38,38,0.1)'
: type === 'success'
? 'rgba(22,163,74,0.1)'
: 'var(--bg-secondary)',
color: type === 'error'
? '#dc2626'
: type === 'success'
? '#16a34a'
: 'var(--text-secondary)',
border: `1px solid ${
type === 'error'
? 'rgba(220,38,38,0.3)'
: type === 'success'
? 'rgba(22,163,74,0.3)'
: 'var(--border-primary)'
}`,
});
return ReactDOM.createPortal(
<div style={overlay} onMouseDown={handleOverlayMouseDown} onMouseUp={handleOverlayMouseUp}>
<div style={modal}>
<div style={header}>
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>Account Settings</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
<X size={20} />
</button>
</div>
<div style={tabRow}>
<button style={tabBtn(activeTab === 'profile')} onClick={() => { setActiveTab('profile'); setMessage(null); }}>
<UserIcon size={15} /> Profile
</button>
<button style={tabBtn(activeTab === 'password')} onClick={() => { setActiveTab('password'); setMessage(null); }}>
<Lock size={15} /> Password
</button>
<button style={tabBtn(activeTab === 'danger')} onClick={() => { setActiveTab('danger'); setMessage(null); }}>
<Trash2 size={15} /> Delete Account
</button>
</div>
<div style={body}>
{message && <div style={messageStyle(message.type)}>{message.text}</div>}
{activeTab === 'profile' && (
<form onSubmit={handleProfileSubmit}>
<div style={{ marginBottom: 16 }}>
<label style={label}>Email</label>
<input style={{ ...input, opacity: 0.6, cursor: 'not-allowed' }} value={user?.email || ''} disabled />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<div>
<label style={label}>First Name</label>
<input style={input} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<label style={label}>Last Name</label>
<input style={input} value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<button type="submit" style={primaryBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</form>
)}
{activeTab === 'password' && (
<form onSubmit={handlePasswordSubmit}>
<div style={{ marginBottom: 16 }}>
<label style={label}>Current Password</label>
<input type="password" style={input} value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required />
</div>
<div style={{ marginBottom: 16 }}>
<label style={label}>New Password</label>
<input type="password" style={input} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
</div>
<div style={{ marginBottom: 16 }}>
<label style={label}>Confirm New Password</label>
<input type="password" style={input} value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required />
</div>
<button type="submit" style={primaryBtn} disabled={isSubmitting}>
{isSubmitting ? 'Changing…' : 'Change Password'}
</button>
</form>
)}
{activeTab === 'danger' && (
<form onSubmit={handleDeleteAccount}>
<div style={{
display: 'flex', gap: 10, padding: 12, borderRadius: 8,
background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.3)',
marginBottom: 16,
}}>
<AlertTriangle size={18} style={{ color: '#dc2626', flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
Deleting your account is permanent. All chat history and personal data will be removed.
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label style={label}>Confirm Password</label>
<input type="password" style={input} value={deleteConfirmPassword} onChange={(e) => setDeleteConfirmPassword(e.target.value)} required />
</div>
<div style={{ marginBottom: 16 }}>
<label style={label}>Type <strong>DELETE</strong> to confirm</label>
<input style={input} value={deleteConfirmText} onChange={(e) => setDeleteConfirmText(e.target.value)} placeholder="DELETE" required />
</div>
<button type="submit" style={dangerBtn} disabled={isSubmitting}>
{isSubmitting ? 'Deleting…' : 'Permanently Delete Account'}
</button>
</form>
)}
</div>
</div>
</div>,
document.body
);
};
export default SettingsModal;