NeonClary Cursor commited on
Commit
6004480
·
1 Parent(s): cc7440f

Restore cybersecurity user profile UX and personalize advisor responses

Browse files

Add extended profile and onboarding APIs, signup knowledge level with optional timezone, chat profile context injection, and sidebar profile menu. Refresh Canvas Insights and Documents copy for security operations themes.

Co-authored-by: Cursor <cursoragent@cursor.com>

cybersecurity_config.yaml CHANGED
@@ -8,6 +8,15 @@ app:
8
  primary_color: "#0F172A"
9
  logo_icon: "Shield"
10
  footer_text: "© 2026 Neon AI. All rights reserved."
 
 
 
 
 
 
 
 
 
11
 
12
  homepage:
13
  headline_prefix: "Strengthen Your Security Posture with"
@@ -31,15 +40,34 @@ homepage:
31
  login:
32
  subtitle: "Sign in to continue your security journey"
33
  signup_subtitle: "Create your account for personalized guidance from our cybersecurity advisor panel"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  academic_stages:
35
- - { value: "", label: "Select your experience level" }
36
- - { value: "student", label: "Student / Learner" }
37
- - { value: "career-changer", label: "Career Changer" }
38
- - { value: "junior-analyst", label: "Junior Analyst" }
39
- - { value: "soc-analyst", label: "SOC Analyst" }
40
- - { value: "engineer", label: "Security Engineer" }
41
- - { value: "architect", label: "Architect / Lead" }
42
- - { value: "manager", label: "Manager / Director" }
43
 
44
  chat_page:
45
  placeholder: "Ask your advisors about threats, controls, incidents, compliance, or your security career..."
 
8
  primary_color: "#0F172A"
9
  logo_icon: "Shield"
10
  footer_text: "© 2026 Neon AI. All rights reserved."
11
+ user_avatars:
12
+ - { id: "shield-slate", icon: "Shield", color: "#0F172A", bg: "#F1F5F9" }
13
+ - { id: "user-blue", icon: "User", color: "#2563EB", bg: "#EFF6FF" }
14
+ - { id: "lock-red", icon: "Lock", color: "#DC2626", bg: "#FEF2F2" }
15
+ - { id: "terminal-green", icon: "Terminal", color: "#059669", bg: "#ECFDF5" }
16
+ - { id: "bug-amber", icon: "Bug", color: "#F59E0B", bg: "#FFFBEB" }
17
+ - { id: "server-cyan", icon: "Server", color: "#0891B2", bg: "#ECFEFF" }
18
+ - { id: "key-purple", icon: "KeyRound", color: "#7C3AED", bg: "#F3E8FF" }
19
+ - { id: "radar-rose", icon: "Radar", color: "#E11D48", bg: "#FFF1F2" }
20
 
21
  homepage:
22
  headline_prefix: "Strengthen Your Security Posture with"
 
40
  login:
41
  subtitle: "Sign in to continue your security journey"
42
  signup_subtitle: "Create your account for personalized guidance from our cybersecurity advisor panel"
43
+ knowledge_levels:
44
+ - { value: "", label: "Select your cybersecurity knowledge level" }
45
+ - { value: "newcomer", label: "New to cybersecurity" }
46
+ - { value: "foundational", label: "Foundational — coursework or self-study" }
47
+ - { value: "practitioner", label: "Practitioner — hands-on experience" }
48
+ - { value: "experienced", label: "Experienced — multi-year professional" }
49
+ - { value: "expert", label: "Expert / specialist" }
50
+ timezones:
51
+ - { value: "", label: "Select timezone (optional)" }
52
+ - { value: "America/New_York", label: "Eastern (US)" }
53
+ - { value: "America/Chicago", label: "Central (US)" }
54
+ - { value: "America/Denver", label: "Mountain (US)" }
55
+ - { value: "America/Los_Angeles", label: "Pacific (US)" }
56
+ - { value: "America/Anchorage", label: "Alaska (US)" }
57
+ - { value: "Pacific/Honolulu", label: "Hawaii (US)" }
58
+ - { value: "Europe/London", label: "UK / Ireland" }
59
+ - { value: "Europe/Paris", label: "Central Europe" }
60
+ - { value: "Asia/Tokyo", label: "Japan" }
61
+ - { value: "Asia/Singapore", label: "Singapore" }
62
+ - { value: "Australia/Sydney", label: "Australia (East)" }
63
+ - { value: "UTC", label: "UTC" }
64
  academic_stages:
65
+ - { value: "", label: "Select your cybersecurity knowledge level" }
66
+ - { value: "newcomer", label: "New to cybersecurity" }
67
+ - { value: "foundational", label: "Foundational — coursework or self-study" }
68
+ - { value: "practitioner", label: "Practitioner — hands-on experience" }
69
+ - { value: "experienced", label: "Experienced — multi-year professional" }
70
+ - { value: "expert", label: "Expert / specialist" }
 
 
71
 
72
  chat_page:
73
  placeholder: "Ask your advisors about threats, controls, incidents, compliance, or your security career..."
multi_llm_chatbot_backend/app/api/routes/auth.py CHANGED
@@ -1,6 +1,6 @@
1
  from fastapi import APIRouter, HTTPException, Depends, status
2
  from datetime import datetime, timedelta
3
- from app.models.user import UserCreate, UserLogin, User, Token, UserResponse
4
  from pydantic import BaseModel, model_validator
5
  from typing import Optional
