AI Development Team Claude Opus 4.5 commited on
Commit
4004da2
·
1 Parent(s): adf2769

feat: Add Google/Facebook OAuth, profile page, and responsive UI

Browse files

- Add OAuth authentication (Google & Facebook) to login/signup
- Add profile page for users to view/edit their information
- Update User model with OAuth fields (oauth_provider, oauth_id, profile_picture)
- Add profile endpoints (GET/PUT /auth/profile)
- Update AuthNavbarItem to show profile pictures from OAuth
- Add comprehensive responsive design for mobile/tablet/desktop
- Update CORS to include production URLs
- Add touch-friendly UI elements and safe area support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

api/auth.py CHANGED
@@ -1,8 +1,9 @@
1
  from fastapi import APIRouter, HTTPException, Depends, Header
2
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
3
  from pydantic import BaseModel, EmailStr
4
- from typing import Optional
5
  import logging
 
6
 
7
  logger = logging.getLogger(__name__)
8
  router = APIRouter()
@@ -21,6 +22,8 @@ except ImportError:
21
  get_db = None
22
  logging.warning("Database modules not available. Auth will use mock mode.")
23
 
 
 
24
  class SignupRequest(BaseModel):
25
  email: EmailStr
26
  password: str
@@ -28,22 +31,41 @@ class SignupRequest(BaseModel):
28
  hardware_background: Optional[str] = None
29
  experience_level: Optional[str] = "Intermediate"
30
 
 
31
  class LoginRequest(BaseModel):
32
  email: EmailStr
33
  password: str
34
 
 
 
 
 
 
 
 
35
  class AuthResponse(BaseModel):
36
  user_id: str
37
  email: str
38
  access_token: str
39
  token_type: str = "bearer"
40
 
 
41
  class UserProfileResponse(BaseModel):
42
  user_id: str
43
  email: str
44
- software_background: Optional[str]
45
- hardware_background: Optional[str]
46
- experience_level: Optional[str]
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  def get_db_session():
@@ -53,6 +75,66 @@ def get_db_session():
53
  return None
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  @router.post("/auth/signup", response_model=AuthResponse)
57
  async def signup(request: SignupRequest):
58
  """Handle user registration with background information"""
@@ -146,6 +228,13 @@ async def login(request: LoginRequest):
146
  if not user:
147
  raise HTTPException(status_code=401, detail="Invalid credentials")
148
 
 
 
 
 
 
 
 
149
  # Verify password
150
  if not verify_password(request.password, user.password_hash):
151
  raise HTTPException(status_code=401, detail="Invalid credentials")
@@ -173,6 +262,121 @@ async def login(request: LoginRequest):
173
  raise HTTPException(status_code=500, detail="Error during login")
