Spaces:
Sleeping
Sleeping
NeonClary Cursor commited on
Commit ·
6004480
1
Parent(s): cc7440f
Restore cybersecurity user profile UX and personalize advisor responses
Browse filesAdd 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 +36 -8
- multi_llm_chatbot_backend/app/api/routes/auth.py +38 -13
- multi_llm_chatbot_backend/app/api/routes/chat.py +28 -0
- multi_llm_chatbot_backend/app/api/routes/onboarding.py +150 -0
- multi_llm_chatbot_backend/app/api/routes/user_profile.py +177 -0
- multi_llm_chatbot_backend/app/config.py +10 -0
- multi_llm_chatbot_backend/app/core/auth.py +1 -0
- multi_llm_chatbot_backend/app/core/database.py +3 -0
- multi_llm_chatbot_backend/app/core/improved_orchestrator.py +11 -10
- multi_llm_chatbot_backend/app/core/onboarding_agent.py +178 -0
- multi_llm_chatbot_backend/app/main.py +4 -0
- multi_llm_chatbot_backend/app/models/user.py +11 -0
- multi_llm_chatbot_backend/app/models/user_profile.py +57 -0
- phd-advisor-frontend/src/components/AccountModal.js +339 -0
- phd-advisor-frontend/src/components/ClearDataModal.js +185 -0
- phd-advisor-frontend/src/components/OnboardingChat.js +153 -0
- phd-advisor-frontend/src/components/ProfileWalkthrough.js +243 -0
- phd-advisor-frontend/src/components/Sidebar.js +51 -6
- phd-advisor-frontend/src/components/Signup.js +35 -23
- phd-advisor-frontend/src/components/UserAvatarPicker.js +81 -0
- phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js +5 -5
- phd-advisor-frontend/src/components/canvas/canvasData.js +55 -55
- phd-advisor-frontend/src/pages/ChatPage.js +106 -0
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
|
| 36 |
-
- { value: "
|
| 37 |
-
- { value: "
|
| 38 |
-
- { value: "
|
| 39 |
-
- { value: "
|
| 40 |
-
- { value: "
|
| 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:
|
| 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 |
-
|
| 731 |
-
|
| 732 |
-
"
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 232 |
-
<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,
|
| 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
|
| 23 |
-
? config.login.
|
| 24 |
-
:
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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
|
| 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 |
-
<
|
| 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 |
-
{/*
|
| 289 |
<div className="form-group">
|
| 290 |
<label htmlFor="academicStage" className="form-label">
|
| 291 |
-
|
| 292 |
</label>
|
| 293 |
<div className="input-container">
|
| 294 |
-
<
|
| 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 |
-
{
|
| 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 |
-
{/*
|
| 316 |
<div className="form-group">
|
| 317 |
<label htmlFor="researchArea" className="form-label">
|
| 318 |
-
|
| 319 |
</label>
|
| 320 |
<div className="input-container">
|
| 321 |
-
<
|
| 322 |
-
<
|
| 323 |
-
type="text"
|
| 324 |
id="researchArea"
|
| 325 |
name="researchArea"
|
| 326 |
value={formData.researchArea}
|
| 327 |
onChange={handleInputChange}
|
| 328 |
-
className="form-
|
| 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: '
|
| 36 |
-
desc: '
|
| 37 |
icon: 'book',
|
| 38 |
mode: 'paper',
|
| 39 |
sections: [
|
|
@@ -47,8 +47,8 @@ export const TEMPLATES = [
|
|
| 47 |
},
|
| 48 |
{
|
| 49 |
id: 'thesis-chapter',
|
| 50 |
-
name: '
|
| 51 |
-
desc: '
|
| 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 |
-
|
| 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 → Scope → Findings → Risk rating → Remediation → Appendix',
|
| 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:
|
| 15 |
-
summary: '
|
| 16 |
bullets: [
|
| 17 |
-
'
|
| 18 |
-
'
|
| 19 |
-
'<strong>Risk:</strong>
|
| 20 |
],
|
| 21 |
pinned: true,
|
| 22 |
-
sources:
|
| 23 |
-
updatedMinutesAgo:
|
| 24 |
quotes: [
|
| 25 |
-
'"
|
| 26 |
-
'"
|
| 27 |
],
|
| 28 |
},
|
| 29 |
{
|
|
@@ -31,18 +31,18 @@ export const INSIGHTS = [
|
|
| 31 |
title: 'Controls posture',
|
| 32 |
icon: 'flask',
|
| 33 |
category: 'theory',
|
| 34 |
-
confidence:
|
| 35 |
-
summary: '
|
| 36 |
bullets: [
|
| 37 |
-
'
|
| 38 |
-
'Open:
|
| 39 |
-
'Open:
|
| 40 |
],
|
| 41 |
-
sources:
|
| 42 |
-
updatedMinutesAgo:
|
| 43 |
quotes: [
|
| 44 |
-
'"Need
|
| 45 |
-
'"
|
| 46 |
],
|
| 47 |
},
|
| 48 |
{
|
|
@@ -50,37 +50,37 @@ export const INSIGHTS = [
|
|
| 50 |
title: 'Threat landscape',
|
| 51 |
icon: 'book',
|
| 52 |
category: 'literature',
|
| 53 |
-
confidence:
|
| 54 |
-
summary: 'Strong
|
| 55 |
bullets: [
|
| 56 |
-
'<strong>Coverage:</strong>
|
| 57 |
-
'<strong>Gap:</strong>
|
| 58 |
-
'<strong>Gap:</strong> no
|
| 59 |
],
|
| 60 |
-
sources:
|
| 61 |
-
updatedMinutesAgo:
|
| 62 |
quotes: [
|
| 63 |
-
'"
|
| 64 |
-
'"
|
| 65 |
],
|
| 66 |
},
|
| 67 |
{
|
| 68 |
id: 'i-questions',
|
| 69 |
-
title: 'Open
|
| 70 |
icon: 'sparkles',
|
| 71 |
category: 'theory',
|
| 72 |
-
confidence:
|
| 73 |
-
summary: 'Three live threads.
|
| 74 |
bullets: [
|
| 75 |
-
'<strong>Q1:</strong>
|
| 76 |
-
'<strong>Q2:</strong>
|
| 77 |
-
'<strong>Q3:</strong> Is
|
| 78 |
],
|
| 79 |
-
sources:
|
| 80 |
-
updatedMinutesAgo:
|
| 81 |
quotes: [
|
| 82 |
-
'"
|
| 83 |
-
'"
|
| 84 |
],
|
| 85 |
},
|
| 86 |
{
|
|
@@ -88,19 +88,19 @@ export const INSIGHTS = [
|
|
| 88 |
title: 'Next steps',
|
| 89 |
icon: 'arrow',
|
| 90 |
category: 'action',
|
| 91 |
-
confidence:
|
| 92 |
-
summary: '
|
| 93 |
bullets: [
|
| 94 |
-
'
|
| 95 |
-
'
|
| 96 |
-
'
|
| 97 |
-
'
|
| 98 |
],
|
| 99 |
-
sources:
|
| 100 |
-
updatedMinutesAgo:
|
| 101 |
quotes: [
|
| 102 |
-
'"
|
| 103 |
-
'"
|
| 104 |
],
|
| 105 |
},
|
| 106 |
{
|
|
@@ -108,17 +108,17 @@ export const INSIGHTS = [
|
|
| 108 |
title: 'Blockers & risks',
|
| 109 |
icon: 'alert',
|
| 110 |
category: 'risk',
|
| 111 |
-
confidence:
|
| 112 |
-
summary: 'One technical
|
| 113 |
bullets: [
|
| 114 |
-
'<strong>Technical:</strong>
|
| 115 |
-
'<strong>
|
| 116 |
],
|
| 117 |
-
sources:
|
| 118 |
-
updatedMinutesAgo:
|
| 119 |
quotes: [
|
| 120 |
-
'"
|
| 121 |
-
'"
|
| 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. Q2–Q3 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 > 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 |
};
|