6
  from app.core.auth import (
@@ -89,6 +89,18 @@ async def signup(user_data: UserCreate):
89
  # Insert user into database
90
  result = await db.users.insert_one(user.dict(by_alias=True))
91
  user.id = result.inserted_id
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  # Create access token
94
  access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
@@ -225,23 +237,36 @@ async def change_password(
225
 
226
  @router.patch("/me", response_model=UserResponse)
227
  async def update_profile(
228
- body: UpdateProfileRequest,
229
  current_user: User = Depends(get_current_active_user),
230
  ):
231
- """
232
- Update the authenticated user's profile fields.
233
- @param body: UpdateProfileRequest with optional firstName and lastName
234
- @param current_user: Authenticated user from dependency injection
235
- @return: UserResponse with the updated profile information
236
- """
237
  try:
238
- updates = {}
239
- if body.first_name is not None:
240
- updates["firstName"] = body.first_name
241
- if body.last_name is not None:
242
- updates["lastName"] = body.last_name
243
  db = get_database()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  await db.users.update_one({"_id": current_user.id}, {"$set": updates})
 
 
 
 
 
 
 
245
  updated_user = await db.users.find_one({"_id": current_user.id})
246
  return create_user_response(User(**updated_user))
247
 
 
1
  from fastapi import APIRouter, HTTPException, Depends, status
2
  from datetime import datetime, timedelta
3
+ from app.models.user import UserCreate, UserLogin, User, Token, UserResponse, UserUpdate
4
  from pydantic import BaseModel, model_validator
5
  from typing import Optional
6
  from app.core.auth import (
 
89
  # Insert user into database
90
  result = await db.users.insert_one(user.dict(by_alias=True))
91
  user.id = result.inserted_id
92
+
93
+ profile_seed = {}
94
+ if user_data.researchArea:
95
+ profile_seed["timezone"] = user_data.researchArea
96
+ if profile_seed:
97
+ profile_seed["user_id"] = user.id
98
+ profile_seed["updated_at"] = datetime.utcnow()
99
+ await db.user_profiles.update_one(
100
+ {"user_id": user.id},
101
+ {"$set": profile_seed},
102
+ upsert=True,
103
+ )
104
 
105
  # Create access token
106
  access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
 
237
 
238
  @router.patch("/me", response_model=UserResponse)
239
  async def update_profile(
240
+ body: UserUpdate,
241
  current_user: User = Depends(get_current_active_user),
242
  ):
243
+ """Update account fields (name, email, avatar, signup preferences)."""
 
 
 
 
 
244
  try:
 
 
 
 
 
245
  db = get_database()
246
+ updates = {k: v for k, v in body.dict().items() if v is not None}
247
+ if body.firstName is not None:
248
+ updates["firstName"] = body.firstName.strip()
249
+ if body.lastName is not None:
250
+ updates["lastName"] = body.lastName.strip()
251
+ if not updates:
252
+ return create_user_response(current_user)
253
+
254
+ if "email" in updates and updates["email"] != current_user.email:
255
+ existing = await get_user_by_email(updates["email"])
256
+ if existing:
257
+ raise HTTPException(
258
+ status_code=status.HTTP_400_BAD_REQUEST,
259
+ detail="Email already in use",
260
+ )
261
+
262
  await db.users.update_one({"_id": current_user.id}, {"$set": updates})
263
+ if body.researchArea:
264
+ await db.user_profiles.update_one(
265
+ {"user_id": current_user.id},
266
+ {"$set": {"timezone": body.researchArea, "updated_at": datetime.utcnow()},
267
+ "$setOnInsert": {"user_id": current_user.id}},
268
+ upsert=True,
269
+ )
270
  updated_user = await db.users.find_one({"_id": current_user.id})
271
  return create_user_response(User(**updated_user))
272
 
multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -22,6 +22,33 @@ logger = logging.getLogger(__name__)
22
  router = APIRouter()
23
  session_manager = get_session_manager()
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # Enhanced data models
26
  class UserInput(BaseModel):
27
  user_input: str
@@ -91,6 +118,7 @@ async def chat_stream(
91
  sid = await get_or_create_session_for_request_async(request)
92
 
93
  session = session_manager.get_session(sid)
 
94
 
95
  # Append user message to in-memory session and persist to MongoDB
96
  session.append_message("user", message.user_input)
 
22
  router = APIRouter()
23
  session_manager = get_session_manager()
24
 
25
+ _PROFILE_FIELDS = [
26
+ "cyber_role", "organization_type", "primary_domains", "certifications",
27
+ "tools_stack", "compliance_focus", "current_goals", "learning_preferences", "timezone",
28
+ ]
29
+
30
+
31
+ async def _attach_user_profile_context(session, user: User) -> None:
32
+ """Load Mongo profile + signup fields into session for persona prompts."""
33
+ try:
34
+ db = get_database()
35
+ profile = await db.user_profiles.find_one({"user_id": user.id}) or {}
36
+ parts = []
37
+ if user.academicStage:
38
+ parts.append(f"knowledge_level: {user.academicStage}")
39
+ if user.researchArea and not profile.get("timezone"):
40
+ parts.append(f"timezone: {user.researchArea}")
41
+ for key in _PROFILE_FIELDS:
42
+ val = profile.get(key)
43
+ if val:
44
+ if isinstance(val, list):
45
+ val = ", ".join(str(v) for v in val)
46
+ parts.append(f"{key}: {val}")
47
+ if parts:
48
+ session.user_profile_context = "USER SECURITY PROFILE: " + "; ".join(parts)
49
+ except Exception as prof_err:
50
+ logger.warning(f"Could not load user profile: {prof_err}")
51
+
52
  # Enhanced data models
53
  class UserInput(BaseModel):
54
  user_input: str
 
118
  sid = await get_or_create_session_for_request_async(request)
119
 
120
  session = session_manager.get_session(sid)
121
+ await _attach_user_profile_context(session, current_user)
122
 
123
  # Append user message to in-memory session and persist to MongoDB
124
  session.append_message("user", message.user_input)
multi_llm_chatbot_backend/app/api/routes/onboarding.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework
2
+ # All Rights Reserved 2008-2025
3
+ # Licensed under the BSD 3-Clause License
4
+ # https://opensource.org/licenses/BSD-3-Clause
5
+ #
6
+ # Copyright (c) 2008-2025, Neongecko.com Inc.
7
+ #
8
+ # Redistribution and use in source and binary forms, with or without
9
+ # modification, are permitted provided that the following conditions are met:
10
+ # 1. Redistributions of source code must retain the above copyright notice,
11
+ # this list of conditions and the following disclaimer.
12
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ # this list of conditions and the following disclaimer in the documentation
14
+ # and/or other materials provided with the distribution.
15
+ # 3. Neither the name of the copyright holder nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software
17
+ # without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+
31
+ import logging
32
+ from datetime import datetime
33
+ from typing import Any, Dict, Optional
34
+
35
+ from fastapi import APIRouter, Depends
36
+ from pydantic import BaseModel
37
+
38
+ from app.api.routes.user_profile import _is_field_filled
39
+ from app.core.auth import get_current_active_user
40
+ from app.core.bootstrap import create_llm_client
41
+ from app.core.database import get_database
42
+ from app.core.onboarding_agent import OnboardingAgent, PROFILE_FIELDS
43
+ from app.models.user import User
44
+
45
+ LOG = logging.getLogger(__name__)
46
+
47
+ router = APIRouter()
48
+
49
+ ONBOARDING_COLLECTION = "onboarding_conversations"
50
+
51
+
52
+ class OnboardingMessage(BaseModel):
53
+ user_input: str
54
+
55
+
56
+ def _progress(profile: Dict[str, Any]) -> int:
57
+ filled = sum(1 for k, *_ in PROFILE_FIELDS if _is_field_filled(profile.get(k)))
58
+ return int(filled / len(PROFILE_FIELDS) * 100)
59
+
60
+
61
+ def _next_missing_question(profile: Dict[str, Any]) -> Optional[str]:
62
+ """Return the human-friendly question for the first unfilled field."""
63
+ for key, question, _desc in PROFILE_FIELDS:
64
+ if not _is_field_filled(profile.get(key)):
65
+ return question
66
+ return None
67
+
68
+
69
+ @router.get("/onboarding/start")
70
+ async def onboarding_start(
71
+ current_user: User = Depends(get_current_active_user),
72
+ ) -> Dict[str, Any]:
73
+ """Return conversation history (if any) and current progress.
74
+
75
+ If the user has an in-progress conversation it is returned so the
76
+ frontend can restore the chat. Otherwise a fresh contextual welcome
77
+ message is generated based on which fields are still missing.
78
+ """
79
+ db = get_database()
80
+ profile = await db.user_profiles.find_one({"user_id": current_user.id}) or {}
81
+ progress = _progress(profile)
82
+
83
+ if progress >= 100:
84
+ await db[ONBOARDING_COLLECTION].delete_many({"user_id": current_user.id})
85
+ return {
86
+ "messages": [{"role": "agent",
87
+ "text": "Your profile is already complete! Feel free to update anything by chatting here."}],
88
+ "progress": 100,
89
+ "complete": True,
90
+ }
91
+
92
+ conv = await db[ONBOARDING_COLLECTION].find_one({"user_id": current_user.id})
93
+
94
+ if conv and conv.get("messages"):
95
+ return {
96
+ "messages": conv["messages"],
97
+ "progress": progress,
98
+ "complete": False,
99
+ }
100
+
101
+ next_q = _next_missing_question(profile)
102
+ if progress == 0:
103
+ greeting = (
104
+ f"Hey {current_user.firstName}! I'd like to learn a bit about your security background so "
105
+ "your advisors can tailor depth and examples. "
106
+ f"Let's start — {next_q.lower() if next_q else 'tell me about your role and goals!'}"
107
+ )
108
+ else:
109
+ greeting = (
110
+ f"Welcome back, {current_user.firstName}! You're {progress}% done. "
111
+ f"Let's pick up where we left off — {next_q.lower() if next_q else 'what else can you tell me?'}"
112
+ )
113
+
114
+ messages = [{"role": "agent", "text": greeting}]
115
+ await db[ONBOARDING_COLLECTION].update_one(
116
+ {"user_id": current_user.id},
117
+ {"$set": {"messages": messages, "updated_at": datetime.utcnow()},
118
+ "$setOnInsert": {"user_id": current_user.id}},
119
+ upsert=True,
120
+ )
121
+
122
+ return {"messages": messages, "progress": progress, "complete": False}
123
+
124
+
125
+ @router.post("/onboarding/chat")
126
+ async def onboarding_chat(
127
+ msg: OnboardingMessage,
128
+ current_user: User = Depends(get_current_active_user),
129
+ ) -> Dict[str, Any]:
130
+ db = get_database()
131
+ profile = await db.user_profiles.find_one({"user_id": current_user.id}) or {}
132
+
133
+ agent = OnboardingAgent(create_llm_client())
134
+ result = await agent.chat(msg.user_input, current_user.id, profile)
135
+
136
+ user_msg = {"role": "user", "text": msg.user_input}
137
+ agent_msg = {"role": "agent", "text": result["reply"]}
138
+
139
+ await db[ONBOARDING_COLLECTION].update_one(
140
+ {"user_id": current_user.id},
141
+ {"$push": {"messages": {"$each": [user_msg, agent_msg]}},
142
+ "$set": {"updated_at": datetime.utcnow()},
143
+ "$setOnInsert": {"user_id": current_user.id}},
144
+ upsert=True,
145
+ )
146
+
147
+ if result.get("complete"):
148
+ await db[ONBOARDING_COLLECTION].delete_many({"user_id": current_user.id})
149
+
150
+ return result
multi_llm_chatbot_backend/app/api/routes/user_profile.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from pydantic import BaseModel
7
+
8
+ from app.core.auth import get_current_active_user
9
+ from app.core.database import get_database
10
+ from app.models.user import User
11
+ from app.models.user_profile import UserProfileResponse, UserProfileUpdate
12
+
13
+ LOG = logging.getLogger(__name__)
14
+
15
+ router = APIRouter()
16
+
17
+ PROFILE_FIELDS = [
18
+ "cyber_role",
19
+ "organization_type",
20
+ "primary_domains",
21
+ "certifications",
22
+ "tools_stack",
23
+ "compliance_focus",
24
+ "current_goals",
25
+ "learning_preferences",
26
+ "timezone",
27
+ ]
28
+
29
+ LIST_FIELDS = {"primary_domains", "certifications", "tools_stack"}
30
+
31
+ _SELECT_OPTIONS: Dict[str, List[str]] = {
32
+ "cyber_role": [
33
+ "Student / Learner",
34
+ "Career changer",
35
+ "SOC analyst",
36
+ "Security engineer",
37
+ "Architect / lead",
38
+ "Manager / director",
39
+ "Consultant",
40
+ "Other",
41
+ ],
42
+ "organization_type": [
43
+ "Startup",
44
+ "Mid-size company",
45
+ "Enterprise",
46
+ "Government / public sector",
47
+ "Education",
48
+ "MSP / MSSP",
49
+ "Independent / job seeker",
50
+ ],
51
+ }
52
+
53
+
54
+ def _is_field_filled(value: Any) -> bool:
55
+ if value is None:
56
+ return False
57
+ if isinstance(value, str):
58
+ return bool(value.strip())
59
+ if isinstance(value, list):
60
+ return len(value) > 0
61
+ return bool(value)
62
+
63
+
64
+ def _calc_completion(doc: Dict[str, Any]) -> int:
65
+ filled = sum(1 for f in PROFILE_FIELDS if _is_field_filled(doc.get(f)))
66
+ return int(filled / len(PROFILE_FIELDS) * 100)
67
+
68
+
69
+ _SELECT_LOOKUP: Dict[str, Dict[str, str]] = {
70
+ field: {opt.lower(): opt for opt in opts}
71
+ for field, opts in _SELECT_OPTIONS.items()
72
+ }
73
+
74
+
75
+ def _normalize_select(key: str, value: str) -> str:
76
+ lookup = _SELECT_LOOKUP.get(key)
77
+ if not lookup or not isinstance(value, str):
78
+ return value
79
+ v = value.strip()
80
+ if v.lower() in lookup:
81
+ return lookup[v.lower()]
82
+ for canonical_lower, canonical in lookup.items():
83
+ if canonical_lower in v.lower():
84
+ return canonical
85
+ return v
86
+
87
+
88
+ def _normalize_field(key: str, value: Any) -> Any:
89
+ if key in LIST_FIELDS:
90
+ if isinstance(value, str):
91
+ return [s.strip() for s in value.split(",") if s.strip()]
92
+ if isinstance(value, list):
93
+ return value
94
+ return []
95
+ if key in _SELECT_OPTIONS:
96
+ return _normalize_select(key, value)
97
+ if isinstance(value, list):
98
+ return ", ".join(str(v) for v in value if v)
99
+ return value
100
+
101
+
102
+ @router.get("/users/me/profile", response_model=UserProfileResponse)
103
+ async def get_my_profile(
104
+ current_user: User = Depends(get_current_active_user),
105
+ ) -> UserProfileResponse:
106
+ db = get_database()
107
+ doc = await db.user_profiles.find_one({"user_id": current_user.id})
108
+ if not doc:
109
+ resp = UserProfileResponse(user_id=str(current_user.id))
110
+ if current_user.researchArea:
111
+ resp.timezone = current_user.researchArea
112
+ return resp
113
+ fields = {k: _normalize_field(k, doc.get(k)) for k in PROFILE_FIELDS}
114
+ return UserProfileResponse(
115
+ user_id=str(doc["user_id"]),
116
+ **fields,
117
+ advisor_notes=doc.get("advisor_notes"),
118
+ updated_at=doc.get("updated_at"),
119
+ completion_pct=_calc_completion(doc),
120
+ )
121
+
122
+
123
+ @router.put("/users/me/profile", response_model=UserProfileResponse)
124
+ async def update_my_profile(
125
+ updates: UserProfileUpdate,
126
+ current_user: User = Depends(get_current_active_user),
127
+ ) -> UserProfileResponse:
128
+ db = get_database()
129
+ update_data = {k: v for k, v in updates.dict().items() if v is not None}
130
+ update_data["updated_at"] = datetime.utcnow()
131
+ await db.user_profiles.update_one(
132
+ {"user_id": current_user.id},
133
+ {"$set": update_data, "$setOnInsert": {"user_id": current_user.id}},
134
+ upsert=True,
135
+ )
136
+ doc = await db.user_profiles.find_one({"user_id": current_user.id})
137
+ fields = {k: _normalize_field(k, doc.get(k)) for k in PROFILE_FIELDS}
138
+ return UserProfileResponse(
139
+ user_id=str(doc["user_id"]),
140
+ **fields,
141
+ advisor_notes=doc.get("advisor_notes"),
142
+ updated_at=doc.get("updated_at"),
143
+ completion_pct=_calc_completion(doc),
144
+ )
145
+
146
+
147
+ class ClearDataRequest(BaseModel):
148
+ profile: bool = False
149
+ chats: bool = False
150
+ canvas: bool = False
151
+
152
+
153
+ @router.post("/users/me/clear-data")
154
+ async def clear_user_data(
155
+ req: ClearDataRequest,
156
+ current_user: User = Depends(get_current_active_user),
157
+ ) -> Dict[str, List[str]]:
158
+ db = get_database()
159
+ cleared: List[str] = []
160
+
161
+ if req.profile:
162
+ await db.user_profiles.delete_many({"user_id": current_user.id})
163
+ await db.onboarding_conversations.delete_many({"user_id": current_user.id})
164
+ cleared.append("profile")
165
+
166
+ if req.chats:
167
+ result = await db.chat_sessions.update_many(
168
+ {"user_id": current_user.id, "is_active": True},
169
+ {"$set": {"is_active": False, "updated_at": datetime.utcnow()}},
170
+ )
171
+ cleared.append(f"chats ({result.modified_count})")
172
+
173
+ if req.canvas:
174
+ await db.phd_canvases.delete_many({"user_id": str(current_user.id)})
175
+ cleared.append("canvas")
176
+
177
+ return {"cleared": cleared}
multi_llm_chatbot_backend/app/config.py CHANGED
@@ -48,11 +48,19 @@ class FeatureConfig(_IconValidatorMixin):
48
  icon: str = "HelpCircle"
49
 
50
 
 
 
 
 
 
 
 
51
  class AppConfig(BaseModel):
52
  title: str = "Advisor Canvas"
53
  subtitle: str = "AI-Powered Guidance"
54
  primary_color: str = "#7C3AED"
55
  footer_text: str = ""
 
56
 
57
 
58
  class HomepageConfig(BaseModel):
@@ -72,6 +80,8 @@ class LoginConfig(BaseModel):
72
  subtitle: str = "Sign in to continue"
73
  signup_subtitle: str = "Create your account to get personalized guidance from expert advisors"
74
  academic_stages: List[AcademicStage] = []
 
 
75
 
76
 
77
  class ExampleCategory(_IconValidatorMixin):
 
48
  icon: str = "HelpCircle"
49
 
50
 
51
+ class UserAvatarOption(BaseModel):
52
+ id: str
53
+ icon: str = "User"
54
+ color: str = "#2563EB"
55
+ bg: str = "#EFF6FF"
56
+
57
+
58
  class AppConfig(BaseModel):
59
  title: str = "Advisor Canvas"
60
  subtitle: str = "AI-Powered Guidance"
61
  primary_color: str = "#7C3AED"
62
  footer_text: str = ""
63
+ user_avatars: List[UserAvatarOption] = []
64
 
65
 
66
  class HomepageConfig(BaseModel):
 
80
  subtitle: str = "Sign in to continue"
81
  signup_subtitle: str = "Create your account to get personalized guidance from expert advisors"
82
  academic_stages: List[AcademicStage] = []
83
+ knowledge_levels: List[AcademicStage] = []
84
+ timezones: List[AcademicStage] = []
85
 
86
 
87
  class ExampleCategory(_IconValidatorMixin):
multi_llm_chatbot_backend/app/core/auth.py CHANGED
@@ -113,6 +113,7 @@ def create_user_response(user: User) -> UserResponse:
113
  email=user.email,
114
  academicStage=user.academicStage,
115
  researchArea=user.researchArea,
 
116
  created_at=user.created_at,
117
  last_login=user.last_login
118
  )
 
113
  email=user.email,
114
  academicStage=user.academicStage,
115
  researchArea=user.researchArea,
116
+ avatarId=user.avatarId,
117
  created_at=user.created_at,
118
  last_login=user.last_login
119
  )
multi_llm_chatbot_backend/app/core/database.py CHANGED
@@ -73,6 +73,9 @@ async def create_indexes():
73
  await db.database.chat_sessions.create_index("user_id")
74
  await db.database.chat_sessions.create_index("created_at")
75
  await db.database.chat_sessions.create_index([("user_id", 1), ("created_at", -1)])
 
 
 
76
 
77
  logger.info("Database indexes created successfully")
78
  except Exception as e:
 
73
  await db.database.chat_sessions.create_index("user_id")
74
  await db.database.chat_sessions.create_index("created_at")
75
  await db.database.chat_sessions.create_index([("user_id", 1), ("created_at", -1)])
76
+
77
+ await db.database.user_profiles.create_index("user_id", unique=True)
78
+ await db.database.onboarding_conversations.create_index("user_id", unique=True)
79
 
80
  logger.info("Database indexes created successfully")
81
  except Exception as e:
multi_llm_chatbot_backend/app/core/improved_orchestrator.py CHANGED
@@ -709,11 +709,6 @@ When analyzing the document context:
709
 
710
  Always cite your sources when referencing information from their documents using the format: "According to your [document_name]..." or "In your [section_name] from [document_name]..."
711
  """
712
-
713
- enhanced_context.append({
714
- "role": "system",
715
- "content": system_message
716
- })
717
  else:
718
  # NO DOCUMENTS - Explicitly tell persona not to reference documents
719
  system_message = f"""{persona.system_prompt}
@@ -726,11 +721,17 @@ When analyzing the document context:
726
  3. Provide general guidance based on best practices in your area of expertise
727
 
728
  Do NOT make up document names or pretend to have access to files that don't exist."""
729
-
730
- enhanced_context.append({
731
- "role": "system",
732
- "content": system_message
733
- })
 
 
 
 
 
 
734
 
735
  # Add recent conversation messages (excluding system messages to avoid duplication)
736
  for message in recent_messages:
 
709
 
710
  Always cite your sources when referencing information from their documents using the format: "According to your [document_name]..." or "In your [section_name] from [document_name]..."
711
  """
 
 
 
 
 
712
  else:
713
  # NO DOCUMENTS - Explicitly tell persona not to reference documents
714
  system_message = f"""{persona.system_prompt}
 
721
  3. Provide general guidance based on best practices in your area of expertise
722
 
723
  Do NOT make up document names or pretend to have access to files that don't exist."""
724
+
725
+ if hasattr(session, "user_profile_context") and session.user_profile_context:
726
+ system_message += (
727
+ f"\n\n{session.user_profile_context}\n"
728
+ "Use this background to calibrate technical depth, examples, and priorities."
729
+ )
730
+
731
+ enhanced_context.append({
732
+ "role": "system",
733
+ "content": system_message,
734
+ })
735
 
736
  # Add recent conversation messages (excluding system messages to avoid duplication)
737
  for message in recent_messages:
multi_llm_chatbot_backend/app/core/onboarding_agent.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OnboardingAgent — conversational profile gathering for cybersecurity advisors.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from datetime import datetime
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from app.core.database import get_database
12
+
13
+ LOG = logging.getLogger(__name__)
14
+
15
+ PROFILE_FIELDS: List[tuple] = [
16
+ ("cyber_role", "What best describes your role right now?",
17
+ "Job or learning role: student, SOC analyst, engineer, architect, manager, career changer, etc."),
18
+ ("organization_type", "What type of organization are you in (or targeting)?",
19
+ "Startup, enterprise, government, education, MSP, or independent/job seeker"),
20
+ ("primary_domains", "Which security domains do you focus on most?",
21
+ "Comma-separated areas such as network, cloud, appsec, GRC, IR, identity, OT"),
22
+ ("certifications", "Do you hold or are you pursuing any certifications?",
23
+ "List such as Security+, CySA+, CISSP, OSCP, CCSP, or none yet"),
24
+ ("tools_stack", "What tools or platforms do you work with regularly?",
25
+ "SIEM, EDR, cloud security, ticketing, SOAR, etc."),
26
+ ("compliance_focus", "Any compliance or regulatory frameworks you care about?",
27
+ "SOC 2, ISO 27001, NIST, HIPAA, PCI-DSS, FedRAMP, or none"),
28
+ ("current_goals", "What are you trying to accomplish in the next few months?",
29
+ "Incident readiness, certification, job search, architecture review, audit prep, etc."),
30
+ ("learning_preferences", "How do you prefer to learn new security concepts?",
31
+ "Hands-on labs, reading, videos, mentorship, certifications, capture-the-flag, etc."),
32
+ ("timezone", "What time zone are you usually in?",
33
+ "IANA timezone or region such as America/New_York, Europe/London, UTC"),
34
+ ]
35
+
36
+
37
+ class OnboardingAgent:
38
+ def __init__(self, llm: Any) -> None:
39
+ self.llm = llm
40
+
41
+ SKIP_SENTINEL = "__skipped__"
42
+
43
+ @staticmethod
44
+ def _field_has_value(val: Any) -> bool:
45
+ if val is None:
46
+ return False
47
+ if isinstance(val, str):
48
+ return bool(val.strip())
49
+ if isinstance(val, list):
50
+ return len(val) > 0
51
+ return bool(val)
52
+
53
+ async def chat(self, user_input: str, user_id: Any, existing_profile: Dict[str, Any]) -> Dict[str, Any]:
54
+ filled = {k for k, _, _d in PROFILE_FIELDS
55
+ if self._field_has_value(existing_profile.get(k))}
56
+ missing = [(k, q, desc) for k, q, desc in PROFILE_FIELDS if k not in filled]
57
+ completion = int(len(filled) / len(PROFILE_FIELDS) * 100)
58
+ current_field = missing[0][0] if missing else None
59
+
60
+ extracted = await self._extract_fields(user_input, missing, current_field)
61
+
62
+ db = get_database()
63
+ if extracted:
64
+ from app.api.routes.user_profile import _normalize_field
65
+ real_values: Dict[str, Any] = {}
66
+ skipped_keys: set = set()
67
+ for k, v in extracted.items():
68
+ if v == self.SKIP_SENTINEL:
69
+ skipped_keys.add(k)
70
+ elif v:
71
+ real_values[k] = _normalize_field(k, v)
72
+
73
+ update: Dict[str, Any] = {}
74
+ if real_values:
75
+ update.update(real_values)
76
+ for sk in skipped_keys:
77
+ update[sk] = ""
78
+
79
+ if update:
80
+ update["updated_at"] = datetime.utcnow()
81
+ await db.user_profiles.update_one(
82
+ {"user_id": user_id},
83
+ {"$set": update, "$setOnInsert": {"user_id": user_id}},
84
+ upsert=True,
85
+ )
86
+ filled.update(real_values.keys())
87
+ filled.update(skipped_keys)
88
+ missing = [(k, q, desc) for k, q, desc in PROFILE_FIELDS if k not in filled]
89
+ completion = int(len(filled) / len(PROFILE_FIELDS) * 100)
90
+
91
+ if not missing:
92
+ return {
93
+ "reply": "Great — your security profile is complete. Your advisors will tailor depth, "
94
+ "examples, and next steps to your role, tools, and goals.",
95
+ "progress": 100,
96
+ "complete": True,
97
+ }
98
+
99
+ reply = await self._generate_next_question(user_input, existing_profile, filled, missing)
100
+ return {"reply": reply, "progress": completion, "complete": False}
101
+
102
+ async def _extract_fields(
103
+ self, text: str, missing_fields: List[tuple], current_field: Optional[str] = None
104
+ ) -> Dict[str, Any]:
105
+ if not text.strip():
106
+ return {}
107
+ skip_instruction = ""
108
+ if current_field:
109
+ skip_instruction = (
110
+ f'\nThe question just asked was about "{current_field}". '
111
+ "If the user declines, refuses, says they don't know, says skip, "
112
+ f'return {{"{current_field}": "__skipped__"}}.'
113
+ )
114
+ field_descriptions = "\n".join(f'- "{k}": {desc}' for k, _q, desc in missing_fields)
115
+ system = (
116
+ "Extract cybersecurity profile fields from the user's message. "
117
+ "Return ONLY valid JSON with field names as keys. "
118
+ "For list fields return a JSON array. "
119
+ f"{skip_instruction}\n"
120
+ f"Fields:\n{field_descriptions}"
121
+ )
122
+ try:
123
+ raw = await self.llm.generate(
124
+ system_prompt=system,
125
+ context=[{"role": "user", "content": text}],
126
+ temperature=0.1,
127
+ max_tokens=512,
128
+ )
129
+ cleaned = re.sub(r"```(?:json)?", "", raw).strip()
130
+ m = re.search(r"\{.*\}", cleaned, re.DOTALL)
131
+ if m:
132
+ return json.loads(m.group(0))
133
+ except Exception as e:
134
+ LOG.warning(f"Extraction failed: {e}")
135
+ return {}
136
+
137
+ async def _generate_next_question(
138
+ self, user_input: str, profile: Dict[str, Any], filled: set, missing: List[tuple]
139
+ ) -> str:
140
+ filled_parts: List[str] = []
141
+ for k in filled:
142
+ val = profile.get(k)
143
+ if val:
144
+ filled_parts.append(f"{k}={val}")
145
+ else:
146
+ filled_parts.append(f"{k}=DECLINED")
147
+ filled_summary = ", ".join(filled_parts) or "nothing yet"
148
+ next_field_key, next_field_q, _ = missing[0]
149
+ system = (
150
+ "You are a friendly cybersecurity onboarding assistant. "
151
+ "You help users build a profile so AI security advisors can personalize answers.\n"
152
+ "RULES:\n"
153
+ "- Respond in exactly ONE short paragraph (2-3 sentences).\n"
154
+ "- Briefly acknowledge what they said, then ask ONE clear question.\n"
155
+ "- End with a question mark.\n"
156
+ "- No headings or labels like 'Question:'.\n"
157
+ "- If they skipped a topic, say 'No problem!' and move on.\n"
158
+ "- Never repeat topics already gathered or declined."
159
+ )
160
+ user_prompt = (
161
+ f"User just said: \"{user_input}\"\n"
162
+ f"Already gathered: {filled_summary}\n"
163
+ f"Next topic: {next_field_key} — {next_field_q}\n"
164
+ "Write a warm response that acknowledges them and asks about the next topic."
165
+ )
166
+ try:
167
+ reply = await self.llm.generate(
168
+ system_prompt=system,
169
+ context=[{"role": "user", "content": user_prompt}],
170
+ temperature=0.7,
171
+ max_tokens=300,
172
+ )
173
+ if reply and "?" not in reply:
174
+ reply = f"{reply} {next_field_q}"
175
+ return reply
176
+ except Exception as e:
177
+ LOG.error(f"Question generation failed: {e}")
178
+ return missing[0][1] if missing else "Tell me more about your security background!"
multi_llm_chatbot_backend/app/main.py CHANGED
@@ -22,6 +22,8 @@ from app.api.routes import router as main_router
22
  from app.api.routes.auth import router as auth_router
23
  from app.api.routes.chat_sessions import router as chat_sessions_router
24
  from app.api.routes.phd_canvas import router as phd_canvas_router
 
 
25
 
26
  import logging
27
 
@@ -60,6 +62,8 @@ app.include_router(main_router)
60
  app.include_router(auth_router, prefix="/auth", tags=["authentication"])
61
  app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"])
62
  app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"])
 
 
63
 
64
  # Serve bundled avatar images
65
  _avatars_dir = Path(__file__).resolve().parent / "assets" / "avatars"
 
22
  from app.api.routes.auth import router as auth_router
23
  from app.api.routes.chat_sessions import router as chat_sessions_router
24
  from app.api.routes.phd_canvas import router as phd_canvas_router
25
+ from app.api.routes.user_profile import router as user_profile_router
26
+ from app.api.routes.onboarding import router as onboarding_router
27
 
28
  import logging
29
 
 
62
  app.include_router(auth_router, prefix="/auth", tags=["authentication"])
63
  app.include_router(chat_sessions_router, prefix="/api", tags=["chat-sessions"])
64
  app.include_router(phd_canvas_router, prefix="/api", tags=["phd-canvas"])
65
+ app.include_router(user_profile_router, prefix="/api", tags=["user-profile"])
66
+ app.include_router(onboarding_router, prefix="/api", tags=["onboarding"])
67
 
68
  # Serve bundled avatar images
69
  _avatars_dir = Path(__file__).resolve().parent / "assets" / "avatars"
multi_llm_chatbot_backend/app/models/user.py CHANGED
@@ -47,10 +47,20 @@ class User(BaseModel):
47
  hashed_password: str
48
  academicStage: Optional[str] = None
49
  researchArea: Optional[str] = None
 
50
  created_at: datetime = Field(default_factory=datetime.utcnow)
51
  last_login: Optional[datetime] = None
52
  is_active: bool = True
53
 
 
 
 
 
 
 
 
 
 
54
  class UserResponse(BaseModel):
55
  id: str
56
  firstName: str
@@ -58,6 +68,7 @@ class UserResponse(BaseModel):
58
  email: str
59
  academicStage: Optional[str] = None
60
  researchArea: Optional[str] = None
 
61
  created_at: datetime
62
  last_login: Optional[datetime] = None
63
 
 
47
  hashed_password: str
48
  academicStage: Optional[str] = None
49
  researchArea: Optional[str] = None
50
+ avatarId: Optional[str] = None
51
  created_at: datetime = Field(default_factory=datetime.utcnow)
52
  last_login: Optional[datetime] = None
53
  is_active: bool = True
54
 
55
+ class UserUpdate(BaseModel):
56
+ avatarId: Optional[str] = None
57
+ firstName: Optional[str] = None
58
+ lastName: Optional[str] = None
59
+ email: Optional[EmailStr] = None
60
+ academicStage: Optional[str] = None
61
+ researchArea: Optional[str] = None
62
+
63
+
64
  class UserResponse(BaseModel):
65
  id: str
66
  firstName: str
 
68
  email: str
69
  academicStage: Optional[str] = None
70
  researchArea: Optional[str] = None
71
+ avatarId: Optional[str] = None
72
  created_at: datetime
73
  last_login: Optional[datetime] = None
74
 
multi_llm_chatbot_backend/app/models/user_profile.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Optional
3
+
4
+ from bson import ObjectId
5
+ from pydantic import BaseModel, Field
6
+
7
+ from app.models.user import PyObjectId
8
+
9
+
10
+ class UserProfile(BaseModel):
11
+ class Config:
12
+ allow_population_by_field_name = True
13
+ arbitrary_types_allowed = True
14
+ json_encoders = {ObjectId: str}
15
+
16
+ id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
17
+ user_id: PyObjectId
18
+ cyber_role: Optional[str] = None
19
+ organization_type: Optional[str] = None
20
+ primary_domains: List[str] = []
21
+ certifications: List[str] = []
22
+ tools_stack: List[str] = []
23
+ compliance_focus: Optional[str] = None
24
+ current_goals: Optional[str] = None
25
+ learning_preferences: Optional[str] = None
26
+ timezone: Optional[str] = None
27
+ advisor_notes: Optional[str] = None
28
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
29
+
30
+
31
+ class UserProfileUpdate(BaseModel):
32
+ cyber_role: Optional[str] = None
33
+ organization_type: Optional[str] = None
34
+ primary_domains: Optional[List[str]] = None
35
+ certifications: Optional[List[str]] = None
36
+ tools_stack: Optional[List[str]] = None
37
+ compliance_focus: Optional[str] = None
38
+ current_goals: Optional[str] = None
39
+ learning_preferences: Optional[str] = None
40
+ timezone: Optional[str] = None
41
+ advisor_notes: Optional[str] = None
42
+
43
+
44
+ class UserProfileResponse(BaseModel):
45
+ user_id: str
46
+ cyber_role: Optional[str] = None
47
+ organization_type: Optional[str] = None
48
+ primary_domains: List[str] = []
49
+ certifications: List[str] = []
50
+ tools_stack: List[str] = []
51
+ compliance_focus: Optional[str] = None
52
+ current_goals: Optional[str] = None
53
+ learning_preferences: Optional[str] = None
54
+ timezone: Optional[str] = None
55
+ advisor_notes: Optional[str] = None
56
+ updated_at: Optional[datetime] = None
57
+ completion_pct: int = 0
phd-advisor-frontend/src/components/AccountModal.js ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { User, Mail, Save, Trash2, AlertTriangle, X, Loader2, CheckCircle, Lock, ChevronDown, ChevronUp, Eye, EyeOff } from 'lucide-react';
3
+ import { useTheme } from '../contexts/ThemeContext';
4
+
5
+ const AccountModal = ({ user, authToken, onClose, onAccountUpdated, onAccountDeleted }) => {
6
+ const { isDark } = useTheme();
7
+ const [firstName, setFirstName] = useState(user?.firstName || '');
8
+ const [lastName, setLastName] = useState(user?.lastName || '');
9
+ const [email, setEmail] = useState(user?.email || '');
10
+ const [saving, setSaving] = useState(false);
11
+ const [saved, setSaved] = useState(false);
12
+ const [error, setError] = useState(null);
13
+ const [showPassword, setShowPassword] = useState(false);
14
+ const [currentPassword, setCurrentPassword] = useState('');
15
+ const [newPassword, setNewPassword] = useState('');
16
+ const [confirmPassword, setConfirmPassword] = useState('');
17
+ const [changingPw, setChangingPw] = useState(false);
18
+ const [pwSuccess, setPwSuccess] = useState(false);
19
+ const [pwError, setPwError] = useState(null);
20
+ const [showCurrent, setShowCurrent] = useState(false);
21
+ const [showNew, setShowNew] = useState(false);
22
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
23
+ const [deleting, setDeleting] = useState(false);
24
+ const [deleteInput, setDeleteInput] = useState('');
25
+
26
+ const hasChanges =
27
+ firstName !== (user?.firstName || '') ||
28
+ lastName !== (user?.lastName || '') ||
29
+ email !== (user?.email || '');
30
+
31
+ const handleSave = async () => {
32
+ if (!firstName.trim() || !email.trim()) {
33
+ setError('First name and email are required.');
34
+ return;
35
+ }
36
+ setSaving(true);
37
+ setError(null);
38
+ try {
39
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/auth/me`, {
40
+ method: 'PATCH',
41
+ headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim() }),
43
+ });
44
+ if (resp.ok) {
45
+ const updated = await resp.json();
46
+ setSaved(true);
47
+ if (onAccountUpdated) onAccountUpdated(updated);
48
+ setTimeout(() => setSaved(false), 2000);
49
+ } else {
50
+ const data = await resp.json().catch(() => ({}));
51
+ setError(data.detail || 'Failed to update account.');
52
+ }
53
+ } catch {
54
+ setError('Network error.');
55
+ } finally {
56
+ setSaving(false);
57
+ }
58
+ };
59
+
60
+ const handleChangePassword = async () => {
61
+ setPwError(null);
62
+ if (!currentPassword) { setPwError('Enter your current password.'); return; }
63
+ if (newPassword.length < 6) { setPwError('New password must be at least 6 characters.'); return; }
64
+ if (newPassword !== confirmPassword) { setPwError('New passwords do not match.'); return; }
65
+ setChangingPw(true);
66
+ try {
67
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/auth/me/password`, {
68
+ method: 'POST',
69
+ headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
71
+ });
72
+ if (resp.ok) {
73
+ setPwSuccess(true);
74
+ setCurrentPassword(''); setNewPassword(''); setConfirmPassword('');
75
+ setTimeout(() => { setPwSuccess(false); setShowPassword(false); }, 2000);
76
+ } else {
77
+ const data = await resp.json().catch(() => ({}));
78
+ setPwError(data.detail || 'Failed to change password.');
79
+ }
80
+ } catch {
81
+ setPwError('Network error.');
82
+ } finally {
83
+ setChangingPw(false);
84
+ }
85
+ };
86
+
87
+ const handleDelete = async () => {
88
+ setDeleting(true);
89
+ try {
90
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/auth/me`, {
91
+ method: 'DELETE',
92
+ headers: { 'Authorization': `Bearer ${authToken}` },
93
+ });
94
+ if (resp.ok) {
95
+ if (onAccountDeleted) onAccountDeleted();
96
+ } else {
97
+ setError('Failed to delete account.');
98
+ setShowDeleteConfirm(false);
99
+ }
100
+ } catch {
101
+ setError('Network error.');
102
+ setShowDeleteConfirm(false);
103
+ } finally {
104
+ setDeleting(false);
105
+ }
106
+ };
107
+
108
+ const overlay = {
109
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
110
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
111
+ };
112
+ const modal = {
113
+ background: isDark ? '#1f2937' : '#fff',
114
+ borderRadius: 16, padding: 28, width: 420, maxWidth: '92vw',
115
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
116
+ color: isDark ? '#f3f4f6' : '#111827',
117
+ };
118
+ const inputStyle = {
119
+ width: '100%', padding: '10px 12px', borderRadius: 10, fontSize: 14,
120
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
121
+ background: isDark ? '#111827' : '#f9fafb',
122
+ color: isDark ? '#f3f4f6' : '#111827', outline: 'none',
123
+ boxSizing: 'border-box',
124
+ };
125
+ const labelStyle = {
126
+ display: 'block', fontSize: 12, fontWeight: 600,
127
+ color: isDark ? '#9ca3af' : '#6b7280', marginBottom: 5,
128
+ };
129
+
130
+ if (showDeleteConfirm) {
131
+ return (
132
+ <div style={overlay} onClick={() => setShowDeleteConfirm(false)}>
133
+ <div style={modal} onClick={e => e.stopPropagation()}>
134
+ <div style={{ textAlign: 'center', padding: '8px 0' }}>
135
+ <AlertTriangle size={48} style={{ color: '#ef4444', marginBottom: 14 }} />
136
+ <h3 style={{ margin: '0 0 10px', fontSize: 18 }}>Delete Account?</h3>
137
+ <p style={{ color: isDark ? '#9ca3af' : '#6b7280', fontSize: 13, lineHeight: 1.6, marginBottom: 18 }}>
138
+ This will <strong>permanently</strong> delete your account and all associated data
139
+ (profile, chats, canvas). This action cannot be undone.
140
+ </p>
141
+ <p style={{ fontSize: 13, marginBottom: 10 }}>
142
+ Type <strong>DELETE</strong> to confirm:
143
+ </p>
144
+ <input
145
+ value={deleteInput}
146
+ onChange={e => setDeleteInput(e.target.value)}
147
+ placeholder="DELETE"
148
+ style={{ ...inputStyle, textAlign: 'center', maxWidth: 200, margin: '0 auto 18px' }}
149
+ />
150
+ <div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
151
+ <button
152
+ onClick={() => setShowDeleteConfirm(false)}
153
+ style={{
154
+ padding: '10px 24px', borderRadius: 10,
155
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
156
+ background: 'transparent', color: isDark ? '#d1d5db' : '#374151',
157
+ fontSize: 14, cursor: 'pointer',
158
+ }}
159
+ >
160
+ Cancel
161
+ </button>
162
+ <button
163
+ onClick={handleDelete}
164
+ disabled={deleteInput !== 'DELETE' || deleting}
165
+ style={{
166
+ padding: '10px 24px', borderRadius: 10, border: 'none',
167
+ background: deleteInput === 'DELETE' ? '#ef4444' : (isDark ? '#374151' : '#e5e7eb'),
168
+ color: deleteInput === 'DELETE' ? '#fff' : (isDark ? '#6b7280' : '#9ca3af'),
169
+ fontSize: 14, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8,
170
+ cursor: deleteInput === 'DELETE' ? 'pointer' : 'default',
171
+ opacity: deleting ? 0.7 : 1,
172
+ }}
173
+ >
174
+ {deleting ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> : <Trash2 size={16} />}
175
+ {deleting ? 'Deleting...' : 'Delete Forever'}
176
+ </button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ return (
185
+ <div style={overlay} onClick={onClose}>
186
+ <div style={modal} onClick={e => e.stopPropagation()}>
187
+ {/* Header */}
188
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 22 }}>
189
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
190
+ <User size={22} style={{ color: isDark ? '#60a5fa' : '#3b82f6' }} />
191
+ <h3 style={{ margin: 0, fontSize: 18 }}>Account</h3>
192
+ </div>
193
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: isDark ? '#9ca3af' : '#6b7280', padding: 4 }}>
194
+ <X size={20} />
195
+ </button>
196
+ </div>
197
+
198
+ {/* Fields */}
199
+ <div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
200
+ <div style={{ flex: 1 }}>
201
+ <label style={labelStyle}>First Name</label>
202
+ <input value={firstName} onChange={e => setFirstName(e.target.value)} style={inputStyle} />
203
+ </div>
204
+ <div style={{ flex: 1 }}>
205
+ <label style={labelStyle}>Last Name</label>
206
+ <input value={lastName} onChange={e => setLastName(e.target.value)} style={inputStyle} />
207
+ </div>
208
+ </div>
209
+
210
+ <div style={{ marginBottom: 20 }}>
211
+ <label style={labelStyle}>Email Address</label>
212
+ <div style={{ position: 'relative' }}>
213
+ <Mail size={16} style={{ position: 'absolute', left: 12, top: 12, color: isDark ? '#6b7280' : '#9ca3af' }} />
214
+ <input value={email} onChange={e => setEmail(e.target.value)} type="email" style={{ ...inputStyle, paddingLeft: 36 }} />
215
+ </div>
216
+ </div>
217
+
218
+ {/* Change Password */}
219
+ <div style={{
220
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`, borderRadius: 12,
221
+ marginBottom: 20, overflow: 'hidden',
222
+ }}>
223
+ <button
224
+ onClick={() => { setShowPassword(!showPassword); setPwError(null); }}
225
+ style={{
226
+ width: '100%', padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 8,
227
+ background: 'transparent', border: 'none', cursor: 'pointer',
228
+ color: isDark ? '#d1d5db' : '#374151', fontSize: 13, fontWeight: 600,
229
+ }}
230
+ >
231
+ <Lock size={15} />
232
+ Change Password
233
+ <span style={{ marginLeft: 'auto' }}>
234
+ {showPassword ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
235
+ </span>
236
+ </button>
237
+ {showPassword && (
238
+ <div style={{ padding: '0 14px 14px' }}>
239
+ <div style={{ marginBottom: 10 }}>
240
+ <label style={labelStyle}>Current Password</label>
241
+ <div style={{ position: 'relative' }}>
242
+ <input value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
243
+ type={showCurrent ? 'text' : 'password'} style={inputStyle} placeholder="Enter current password" />
244
+ <button onClick={() => setShowCurrent(!showCurrent)} style={{
245
+ position: 'absolute', right: 8, top: 8, background: 'none', border: 'none',
246
+ cursor: 'pointer', color: isDark ? '#6b7280' : '#9ca3af', padding: 2,
247
+ }}>
248
+ {showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
249
+ </button>
250
+ </div>
251
+ </div>
252
+ <div style={{ marginBottom: 10 }}>
253
+ <label style={labelStyle}>New Password</label>
254
+ <div style={{ position: 'relative' }}>
255
+ <input value={newPassword} onChange={e => setNewPassword(e.target.value)}
256
+ type={showNew ? 'text' : 'password'} style={inputStyle} placeholder="At least 6 characters" />
257
+ <button onClick={() => setShowNew(!showNew)} style={{
258
+ position: 'absolute', right: 8, top: 8, background: 'none', border: 'none',
259
+ cursor: 'pointer', color: isDark ? '#6b7280' : '#9ca3af', padding: 2,
260
+ }}>
261
+ {showNew ? <EyeOff size={16} /> : <Eye size={16} />}
262
+ </button>
263
+ </div>
264
+ </div>
265
+ <div style={{ marginBottom: 12 }}>
266
+ <label style={labelStyle}>Confirm New Password</label>
267
+ <input value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
268
+ type={showNew ? 'text' : 'password'} style={inputStyle} placeholder="Re-enter new password" />
269
+ </div>
270
+ {pwError && (
271
+ <div style={{
272
+ background: isDark ? 'rgba(239,68,68,0.12)' : '#fef2f2',
273
+ color: '#ef4444', padding: '6px 10px', borderRadius: 8, fontSize: 12, marginBottom: 10,
274
+ }}>{pwError}</div>
275
+ )}
276
+ <button
277
+ onClick={handleChangePassword}
278
+ disabled={changingPw || !currentPassword || !newPassword}
279
+ style={{
280
+ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600,
281
+ background: (currentPassword && newPassword) ? '#3b82f6' : (isDark ? '#374151' : '#e5e7eb'),
282
+ color: (currentPassword && newPassword) ? '#fff' : (isDark ? '#6b7280' : '#9ca3af'),
283
+ cursor: (currentPassword && newPassword) ? 'pointer' : 'default',
284
+ display: 'flex', alignItems: 'center', gap: 7, opacity: changingPw ? 0.7 : 1,
285
+ }}
286
+ >
287
+ {changingPw ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> :
288
+ pwSuccess ? <CheckCircle size={14} /> : <Lock size={14} />}
289
+ {changingPw ? 'Updating...' : pwSuccess ? 'Password Changed!' : 'Update Password'}
290
+ </button>
291
+ </div>
292
+ )}
293
+ </div>
294
+
295
+ {error && (
296
+ <div style={{
297
+ background: isDark ? 'rgba(239,68,68,0.12)' : '#fef2f2',
298
+ color: '#ef4444', padding: '8px 12px', borderRadius: 8, fontSize: 13, marginBottom: 14,
299
+ }}>
300
+ {error}
301
+ </div>
302
+ )}
303
+
304
+ {/* Save */}
305
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
306
+ <button
307
+ onClick={handleSave}
308
+ disabled={!hasChanges || saving}
309
+ style={{
310
+ padding: '10px 24px', borderRadius: 10, border: 'none',
311
+ background: hasChanges ? '#3b82f6' : (isDark ? '#374151' : '#e5e7eb'),
312
+ color: hasChanges ? '#fff' : (isDark ? '#6b7280' : '#9ca3af'),
313
+ fontSize: 14, fontWeight: 600, cursor: hasChanges ? 'pointer' : 'default',
314
+ display: 'flex', alignItems: 'center', gap: 8, opacity: saving ? 0.7 : 1,
315
+ }}
316
+ >
317
+ {saving ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> :
318
+ saved ? <CheckCircle size={16} /> : <Save size={16} />}
319
+ {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Changes'}
320
+ </button>
321
+
322
+ <button
323
+ onClick={() => setShowDeleteConfirm(true)}
324
+ style={{
325
+ padding: '10px 16px', borderRadius: 10, border: 'none',
326
+ background: 'transparent', color: '#ef4444', fontSize: 13,
327
+ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6,
328
+ }}
329
+ >
330
+ <Trash2 size={14} />
331
+ Delete Account
332
+ </button>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ );
337
+ };
338
+
339
+ export default AccountModal;
phd-advisor-frontend/src/components/ClearDataModal.js ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Trash2, AlertTriangle, X, Loader2, CheckCircle } from 'lucide-react';
3
+ import { useTheme } from '../contexts/ThemeContext';
4
+
5
+ const ClearDataModal = ({ authToken, onClose, onDataCleared }) => {
6
+ const { isDark } = useTheme();
7
+ const [profile, setProfile] = useState(false);
8
+ const [chats, setChats] = useState(false);
9
+ const [canvas, setCanvas] = useState(false);
10
+ const [clearing, setClearing] = useState(false);
11
+ const [result, setResult] = useState(null);
12
+
13
+ const noneSelected = !profile && !chats && !canvas;
14
+
15
+ const handleClear = async () => {
16
+ if (noneSelected) return;
17
+ setClearing(true);
18
+ try {
19
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/clear-data`, {
20
+ method: 'POST',
21
+ headers: {
22
+ 'Authorization': `Bearer ${authToken}`,
23
+ 'Content-Type': 'application/json',
24
+ },
25
+ body: JSON.stringify({ profile, chats, canvas }),
26
+ });
27
+ if (resp.ok) {
28
+ const data = await resp.json();
29
+ setResult(data.cleared);
30
+ if (onDataCleared) onDataCleared({ profile, chats, canvas });
31
+ } else {
32
+ setResult(['Error clearing data']);
33
+ }
34
+ } catch {
35
+ setResult(['Network error']);
36
+ } finally {
37
+ setClearing(false);
38
+ }
39
+ };
40
+
41
+ const overlay = {
42
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
43
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
44
+ };
45
+
46
+ const modal = {
47
+ background: isDark ? '#1f2937' : '#fff',
48
+ borderRadius: 16, padding: 28, width: 400, maxWidth: '90vw',
49
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
50
+ color: isDark ? '#f3f4f6' : '#111827',
51
+ };
52
+
53
+ const checkRow = {
54
+ display: 'flex', alignItems: 'center', gap: 12,
55
+ padding: '12px 14px', borderRadius: 10, cursor: 'pointer',
56
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
57
+ marginBottom: 10, transition: 'all 0.15s ease',
58
+ };
59
+
60
+ const checkRowActive = (active) => ({
61
+ ...checkRow,
62
+ background: active
63
+ ? (isDark ? 'rgba(239,68,68,0.12)' : 'rgba(239,68,68,0.06)')
64
+ : 'transparent',
65
+ borderColor: active ? '#ef4444' : (isDark ? '#374151' : '#e5e7eb'),
66
+ });
67
+
68
+ const checkbox = (checked) => ({
69
+ width: 20, height: 20, borderRadius: 4, flexShrink: 0,
70
+ border: `2px solid ${checked ? '#ef4444' : (isDark ? '#6b7280' : '#9ca3af')}`,
71
+ background: checked ? '#ef4444' : 'transparent',
72
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
73
+ transition: 'all 0.15s ease', color: '#fff', fontSize: 13, fontWeight: 700,
74
+ });
75
+
76
+ if (result) {
77
+ return (
78
+ <div style={overlay} onClick={onClose}>
79
+ <div style={modal} onClick={(e) => e.stopPropagation()}>
80
+ <div style={{ textAlign: 'center', padding: '16px 0' }}>
81
+ <CheckCircle size={48} style={{ color: '#22c55e', marginBottom: 16 }} />
82
+ <h3 style={{ margin: '0 0 12px', fontSize: 18 }}>Data Cleared</h3>
83
+ <p style={{ color: isDark ? '#9ca3af' : '#6b7280', fontSize: 14, lineHeight: 1.6 }}>
84
+ {result.join(', ')}
85
+ </p>
86
+ <button
87
+ onClick={onClose}
88
+ style={{
89
+ marginTop: 20, padding: '10px 32px', borderRadius: 10,
90
+ border: 'none', background: '#3b82f6', color: '#fff',
91
+ fontSize: 14, fontWeight: 600, cursor: 'pointer',
92
+ }}
93
+ >
94
+ Done
95
+ </button>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ return (
103
+ <div style={overlay} onClick={onClose}>
104
+ <div style={modal} onClick={(e) => e.stopPropagation()}>
105
+ {/* Header */}
106
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
107
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
108
+ <AlertTriangle size={22} style={{ color: '#f59e0b' }} />
109
+ <h3 style={{ margin: 0, fontSize: 18 }}>Clear User Data</h3>
110
+ </div>
111
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: isDark ? '#9ca3af' : '#6b7280', padding: 4 }}>
112
+ <X size={20} />
113
+ </button>
114
+ </div>
115
+
116
+ <p style={{ color: isDark ? '#9ca3af' : '#6b7280', fontSize: 13, marginBottom: 18, lineHeight: 1.5 }}>
117
+ Select which data to clear. Profile data removal will reset your onboarding progress.
118
+ </p>
119
+
120
+ {/* Checkboxes */}
121
+ <div onClick={() => setProfile(!profile)} style={checkRowActive(profile)}>
122
+ <div style={checkbox(profile)}>{profile && '✓'}</div>
123
+ <div>
124
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Profile Information</div>
125
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
126
+ Major, GPA, career goals, learning style, etc. Resets "Tell us about yourself."
127
+ </div>
128
+ </div>
129
+ </div>
130
+
131
+ <div onClick={() => setChats(!chats)} style={checkRowActive(chats)}>
132
+ <div style={checkbox(chats)}>{chats && '✓'}</div>
133
+ <div>
134
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Chat History</div>
135
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
136
+ All conversation sessions and messages.
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ <div onClick={() => setCanvas(!canvas)} style={checkRowActive(canvas)}>
142
+ <div style={checkbox(canvas)}>{canvas && '✓'}</div>
143
+ <div>
144
+ <div style={{ fontWeight: 600, fontSize: 14 }}>Canvas</div>
145
+ <div style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280', marginTop: 2 }}>
146
+ All collected insights and research notes.
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ {/* Actions */}
152
+ <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
153
+ <button
154
+ onClick={onClose}
155
+ style={{
156
+ padding: '10px 20px', borderRadius: 10,
157
+ border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
158
+ background: 'transparent', color: isDark ? '#d1d5db' : '#374151',
159
+ fontSize: 14, cursor: 'pointer',
160
+ }}
161
+ >
162
+ Cancel
163
+ </button>
164
+ <button
165
+ onClick={handleClear}
166
+ disabled={noneSelected || clearing}
167
+ style={{
168
+ padding: '10px 20px', borderRadius: 10, border: 'none',
169
+ background: noneSelected ? (isDark ? '#374151' : '#e5e7eb') : '#ef4444',
170
+ color: noneSelected ? (isDark ? '#6b7280' : '#9ca3af') : '#fff',
171
+ fontSize: 14, fontWeight: 600, cursor: noneSelected ? 'default' : 'pointer',
172
+ display: 'flex', alignItems: 'center', gap: 8,
173
+ opacity: clearing ? 0.7 : 1,
174
+ }}
175
+ >
176
+ {clearing ? <Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} /> : <Trash2 size={16} />}
177
+ {clearing ? 'Clearing...' : 'Clear Selected'}
178
+ </button>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ );
183
+ };
184
+
185
+ export default ClearDataModal;
phd-advisor-frontend/src/components/OnboardingChat.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { X, Send, MessageCircle } from 'lucide-react';
3
+
4
+ const OnboardingChat = ({ authToken, onClose, userName }) => {
5
+ const [messages, setMessages] = useState([]);
6
+ const [input, setInput] = useState('');
7
+ const [loading, setLoading] = useState(false);
8
+ const [progress, setProgress] = useState(0);
9
+ const [complete, setComplete] = useState(false);
10
+ const endRef = useRef(null);
11
+
12
+ useEffect(() => {
13
+ startOnboarding();
14
+ }, []);
15
+
16
+ useEffect(() => {
17
+ endRef.current?.scrollIntoView({ behavior: 'smooth' });
18
+ }, [messages]);
19
+
20
+ const startOnboarding = async () => {
21
+ try {
22
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/onboarding/start`, {
23
+ headers: { 'Authorization': `Bearer ${authToken}` },
24
+ });
25
+ if (resp.ok) {
26
+ const data = await resp.json();
27
+ setMessages(data.messages || [{ role: 'agent', text: data.reply }]);
28
+ setProgress(data.progress);
29
+ setComplete(data.complete || false);
30
+ }
31
+ } catch (e) {
32
+ setMessages([{ role: 'agent', text: "Hi! What is your security role and what are you trying to accomplish right now?" }]);
33
+ }
34
+ };
35
+
36
+ const sendMessage = async () => {
37
+ if (!input.trim() || loading) return;
38
+ const userText = input;
39
+ setInput('');
40
+ setMessages(prev => [...prev, { role: 'user', text: userText }]);
41
+ setLoading(true);
42
+ try {
43
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/onboarding/chat`, {
44
+ method: 'POST',
45
+ headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({ user_input: userText }),
47
+ });
48
+ if (resp.ok) {
49
+ const data = await resp.json();
50
+ setMessages(prev => [...prev, { role: 'agent', text: data.reply }]);
51
+ setProgress(data.progress);
52
+ setComplete(data.complete);
53
+ }
54
+ } catch (e) {
55
+ setMessages(prev => [...prev, { role: 'agent', text: "Sorry, I had trouble processing that. Try again?" }]);
56
+ } finally {
57
+ setLoading(false);
58
+ }
59
+ };
60
+
61
+ return (
62
+ <div style={{
63
+ position: 'fixed', inset: 0, zIndex: 9999,
64
+ background: 'rgba(0,0,0,0.5)', display: 'flex',
65
+ alignItems: 'center', justifyContent: 'center',
66
+ }}>
67
+ <div style={{
68
+ background: 'var(--bg-primary)', borderRadius: 16,
69
+ width: '90%', maxWidth: 500, height: '70vh', maxHeight: 600,
70
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
71
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
72
+ }}>
73
+ {/* Header */}
74
+ <div style={{
75
+ padding: '14px 18px', borderBottom: '1px solid var(--border-primary)',
76
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
77
+ }}>
78
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
79
+ <MessageCircle size={18} style={{ color: 'var(--accent-primary)' }} />
80
+ <span style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: 14 }}>
81
+ Tell us about yourself
82
+ </span>
83
+ </div>
84
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
85
+ <div style={{
86
+ background: 'var(--bg-secondary)', borderRadius: 8, padding: '4px 10px',
87
+ fontSize: 11, fontWeight: 600, color: 'var(--accent-primary)',
88
+ }}>{progress}% complete</div>
89
+ <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
90
+ <X size={18} />
91
+ </button>
92
+ </div>
93
+ </div>
94
+
95
+ {/* Messages */}
96
+ <div style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
97
+ {messages.map((m, i) => (
98
+ <div key={i} style={{
99
+ alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
100
+ maxWidth: '80%',
101
+ background: m.role === 'user' ? 'var(--accent-primary)' : 'var(--bg-secondary)',
102
+ color: m.role === 'user' ? '#fff' : 'var(--text-primary)',
103
+ padding: '10px 14px', borderRadius: 12, fontSize: 13, lineHeight: 1.5,
104
+ }}>
105
+ {m.text}
106
+ </div>
107
+ ))}
108
+ {loading && (
109
+ <div style={{ alignSelf: 'flex-start', color: 'var(--text-secondary)', fontSize: 12 }}>
110
+ Thinking...
111
+ </div>
112
+ )}
113
+ <div ref={endRef} />
114
+ </div>
115
+
116
+ {/* Input */}
117
+ {!complete && (
118
+ <div style={{
119
+ padding: '10px 14px', borderTop: '1px solid var(--border-primary)',
120
+ display: 'flex', gap: 8,
121
+ }}>
122
+ <input
123
+ value={input}
124
+ onChange={e => setInput(e.target.value)}
125
+ onKeyDown={e => e.key === 'Enter' && sendMessage()}
126
+ placeholder="Type your answer..."
127
+ disabled={loading}
128
+ style={{
129
+ flex: 1, padding: '8px 12px', borderRadius: 8,
130
+ border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
131
+ color: 'var(--text-primary)', fontSize: 13, outline: 'none',
132
+ }}
133
+ />
134
+ <button
135
+ onClick={sendMessage}
136
+ disabled={!input.trim() || loading}
137
+ style={{
138
+ padding: '8px 12px', borderRadius: 8, border: 'none',
139
+ background: input.trim() ? 'var(--accent-primary)' : 'var(--bg-secondary)',
140
+ color: input.trim() ? '#fff' : 'var(--text-secondary)',
141
+ cursor: input.trim() ? 'pointer' : 'default',
142
+ }}
143
+ >
144
+ <Send size={16} />
145
+ </button>
146
+ </div>
147
+ )}
148
+ </div>
149
+ </div>
150
+ );
151
+ };
152
+
153
+ export default OnboardingChat;
phd-advisor-frontend/src/components/ProfileWalkthrough.js ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
3
+
4
+ const STEPS = [
5
+ {
6
+ title: 'Role & environment',
7
+ fields: [
8
+ { key: 'cyber_role', label: 'Your role', type: 'select', options: ['Student / Learner', 'Career changer', 'SOC analyst', 'Security engineer', 'Architect / lead', 'Manager / director', 'Consultant', 'Other'] },
9
+ { key: 'organization_type', label: 'Organization type', type: 'select', options: ['Startup', 'Mid-size company', 'Enterprise', 'Government / public sector', 'Education', 'MSP / MSSP', 'Independent / job seeker'] },
10
+ { key: 'timezone', label: 'Time zone', type: 'text', placeholder: 'e.g. America/New_York' },
11
+ ],
12
+ },
13
+ {
14
+ title: 'Focus & tools',
15
+ fields: [
16
+ { key: 'primary_domains', label: 'Primary domains (comma-separated)', type: 'text', placeholder: 'e.g. cloud, appsec, IR, GRC, identity' },
17
+ { key: 'certifications', label: 'Certifications (comma-separated)', type: 'text', placeholder: 'e.g. Security+, CISSP, OSCP, or none yet' },
18
+ { key: 'tools_stack', label: 'Tools & platforms (comma-separated)', type: 'text', placeholder: 'e.g. Splunk, CrowdStrike, AWS, Jira' },
19
+ ],
20
+ },
21
+ {
22
+ title: 'Goals & learning',
23
+ fields: [
24
+ { key: 'compliance_focus', label: 'Compliance / frameworks', type: 'text', placeholder: 'e.g. SOC 2, NIST CSF, ISO 27001, HIPAA' },
25
+ { key: 'current_goals', label: 'Current goals', type: 'textarea', placeholder: 'Audit prep, cert study, incident readiness, architecture review...' },
26
+ { key: 'learning_preferences', label: 'How you learn best', type: 'text', placeholder: 'Labs, reading, CTFs, mentorship, certifications...' },
27
+ ],
28
+ },
29
+ ];
30
+
31
+ const ProfileWalkthrough = ({ authToken, onClose, existingProfile }) => {
32
+ const [step, setStep] = useState(0);
33
+ const [formData, setFormData] = useState({});
34
+ const [saving, setSaving] = useState(false);
35
+ const [loading, setLoading] = useState(true);
36
+
37
+ useEffect(() => {
38
+ let cancelled = false;
39
+ const fetchProfile = async () => {
40
+ try {
41
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
42
+ headers: { 'Authorization': `Bearer ${authToken}` },
43
+ });
44
+ if (resp.ok && !cancelled) {
45
+ const profile = await resp.json();
46
+ const init = {};
47
+ STEPS.forEach(s => s.fields.forEach(f => {
48
+ const val = profile[f.key];
49
+ if (Array.isArray(val)) init[f.key] = val.join(', ');
50
+ else if (val) init[f.key] = val;
51
+ }));
52
+ setFormData(init);
53
+ }
54
+ } catch (e) {
55
+ // Fall back to existingProfile prop
56
+ if (!cancelled && existingProfile) {
57
+ const init = {};
58
+ STEPS.forEach(s => s.fields.forEach(f => {
59
+ const val = existingProfile[f.key];
60
+ if (Array.isArray(val)) init[f.key] = val.join(', ');
61
+ else if (val) init[f.key] = val;
62
+ }));
63
+ setFormData(init);
64
+ }
65
+ } finally {
66
+ if (!cancelled) setLoading(false);
67
+ }
68
+ };
69
+ fetchProfile();
70
+ return () => { cancelled = true; };
71
+ }, [authToken, existingProfile]);
72
+
73
+ const handleChange = (key, value) => setFormData(prev => ({ ...prev, [key]: value }));
74
+
75
+ const saveProfile = async () => {
76
+ const payload = { ...formData };
77
+ ['primary_domains', 'certifications', 'tools_stack'].forEach(k => {
78
+ if (typeof payload[k] === 'string') {
79
+ payload[k] = payload[k].split(',').map(s => s.trim()).filter(Boolean);
80
+ }
81
+ });
82
+ const hasData = Object.values(payload).some(v =>
83
+ Array.isArray(v) ? v.length > 0 : Boolean(v)
84
+ );
85
+ if (!hasData) return;
86
+ try {
87
+ await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
88
+ method: 'PUT',
89
+ headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(payload),
91
+ });
92
+ } catch (e) {
93
+ console.error('Failed to save profile:', e);
94
+ }
95
+ };
96
+
97
+ const handleSave = async () => {
98
+ setSaving(true);
99
+ await saveProfile();
100
+ setSaving(false);
101
+ onClose();
102
+ };
103
+
104
+ const handleClose = async () => {
105
+ await saveProfile();
106
+ onClose();
107
+ };
108
+
109
+ const currentStep = STEPS[step];
110
+ const isLast = step === STEPS.length - 1;
111
+
112
+ return (
113
+ <div onClick={handleClose} style={{
114
+ position: 'fixed', inset: 0, zIndex: 9999,
115
+ background: 'rgba(0,0,0,0.5)', display: 'flex',
116
+ alignItems: 'center', justifyContent: 'center',
117
+ }}>
118
+ <div onClick={e => e.stopPropagation()} style={{
119
+ background: 'var(--bg-primary)', borderRadius: 16,
120
+ width: '90%', maxWidth: 480, padding: 24,
121
+ boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
122
+ }}>
123
+ {loading ? (
124
+ <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-secondary)', fontSize: 14 }}>
125
+ Loading profile...
126
+ </div>
127
+ ) : <>
128
+ {/* Header */}
129
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
130
+ <h3 style={{ margin: 0, fontSize: 16, color: 'var(--text-primary)' }}>
131
+ {currentStep.title} ({step + 1}/{STEPS.length})
132
+ </h3>
133
+ <button onClick={handleClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}>
134
+ <X size={18} />
135
+ </button>
136
+ </div>
137
+
138
+ {/* Progress bar */}
139
+ <div style={{ height: 4, background: 'var(--bg-secondary)', borderRadius: 2, marginBottom: 20 }}>
140
+ <div style={{
141
+ height: '100%', borderRadius: 2, background: 'var(--accent-primary)',
142
+ width: `${((step + 1) / STEPS.length) * 100}%`, transition: 'width 0.3s',
143
+ }} />
144
+ </div>
145
+
146
+ {/* Fields */}
147
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
148
+ {currentStep.fields.map(f => (
149
+ <div key={f.key}>
150
+ <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
151
+ {f.label}
152
+ </label>
153
+ {f.type === 'select' ? (
154
+ <select
155
+ value={formData[f.key] || ''}
156
+ onChange={e => handleChange(f.key, e.target.value)}
157
+ style={{
158
+ width: '100%', padding: '8px 10px', borderRadius: 8,
159
+ border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
160
+ color: 'var(--text-primary)', fontSize: 13,
161
+ }}
162
+ >
163
+ <option value="">Select...</option>
164
+ {f.options.map(o => <option key={o} value={o}>{o}</option>)}
165
+ </select>
166
+ ) : f.type === 'textarea' ? (
167
+ <textarea
168
+ value={formData[f.key] || ''}
169
+ onChange={e => handleChange(f.key, e.target.value)}
170
+ placeholder={f.placeholder}
171
+ rows={3}
172
+ style={{
173
+ width: '100%', padding: '8px 10px', borderRadius: 8,
174
+ border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
175
+ color: 'var(--text-primary)', fontSize: 13, resize: 'vertical',
176
+ }}
177
+ />
178
+ ) : (
179
+ <input
180
+ type="text"
181
+ value={formData[f.key] || ''}
182
+ onChange={e => handleChange(f.key, e.target.value)}
183
+ placeholder={f.placeholder}
184
+ style={{
185
+ width: '100%', padding: '8px 10px', borderRadius: 8,
186
+ border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
187
+ color: 'var(--text-primary)', fontSize: 13,
188
+ }}
189
+ />
190
+ )}
191
+ </div>
192
+ ))}
193
+ </div>
194
+
195
+ {/* Navigation */}
196
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
197
+ <button
198
+ onClick={() => setStep(s => s - 1)}
199
+ disabled={step === 0}
200
+ style={{
201
+ display: 'flex', alignItems: 'center', gap: 4, padding: '8px 14px',
202
+ borderRadius: 8, border: '1px solid var(--border-primary)',
203
+ background: 'var(--bg-secondary)', color: 'var(--text-primary)',
204
+ cursor: step === 0 ? 'default' : 'pointer', opacity: step === 0 ? 0.4 : 1,
205
+ fontSize: 13,
206
+ }}
207
+ >
208
+ <ChevronLeft size={14} /> Back
209
+ </button>
210
+ {isLast ? (
211
+ <button
212
+ onClick={handleSave}
213
+ disabled={saving}
214
+ style={{
215
+ display: 'flex', alignItems: 'center', gap: 4, padding: '8px 16px',
216
+ borderRadius: 8, border: 'none',
217
+ background: 'var(--accent-primary)', color: '#fff',
218
+ cursor: 'pointer', fontSize: 13, fontWeight: 600,
219
+ }}
220
+ >
221
+ <Check size={14} /> {saving ? 'Saving...' : 'Save Profile'}
222
+ </button>
223
+ ) : (
224
+ <button
225
+ onClick={() => setStep(s => s + 1)}
226
+ style={{
227
+ display: 'flex', alignItems: 'center', gap: 4, padding: '8px 14px',
228
+ borderRadius: 8, border: 'none',
229
+ background: 'var(--accent-primary)', color: '#fff',
230
+ cursor: 'pointer', fontSize: 13, fontWeight: 600,
231
+ }}
232
+ >
233
+ Next <ChevronRight size={14} />
234
+ </button>
235
+ )}
236
+ </div>
237
+ </>}
238
+ </div>
239
+ </div>
240
+ );
241
+ };
242
+
243
+ export default ProfileWalkthrough;
phd-advisor-frontend/src/components/Sidebar.js CHANGED
@@ -7,12 +7,17 @@ import {
7
  Trash2,
8
  LogOut,
9
  User,
10
- Settings,
 
 
11
  PanelLeft,
12
  FileText,
13
  ChevronRight,
14
  Clock
15
  } from 'lucide-react';
 
 
 
16
  import CopyrightNotice from './CopyrightNotice';
17
  import '../styles/Sidebar.css';
18
 
@@ -34,8 +39,18 @@ const Sidebar = ({
34
  widgetGroups = [],
35
  deliverableProjects = [],
36
  insightSections = [],
 
 
 
 
 
37
  }) => {
 
38
  const isOnCanvas = pageContext === 'canvas';
 
 
 
 
39
  const [expanded, setExpanded] = useState(() => {
40
  try { return JSON.parse(localStorage.getItem('sidebar-expanded-v1') || '{}'); } catch { return {}; }
41
  });
@@ -198,8 +213,17 @@ const Sidebar = ({
198
  <>
199
  <div className="user-section">
200
  <div className="user-info">
201
- <div className="user-avatar">
202
- <User size={20} />
 
 
 
 
 
 
 
 
 
203
  </div>
204
  <div className="user-details">
205
  <span className="user-name">{user.firstName} {user.lastName}</span>
@@ -227,9 +251,21 @@ const Sidebar = ({
227
 
228
  {showUserMenu && (
229
  <div className="user-menu">
230
- <button className="user-menu-item">
231
- <Settings size={16} />
232
- <span>Settings</span>
 
 
 
 
 
 
 
 
 
 
 
 
233
  </button>
234
  <button className="user-menu-item sign-out" onClick={onSignOut}>
235
  <LogOut size={16} />
@@ -524,6 +560,15 @@ const Sidebar = ({
524
  </div>
525
  </div>
526
 
 
 
 
 
 
 
 
 
 
527
  {isMobileOpen && (
528
  <div
529
  className="mobile-sidebar-overlay visible"
 
7
  Trash2,
8
  LogOut,
9
  User,
10
+ UserCircle,
11
+ DatabaseZap,
12
+ KeyRound,
13
  PanelLeft,
14
  FileText,
15
  ChevronRight,
16
  Clock
17
  } from 'lucide-react';
18
+ import * as LucideIcons from 'lucide-react';
19
+ import { useAppConfig } from '../contexts/AppConfigContext';
20
+ import UserAvatarPicker from './UserAvatarPicker';
21
  import CopyrightNotice from './CopyrightNotice';
22
  import '../styles/Sidebar.css';
23
 
 
39
  widgetGroups = [],
40
  deliverableProjects = [],
41
  insightSections = [],
42
+ userAvatarId,
43
+ onAvatarChange,
44
+ onOpenProfile,
45
+ onOpenAccount,
46
+ onOpenClearData,
47
  }) => {
48
+ const { config } = useAppConfig();
49
  const isOnCanvas = pageContext === 'canvas';
50
+ const [showAvatarPicker, setShowAvatarPicker] = useState(false);
51
+ const avatarOptions = config?.app?.user_avatars || [];
52
+ const currentAvatar = avatarOptions.find(a => a.id === userAvatarId);
53
+ const AvatarIcon = currentAvatar ? (LucideIcons[currentAvatar.icon] || User) : User;
54
  const [expanded, setExpanded] = useState(() => {
55
  try { return JSON.parse(localStorage.getItem('sidebar-expanded-v1') || '{}'); } catch { return {}; }
56
  });
 
213
  <>
214
  <div className="user-section">
215
  <div className="user-info">
216
+ <div
217
+ className="user-avatar"
218
+ onClick={() => onAvatarChange && setShowAvatarPicker(true)}
219
+ style={{
220
+ cursor: onAvatarChange ? 'pointer' : undefined,
221
+ backgroundColor: currentAvatar?.bg || undefined,
222
+ color: currentAvatar?.color || undefined,
223
+ }}
224
+ title={onAvatarChange ? 'Change avatar' : undefined}
225
+ >
226
+ <AvatarIcon size={20} />
227
  </div>
228
  <div className="user-details">
229
  <span className="user-name">{user.firstName} {user.lastName}</span>
 
251
 
252
  {showUserMenu && (
253
  <div className="user-menu">
254
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); setShowAvatarPicker(true); }}>
255
+ <User size={16} />
256
+ <span>Change Avatar</span>
257
+ </button>
258
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenProfile) onOpenProfile(); }}>
259
+ <UserCircle size={16} />
260
+ <span>Profile</span>
261
+ </button>
262
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenAccount) onOpenAccount(); }}>
263
+ <KeyRound size={16} />
264
+ <span>Account</span>
265
+ </button>
266
+ <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenClearData) onOpenClearData(); }}>
267
+ <DatabaseZap size={16} />
268
+ <span>Clear User Data</span>
269
  </button>
270
  <button className="user-menu-item sign-out" onClick={onSignOut}>
271
  <LogOut size={16} />
 
560
  </div>
561
  </div>
562
 
563
+ {showAvatarPicker && (
564
+ <UserAvatarPicker
565
+ options={avatarOptions}
566
+ currentId={userAvatarId}
567
+ onSelect={(id) => { onAvatarChange?.(id); setShowAvatarPicker(false); }}
568
+ onClose={() => setShowAvatarPicker(false)}
569
+ />
570
+ )}
571
+
572
  {isMobileOpen && (
573
  <div
574
  className="mobile-sidebar-overlay visible"
phd-advisor-frontend/src/components/Signup.js CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState } from 'react';
2
- import { Eye, EyeOff, Mail, Lock, User, ArrowRight, BookOpen, Phone, GraduationCap } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
  import '../styles/Signup.css';
5
 
@@ -19,14 +19,22 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
19
  const [isLoading, setIsLoading] = useState(false);
20
  const [errors, setErrors] = useState({});
21
 
22
- const academicStages = config?.login?.academic_stages?.length
23
- ? config.login.academic_stages
24
- : [
25
- { value: '', label: 'Select your stage' },
26
- { value: 'beginner', label: 'Beginner' },
27
- { value: 'intermediate', label: 'Intermediate' },
28
- { value: 'advanced', label: 'Advanced' },
29
- ];
 
 
 
 
 
 
 
 
30
 
31
  const handleInputChange = (e) => {
32
  const { name, value } = e.target;
@@ -75,7 +83,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
75
  }
76
 
77
  if (!formData.academicStage) {
78
- newErrors.academicStage = 'Please select your academic stage';
79
  }
80
 
81
  setErrors(newErrors);
@@ -140,7 +148,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
140
  {/* Header */}
141
  <div className="signup-header">
142
  <div className="logo-container">
143
- <BookOpen className="logo-icon" />
144
  </div>
145
  <h1 className="signup-title">Join Our Community</h1>
146
  <p className="signup-subtitle">
@@ -285,13 +293,13 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
285
  </div>
286
  </div>
287
 
288
- {/* Academic Stage */}
289
  <div className="form-group">
290
  <label htmlFor="academicStage" className="form-label">
291
- Academic Stage
292
  </label>
293
  <div className="input-container">
294
- <GraduationCap className="input-icon" />
295
  <select
296
  id="academicStage"
297
  name="academicStage"
@@ -300,7 +308,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
300
  className={`form-select ${errors.academicStage ? 'error' : ''}`}
301
  disabled={isLoading}
302
  >
303
- {academicStages.map(stage => (
304
  <option key={stage.value} value={stage.value}>
305
  {stage.label}
306
  </option>
@@ -312,23 +320,27 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
312
  )}
313
  </div>
314
 
315
- {/* Research Area (Optional) */}
316
  <div className="form-group">
317
  <label htmlFor="researchArea" className="form-label">
318
- Research Area <span className="optional">(Optional)</span>
319
  </label>
320
  <div className="input-container">
321
- <BookOpen className="input-icon" />
322
- <input
323
- type="text"
324
  id="researchArea"
325
  name="researchArea"
326
  value={formData.researchArea}
327
  onChange={handleInputChange}
328
- className="form-input"
329
- placeholder="e.g., Computer Science, Biology, Psychology..."
330
  disabled={isLoading}
331
- />
 
 
 
 
 
 
332
  </div>
333
  </div>
334
 
 
1
  import React, { useState } from 'react';
2
+ import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Shield, Phone, Globe } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
  import '../styles/Signup.css';
5
 
 
19
  const [isLoading, setIsLoading] = useState(false);
20
  const [errors, setErrors] = useState({});
21
 
22
+ const knowledgeLevels = config?.login?.knowledge_levels?.length
23
+ ? config.login.knowledge_levels
24
+ : config?.login?.academic_stages?.length
25
+ ? config.login.academic_stages
26
+ : [
27
+ { value: '', label: 'Select your cybersecurity knowledge level' },
28
+ { value: 'newcomer', label: 'New to cybersecurity' },
29
+ { value: 'foundational', label: 'Foundational' },
30
+ { value: 'practitioner', label: 'Practitioner' },
31
+ { value: 'experienced', label: 'Experienced' },
32
+ { value: 'expert', label: 'Expert / specialist' },
33
+ ];
34
+
35
+ const timezones = config?.login?.timezones?.length
36
+ ? config.login.timezones
37
+ : [{ value: '', label: 'Select timezone (optional)' }];
38
 
39
  const handleInputChange = (e) => {
40
  const { name, value } = e.target;
 
83
  }
84
 
85
  if (!formData.academicStage) {
86
+ newErrors.academicStage = 'Please select your cybersecurity knowledge level';
87
  }
88
 
89
  setErrors(newErrors);
 
148
  {/* Header */}
149
  <div className="signup-header">
150
  <div className="logo-container">
151
+ <Shield className="logo-icon" />
152
  </div>
153
  <h1 className="signup-title">Join Our Community</h1>
154
  <p className="signup-subtitle">
 
293
  </div>
294
  </div>
295
 
296
+ {/* Cybersecurity knowledge level */}
297
  <div className="form-group">
298
  <label htmlFor="academicStage" className="form-label">
299
+ Cybersecurity knowledge level
300
  </label>
301
  <div className="input-container">
302
+ <Shield className="input-icon" />
303
  <select
304
  id="academicStage"
305
  name="academicStage"
 
308
  className={`form-select ${errors.academicStage ? 'error' : ''}`}
309
  disabled={isLoading}
310
  >
311
+ {knowledgeLevels.map(stage => (
312
  <option key={stage.value} value={stage.value}>
313
  {stage.label}
314
  </option>
 
320
  )}
321
  </div>
322
 
323
+ {/* Time zone (optional) */}
324
  <div className="form-group">
325
  <label htmlFor="researchArea" className="form-label">
326
+ Time zone <span className="optional">(Optional)</span>
327
  </label>
328
  <div className="input-container">
329
+ <Globe className="input-icon" />
330
+ <select
 
331
  id="researchArea"
332
  name="researchArea"
333
  value={formData.researchArea}
334
  onChange={handleInputChange}
335
+ className="form-select"
 
336
  disabled={isLoading}
337
+ >
338
+ {timezones.map(tz => (
339
+ <option key={tz.value} value={tz.value}>
340
+ {tz.label}
341
+ </option>
342
+ ))}
343
+ </select>
344
  </div>
345
  </div>
346
 
phd-advisor-frontend/src/components/UserAvatarPicker.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import * as LucideIcons from 'lucide-react';
3
+ import { X } from 'lucide-react';
4
+
5
+ const UserAvatarPicker = ({ avatarOptions, currentAvatarId, onSelect, onClose }) => {
6
+ const resolveIcon = (name) => LucideIcons[name] || LucideIcons.User;
7
+
8
+ return (
9
+ <div className="avatar-picker-overlay" onClick={onClose}>
10
+ <div className="avatar-picker-modal" onClick={e => e.stopPropagation()}>
11
+ <div className="avatar-picker-header">
12
+ <h3>Choose Your Avatar</h3>
13
+ <button onClick={onClose} className="avatar-picker-close"><X size={18} /></button>
14
+ </div>
15
+ <div className="avatar-picker-grid">
16
+ {avatarOptions.map(opt => {
17
+ const Icon = resolveIcon(opt.icon);
18
+ const isSelected = currentAvatarId === opt.id;
19
+ return (
20
+ <button
21
+ key={opt.id}
22
+ className={`avatar-option ${isSelected ? 'selected' : ''}`}
23
+ onClick={() => onSelect(opt.id)}
24
+ style={{
25
+ '--av-color': opt.color,
26
+ '--av-bg': opt.bg,
27
+ border: isSelected ? `2px solid ${opt.color}` : '2px solid transparent',
28
+ }}
29
+ >
30
+ <div className="avatar-option-icon" style={{ backgroundColor: opt.bg, color: opt.color }}>
31
+ <Icon size={24} />
32
+ </div>
33
+ </button>
34
+ );
35
+ })}
36
+ </div>
37
+ </div>
38
+
39
+ <style jsx>{`
40
+ .avatar-picker-overlay {
41
+ position: fixed; inset: 0; z-index: 9999;
42
+ background: rgba(0,0,0,0.5); display: flex;
43
+ align-items: center; justify-content: center;
44
+ }
45
+ .avatar-picker-modal {
46
+ background: var(--bg-primary); border-radius: 16px;
47
+ padding: 24px; min-width: 320px; max-width: 400px;
48
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
49
+ }
50
+ .avatar-picker-header {
51
+ display: flex; justify-content: space-between; align-items: center;
52
+ margin-bottom: 16px;
53
+ }
54
+ .avatar-picker-header h3 {
55
+ margin: 0; color: var(--text-primary); font-size: 16px;
56
+ }
57
+ .avatar-picker-close {
58
+ background: none; border: none; cursor: pointer;
59
+ color: var(--text-secondary); padding: 4px;
60
+ }
61
+ .avatar-picker-grid {
62
+ display: grid; grid-template-columns: repeat(5, 1fr);
63
+ gap: 10px;
64
+ }
65
+ .avatar-option {
66
+ background: none; cursor: pointer; padding: 6px;
67
+ border-radius: 12px; transition: all 0.15s;
68
+ display: flex; align-items: center; justify-content: center;
69
+ }
70
+ .avatar-option:hover { transform: scale(1.1); }
71
+ .avatar-option.selected { background: var(--av-bg); }
72
+ .avatar-option-icon {
73
+ width: 44px; height: 44px; border-radius: 50%;
74
+ display: flex; align-items: center; justify-content: center;
75
+ }
76
+ `}</style>
77
+ </div>
78
+ );
79
+ };
80
+
81
+ export default UserAvatarPicker;
phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js CHANGED
@@ -32,8 +32,8 @@ const newId = (p) => p + Math.random().toString(36).slice(2, 8);
32
  export const TEMPLATES = [
33
  {
34
  id: 'research-paper',
35
- name: 'Research Paper',
36
- desc: 'AbstractIntroductionMethodsResultsDiscussionReferences',
37
  icon: 'book',
38
  mode: 'paper',
39
  sections: [
@@ -47,8 +47,8 @@ export const TEMPLATES = [
47
  },
48
  {
49
  id: 'thesis-chapter',
50
- name: 'Thesis Chapter',
51
- desc: 'Standard chapter scaffolding for a dissertation.',
52
  icon: 'book',
53
  mode: 'paper',
54
  sections: [
@@ -466,7 +466,7 @@ const DeliverablesView = ({ allStates }) => {
466
  <div>
467
  <h1 className="page-title">Documents</h1>
468
  <div className="page-sub">
469
- Your one-stop deliverable center. Drafts auto-save. Versions kept for rollback.
470
  {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
471
  </div>
472
  </div>
 
32
  export const TEMPLATES = [
33
  {
34
  id: 'research-paper',
35
+ name: 'Security Assessment Report',
36
+ desc: 'Executive summary ScopeFindingsRisk rating RemediationAppendix',
37
  icon: 'book',
38
  mode: 'paper',
39
  sections: [
 
47
  },
48
  {
49
  id: 'thesis-chapter',
50
+ name: 'Incident Report',
51
+ desc: 'Timeline Impact Root cause → Containment → Lessons learned',
52
  icon: 'book',
53
  mode: 'paper',
54
  sections: [
 
466
  <div>
467
  <h1 className="page-title">Documents</h1>
468
  <div className="page-sub">
469
+ Security deliverables hub — policies, IR reports, audit evidence, and architecture briefs. Drafts auto-save. Versions kept for rollback.
470
  {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
471
  </div>
472
  </div>
phd-advisor-frontend/src/components/canvas/canvasData.js CHANGED
@@ -11,19 +11,19 @@ export const INSIGHTS = [
11
  title: 'Program progress',
12
  icon: 'graph',
13
  category: 'progress',
14
- confidence: 78,
15
- summary: 'Primary recordings from 4 of 6 planned animals are complete. Remaining two scheduled for May 18 and May 25. Analysis pipeline working on existing data; first results draft expected June.',
16
  bullets: [
17
- 'V1 recordings: <strong>4/6 animals</strong> complete (M1–M4)',
18
- 'Pipeline: spike-sorting validated, GLM model converging on M1–M2',
19
- '<strong>Risk:</strong> M3 fixation drift suspected; need re-review with adv.',
20
  ],
21
  pinned: true,
22
- sources: 12,
23
- updatedMinutesAgo: 3,
24
  quotes: [
25
- '"Animal M4 recording finished today, sorting completes tomorrow." — May 6 lab notes',
26
- '"Pipeline is happy with M1, M2; M3 looks drifty." — chat with Reineke advisor',
27
  ],
28
  },
29
  {
@@ -31,18 +31,18 @@ export const INSIGHTS = [
31
  title: 'Controls posture',
32
  icon: 'flask',
33
  category: 'theory',
34
- confidence: 64,
35
- summary: 'GLM with spike-history kernel + visual drive is your declared model. You\'ve resisted committing to a specific predictive-coding formulation; this comes up in every advisor meeting.',
36
  bullets: [
37
- 'Decided: <strong>GLM with history kernel</strong> + drift-reg covariates',
38
- 'Open: which predictive-coding variant Rao & Ballard vs. Bastos top-down',
39
- 'Open: how to operationalize "prediction error" from extracellular spikes',
40
  ],
41
- sources: 8,
42
- updatedMinutesAgo: 12,
43
  quotes: [
44
- '"Need to pick a PC formulation by next 1:1." — meeting notes May 2',
45
- '"Bastos lets you predict laminar profile; Rao&Ballard does not." — methodologist chat',
46
  ],
47
  },
48
  {
@@ -50,37 +50,37 @@ export const INSIGHTS = [
50
  title: 'Threat landscape',
51
  icon: 'book',
52
  category: 'literature',
53
- confidence: 71,
54
- summary: 'Strong on canonical predictive coding (Rao & Ballard 1999, Bastos 2012, Keller & Mrsic-Flogel 2018). Thin on recent feedback-circuit anatomy and on counter-evidence — this is showing up as a critique gap.',
55
  bullets: [
56
- '<strong>Coverage:</strong> 47 papers; ~30 well-summarized',
57
- '<strong>Gap:</strong> sparse on L5b feedback anatomy (Harris/Shepherd lab)',
58
- '<strong>Gap:</strong> no engagement with anti-PC critiques (e.g. Heeger 2017)',
59
  ],
60
- sources: 47,
61
- updatedMinutesAgo: 22,
62
  quotes: [
63
- '"Have you read Heeger 2017? It changes a lot." — lit-review aide',
64
- '"L5b feedback anatomy is your weak spot." — devil\'s advocate',
65
  ],
66
  },
67
  {
68
  id: 'i-questions',
69
- title: 'Open research questions',
70
  icon: 'sparkles',
71
  category: 'theory',
72
- confidence: 58,
73
- summary: 'Three live threads. Question 1 (does L2/3 spiking encode prediction error?) is the dissertation core. Q2 and Q3 are scoped to specific aims.',
74
  bullets: [
75
- '<strong>Q1:</strong> Does L2/3 firing during oddball encode prediction error vs. surprise?',
76
- '<strong>Q2:</strong> How does this depend on context length (1 vs. 4 vs. 16 trials)?',
77
- '<strong>Q3:</strong> Is the signal sharpened by feedback from V2/RSC?',
78
  ],
79
- sources: 6,
80
- updatedMinutesAgo: 38,
81
  quotes: [
82
- '"Q1 is what the whole dissertation rests on." — methodologist',
83
- '"Q3 is exciting but probably out of scope for the thesis." — Reineke',
84
  ],
85
  },
86
  {
@@ -88,19 +88,19 @@ export const INSIGHTS = [
88
  title: 'Next steps',
89
  icon: 'arrow',
90
  category: 'action',
91
- confidence: 82,
92
- summary: 'Concrete, near-term actions. Two of these have been on the list for 3+ weeks.',
93
  bullets: [
94
- 'Re-review M3 drift artifact w/ adv. (overdue, 3w)',
95
- 'Draft Aim 2 analysis section (target: May 22)',
96
- 'Read Heeger 2017 + Aitchison & Lengyel 2017',
97
- 'Schedule pilot with M5 (May 18)',
98
  ],
99
- sources: 5,
100
- updatedMinutesAgo: 8,
101
  quotes: [
102
- '"Aim 2 draft has to land by May 22 or quals slip." — Reineke',
103
- '"M3 review keeps getting punted." — last 3 advisor meetings',
104
  ],
105
  },
106
  {
@@ -108,17 +108,17 @@ export const INSIGHTS = [
108
  title: 'Blockers & risks',
109
  icon: 'alert',
110
  category: 'risk',
111
- confidence: 70,
112
- summary: 'One technical, one structural. The structural one is more important and you are deferring it.',
113
  bullets: [
114
- '<strong>Technical:</strong> Drift on M3 may lose 1 animal of data',
115
- '<strong>Structural:</strong> No clear predictive-coding theory commitment yet hard to define what counts as evidence',
116
  ],
117
- sources: 4,
118
- updatedMinutesAgo: 18,
119
  quotes: [
120
- '"If M3 is unusable you\'re at n=5 still publishable but tight." — methodologist',
121
- '"Without a theory commitment you can\'t falsify anything." — devil\'s advocate',
122
  ],
123
  },
124
  ];
 
11
  title: 'Program progress',
12
  icon: 'graph',
13
  category: 'progress',
14
+ confidence: 82,
15
+ summary: 'Zero Trust Phase 2 is 78% complete. MFA enforced for workforce; service accounts and legacy VPN exceptions remain the main gaps before audit sampling.',
16
  bullets: [
17
+ 'Identity: <strong>MFA 94%</strong> workforce · service accounts in remediation',
18
+ 'Network: micro-segmentation pilot on <strong>3 app tiers</strong>',
19
+ '<strong>Risk:</strong> 12 VPN exceptions still lack compensating controls',
20
  ],
21
  pinned: true,
22
+ sources: 18,
23
+ updatedMinutesAgo: 5,
24
  quotes: [
25
+ '"MFA rollout blocked on two legacy HR integrations." — IAM workstream notes',
26
+ '"Auditors will sample VPN exception register first." — GRC advisor chat',
27
  ],
28
  },
29
  {
 
31
  title: 'Controls posture',
32
  icon: 'flask',
33
  category: 'theory',
34
+ confidence: 71,
35
+ summary: 'SOC 2 CC6/CC7 mappings are drafted. Detection use cases cover ransomware and cred theft; log retention and IR tabletop evidence are still thin.',
36
  bullets: [
37
+ 'Mapped: <strong>CC6.1–CC6.7</strong> access controls with Okta + AWS',
38
+ 'Open: centralized logging retention proof for <strong>365 days</strong>',
39
+ 'Open: tabletop scenario for <strong>ransomware + exfil</strong> not yet run',
40
  ],
41
+ sources: 14,
42
+ updatedMinutesAgo: 14,
43
  quotes: [
44
+ '"Need SIEM retention screenshots before fieldwork." — compliance advisor',
45
+ '"Tabletop scheduled but not executed." — IR lead notes',
46
  ],
47
  },
48
  {
 
50
  title: 'Threat landscape',
51
  icon: 'book',
52
  category: 'literature',
53
+ confidence: 76,
54
+ summary: 'Strong coverage of identity attacks, SaaS misconfigurations, and supply-chain risks for your stack. Weaker on OT exposure and insider threat playbooks.',
55
  bullets: [
56
+ '<strong>Coverage:</strong> MITRE techniques for cloud identity & SaaS',
57
+ '<strong>Gap:</strong> limited intel on <strong>OAuth consent phishing</strong> variants',
58
+ '<strong>Gap:</strong> no formal insider-threat escalation path documented',
59
  ],
60
+ sources: 32,
61
+ updatedMinutesAgo: 28,
62
  quotes: [
63
+ '"OAuth abuse is the fastest-moving thread in your sector." — threat intel advisor',
64
+ '"Insider playbook is a one-pager — not enough for audit." — GRC advisor',
65
  ],
66
  },
67
  {
68
  id: 'i-questions',
69
+ title: 'Open security questions',
70
  icon: 'sparkles',
71
  category: 'theory',
72
+ confidence: 63,
73
+ summary: 'Three live threads. Q1 (scope of zero trust for contractors) gates architecture sign-off. Q2Q3 affect detection engineering priorities.',
74
  bullets: [
75
+ '<strong>Q1:</strong> Do contractors get full ZTNA or bastion-only access?',
76
+ '<strong>Q2:</strong> Which SIEM detections are in-scope for SOC 2 evidence?',
77
+ '<strong>Q3:</strong> Is customer data in EU regions in scope for DPA addendum?',
78
  ],
79
+ sources: 9,
80
+ updatedMinutesAgo: 41,
81
  quotes: [
82
+ '"Contractor access model blocks network design." — architect advisor',
83
+ '"EU data residency may expand audit scope." — privacy advisor',
84
  ],
85
  },
86
  {
 
88
  title: 'Next steps',
89
  icon: 'arrow',
90
  category: 'action',
91
+ confidence: 85,
92
+ summary: 'Near-term actions tied to audit date and production cutover. Two items have slipped one sprint.',
93
  bullets: [
94
+ 'Close <strong>12 VPN exceptions</strong> or document compensating controls',
95
+ 'Run ransomware tabletop & upload minutes to evidence locker',
96
+ 'Ship <strong>5 high-fidelity detections</strong> to production SIEM',
97
+ 'Finalize vendor SOC 2 bridge letter for subprocessors',
98
  ],
99
+ sources: 7,
100
+ updatedMinutesAgo: 9,
101
  quotes: [
102
+ '"VPN exceptions are the #1 audit finding risk." — GRC advisor',
103
+ '"Detections without tuning will false-positive in week one." — SOC advisor',
104
  ],
105
  },
106
  {
 
108
  title: 'Blockers & risks',
109
  icon: 'alert',
110
  category: 'risk',
111
+ confidence: 74,
112
+ summary: 'One technical blocker (legacy logging), one governance blocker (exception approvals). Governance is the higher audit risk.',
113
  bullets: [
114
+ '<strong>Technical:</strong> legacy app logs not reaching SIEM 18% of prod traffic',
115
+ '<strong>Governance:</strong> exception approval SLA &gt; 10 days auditors will flag',
116
  ],
117
+ sources: 6,
118
+ updatedMinutesAgo: 20,
119
  quotes: [
120
+ '"Without those logs you cannot prove detective controls." — detection engineer',
121
+ '"Exception backlog reads as control failure." — devil\'s advocate advisor',
122
  ],
123
  },
124
  ];
phd-advisor-frontend/src/pages/ChatPage.js CHANGED
@@ -12,6 +12,10 @@ import { useTheme } from '../contexts/ThemeContext';
12
  import '../styles/ChatPage.css';
13
  import '../styles/EnhancedChatInput.css';
14
  import AdvisorCarousel from '../components/AdvisorCarousel';
 
 
 
 
15
 
16
  const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => {
17
  const { config, advisors, getAdvisorColors } = useAppConfig();
@@ -33,6 +37,46 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig
33
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
34
  const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  const scrollToBottom = () => {
@@ -763,6 +807,11 @@ const handleNewChat = async (sessionId = null) => {
763
  onMobileToggle={setIsMobileMenuOpen}
764
  onNavigateToCanvas={onNavigateToCanvas}
765
  refreshTrigger={sidebarRefreshTrigger}
 
 
 
 
 
766
  />
767
 
768
  <div className={`main-chat-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
@@ -947,10 +996,67 @@ const handleNewChat = async (sessionId = null) => {
947
  ? `Reply to ${replyingTo.advisorName}...`
948
  : chatPlaceholder
949
  }
 
 
 
950
  />
951
  </div>
952
  </div>
953
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
  </div>
955
  );
956
  };
 
12
  import '../styles/ChatPage.css';
13
  import '../styles/EnhancedChatInput.css';
14
  import AdvisorCarousel from '../components/AdvisorCarousel';
15
+ import OnboardingChat from '../components/OnboardingChat';
16
+ import ProfileWalkthrough from '../components/ProfileWalkthrough';
17
+ import ClearDataModal from '../components/ClearDataModal';
18
+ import AccountModal from '../components/AccountModal';
19
 
20
  const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSignOut }) => {
21
  const { config, advisors, getAdvisorColors } = useAppConfig();
 
37
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
38
  const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
39
 
40
+ const [userAvatarId, setUserAvatarId] = useState(
41
+ () => localStorage.getItem('userAvatarId') || (user?.avatarId ?? null)
42
+ );
43
+ const avatarOptions = config?.app?.user_avatars || [];
44
+
45
+ const handleAvatarChange = async (id) => {
46
+ setUserAvatarId(id);
47
+ localStorage.setItem('userAvatarId', id);
48
+ try {
49
+ await fetch(`${process.env.REACT_APP_API_URL}/auth/me`, {
50
+ method: 'PATCH',
51
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ avatarId: id }),
53
+ });
54
+ } catch (e) {
55
+ console.error('Failed to save avatar:', e);
56
+ }
57
+ };
58
+
59
+ const [showOnboarding, setShowOnboarding] = useState(false);
60
+ const [showProfileForm, setShowProfileForm] = useState(false);
61
+ const [showClearData, setShowClearData] = useState(false);
62
+ const [showAccount, setShowAccount] = useState(false);
63
+ const [userProfile, setUserProfile] = useState(null);
64
+
65
+ const loadProfile = async () => {
66
+ try {
67
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
68
+ headers: { Authorization: `Bearer ${authToken}` },
69
+ });
70
+ if (resp.ok) setUserProfile(await resp.json());
71
+ } catch (e) {
72
+ /* ignore */
73
+ }
74
+ };
75
+
76
+ useEffect(() => {
77
+ if (authToken) loadProfile();
78
+ }, [authToken]);
79
+
80
 