174
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  @router.get("/auth/profile", response_model=UserProfileResponse)
177
  async def get_profile(
178
  authorization: Optional[str] = Header(None),
@@ -185,9 +389,12 @@ async def get_profile(
185
  return UserProfileResponse(
186
  user_id="mock_user_id",
187
  email="mock@example.com",
 
188
  software_background="Python, JavaScript",
189
  hardware_background="Arduino, Raspberry Pi",
190
- experience_level="Intermediate"
 
 
191
  )
192
 
193
  # Get token from Authorization header or credentials
@@ -218,9 +425,12 @@ async def get_profile(
218
  return UserProfileResponse(
219
  user_id=str(user.id),
220
  email=user.email,
 
221
  software_background=user.software_background,
222
  hardware_background=user.hardware_background,
223
- experience_level=user.experience_level
 
 
224
  )
225
  finally:
226
  db.close()
@@ -232,10 +442,92 @@ async def get_profile(
232
  raise HTTPException(status_code=500, detail="Error retrieving profile")
233
 
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  @router.get("/auth/health")
236
  async def auth_health():
237
  """Health check for auth service"""
238
  return {
239
  "status": "auth service is running",
240
- "database_enabled": DB_ENABLED
 
241
  }
 
1
  from fastapi import APIRouter, HTTPException, Depends, Header
2
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
3
  from pydantic import BaseModel, EmailStr
4
+ from typing import Optional, Literal
5
  import logging
6
+ import httpx
7
 
8
  logger = logging.getLogger(__name__)
9
  router = APIRouter()
 
22
  get_db = None
23
  logging.warning("Database modules not available. Auth will use mock mode.")
24
 
25
+
26
+ # Request/Response Models
27
  class SignupRequest(BaseModel):
28
  email: EmailStr
29
  password: str
 
31
  hardware_background: Optional[str] = None
32
  experience_level: Optional[str] = "Intermediate"
33
 
34
+
35
  class LoginRequest(BaseModel):
36
  email: EmailStr
37
  password: str
38
 
39
+
40
+ class OAuthRequest(BaseModel):
41
+ """OAuth login/signup request"""
42
+ provider: Literal["google", "facebook"]
43
+ access_token: str
44
+
45
+
46
  class AuthResponse(BaseModel):
47
  user_id: str
48
  email: str
49
  access_token: str
50
  token_type: str = "bearer"
51
 
52
+
53
  class UserProfileResponse(BaseModel):
54
  user_id: str
55
  email: str
56
+ full_name: Optional[str] = None
57
+ software_background: Optional[str] = None
58
+ hardware_background: Optional[str] = None
59
+ experience_level: Optional[str] = None
60
+ oauth_provider: Optional[str] = None
61
+ profile_picture: Optional[str] = None
62
+
63
+
64
+ class ProfileUpdateRequest(BaseModel):
65
+ full_name: Optional[str] = None
66
+ software_background: Optional[str] = None
67
+ hardware_background: Optional[str] = None
68
+ experience_level: Optional[str] = None
69
 
70
 
71
  def get_db_session():
 
75
  return None
76
 
77
 
78
+ # OAuth Helper Functions
79
+ async def verify_google_token(access_token: str) -> dict:
80
+ """Verify Google OAuth token and get user info"""
81
+ try:
82
+ async with httpx.AsyncClient() as client:
83
+ response = await client.get(
84
+ "https://www.googleapis.com/oauth2/v3/userinfo",
85
+ headers={"Authorization": f"Bearer {access_token}"}
86
+ )
87
+
88
+ if response.status_code != 200:
89
+ logger.error(f"Google token verification failed: {response.text}")
90
+ return None
91
+
92
+ data = response.json()
93
+ return {
94
+ "email": data.get("email"),
95
+ "name": data.get("name"),
96
+ "picture": data.get("picture"),
97
+ "provider_id": data.get("sub"),
98
+ "provider": "google"
99
+ }
100
+ except Exception as e:
101
+ logger.error(f"Error verifying Google token: {e}")
102
+ return None
103
+
104
+
105
+ async def verify_facebook_token(access_token: str) -> dict:
106
+ """Verify Facebook OAuth token and get user info"""
107
+ try:
108
+ async with httpx.AsyncClient() as client:
109
+ response = await client.get(
110
+ "https://graph.facebook.com/me",
111
+ params={
112
+ "fields": "id,name,email,picture.type(large)",
113
+ "access_token": access_token
114
+ }
115
+ )
116
+
117
+ if response.status_code != 200:
118
+ logger.error(f"Facebook token verification failed: {response.text}")
119
+ return None
120
+
121
+ data = response.json()
122
+ picture_url = None
123
+ if data.get("picture") and data["picture"].get("data"):
124
+ picture_url = data["picture"]["data"].get("url")
125
+
126
+ return {
127
+ "email": data.get("email"),
128
+ "name": data.get("name"),
129
+ "picture": picture_url,
130
+ "provider_id": data.get("id"),
131
+ "provider": "facebook"
132
+ }
133
+ except Exception as e:
134
+ logger.error(f"Error verifying Facebook token: {e}")
135
+ return None
136
+
137
+
138
  @router.post("/auth/signup", response_model=AuthResponse)
139
  async def signup(request: SignupRequest):
140
  """Handle user registration with background information"""
 
228
  if not user:
229
  raise HTTPException(status_code=401, detail="Invalid credentials")
230
 
231
+ # Check if user is OAuth-only
232
+ if user.oauth_provider and not user.password_hash:
233
+ raise HTTPException(
234
+ status_code=401,
235
+ detail=f"This account uses {user.oauth_provider} login. Please use the {user.oauth_provider} button to sign in."
236
+ )
237
+
238
  # Verify password
239
  if not verify_password(request.password, user.password_hash):
240
  raise HTTPException(status_code=401, detail="Invalid credentials")
 
262
  raise HTTPException(status_code=500, detail="Error during login")
263
 
264
 
265
+ @router.post("/auth/oauth", response_model=AuthResponse)
266
+ async def oauth_login(request: OAuthRequest):
267
+ """Handle OAuth login/signup (Google or Facebook)"""
268
+ try:
269
+ if not DB_ENABLED:
270
+ # Mock mode for testing
271
+ return AuthResponse(
272
+ user_id="mock_oauth_user_id",
273
+ email="oauth@example.com",
274
+ access_token="mock_oauth_access_token",
275
+ token_type="bearer"
276
+ )
277
+
278
+ # Verify OAuth token
279
+ user_info = None
280
+ if request.provider == "google":
281
+ user_info = await verify_google_token(request.access_token)
282
+ elif request.provider == "facebook":
283
+ user_info = await verify_facebook_token(request.access_token)
284
+ else:
285
+ raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
286
+
287
+ if not user_info or not user_info.get("email"):
288
+ raise HTTPException(status_code=401, detail="Could not verify OAuth token or get user email")
289
+
290
+ # Get database session
291
+ from database.db import SessionLocal
292
+ db = SessionLocal()
293
+
294
+ try:
295
+ # Check if user exists by OAuth ID
296
+ existing_user = db.query(DBUser).filter(
297
+ DBUser.oauth_provider == request.provider,
298
+ DBUser.oauth_id == user_info["provider_id"]
299
+ ).first()
300
+
301
+ if existing_user:
302
+ # User exists, log them in
303
+ access_token = create_access_token(data={
304
+ "sub": str(existing_user.id),
305
+ "email": existing_user.email
306
+ })
307
+ logger.info(f"OAuth user logged in: {existing_user.email}")
308
+ return AuthResponse(
309
+ user_id=str(existing_user.id),
310
+ email=existing_user.email,
311
+ access_token=access_token,
312
+ token_type="bearer"
313
+ )
314
+
315
+ # Check if user exists by email
316
+ existing_email_user = db.query(DBUser).filter(DBUser.email == user_info["email"]).first()
317
+
318
+ if existing_email_user:
319
+ # Link OAuth to existing account
320
+ existing_email_user.oauth_provider = request.provider
321
+ existing_email_user.oauth_id = user_info["provider_id"]
322
+ if user_info.get("picture"):
323
+ existing_email_user.profile_picture = user_info["picture"]
324
+ if user_info.get("name") and not existing_email_user.full_name:
325
+ existing_email_user.full_name = user_info["name"]
326
+ db.commit()
327
+
328
+ access_token = create_access_token(data={
329
+ "sub": str(existing_email_user.id),
330
+ "email": existing_email_user.email
331
+ })
332
+ logger.info(f"Linked OAuth to existing user: {existing_email_user.email}")
333
+ return AuthResponse(
334
+ user_id=str(existing_email_user.id),
335
+ email=existing_email_user.email,
336
+ access_token=access_token,
337
+ token_type="bearer"
338
+ )
339
+
340
+ # Create new OAuth user
341
+ new_user = DBUser(
342
+ email=user_info["email"],
343
+ password_hash=None, # OAuth users don't have passwords
344
+ full_name=user_info.get("name"),
345
+ oauth_provider=request.provider,
346
+ oauth_id=user_info["provider_id"],
347
+ profile_picture=user_info.get("picture")
348
+ )
349
+
350
+ db.add(new_user)
351
+ db.commit()
352
+ db.refresh(new_user)
353
+
354
+ access_token = create_access_token(data={
355
+ "sub": str(new_user.id),
356
+ "email": new_user.email
357
+ })
358
+ logger.info(f"New OAuth user registered: {new_user.email}")
359
+
360
+ return AuthResponse(
361
+ user_id=str(new_user.id),
362
+ email=new_user.email,
363
+ access_token=access_token,
364
+ token_type="bearer"
365
+ )
366
+
367
+ except IntegrityError:
368
+ db.rollback()
369
+ raise HTTPException(status_code=400, detail="Error creating user account")
370
+ finally:
371
+ db.close()
372
+
373
+ except HTTPException:
374
+ raise
375
+ except Exception as e:
376
+ logger.error(f"Error during OAuth login: {str(e)}")
377
+ raise HTTPException(status_code=500, detail="Error during OAuth authentication")
378
+
379
+
380
  @router.get("/auth/profile", response_model=UserProfileResponse)
381
  async def get_profile(
382
  authorization: Optional[str] = Header(None),
 
389
  return UserProfileResponse(
390
  user_id="mock_user_id",
391
  email="mock@example.com",
392
+ full_name="Mock User",
393
  software_background="Python, JavaScript",
394
  hardware_background="Arduino, Raspberry Pi",
395
+ experience_level="Intermediate",
396
+ oauth_provider=None,
397
+ profile_picture=None
398
  )
399
 
400
  # Get token from Authorization header or credentials
 
425
  return UserProfileResponse(
426
  user_id=str(user.id),
427
  email=user.email,
428
+ full_name=getattr(user, 'full_name', None),
429
  software_background=user.software_background,
430
  hardware_background=user.hardware_background,
431
+ experience_level=user.experience_level,
432
+ oauth_provider=getattr(user, 'oauth_provider', None),
433
+ profile_picture=getattr(user, 'profile_picture', None)
434
  )
435
  finally:
436
  db.close()
 
442
  raise HTTPException(status_code=500, detail="Error retrieving profile")
443
 
444
 
445
+ @router.put("/auth/profile", response_model=UserProfileResponse)
446
+ async def update_profile(
447
+ request: ProfileUpdateRequest,
448
+ authorization: Optional[str] = Header(None),
449
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
450
+ ):
451
+ """Update user profile information"""
452
+ try:
453
+ if not DB_ENABLED:
454
+ # Mock mode
455
+ return UserProfileResponse(
456
+ user_id="mock_user_id",
457
+ email="mock@example.com",
458
+ full_name=request.full_name,
459
+ software_background=request.software_background,
460
+ hardware_background=request.hardware_background,
461
+ experience_level=request.experience_level,
462
+ oauth_provider=None,
463
+ profile_picture=None
464
+ )
465
+
466
+ # Get token
467
+ token = None
468
+ if credentials:
469
+ token = credentials.credentials
470
+ elif authorization and authorization.startswith("Bearer "):
471
+ token = authorization.replace("Bearer ", "")
472
+
473
+ if not token:
474
+ raise HTTPException(status_code=401, detail="Authorization token required")
475
+
476
+ # Decode token and get user ID
477
+ user_id = get_current_user_id_from_token(token)
478
+ if not user_id:
479
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
480
+
481
+ # Get database session
482
+ from database.db import SessionLocal
483
+ db = SessionLocal()
484
+
485
+ try:
486
+ # Get user from database
487
+ user = db.query(DBUser).filter(DBUser.id == user_id).first()
488
+ if not user:
489
+ raise HTTPException(status_code=404, detail="User not found")
490
+
491
+ # Update fields if provided
492
+ if request.full_name is not None:
493
+ user.full_name = request.full_name
494
+ if request.software_background is not None:
495
+ user.software_background = request.software_background
496
+ if request.hardware_background is not None:
497
+ user.hardware_background = request.hardware_background
498
+ if request.experience_level is not None:
499
+ user.experience_level = request.experience_level
500
+
501
+ db.commit()
502
+ db.refresh(user)
503
+
504
+ logger.info(f"User profile updated: {user.email}")
505
+
506
+ return UserProfileResponse(
507
+ user_id=str(user.id),
508
+ email=user.email,
509
+ full_name=getattr(user, 'full_name', None),
510
+ software_background=user.software_background,
511
+ hardware_background=user.hardware_background,
512
+ experience_level=user.experience_level,
513
+ oauth_provider=getattr(user, 'oauth_provider', None),
514
+ profile_picture=getattr(user, 'profile_picture', None)
515
+ )
516
+ finally:
517
+ db.close()
518
+
519
+ except HTTPException:
520
+ raise
521
+ except Exception as e:
522
+ logger.error(f"Error updating profile: {str(e)}")
523
+ raise HTTPException(status_code=500, detail="Error updating profile")
524
+
525
+
526
  @router.get("/auth/health")
527
  async def auth_health():
528
  """Health check for auth service"""
529
  return {
530
  "status": "auth service is running",
531
+ "database_enabled": DB_ENABLED,
532
+ "oauth_providers": ["google", "facebook"]
533
  }
database/models.py CHANGED
@@ -12,13 +12,25 @@ class User(Base):
12
 
13
  id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
14
  email = Column(String(255), unique=True, nullable=False, index=True)
15
- password_hash = Column(String(255), nullable=False)
 
16
  software_background = Column(Text, nullable=True)
17
  hardware_background = Column(Text, nullable=True)
18
  experience_level = Column(String(50), nullable=True, default="Intermediate")
 
 
 
 
 
 
19
  created_at = Column(DateTime(timezone=True), server_default=func.now())
20
  updated_at = Column(DateTime(timezone=True), onupdate=func.now())
21
 
 
 
 
 
 
22
  class Personalization(Base):
23
  __tablename__ = "personalizations"
24
 
 
12
 
13
  id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
14
  email = Column(String(255), unique=True, nullable=False, index=True)
15
+ password_hash = Column(String(255), nullable=True) # Nullable for OAuth users
16
+ full_name = Column(String(255), nullable=True)
17
  software_background = Column(Text, nullable=True)
18
  hardware_background = Column(Text, nullable=True)
19
  experience_level = Column(String(50), nullable=True, default="Intermediate")
20
+
21
+ # OAuth fields
22
+ oauth_provider = Column(String(50), nullable=True) # 'google', 'facebook', or None
23
+ oauth_id = Column(String(255), nullable=True) # Provider's user ID
24
+ profile_picture = Column(String(500), nullable=True) # Profile picture URL from OAuth
25
+
26
  created_at = Column(DateTime(timezone=True), server_default=func.now())
27
  updated_at = Column(DateTime(timezone=True), onupdate=func.now())
28
 
29
+ # Index for OAuth lookup
30
+ __table_args__ = (
31
+ Index('idx_user_oauth', 'oauth_provider', 'oauth_id'),
32
+ )
33
+
34
  class Personalization(Base):
35
  __tablename__ = "personalizations"
36
 
main.py CHANGED
@@ -108,6 +108,12 @@ app.add_middleware(
108
  "http://localhost:8000",
109
  "http://127.0.0.1:3000",
110
  "http://127.0.0.1:3001",
 
 
 
 
 
 
111
  ],
112
  allow_credentials=True,
113
  allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
 
108
  "http://localhost:8000",
109
  "http://127.0.0.1:3000",
110
  "http://127.0.0.1:3001",
111
+ # Production URLs
112
+ "https://naveed247365-ai-textbook-frontend.hf.space",
113
+ "https://ai-native-textbook.vercel.app",
114
+ # OAuth providers
115
+ "https://accounts.google.com",
116
+ "https://www.facebook.com",
117
  ],
118
  allow_credentials=True,
119
  allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
src/auth/schemas.py CHANGED
@@ -1,8 +1,9 @@
1
  """
2
  Authentication schemas for request/response validation
 
3
  """
4
- from pydantic import BaseModel, EmailStr
5
- from typing import Optional
6
  from datetime import datetime
7
  import uuid
8
 
@@ -14,6 +15,9 @@ class UserBase(BaseModel):
14
 
15
  class UserCreate(UserBase):
16
  password: str
 
 
 
17
 
18
  class Config:
19
  from_attributes = True
@@ -22,6 +26,27 @@ class UserCreate(UserBase):
22
  class UserUpdate(BaseModel):
23
  full_name: Optional[str] = None
24
  email: Optional[EmailStr] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  class Config:
27
  from_attributes = True
@@ -30,6 +55,10 @@ class UserUpdate(BaseModel):
30
  class UserInDB(UserBase):
31
  id: uuid.UUID
32
  is_active: bool
 
 
 
 
33
  created_at: datetime
34
  updated_at: Optional[datetime] = None
35
 
@@ -45,9 +74,43 @@ class UserLogin(BaseModel):
45
  class Token(BaseModel):
46
  access_token: str
47
  token_type: str = "bearer"
48
- expires_in: int
 
49
 
50
 
51
  class TokenData(BaseModel):
52
  user_id: Optional[str] = None
53
- username: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Authentication schemas for request/response validation
3
+ Supports email/password and OAuth (Google/Facebook) authentication
4
  """
5
+ from pydantic import BaseModel, EmailStr, Field
6
+ from typing import Optional, Literal
7
  from datetime import datetime
8
  import uuid
9
 
 
15
 
16
  class UserCreate(UserBase):
17
  password: str
18
+ software_background: Optional[str] = None
19
+ hardware_background: Optional[str] = None
20
+ experience_level: Optional[str] = "Intermediate"
21
 
22
  class Config:
23
  from_attributes = True
 
26
  class UserUpdate(BaseModel):
27
  full_name: Optional[str] = None
28
  email: Optional[EmailStr] = None
29
+ software_background: Optional[str] = None
30
+ hardware_background: Optional[str] = None
31
+ experience_level: Optional[str] = None
32
+
33
+ class Config:
34
+ from_attributes = True
35
+
36
+
37
+ class UserProfile(BaseModel):
38
+ """User profile response"""
39
+ id: uuid.UUID
40
+ email: EmailStr
41
+ full_name: Optional[str] = None
42
+ software_background: Optional[str] = None
43
+ hardware_background: Optional[str] = None
44
+ experience_level: Optional[str] = None
45
+ oauth_provider: Optional[str] = None
46
+ profile_picture: Optional[str] = None
47
+ is_active: bool
48
+ created_at: datetime
49
+ updated_at: Optional[datetime] = None
50
 
51
  class Config:
52
  from_attributes = True
 
55
  class UserInDB(UserBase):
56
  id: uuid.UUID
57
  is_active: bool
58
+ software_background: Optional[str] = None
59
+ hardware_background: Optional[str] = None
60
+ experience_level: Optional[str] = None
61
+ oauth_provider: Optional[str] = None
62
  created_at: datetime
63
  updated_at: Optional[datetime] = None
64
 
 
74
  class Token(BaseModel):
75
  access_token: str
76
  token_type: str = "bearer"
77
+ expires_in: Optional[int] = 3600
78
+ email: Optional[str] = None # Include email in response
79
 
80
 
81
  class TokenData(BaseModel):
82
  user_id: Optional[str] = None
83
+ username: Optional[str] = None
84
+
85
+
86
+ # OAuth Schemas
87
+ class OAuthRequest(BaseModel):
88
+ """OAuth login/signup request"""
89
+ provider: Literal["google", "facebook"]
90
+ access_token: str # Token from OAuth provider
91
+
92
+
93
+ class OAuthUserInfo(BaseModel):
94
+ """User info from OAuth provider"""
95
+ email: EmailStr
96
+ name: Optional[str] = None
97
+ picture: Optional[str] = None
98
+ provider_id: str
99
+ provider: str
100
+
101
+
102
+ class GoogleTokenInfo(BaseModel):
103
+ """Google token info response"""
104
+ email: EmailStr
105
+ email_verified: Optional[bool] = None
106
+ name: Optional[str] = None
107
+ picture: Optional[str] = None
108
+ sub: str # Google user ID
109
+
110
+
111
+ class FacebookUserInfo(BaseModel):
112
+ """Facebook user info response"""
113
+ id: str
114
+ email: Optional[EmailStr] = None
115
+ name: Optional[str] = None
116
+ picture: Optional[dict] = None
src/config/settings.py CHANGED
@@ -22,6 +22,12 @@ class Settings(BaseSettings):
22
  jwt_algorithm: str = "HS256"
23
  jwt_expires_in: int = 3600 # 1 hour default
24
 
 
 
 
 
 
 
25
  # Application settings
26
  debug: bool = False
27
  log_level: str = "info"
 
22
  jwt_algorithm: str = "HS256"
23
  jwt_expires_in: int = 3600 # 1 hour default
24
 
25
+ # OAuth settings (optional - for Google/Facebook login)
26
+ google_client_id: Optional[str] = None
27
+ google_client_secret: Optional[str] = None
28
+ facebook_app_id: Optional[str] = None
29
+ facebook_app_secret: Optional[str] = None
30
+
31
  # Application settings
32
  debug: bool = False
33
  log_level: str = "info"
src/db/crud.py CHANGED
@@ -18,18 +18,35 @@ logger = logging.getLogger(__name__)
18
 
19
 
20
  # User CRUD Operations
21
- async def create_user(db: AsyncSession, email: str, hashed_password: str, full_name: Optional[str] = None) -> User:
22
- """Create a new user"""
 
 
 
 
 
 
 
 
 
 
 
23
  try:
24
  db_user = User(
25
  email=email,
26
  hashed_password=hashed_password,
27
- full_name=full_name
 
 
 
 
 
 
28
  )
29
  db.add(db_user)
30
  await db.commit()
31
  await db.refresh(db_user)
32
- logger.info(f"User created with email: {email}")
33
  return db_user
34
  except IntegrityError:
35
  await db.rollback()
@@ -44,6 +61,22 @@ async def create_user(db: AsyncSession, email: str, hashed_password: str, full_n
44
  raise
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  async def get_user_by_id(db: AsyncSession, user_id: UUID) -> Optional[User]:
48
  """Get a user by ID"""
49
  try:
 
18
 
19
 
20
  # User CRUD Operations
21
+ async def create_user(
22
+ db: AsyncSession,
23
+ email: str,
24
+ hashed_password: Optional[str] = None,
25
+ full_name: Optional[str] = None,
26
+ software_background: Optional[str] = None,
27
+ hardware_background: Optional[str] = None,
28
+ experience_level: Optional[str] = "Intermediate",
29
+ oauth_provider: Optional[str] = None,
30
+ oauth_id: Optional[str] = None,
31
+ profile_picture: Optional[str] = None
32
+ ) -> User:
33
+ """Create a new user (supports both email/password and OAuth)"""
34
  try:
35
  db_user = User(
36
  email=email,
37
  hashed_password=hashed_password,
38
+ full_name=full_name,
39
+ software_background=software_background,
40
+ hardware_background=hardware_background,
41
+ experience_level=experience_level,
42
+ oauth_provider=oauth_provider,
43
+ oauth_id=oauth_id,
44
+ profile_picture=profile_picture
45
  )
46
  db.add(db_user)
47
  await db.commit()
48
  await db.refresh(db_user)
49
+ logger.info(f"User created with email: {email}, oauth_provider: {oauth_provider}")
50
  return db_user
51
  except IntegrityError:
52
  await db.rollback()
 
61
  raise
62
 
63
 
64
+ async def get_user_by_oauth(db: AsyncSession, oauth_provider: str, oauth_id: str) -> Optional[User]:
65
+ """Get a user by OAuth provider and ID"""
66
+ try:
67
+ result = await db.execute(
68
+ select(User).filter(
69
+ User.oauth_provider == oauth_provider,
70
+ User.oauth_id == oauth_id
71
+ )
72
+ )
73
+ user = result.scalar_one_or_none()
74
+ return user
75
+ except Exception as e:
76
+ logger.error(f"Error getting user by OAuth: {e}")
77
+ raise
78
+
79
+
80
  async def get_user_by_id(db: AsyncSession, user_id: UUID) -> Optional[User]:
81
  """Get a user by ID"""
82
  try:
src/db/models/user.py CHANGED
@@ -1,10 +1,12 @@
1
  """
2
  User model for the AI Backend with RAG + Authentication
 
3
  """
4
- from sqlalchemy import Column, String, Boolean, Text, Index
5
  from sqlalchemy.dialects.postgresql import UUID
6
  from sqlalchemy.orm import relationship
7
  from uuid import uuid4
 
8
  from ...db.base import Base
9
 
10
 
@@ -13,16 +15,31 @@ class User(Base):
13
 
14
  id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4, unique=True, nullable=False)
15
  email = Column(String(255), unique=True, nullable=False, index=True)
16
- hashed_password = Column(Text, nullable=False)
17
  full_name = Column(String(255), nullable=True)
18
  is_active = Column(Boolean, default=True, nullable=False)
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  # Relationships
21
  chat_histories = relationship("ChatHistory", back_populates="user", cascade="all, delete-orphan")
22
  documents = relationship("Document", back_populates="user", cascade="all, delete-orphan")
23
 
24
  def __repr__(self):
25
- return f"<User(id={self.id}, email='{self.email}', full_name='{self.full_name}')>"
26
 
27
  # Create indexes
28
- Index('idx_user_email', User.email)
 
 
1
  """
2
  User model for the AI Backend with RAG + Authentication
3
+ Supports email/password and OAuth (Google/Facebook) authentication
4
  """
5
+ from sqlalchemy import Column, String, Boolean, Text, Index, DateTime
6
  from sqlalchemy.dialects.postgresql import UUID
7
  from sqlalchemy.orm import relationship
8
  from uuid import uuid4
9
+ from datetime import datetime
10
  from ...db.base import Base
11
 
12
 
 
15
 
16
  id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4, unique=True, nullable=False)
17
  email = Column(String(255), unique=True, nullable=False, index=True)
18
+ hashed_password = Column(Text, nullable=True) # Nullable for OAuth users
19
  full_name = Column(String(255), nullable=True)
20
  is_active = Column(Boolean, default=True, nullable=False)
21
 
22
+ # Profile fields for personalization
23
+ software_background = Column(Text, nullable=True)
24
+ hardware_background = Column(Text, nullable=True)
25
+ experience_level = Column(String(50), nullable=True, default="Intermediate")
26
+
27
+ # OAuth fields
28
+ oauth_provider = Column(String(50), nullable=True) # 'google', 'facebook', or None
29
+ oauth_id = Column(String(255), nullable=True) # Provider's user ID
30
+ profile_picture = Column(String(500), nullable=True) # Profile picture URL from OAuth
31
+
32
+ # Timestamps
33
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
34
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
35
+
36
  # Relationships
37
  chat_histories = relationship("ChatHistory", back_populates="user", cascade="all, delete-orphan")
38
  documents = relationship("Document", back_populates="user", cascade="all, delete-orphan")
39
 
40
  def __repr__(self):
41
+ return f"<User(id={self.id}, email='{self.email}', full_name='{self.full_name}', oauth_provider='{self.oauth_provider}')>"
42
 
43
  # Create indexes
44
+ Index('idx_user_email', User.email)
45
+ Index('idx_user_oauth', User.oauth_provider, User.oauth_id)
src/routes/auth.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Authentication API routes for the AI Backend with RAG + Authentication
3
- Implements signup, login, and user profile endpoints
4
  """
5
  from fastapi import APIRouter, HTTPException, status, Depends
6
  from fastapi.security import HTTPBearer
@@ -8,9 +8,13 @@ import logging
8
  from typing import Optional
9
  from uuid import UUID
10
  import re
 
11
 
12
  from ..auth.auth import AuthHandler
13
- from ..models.auth import UserCreate, UserLogin, Token
 
 
 
14
  from ..config.settings import settings
15
  from ..config.database import get_db_session
16
  from ..db import crud
@@ -30,7 +34,7 @@ async def signup(
30
  db: AsyncSession = Depends(get_db_session)
31
  ):
32
  """
33
- Register a new user
34
  """
35
  try:
36
  # Validate email format
@@ -49,13 +53,16 @@ async def signup(
49
  detail="User with this email already exists"
50
  )
51
 
52
- # Create new user with hashed password
53
  hashed_password = auth_handler.get_password_hash(user_data.password)
54
  db_user = await crud.create_user(
55
  db,
56
  email=user_data.email,
57
  hashed_password=hashed_password,
58
- full_name=user_data.full_name
 
 
 
59
  )
60
 
61
  # Create access token
@@ -65,7 +72,9 @@ async def signup(
65
 
66
  return {
67
  "access_token": access_token,
68
- "token_type": "bearer"
 
 
69
  }
70
  except HTTPException:
71
  raise
@@ -89,7 +98,22 @@ async def login(
89
  # Find user by email
90
  user = await crud.get_user_by_email(db, user_credentials.email)
91
 
92
- if not user or not auth_handler.verify_password(user_credentials.password, user.hashed_password):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  raise HTTPException(
94
  status_code=status.HTTP_401_UNAUTHORIZED,
95
  detail="Incorrect email or password",
@@ -110,7 +134,9 @@ async def login(
110
 
111
  return {
112
  "access_token": access_token,
113
- "token_type": "bearer"
 
 
114
  }
115
  except HTTPException:
116
  raise
@@ -122,7 +148,163 @@ async def login(
122
  )
123
 
124
 
125
- @router.get("/me")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  async def get_current_user(
127
  current_user_id: str = Depends(auth_handler.get_current_user),
128
  db: AsyncSession = Depends(get_db_session)
@@ -139,13 +321,19 @@ async def get_current_user(
139
  detail="User not found"
140
  )
141
 
142
- return {
143
- "id": str(user.id),
144
- "email": user.email,
145
- "full_name": user.full_name,
146
- "is_active": user.is_active,
147
- "created_at": user.created_at
148
- }
 
 
 
 
 
 
149
  except HTTPException:
150
  raise
151
  except Exception as e:
@@ -153,4 +341,75 @@ async def get_current_user(
153
  raise HTTPException(
154
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
155
  detail="An error occurred while retrieving user profile"
156
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Authentication API routes for the AI Backend with RAG + Authentication
3
+ Implements signup, login, OAuth (Google/Facebook), and user profile endpoints
4
  """
5
  from fastapi import APIRouter, HTTPException, status, Depends
6
  from fastapi.security import HTTPBearer
 
8
  from typing import Optional
9
  from uuid import UUID
10
  import re
11
+ import httpx
12
 
13
  from ..auth.auth import AuthHandler
14
+ from ..auth.schemas import (
15
+ UserCreate, UserLogin, Token, UserProfile, UserUpdate,
16
+ OAuthRequest, GoogleTokenInfo, FacebookUserInfo
17
+ )
18
  from ..config.settings import settings
19
  from ..config.database import get_db_session
20
  from ..db import crud
 
34
  db: AsyncSession = Depends(get_db_session)
35
  ):
36
  """
37
+ Register a new user with email and password
38
  """
39
  try:
40
  # Validate email format
 
53
  detail="User with this email already exists"
54
  )
55
 
56
+ # Create new user with hashed password and profile data
57
  hashed_password = auth_handler.get_password_hash(user_data.password)
58
  db_user = await crud.create_user(
59
  db,
60
  email=user_data.email,
61
  hashed_password=hashed_password,
62
+ full_name=user_data.full_name,
63
+ software_background=user_data.software_background,
64
+ hardware_background=user_data.hardware_background,
65
+ experience_level=user_data.experience_level or "Intermediate"
66
  )
67
 
68
  # Create access token
 
72
 
73
  return {
74
  "access_token": access_token,
75
+ "token_type": "bearer",
76
+ "expires_in": settings.jwt_expires_in,
77
+ "email": db_user.email
78
  }
79
  except HTTPException:
80
  raise
 
98
  # Find user by email
99
  user = await crud.get_user_by_email(db, user_credentials.email)
100
 
101
+ if not user:
102
+ raise HTTPException(
103
+ status_code=status.HTTP_401_UNAUTHORIZED,
104
+ detail="Incorrect email or password",
105
+ headers={"WWW-Authenticate": "Bearer"},
106
+ )
107
+
108
+ # Check if user is OAuth-only (no password)
109
+ if user.oauth_provider and not user.hashed_password:
110
+ raise HTTPException(
111
+ status_code=status.HTTP_401_UNAUTHORIZED,
112
+ detail=f"This account uses {user.oauth_provider} login. Please use the {user.oauth_provider} button to sign in.",
113
+ headers={"WWW-Authenticate": "Bearer"},
114
+ )
115
+
116
+ if not auth_handler.verify_password(user_credentials.password, user.hashed_password):
117
  raise HTTPException(
118
  status_code=status.HTTP_401_UNAUTHORIZED,
119
  detail="Incorrect email or password",
 
134
 
135
  return {
136
  "access_token": access_token,
137
+ "token_type": "bearer",
138
+ "expires_in": settings.jwt_expires_in,
139
+ "email": user.email
140
  }
141
  except HTTPException:
142
  raise
 
148
  )
149
 
150
 
151
+ @router.post("/oauth", response_model=Token)
152
+ async def oauth_login(
153
+ oauth_data: OAuthRequest,
154
+ db: AsyncSession = Depends(get_db_session)
155
+ ):
156
+ """
157
+ Authenticate or register user via OAuth (Google or Facebook)
158
+ """
159
+ try:
160
+ user_info = None
161
+
162
+ if oauth_data.provider == "google":
163
+ user_info = await verify_google_token(oauth_data.access_token)
164
+ elif oauth_data.provider == "facebook":
165
+ user_info = await verify_facebook_token(oauth_data.access_token)
166
+ else:
167
+ raise HTTPException(
168
+ status_code=status.HTTP_400_BAD_REQUEST,
169
+ detail="Unsupported OAuth provider"
170
+ )
171
+
172
+ if not user_info or not user_info.get("email"):
173
+ raise HTTPException(
174
+ status_code=status.HTTP_401_UNAUTHORIZED,
175
+ detail="Could not verify OAuth token or get user email"
176
+ )
177
+
178
+ # Check if user exists by OAuth ID
179
+ existing_user = await crud.get_user_by_oauth(
180
+ db, oauth_data.provider, user_info["provider_id"]
181
+ )
182
+
183
+ if existing_user:
184
+ # User exists, log them in
185
+ access_token = auth_handler.create_access_token(str(existing_user.id))
186
+ logger.info(f"OAuth user logged in: {existing_user.email}")
187
+ return {
188
+ "access_token": access_token,
189
+ "token_type": "bearer",
190
+ "expires_in": settings.jwt_expires_in,
191
+ "email": existing_user.email
192
+ }
193
+
194
+ # Check if user exists by email (might have signed up with password)
195
+ existing_email_user = await crud.get_user_by_email(db, user_info["email"])
196
+
197
+ if existing_email_user:
198
+ # Link OAuth to existing account
199
+ await crud.update_user(
200
+ db,
201
+ existing_email_user.id,
202
+ oauth_provider=oauth_data.provider,
203
+ oauth_id=user_info["provider_id"],
204
+ profile_picture=user_info.get("picture")
205
+ )
206
+ access_token = auth_handler.create_access_token(str(existing_email_user.id))
207
+ logger.info(f"Linked OAuth to existing user: {existing_email_user.email}")
208
+ return {
209
+ "access_token": access_token,
210
+ "token_type": "bearer",
211
+ "expires_in": settings.jwt_expires_in,
212
+ "email": existing_email_user.email
213
+ }
214
+
215
+ # Create new OAuth user
216
+ new_user = await crud.create_user(
217
+ db,
218
+ email=user_info["email"],
219
+ hashed_password=None, # OAuth users don't have passwords
220
+ full_name=user_info.get("name"),
221
+ oauth_provider=oauth_data.provider,
222
+ oauth_id=user_info["provider_id"],
223
+ profile_picture=user_info.get("picture")
224
+ )
225
+
226
+ access_token = auth_handler.create_access_token(str(new_user.id))
227
+ logger.info(f"New OAuth user registered: {new_user.email}")
228
+
229
+ return {
230
+ "access_token": access_token,
231
+ "token_type": "bearer",
232
+ "expires_in": settings.jwt_expires_in,
233
+ "email": new_user.email
234
+ }
235
+
236
+ except HTTPException:
237
+ raise
238
+ except Exception as e:
239
+ logger.error(f"Error during OAuth login: {e}")
240
+ raise HTTPException(
241
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
242
+ detail="An error occurred during OAuth authentication"
243
+ )
244
+
245
+
246
+ async def verify_google_token(access_token: str) -> dict:
247
+ """Verify Google OAuth token and get user info"""
248
+ try:
249
+ async with httpx.AsyncClient() as client:
250
+ # Get user info from Google
251
+ response = await client.get(
252
+ "https://www.googleapis.com/oauth2/v3/userinfo",
253
+ headers={"Authorization": f"Bearer {access_token}"}
254
+ )
255
+
256
+ if response.status_code != 200:
257
+ logger.error(f"Google token verification failed: {response.text}")
258
+ return None
259
+
260
+ data = response.json()
261
+ return {
262
+ "email": data.get("email"),
263
+ "name": data.get("name"),
264
+ "picture": data.get("picture"),
265
+ "provider_id": data.get("sub"),
266
+ "provider": "google"
267
+ }
268
+ except Exception as e:
269
+ logger.error(f"Error verifying Google token: {e}")
270
+ return None
271
+
272
+
273
+ async def verify_facebook_token(access_token: str) -> dict:
274
+ """Verify Facebook OAuth token and get user info"""
275
+ try:
276
+ async with httpx.AsyncClient() as client:
277
+ # Get user info from Facebook
278
+ response = await client.get(
279
+ "https://graph.facebook.com/me",
280
+ params={
281
+ "fields": "id,name,email,picture.type(large)",
282
+ "access_token": access_token
283
+ }
284
+ )
285
+
286
+ if response.status_code != 200:
287
+ logger.error(f"Facebook token verification failed: {response.text}")
288
+ return None
289
+
290
+ data = response.json()
291
+ picture_url = None
292
+ if data.get("picture") and data["picture"].get("data"):
293
+ picture_url = data["picture"]["data"].get("url")
294
+
295
+ return {
296
+ "email": data.get("email"),
297
+ "name": data.get("name"),
298
+ "picture": picture_url,
299
+ "provider_id": data.get("id"),
300
+ "provider": "facebook"
301
+ }
302
+ except Exception as e:
303
+ logger.error(f"Error verifying Facebook token: {e}")
304
+ return None
305
+
306
+
307
+ @router.get("/me", response_model=UserProfile)
308
  async def get_current_user(
309
  current_user_id: str = Depends(auth_handler.get_current_user),
310
  db: AsyncSession = Depends(get_db_session)
 
321
  detail="User not found"
322
  )
323
 
324
+ return UserProfile(
325
+ id=user.id,
326
+ email=user.email,
327
+ full_name=user.full_name,
328
+ software_background=user.software_background,
329
+ hardware_background=user.hardware_background,
330
+ experience_level=user.experience_level,
331
+ oauth_provider=user.oauth_provider,
332
+ profile_picture=user.profile_picture,
333
+ is_active=user.is_active,
334
+ created_at=user.created_at,
335
+ updated_at=user.updated_at
336
+ )
337
  except HTTPException:
338
  raise
339
  except Exception as e:
 
341
  raise HTTPException(
342
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
343
  detail="An error occurred while retrieving user profile"
344
+ )
345
+
346
+
347
+ @router.get("/profile", response_model=UserProfile)
348
+ async def get_profile(
349
+ current_user_id: str = Depends(auth_handler.get_current_user),
350
+ db: AsyncSession = Depends(get_db_session)
351
+ ):
352
+ """
353
+ Get current user profile (alias for /me)
354
+ """
355
+ return await get_current_user(current_user_id, db)
356
+
357
+
358
+ @router.put("/profile", response_model=UserProfile)
359
+ async def update_profile(
360
+ profile_data: UserUpdate,
361
+ current_user_id: str = Depends(auth_handler.get_current_user),
362
+ db: AsyncSession = Depends(get_db_session)
363
+ ):
364
+ """
365
+ Update current user profile
366
+ """
367
+ try:
368
+ # Build update dict with only provided fields
369
+ update_data = {}
370
+ if profile_data.full_name is not None:
371
+ update_data["full_name"] = profile_data.full_name
372
+ if profile_data.software_background is not None:
373
+ update_data["software_background"] = profile_data.software_background
374
+ if profile_data.hardware_background is not None:
375
+ update_data["hardware_background"] = profile_data.hardware_background
376
+ if profile_data.experience_level is not None:
377
+ update_data["experience_level"] = profile_data.experience_level
378
+
379
+ if not update_data:
380
+ raise HTTPException(
381
+ status_code=status.HTTP_400_BAD_REQUEST,
382
+ detail="No fields to update"
383
+ )
384
+
385
+ updated_user = await crud.update_user(db, UUID(current_user_id), **update_data)
386
+
387
+ if not updated_user:
388
+ raise HTTPException(
389
+ status_code=status.HTTP_404_NOT_FOUND,
390
+ detail="User not found"
391
+ )
392
+
393
+ logger.info(f"User profile updated: {updated_user.email}")
394
+
395
+ return UserProfile(
396
+ id=updated_user.id,
397
+ email=updated_user.email,
398
+ full_name=updated_user.full_name,
399
+ software_background=updated_user.software_background,
400
+ hardware_background=updated_user.hardware_background,
401
+ experience_level=updated_user.experience_level,
402
+ oauth_provider=updated_user.oauth_provider,
403
+ profile_picture=updated_user.profile_picture,
404
+ is_active=updated_user.is_active,
405
+ created_at=updated_user.created_at,
406
+ updated_at=updated_user.updated_at
407
+ )
408
+ except HTTPException:
409
+ raise
410
+ except Exception as e:
411
+ logger.error(f"Error updating user profile: {e}")
412
+ raise HTTPException(
413
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
414
+ detail="An error occurred while updating user profile"
415
+ )