Spaces:
Sleeping
Sleeping
Kennedy Johnson Cursor commited on
Commit ·
5e77c6e
1
Parent(s): 8fc16a2
Fix persistent auth so users stay logged in across refreshes and redeploys.
Browse filesPersist JWT signing secrets under DATA_DIR when unset, validate stored sessions on app startup, and restore auth state synchronously from localStorage to avoid false re-login prompts.
Co-authored-by: Cursor <cursoragent@cursor.com>
- multi_llm_chatbot_backend/app/config.py +38 -5
- multi_llm_chatbot_backend/app/core/auth.py +3 -2
- multi_llm_chatbot_backend/app/tests/unit/test_jwt_secret.py +30 -0
- phd-advisor-frontend/src/App.js +88 -36
- phd-advisor-frontend/src/components/Login.js +2 -3
- phd-advisor-frontend/src/components/Signup.js +2 -3
- phd-advisor-frontend/src/styles/components.css +14 -0
- phd-advisor-frontend/src/utils/authStorage.js +31 -0
multi_llm_chatbot_backend/app/config.py
CHANGED
|
@@ -215,6 +215,43 @@ class OrchestratorConfig(BaseModel):
|
|
| 215 |
return self
|
| 216 |
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
class AuthConfig(BaseModel):
|
| 219 |
jwt_secret: str = Field(default=os.getenv("JWT_SECRET_KEY", ""))
|
| 220 |
algorithm: str = "HS256"
|
|
@@ -222,11 +259,7 @@ class AuthConfig(BaseModel):
|
|
| 222 |
|
| 223 |
@model_validator(mode="after")
|
| 224 |
def _validate_jwt_secret(self):
|
| 225 |
-
|
| 226 |
-
logger.warning(
|
| 227 |
-
"Insecure default JWT secret will be used. "
|
| 228 |
-
"Set auth.jwt_secret in config.yaml for production use.")
|
| 229 |
-
self.jwt_secret = "your-secret-key-change-me"
|
| 230 |
return self
|
| 231 |
|
| 232 |
|
|
|
|
| 215 |
return self
|
| 216 |
|
| 217 |
|
| 218 |
+
def resolve_jwt_secret(explicit: str = "") -> str:
|
| 219 |
+
"""Resolve a stable JWT signing secret across restarts.
|
| 220 |
+
|
| 221 |
+
Precedence: config value → JWT_SECRET_KEY env → persisted file under
|
| 222 |
+
DATA_DIR (generated on first run) → insecure dev fallback.
|
| 223 |
+
"""
|
| 224 |
+
if explicit and explicit.strip():
|
| 225 |
+
return explicit.strip()
|
| 226 |
+
|
| 227 |
+
env_secret = os.getenv("JWT_SECRET_KEY", "").strip()
|
| 228 |
+
if env_secret:
|
| 229 |
+
return env_secret
|
| 230 |
+
|
| 231 |
+
data_dir = Path(os.environ.get("DATA_DIR") or Path.home() / "data")
|
| 232 |
+
secret_path = data_dir / "jwt_secret"
|
| 233 |
+
try:
|
| 234 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 235 |
+
if secret_path.is_file():
|
| 236 |
+
stored = secret_path.read_text(encoding="utf-8").strip()
|
| 237 |
+
if stored:
|
| 238 |
+
return stored
|
| 239 |
+
import secrets
|
| 240 |
+
|
| 241 |
+
generated = secrets.token_urlsafe(48)
|
| 242 |
+
secret_path.write_text(generated, encoding="utf-8")
|
| 243 |
+
logger.info("Generated persistent JWT secret at %s", secret_path)
|
| 244 |
+
return generated
|
| 245 |
+
except OSError as exc:
|
| 246 |
+
logger.warning(
|
| 247 |
+
"Cannot persist JWT secret at %s (%s); using insecure default. "
|
| 248 |
+
"Set JWT_SECRET_KEY or auth.jwt_secret for production.",
|
| 249 |
+
secret_path,
|
| 250 |
+
exc,
|
| 251 |
+
)
|
| 252 |
+
return "your-secret-key-change-me"
|
| 253 |
+
|
| 254 |
+
|
| 255 |
class AuthConfig(BaseModel):
|
| 256 |
jwt_secret: str = Field(default=os.getenv("JWT_SECRET_KEY", ""))
|
| 257 |
algorithm: str = "HS256"
|
|
|
|
| 259 |
|
| 260 |
@model_validator(mode="after")
|
| 261 |
def _validate_jwt_secret(self):
|
| 262 |
+
self.jwt_secret = resolve_jwt_secret(self.jwt_secret)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
return self
|
| 264 |
|
| 265 |
|
multi_llm_chatbot_backend/app/core/auth.py
CHANGED
|
@@ -37,8 +37,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
|
| 37 |
expire = datetime.utcnow() + expires_delta
|
| 38 |
else:
|
| 39 |
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 42 |
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 43 |
return encoded_jwt
|
| 44 |
|
|
|
|
| 37 |
expire = datetime.utcnow() + expires_delta
|
| 38 |
else:
|
| 39 |
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 40 |
+
|
| 41 |
+
# JWT NumericDate must be a Unix timestamp (RFC 7519).
|
| 42 |
+
to_encode.update({"exp": int(expire.timestamp())})
|
| 43 |
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 44 |
return encoded_jwt
|
| 45 |
|
multi_llm_chatbot_backend/app/tests/unit/test_jwt_secret.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
import unittest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
from app.config import resolve_jwt_secret
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestResolveJwtSecret(unittest.TestCase):
|
| 11 |
+
def test_prefers_explicit_config_value(self):
|
| 12 |
+
self.assertEqual(resolve_jwt_secret("from-config"), "from-config")
|
| 13 |
+
|
| 14 |
+
@patch.dict(os.environ, {"JWT_SECRET_KEY": "from-env"}, clear=False)
|
| 15 |
+
def test_uses_env_when_config_empty(self):
|
| 16 |
+
self.assertEqual(resolve_jwt_secret(""), "from-env")
|
| 17 |
+
|
| 18 |
+
def test_persists_generated_secret_to_data_dir(self):
|
| 19 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 20 |
+
with patch.dict(os.environ, {"DATA_DIR": tmp, "JWT_SECRET_KEY": ""}, clear=False):
|
| 21 |
+
first = resolve_jwt_secret("")
|
| 22 |
+
second = resolve_jwt_secret("")
|
| 23 |
+
secret_path = Path(tmp) / "jwt_secret"
|
| 24 |
+
self.assertTrue(secret_path.is_file())
|
| 25 |
+
self.assertEqual(first, second)
|
| 26 |
+
self.assertGreater(len(first), 20)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
unittest.main()
|
phd-advisor-frontend/src/App.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
import { ThemeProvider } from './contexts/ThemeContext';
|
| 3 |
import { AppConfigProvider } from './contexts/AppConfigContext';
|
| 4 |
import HomePage from './pages/HomePage';
|
|
@@ -6,35 +6,88 @@ import ChatPage from './pages/ChatPage';
|
|
| 6 |
import AuthPage from './pages/AuthPage';
|
| 7 |
import CanvasPage from './pages/CanvasPage';
|
| 8 |
import UserGuide from './components/UserGuide';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import './styles/components.css';
|
| 10 |
|
| 11 |
function App() {
|
| 12 |
-
const
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
useEffect(() => {
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
try {
|
| 24 |
-
const
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
-
}
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
const navigateToAuth = () => {
|
|
|
|
|
|
|
|
|
|
| 38 |
setCurrentView('auth');
|
| 39 |
};
|
| 40 |
|
|
@@ -49,44 +102,44 @@ function App() {
|
|
| 49 |
setCurrentView('chat');
|
| 50 |
};
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
const navigateToHome = () => {
|
| 55 |
setCurrentView('home');
|
| 56 |
};
|
| 57 |
|
| 58 |
const handleAuthSuccess = (userData, token) => {
|
|
|
|
| 59 |
setUser(userData);
|
| 60 |
setAuthToken(token);
|
| 61 |
setIsAuthenticated(true);
|
|
|
|
| 62 |
setCurrentView('chat');
|
| 63 |
};
|
| 64 |
|
| 65 |
const handleSignOut = () => {
|
| 66 |
-
|
| 67 |
-
localStorage.removeItem('user');
|
| 68 |
-
setUser(null);
|
| 69 |
-
setAuthToken(null);
|
| 70 |
-
setIsAuthenticated(false);
|
| 71 |
-
setCurrentView('home');
|
| 72 |
};
|
| 73 |
|
| 74 |
return (
|
| 75 |
<AppConfigProvider>
|
| 76 |
<ThemeProvider>
|
| 77 |
<div className="App">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
{currentView === 'home' && (
|
| 79 |
<HomePage
|
| 80 |
onNavigateToHome={navigateToHome}
|
| 81 |
-
onNavigateToChat={
|
| 82 |
-
onNavigateToCanvas={
|
| 83 |
-
isAuthenticated={
|
| 84 |
/>
|
| 85 |
)}
|
| 86 |
-
{currentView === 'auth' && (
|
| 87 |
<AuthPage onAuthSuccess={handleAuthSuccess} />
|
| 88 |
)}
|
| 89 |
-
{currentView === 'canvas' &&
|
| 90 |
<CanvasPage
|
| 91 |
user={user}
|
| 92 |
authToken={authToken}
|
|
@@ -95,7 +148,7 @@ function App() {
|
|
| 95 |
onSignOut={handleSignOut}
|
| 96 |
/>
|
| 97 |
)}
|
| 98 |
-
{currentView === 'chat' &&
|
| 99 |
<ChatPage
|
| 100 |
user={user}
|
| 101 |
authToken={authToken}
|
|
@@ -104,7 +157,6 @@ function App() {
|
|
| 104 |
onSignOut={handleSignOut}
|
| 105 |
/>
|
| 106 |
)}
|
| 107 |
-
{/* Global help center — listens for the 'open-user-guide' event */}
|
| 108 |
<UserGuide />
|
| 109 |
</div>
|
| 110 |
</ThemeProvider>
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
| 2 |
import { ThemeProvider } from './contexts/ThemeContext';
|
| 3 |
import { AppConfigProvider } from './contexts/AppConfigContext';
|
| 4 |
import HomePage from './pages/HomePage';
|
|
|
|
| 6 |
import AuthPage from './pages/AuthPage';
|
| 7 |
import CanvasPage from './pages/CanvasPage';
|
| 8 |
import UserGuide from './components/UserGuide';
|
| 9 |
+
import {
|
| 10 |
+
readStoredAuth,
|
| 11 |
+
persistAuth,
|
| 12 |
+
clearStoredAuth,
|
| 13 |
+
getApiBaseUrl,
|
| 14 |
+
} from './utils/authStorage';
|
| 15 |
import './styles/components.css';
|
| 16 |
|
| 17 |
function App() {
|
| 18 |
+
const initialAuthRef = useRef(null);
|
| 19 |
+
if (initialAuthRef.current === null) {
|
| 20 |
+
initialAuthRef.current = readStoredAuth();
|
| 21 |
+
}
|
| 22 |
+
const initialAuth = initialAuthRef.current;
|
| 23 |
+
const [currentView, setCurrentView] = useState(initialAuth ? 'chat' : 'home');
|
| 24 |
+
const [isAuthenticated, setIsAuthenticated] = useState(Boolean(initialAuth));
|
| 25 |
+
const [user, setUser] = useState(initialAuth?.user ?? null);
|
| 26 |
+
const [authToken, setAuthToken] = useState(initialAuth?.token ?? null);
|
| 27 |
+
const [authBootstrapping, setAuthBootstrapping] = useState(Boolean(initialAuth));
|
| 28 |
|
| 29 |
+
const clearAuthState = useCallback(() => {
|
| 30 |
+
clearStoredAuth();
|
| 31 |
+
setUser(null);
|
| 32 |
+
setAuthToken(null);
|
| 33 |
+
setIsAuthenticated(false);
|
| 34 |
+
setCurrentView('home');
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
// Re-validate stored credentials on startup so stale tokens are cleared
|
| 38 |
+
// and profile data stays fresh after refresh.
|
| 39 |
useEffect(() => {
|
| 40 |
+
if (!initialAuth) {
|
| 41 |
+
return;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
let cancelled = false;
|
| 45 |
+
|
| 46 |
+
const validateStoredSession = async () => {
|
| 47 |
try {
|
| 48 |
+
const response = await fetch(`${getApiBaseUrl()}/auth/me`, {
|
| 49 |
+
headers: { Authorization: `Bearer ${initialAuth.token}` },
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
if (cancelled) {
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (response.ok) {
|
| 57 |
+
const freshUser = await response.json();
|
| 58 |
+
persistAuth(freshUser, initialAuth.token);
|
| 59 |
+
setUser(freshUser);
|
| 60 |
+
setAuthToken(initialAuth.token);
|
| 61 |
+
setIsAuthenticated(true);
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (response.status === 401 || response.status === 403) {
|
| 66 |
+
clearAuthState();
|
| 67 |
+
}
|
| 68 |
+
} catch {
|
| 69 |
+
// Keep the cached session on transient network errors so refresh
|
| 70 |
+
// does not force re-login when the API is briefly unavailable.
|
| 71 |
+
} finally {
|
| 72 |
+
if (!cancelled) {
|
| 73 |
+
setAuthBootstrapping(false);
|
| 74 |
+
}
|
| 75 |
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
validateStoredSession();
|
| 79 |
+
|
| 80 |
+
return () => {
|
| 81 |
+
cancelled = true;
|
| 82 |
+
};
|
| 83 |
+
}, [initialAuth, clearAuthState]);
|
| 84 |
+
|
| 85 |
+
const sessionReady = isAuthenticated && !authBootstrapping;
|
| 86 |
|
| 87 |
const navigateToAuth = () => {
|
| 88 |
+
if (authBootstrapping) {
|
| 89 |
+
return;
|
| 90 |
+
}
|
| 91 |
setCurrentView('auth');
|
| 92 |
};
|
| 93 |
|
|
|
|
| 102 |
setCurrentView('chat');
|
| 103 |
};
|
| 104 |
|
|
|
|
|
|
|
| 105 |
const navigateToHome = () => {
|
| 106 |
setCurrentView('home');
|
| 107 |
};
|
| 108 |
|
| 109 |
const handleAuthSuccess = (userData, token) => {
|
| 110 |
+
persistAuth(userData, token);
|
| 111 |
setUser(userData);
|
| 112 |
setAuthToken(token);
|
| 113 |
setIsAuthenticated(true);
|
| 114 |
+
setAuthBootstrapping(false);
|
| 115 |
setCurrentView('chat');
|
| 116 |
};
|
| 117 |
|
| 118 |
const handleSignOut = () => {
|
| 119 |
+
clearAuthState();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
};
|
| 121 |
|
| 122 |
return (
|
| 123 |
<AppConfigProvider>
|
| 124 |
<ThemeProvider>
|
| 125 |
<div className="App">
|
| 126 |
+
{authBootstrapping && (
|
| 127 |
+
<div className="auth-bootstrap-overlay" aria-live="polite">
|
| 128 |
+
Restoring your session...
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
{currentView === 'home' && (
|
| 132 |
<HomePage
|
| 133 |
onNavigateToHome={navigateToHome}
|
| 134 |
+
onNavigateToChat={sessionReady ? navigateToChat : navigateToAuth}
|
| 135 |
+
onNavigateToCanvas={sessionReady ? navigateToCanvas : navigateToAuth}
|
| 136 |
+
isAuthenticated={sessionReady}
|
| 137 |
/>
|
| 138 |
)}
|
| 139 |
+
{currentView === 'auth' && !sessionReady && (
|
| 140 |
<AuthPage onAuthSuccess={handleAuthSuccess} />
|
| 141 |
)}
|
| 142 |
+
{currentView === 'canvas' && sessionReady && (
|
| 143 |
<CanvasPage
|
| 144 |
user={user}
|
| 145 |
authToken={authToken}
|
|
|
|
| 148 |
onSignOut={handleSignOut}
|
| 149 |
/>
|
| 150 |
)}
|
| 151 |
+
{currentView === 'chat' && sessionReady && (
|
| 152 |
<ChatPage
|
| 153 |
user={user}
|
| 154 |
authToken={authToken}
|
|
|
|
| 157 |
onSignOut={handleSignOut}
|
| 158 |
/>
|
| 159 |
)}
|
|
|
|
| 160 |
<UserGuide />
|
| 161 |
</div>
|
| 162 |
</ThemeProvider>
|
phd-advisor-frontend/src/components/Login.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { Eye, EyeOff, Mail, Lock, ArrowRight, BookOpen, Phone } from 'lucide-react';
|
| 3 |
import { useAppConfig } from '../contexts/AppConfigContext';
|
|
|
|
| 4 |
import CopyrightNotice from './CopyrightNotice';
|
| 5 |
import '../styles/Login.css';
|
| 6 |
|
|
@@ -70,9 +71,7 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
|
|
| 70 |
const data = await response.json();
|
| 71 |
|
| 72 |
if (response.ok) {
|
| 73 |
-
|
| 74 |
-
localStorage.setItem('authToken', data.access_token);
|
| 75 |
-
localStorage.setItem('user', JSON.stringify(data.user));
|
| 76 |
onNavigateToHome?.(data.user, data.access_token);
|
| 77 |
} else {
|
| 78 |
setErrors({ submit: data.detail || 'Login failed. Please try again.' });
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { Eye, EyeOff, Mail, Lock, ArrowRight, BookOpen, Phone } from 'lucide-react';
|
| 3 |
import { useAppConfig } from '../contexts/AppConfigContext';
|
| 4 |
+
import { persistAuth } from '../utils/authStorage';
|
| 5 |
import CopyrightNotice from './CopyrightNotice';
|
| 6 |
import '../styles/Login.css';
|
| 7 |
|
|
|
|
| 71 |
const data = await response.json();
|
| 72 |
|
| 73 |
if (response.ok) {
|
| 74 |
+
persistAuth(data.user, data.access_token);
|
|
|
|
|
|
|
| 75 |
onNavigateToHome?.(data.user, data.access_token);
|
| 76 |
} else {
|
| 77 |
setErrors({ submit: data.detail || 'Login failed. Please try again.' });
|
phd-advisor-frontend/src/components/Signup.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 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 |
|
| 6 |
const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
|
|
@@ -116,9 +117,7 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
|
|
| 116 |
const data = await response.json();
|
| 117 |
|
| 118 |
if (response.ok) {
|
| 119 |
-
|
| 120 |
-
localStorage.setItem('authToken', data.access_token);
|
| 121 |
-
localStorage.setItem('user', JSON.stringify(data.user));
|
| 122 |
onNavigateToHome?.(data.user, data.access_token);
|
| 123 |
} else {
|
| 124 |
setErrors({ submit: data.detail || 'Signup failed. Please try again.' });
|
|
|
|
| 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 { persistAuth } from '../utils/authStorage';
|
| 5 |
import '../styles/Signup.css';
|
| 6 |
|
| 7 |
const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
|
|
|
|
| 117 |
const data = await response.json();
|
| 118 |
|
| 119 |
if (response.ok) {
|
| 120 |
+
persistAuth(data.user, data.access_token);
|
|
|
|
|
|
|
| 121 |
onNavigateToHome?.(data.user, data.access_token);
|
| 122 |
} else {
|
| 123 |
setErrors({ submit: data.detail || 'Signup failed. Please try again.' });
|
phd-advisor-frontend/src/styles/components.css
CHANGED
|
@@ -1293,4 +1293,18 @@
|
|
| 1293 |
text-overflow: initial;
|
| 1294 |
}
|
| 1295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1296 |
}
|
|
|
|
| 1293 |
text-overflow: initial;
|
| 1294 |
}
|
| 1295 |
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
.auth-bootstrap-overlay {
|
| 1299 |
+
position: fixed;
|
| 1300 |
+
inset: 0;
|
| 1301 |
+
z-index: 9999;
|
| 1302 |
+
display: flex;
|
| 1303 |
+
align-items: center;
|
| 1304 |
+
justify-content: center;
|
| 1305 |
+
background: rgba(255, 255, 255, 0.72);
|
| 1306 |
+
color: var(--text-primary, #111827);
|
| 1307 |
+
font-size: 0.95rem;
|
| 1308 |
+
font-weight: 500;
|
| 1309 |
+
pointer-events: none;
|
| 1310 |
}
|
phd-advisor-frontend/src/utils/authStorage.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const AUTH_TOKEN_KEY = 'authToken';
|
| 2 |
+
const USER_KEY = 'user';
|
| 3 |
+
|
| 4 |
+
export function readStoredAuth() {
|
| 5 |
+
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
| 6 |
+
const userData = localStorage.getItem(USER_KEY);
|
| 7 |
+
if (!token || !userData) {
|
| 8 |
+
return null;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
try {
|
| 12 |
+
return { token, user: JSON.parse(userData) };
|
| 13 |
+
} catch {
|
| 14 |
+
clearStoredAuth();
|
| 15 |
+
return null;
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function persistAuth(user, token) {
|
| 20 |
+
localStorage.setItem(AUTH_TOKEN_KEY, token);
|
| 21 |
+
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function clearStoredAuth() {
|
| 25 |
+
localStorage.removeItem(AUTH_TOKEN_KEY);
|
| 26 |
+
localStorage.removeItem(USER_KEY);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function getApiBaseUrl() {
|
| 30 |
+
return process.env.REACT_APP_API_URL || '';
|
| 31 |
+
}
|