81
 
82
  const scrollToBottom = () => {
 
807
  onMobileToggle={setIsMobileMenuOpen}
808
  onNavigateToCanvas={onNavigateToCanvas}
809
  refreshTrigger={sidebarRefreshTrigger}
810
+ userAvatarId={userAvatarId}
811
+ onAvatarChange={handleAvatarChange}
812
+ onOpenProfile={() => setShowProfileForm(true)}
813
+ onOpenAccount={() => setShowAccount(true)}
814
+ onOpenClearData={() => setShowClearData(true)}
815
  />
816
 
817
  <div className={`main-chat-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
 
996
  ? `Reply to ${replyingTo.advisorName}...`
997
  : chatPlaceholder
998
  }
999
+ showProfileButtons={!userProfile || userProfile.completion_pct < 100}
1000
+ onOpenOnboarding={() => setShowOnboarding(true)}
1001
+ onOpenProfileForm={() => setShowProfileForm(true)}
1002
  />
1003
  </div>
1004
  </div>
1005
  </div>
1006
+
1007
+ {showOnboarding && (
1008
+ <OnboardingChat
1009
+ authToken={authToken}
1010
+ userName={user?.firstName}
1011
+ onClose={() => { setShowOnboarding(false); loadProfile(); }}
1012
+ />
1013
+ )}
1014
+
1015
+ {showProfileForm && (
1016
+ <ProfileWalkthrough
1017
+ authToken={authToken}
1018
+ existingProfile={userProfile}
1019
+ onClose={() => { setShowProfileForm(false); loadProfile(); }}
1020
+ />
1021
+ )}
1022
+
1023
+ {showClearData && (
1024
+ <ClearDataModal
1025
+ authToken={authToken}
1026
+ onClose={() => setShowClearData(false)}
1027
+ onDataCleared={({ profile: clearedProfile, chats: clearedChats }) => {
1028
+ if (clearedProfile) {
1029
+ setUserProfile(null);
1030
+ loadProfile();
1031
+ }
1032
+ if (clearedChats) {
1033
+ setMessages([]);
1034
+ setCurrentSessionId(null);
1035
+ setCurrentSessionTitle('');
1036
+ handleNewChat();
1037
+ }
1038
+ }}
1039
+ />
1040
+ )}
1041
+
1042
+ {showAccount && (
1043
+ <AccountModal
1044
+ user={user}
1045
+ authToken={authToken}
1046
+ onClose={() => setShowAccount(false)}
1047
+ onAccountUpdated={(updated) => {
1048
+ if (user) {
1049
+ user.firstName = updated.firstName;
1050
+ user.lastName = updated.lastName;
1051
+ user.email = updated.email;
1052
+ }
1053
+ }}
1054
+ onAccountDeleted={() => {
1055
+ setShowAccount(false);
1056
+ onSignOut();
1057
+ }}
1058
+ />
1059
+ )}
1060
  </div>
1061
  );
1062
  };