NeonRyan commited on
Commit
44797d1
·
unverified ·
1 Parent(s): e0d7a62

feat/Added front end support for settings page (#60)

Browse files

* Added front end support for settings page.

* Wired up backend endpoints to frontend UI.

* Bug Fix

phd-advisor-frontend/src/App.js CHANGED
@@ -59,6 +59,11 @@ function App() {
59
  setCurrentView('chat');
60
  };
61
 
 
 
 
 
 
62
  const handleSignOut = () => {
63
  localStorage.removeItem('authToken');
64
  localStorage.removeItem('user');
@@ -96,6 +101,7 @@ function App() {
96
  onNavigateToHome={navigateToHome}
97
  onNavigateToCanvas={navigateToCanvas}
98
  onSignOut={handleSignOut}
 
99
  />
100
  )}
101
  {/* Global help center — listens for the 'open-user-guide' event */}
 
59
  setCurrentView('chat');
60
  };
61
 
62
+ const handleUserUpdate = (updatedUser) => {
63
+ setUser(updatedUser);
64
+ localStorage.setItem('user', JSON.stringify(updatedUser));
65
+ };
66
+
67
  const handleSignOut = () => {
68
  localStorage.removeItem('authToken');
69
  localStorage.removeItem('user');
 
101
  onNavigateToHome={navigateToHome}
102
  onNavigateToCanvas={navigateToCanvas}
103
  onSignOut={handleSignOut}
104
+ onUserUpdate={handleUserUpdate}
105
  />
106
  )}
107
  {/* Global help center — listens for the 'open-user-guide' event */}
phd-advisor-frontend/src/components/SettingsModal.js ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import { X, User as UserIcon, Lock, Trash2, AlertTriangle } from 'lucide-react';
4
+
5
+ const overlay = {
6
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
7
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
8
+ };
9
+
10
+ const modal = {
11
+ background: 'var(--bg-primary)', borderRadius: 16, padding: 0, width: 560,
12
+ maxWidth: '95vw', maxHeight: '85vh', overflow: 'hidden',
13
+ boxShadow: 'var(--shadow-xl)', display: 'flex', flexDirection: 'column',
14
+ };
15
+
16
+ const header = {
17
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
18
+ padding: '20px 24px', borderBottom: '1px solid var(--border-primary)',
19
+ };
20
+
21
+ const tabRow = {
22
+ display: 'flex', gap: 4, padding: '12px 16px 0',
23
+ borderBottom: '1px solid var(--border-primary)',
24
+ };
25
+
26
+ const tabBtn = (active) => ({
27
+ display: 'flex', alignItems: 'center', gap: 8,
28
+ padding: '10px 14px', background: 'transparent',
29
+ border: 'none', borderBottom: active ? '2px solid var(--accent-primary)' : '2px solid transparent',
30
+ color: active ? 'var(--accent-primary)' : 'var(--text-secondary)',
31
+ cursor: 'pointer', fontSize: 13.5, fontWeight: 500,
32
+ marginBottom: -1,
33
+ });
34
+
35
+ const body = { padding: 24, overflowY: 'auto', flex: 1 };
36
+
37
+ const label = { display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 6 };
38
+
39
+ const input = {
40
+ width: '100%', padding: '10px 12px', borderRadius: 8,
41
+ border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
42
+ color: 'var(--text-primary)', fontSize: 14, boxSizing: 'border-box',
43
+ };
44
+
45
+ const primaryBtn = {
46
+ padding: '10px 16px', background: 'var(--accent-primary)',
47
+ color: '#fff', border: 'none', borderRadius: 8,
48
+ cursor: 'pointer', fontSize: 14, fontWeight: 500,
49
+ };
50
+
51
+ const dangerBtn = {
52
+ padding: '10px 16px', background: '#dc2626',
53
+ color: '#fff', border: 'none', borderRadius: 8,
54
+ cursor: 'pointer', fontSize: 14, fontWeight: 500,
55
+ };
56
+
57
+ const SettingsModal = ({ user, authToken, onUserUpdate, onSignOut, onClose }) => {
58
+ const [activeTab, setActiveTab] = useState('profile');
59
+
60
+ // Track where the mouse went DOWN so we don't close the modal when a user
61
+ // drags to select text inside an input and the mouseup happens outside the modal.
62
+ // (React's onClick fires on the common ancestor of down+up, which can be the
63
+ // overlay itself — causing accidental close on text selection.)
64
+ const mouseDownOnOverlay = useRef(false);
65
+ const handleOverlayMouseDown = (e) => {
66
+ mouseDownOnOverlay.current = e.target === e.currentTarget;
67
+ };
68
+ const handleOverlayMouseUp = (e) => {
69
+ if (mouseDownOnOverlay.current && e.target === e.currentTarget) onClose();
70
+ mouseDownOnOverlay.current = false;
71
+ };
72
+
73
+ const [firstName, setFirstName] = useState(user?.firstName || '');
74
+ const [lastName, setLastName] = useState(user?.lastName || '');
75
+
76
+ const [currentPassword, setCurrentPassword] = useState('');
77
+ const [newPassword, setNewPassword] = useState('');
78
+ const [confirmPassword, setConfirmPassword] = useState('');
79
+
80
+ const [deleteConfirmPassword, setDeleteConfirmPassword] = useState('');
81
+ const [deleteConfirmText, setDeleteConfirmText] = useState('');
82
+
83
+ const [message, setMessage] = useState(null);
84
+ const [isSubmitting, setIsSubmitting] = useState(false);
85
+
86
+ const apiUrl = process.env.REACT_APP_API_URL;
87
+
88
+ const extractError = (data, fallback) => {
89
+ if (!data) return fallback;
90
+ if (typeof data.detail === 'string') return data.detail;
91
+ if (Array.isArray(data.detail) && data.detail[0]?.msg) return data.detail[0].msg;
92
+ return fallback;
93
+ };
94
+
95
+ const handleProfileSubmit = async (e) => {
96
+ e.preventDefault();
97
+ setMessage(null);
98
+ if (!firstName.trim() && !lastName.trim()) {
99
+ setMessage({ type: 'error', text: 'Enter a first or last name.' });
100
+ return;
101
+ }
102
+ setIsSubmitting(true);
103
+ try {
104
+ const response = await fetch(`${apiUrl}/auth/me`, {
105
+ method: 'PATCH',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ Authorization: `Bearer ${authToken}`,
109
+ },
110
+ body: JSON.stringify({
111
+ first_name: firstName.trim(),
112
+ last_name: lastName.trim(),
113
+ }),
114
+ });
115
+ const data = await response.json().catch(() => null);
116
+ if (!response.ok) {
117
+ setMessage({ type: 'error', text: extractError(data, 'Could not update profile.') });
118
+ return;
119
+ }
120
+ onUserUpdate?.(data);
121
+ setFirstName(data.firstName || '');
122
+ setLastName(data.lastName || '');
123
+ setMessage({ type: 'success', text: 'Profile updated.' });
124
+ } catch (err) {
125
+ setMessage({ type: 'error', text: 'Network error. Please try again.' });
126
+ } finally {
127
+ setIsSubmitting(false);
128
+ }
129
+ };
130
+
131
+ const handlePasswordSubmit = async (e) => {
132
+ e.preventDefault();
133
+ setMessage(null);
134
+ if (newPassword !== confirmPassword) {
135
+ setMessage({ type: 'error', text: 'New passwords do not match.' });
136
+ return;
137
+ }
138
+ if (newPassword.length < 8) {
139
+ setMessage({ type: 'error', text: 'New password must be at least 8 characters.' });
140
+ return;
141
+ }
142
+ setIsSubmitting(true);
143
+ try {
144
+ const response = await fetch(`${apiUrl}/auth/me/password`, {
145
+ method: 'POST',
146
+ headers: {
147
+ 'Content-Type': 'application/json',
148
+ Authorization: `Bearer ${authToken}`,
149
+ },
150
+ body: JSON.stringify({
151
+ current_password: currentPassword,
152
+ new_password: newPassword,
153
+ }),
154
+ });
155
+ const data = await response.json().catch(() => null);
156
+ if (!response.ok) {
157
+ setMessage({ type: 'error', text: extractError(data, 'Could not change password.') });
158
+ return;
159
+ }
160
+ setCurrentPassword('');
161
+ setNewPassword('');
162
+ setConfirmPassword('');
163
+ setMessage({ type: 'success', text: 'Password changed.' });
164
+ } catch (err) {
165
+ setMessage({ type: 'error', text: 'Network error. Please try again.' });
166
+ } finally {
167
+ setIsSubmitting(false);
168
+ }
169
+ };
170
+
171
+ const handleDeleteAccount = async (e) => {
172
+ e.preventDefault();
173
+ setMessage(null);
174
+ if (deleteConfirmText !== 'DELETE') {
175
+ setMessage({ type: 'error', text: 'Type DELETE to confirm.' });
176
+ return;
177
+ }
178
+ if (!deleteConfirmPassword) {
179
+ setMessage({ type: 'error', text: 'Password required to delete account.' });
180
+ return;
181
+ }
182
+ setIsSubmitting(true);
183
+ try {
184
+ const response = await fetch(`${apiUrl}/auth/me`, {
185
+ method: 'DELETE',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ Authorization: `Bearer ${authToken}`,
189
+ },
190
+ body: JSON.stringify({ password: deleteConfirmPassword }),
191
+ });
192
+ const data = await response.json().catch(() => null);
193
+ if (!response.ok) {
194
+ setMessage({ type: 'error', text: extractError(data, 'Could not delete account.') });
195
+ return;
196
+ }
197
+ onClose?.();
198
+ onSignOut?.();
199
+ } catch (err) {
200
+ setMessage({ type: 'error', text: 'Network error. Please try again.' });
201
+ } finally {
202
+ setIsSubmitting(false);
203
+ }
204
+ };
205
+
206
+ const messageStyle = (type) => ({
207
+ padding: '10px 12px', borderRadius: 8, marginBottom: 16, fontSize: 13,
208
+ background: type === 'error'
209
+ ? 'rgba(220,38,38,0.1)'
210
+ : type === 'success'
211
+ ? 'rgba(22,163,74,0.1)'
212
+ : 'var(--bg-secondary)',
213
+ color: type === 'error'
214
+ ? '#dc2626'
215
+ : type === 'success'
216
+ ? '#16a34a'
217
+ : 'var(--text-secondary)',
218
+ border: `1px solid ${
219
+ type === 'error'
220
+ ? 'rgba(220,38,38,0.3)'
221
+ : type === 'success'
222
+ ? 'rgba(22,163,74,0.3)'
223
+ : 'var(--border-primary)'
224
+ }`,
225
+ });
226
+
227
+ return ReactDOM.createPortal(
228
+ <div style={overlay} onMouseDown={handleOverlayMouseDown} onMouseUp={handleOverlayMouseUp}>
229
+ <div style={modal}>
230
+ <div style={header}>
231
+ <h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>Account Settings</h3>
232
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
233
+ <X size={20} />
234
+ </button>
235
+ </div>
236
+
237
+ <div style={tabRow}>
238
+ <button style={tabBtn(activeTab === 'profile')} onClick={() => { setActiveTab('profile'); setMessage(null); }}>
239
+ <UserIcon size={15} /> Profile
240
+ </button>
241
+ <button style={tabBtn(activeTab === 'password')} onClick={() => { setActiveTab('password'); setMessage(null); }}>
242
+ <Lock size={15} /> Password
243
+ </button>
244
+ <button style={tabBtn(activeTab === 'danger')} onClick={() => { setActiveTab('danger'); setMessage(null); }}>
245
+ <Trash2 size={15} /> Delete Account
246
+ </button>
247
+ </div>
248
+
249
+ <div style={body}>
250
+ {message && <div style={messageStyle(message.type)}>{message.text}</div>}
251
+
252
+ {activeTab === 'profile' && (
253
+ <form onSubmit={handleProfileSubmit}>
254
+ <div style={{ marginBottom: 16 }}>
255
+ <label style={label}>Email</label>
256
+ <input style={{ ...input, opacity: 0.6, cursor: 'not-allowed' }} value={user?.email || ''} disabled />
257
+ </div>
258
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
259
+ <div>
260
+ <label style={label}>First Name</label>
261
+ <input style={input} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
262
+ </div>
263
+ <div>
264
+ <label style={label}>Last Name</label>
265
+ <input style={input} value={lastName} onChange={(e) => setLastName(e.target.value)} />
266
+ </div>
267
+ </div>
268
+ <button type="submit" style={primaryBtn} disabled={isSubmitting}>
269
+ {isSubmitting ? 'Saving…' : 'Save Changes'}
270
+ </button>
271
+ </form>
272
+ )}
273
+
274
+ {activeTab === 'password' && (
275
+ <form onSubmit={handlePasswordSubmit}>
276
+ <div style={{ marginBottom: 16 }}>
277
+ <label style={label}>Current Password</label>
278
+ <input type="password" style={input} value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required />
279
+ </div>
280
+ <div style={{ marginBottom: 16 }}>
281
+ <label style={label}>New Password</label>
282
+ <input type="password" style={input} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
283
+ </div>
284
+ <div style={{ marginBottom: 16 }}>
285
+ <label style={label}>Confirm New Password</label>
286
+ <input type="password" style={input} value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required />
287
+ </div>
288
+ <button type="submit" style={primaryBtn} disabled={isSubmitting}>
289
+ {isSubmitting ? 'Changing…' : 'Change Password'}
290
+ </button>
291
+ </form>
292
+ )}
293
+
294
+ {activeTab === 'danger' && (
295
+ <form onSubmit={handleDeleteAccount}>
296
+ <div style={{
297
+ display: 'flex', gap: 10, padding: 12, borderRadius: 8,
298
+ background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.3)',
299
+ marginBottom: 16,
300
+ }}>
301
+ <AlertTriangle size={18} style={{ color: '#dc2626', flexShrink: 0, marginTop: 2 }} />
302
+ <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
303
+ Deleting your account is permanent. All chat history and personal data will be removed.
304
+ </div>
305
+ </div>
306
+ <div style={{ marginBottom: 16 }}>
307
+ <label style={label}>Confirm Password</label>
308
+ <input type="password" style={input} value={deleteConfirmPassword} onChange={(e) => setDeleteConfirmPassword(e.target.value)} required />
309
+ </div>
310
+ <div style={{ marginBottom: 16 }}>
311
+ <label style={label}>Type <strong>DELETE</strong> to confirm</label>
312
+ <input style={input} value={deleteConfirmText} onChange={(e) => setDeleteConfirmText(e.target.value)} placeholder="DELETE" required />
313
+ </div>
314
+ <button type="submit" style={dangerBtn} disabled={isSubmitting}>
315
+ {isSubmitting ? 'Deleting…' : 'Permanently Delete Account'}
316
+ </button>
317
+ </form>
318
+ )}
319
+ </div>
320
+ </div>
321
+ </div>,
322
+ document.body
323
+ );
324
+ };
325
+
326
+ export default SettingsModal;
phd-advisor-frontend/src/components/Sidebar.js CHANGED
@@ -16,14 +16,16 @@ import {
16
  import { useAppConfig } from '../contexts/AppConfigContext';
17
  import ConfirmDialog from './ConfirmDialog';
18
  import CopyrightNotice from './CopyrightNotice';
 
19
  import '../styles/Sidebar.css';
20
 
21
- const Sidebar = ({
22
- user,
23
- currentSessionId,
24
- onSelectSession,
25
- onNewChat,
26
  onSignOut,
 
27
  authToken,
28
  onSidebarToggle,
29
  isMobileOpen = false,
@@ -38,6 +40,7 @@ const Sidebar = ({
38
  const [searchTerm, setSearchTerm] = useState('');
39
  const [isLoading, setIsLoading] = useState(true);
40
  const [showUserMenu, setShowUserMenu] = useState(false);
 
41
  const [isCollapsed, setIsCollapsed] = useState(false);
42
  const [isCreatingNewChat, setIsCreatingNewChat] = useState(false);
43
  const [showClearAllConfirm, setShowClearAllConfirm] = useState(false);
@@ -239,7 +242,10 @@ const Sidebar = ({
239
 
240
  {showUserMenu && (
241
  <div className="user-menu">
242
- <button className="user-menu-item">
 
 
 
243
  <Settings size={16} />
244
  <span>Settings</span>
245
  </button>
@@ -425,6 +431,15 @@ const Sidebar = ({
425
  onConfirm={handleClearAllChats}
426
  onCancel={() => setShowClearAllConfirm(false)}
427
  />
 
 
 
 
 
 
 
 
 
428
  </>
429
  );
430
  };
 
16
  import { useAppConfig } from '../contexts/AppConfigContext';
17
  import ConfirmDialog from './ConfirmDialog';
18
  import CopyrightNotice from './CopyrightNotice';
19
+ import SettingsModal from './SettingsModal';
20
  import '../styles/Sidebar.css';
21
 
22
+ const Sidebar = ({
23
+ user,
24
+ currentSessionId,
25
+ onSelectSession,
26
+ onNewChat,
27
  onSignOut,
28
+ onUserUpdate,
29
  authToken,
30
  onSidebarToggle,
31
  isMobileOpen = false,
 
40
  const [searchTerm, setSearchTerm] = useState('');
41
  const [isLoading, setIsLoading] = useState(true);
42
  const [showUserMenu, setShowUserMenu] = useState(false);
43
+ const [showSettings, setShowSettings] = useState(false);
44
  const [isCollapsed, setIsCollapsed] = useState(false);
45
  const [isCreatingNewChat, setIsCreatingNewChat] = useState(false);
46
  const [showClearAllConfirm, setShowClearAllConfirm] = useState(false);
 
242
 
243
  {showUserMenu && (
244
  <div className="user-menu">
245
+ <button
246
+ className="user-menu-item"
247
+ onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
248
+ >
249
  <Settings size={16} />
250
  <span>Settings</span>
251
  </button>
 
431
  onConfirm={handleClearAllChats}
432
  onCancel={() => setShowClearAllConfirm(false)}
433
  />
434
+ {showSettings && (
435
+ <SettingsModal
436
+ user={user}
437
+ authToken={authToken}
438
+ onUserUpdate={onUserUpdate}
439
+ onSignOut={onSignOut}
440
+ onClose={() => setShowSettings(false)}
441
+ />
442
+ )}
443
  </>
444
  );
445
  };
phd-advisor-frontend/src/pages/ChatPage.js CHANGED
@@ -17,7 +17,7 @@ import '../styles/EnhancedChatInput.css';
17
  import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown';
18
  import AdvisorCarousel from '../components/AdvisorCarousel';
19
 
20
- const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => {
21
  const { config, advisors, getAdvisorColors } = useAppConfig();
22
  const [messages, setMessages] = useState([]);
23
  const [isLoading, setIsLoading] = useState(false);
@@ -754,13 +754,14 @@ const handleNewChat = async (sessionId = null) => {
754
  return (
755
  <div className="chat-page-with-sidebar">
756
  {/* Sidebar Component */}
757
- <Sidebar
758
  user={user}
759
  currentSessionId={currentSessionId}
760
  onSelectSession={handleSelectSession}
761
  onNewChat={handleNewChat}
762
  onCurrentSessionDeleted={handleCurrentSessionDeleted}
763
  onSignOut={onSignOut}
 
764
  authToken={authToken}
765
  onSidebarToggle={handleSidebarToggle}
766
  isMobileOpen={isMobileMenuOpen}
 
17
  import AdvisorStatusDropdown from '../components/AdvisorStatusDropdown';
18
  import AdvisorCarousel from '../components/AdvisorCarousel';
19
 
20
+ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut, onUserUpdate }) => {
21
  const { config, advisors, getAdvisorColors } = useAppConfig();
22
  const [messages, setMessages] = useState([]);
23
  const [isLoading, setIsLoading] = useState(false);
 
754
  return (
755
  <div className="chat-page-with-sidebar">
756
  {/* Sidebar Component */}
757
+ <Sidebar
758
  user={user}
759
  currentSessionId={currentSessionId}
760
  onSelectSession={handleSelectSession}
761
  onNewChat={handleNewChat}
762
  onCurrentSessionDeleted={handleCurrentSessionDeleted}
763
  onSignOut={onSignOut}
764
+ onUserUpdate={onUserUpdate}
765
  authToken={authToken}
766
  onSidebarToggle={handleSidebarToggle}
767
  isMobileOpen={isMobileMenuOpen}