feature/entra id authentication added
This commit is contained in:
12
.env.example
12
.env.example
@@ -14,3 +14,15 @@ OPENAI_MODEL=gpt-4-turbo-preview
|
|||||||
MAX_TOKENS=4000
|
MAX_TOKENS=4000
|
||||||
TEMPERATURE=0.7
|
TEMPERATURE=0.7
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Microsoft Entra ID (Azure AD)
|
||||||
|
# Create an app registration at: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps
|
||||||
|
# Add redirect URI: http://localhost:3000/auth/callback
|
||||||
|
ENTRA_TENANT_ID=your-tenant-id
|
||||||
|
ENTRA_CLIENT_ID=your-client-id
|
||||||
|
ENTRA_CLIENT_SECRET=your-client-secret
|
||||||
|
ENTRA_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=change-this-to-a-secure-random-string
|
||||||
|
JWT_EXPIRY_HOURS=24
|
||||||
|
|||||||
162
backend/app/api/auth.py
Normal file
162
backend/app/api/auth.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import msal
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..models.schemas import (
|
||||||
|
AuthCallbackRequest,
|
||||||
|
AuthCallbackResponse,
|
||||||
|
AuthUrlResponse,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_msal_app():
|
||||||
|
"""Create MSAL confidential client application"""
|
||||||
|
if not all(
|
||||||
|
[
|
||||||
|
settings.ENTRA_TENANT_ID,
|
||||||
|
settings.ENTRA_CLIENT_ID,
|
||||||
|
settings.ENTRA_CLIENT_SECRET,
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return msal.ConfidentialClientApplication(
|
||||||
|
client_id=settings.ENTRA_CLIENT_ID,
|
||||||
|
client_credential=settings.ENTRA_CLIENT_SECRET,
|
||||||
|
authority=f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_jwt_token(user_data: dict) -> str:
|
||||||
|
"""Create JWT token with user data"""
|
||||||
|
payload = {
|
||||||
|
"sub": user_data.get("oid") or user_data.get("sub"),
|
||||||
|
"name": user_data.get("name"),
|
||||||
|
"email": user_data.get("preferred_username"),
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS),
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_jwt_token(token: str) -> dict:
|
||||||
|
"""Decode and validate JWT token"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
||||||
|
)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
) -> dict:
|
||||||
|
"""Dependency to get current user from JWT token"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
|
||||||
|
)
|
||||||
|
|
||||||
|
return decode_jwt_token(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_model=AuthUrlResponse)
|
||||||
|
async def login():
|
||||||
|
"""Get Microsoft OAuth2 authorization URL"""
|
||||||
|
msal_app = get_msal_app()
|
||||||
|
|
||||||
|
if not msal_app:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET.",
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_url = msal_app.get_authorization_request_url(
|
||||||
|
scopes=["User.Read"], redirect_uri=settings.ENTRA_REDIRECT_URI
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthUrlResponse(auth_url=auth_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback", response_model=AuthCallbackResponse)
|
||||||
|
async def callback(request: AuthCallbackRequest):
|
||||||
|
"""Exchange authorization code for tokens"""
|
||||||
|
msal_app = get_msal_app()
|
||||||
|
|
||||||
|
if not msal_app:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Authentication not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = msal_app.acquire_token_by_authorization_code(
|
||||||
|
code=request.code,
|
||||||
|
scopes=["User.Read"],
|
||||||
|
redirect_uri=settings.ENTRA_REDIRECT_URI,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Authentication failed: {result.get('error_description', result.get('error'))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract user info from ID token claims
|
||||||
|
id_token_claims = result.get("id_token_claims", {})
|
||||||
|
|
||||||
|
# Create our JWT token
|
||||||
|
token = create_jwt_token(id_token_claims)
|
||||||
|
|
||||||
|
return AuthCallbackResponse(
|
||||||
|
token=token,
|
||||||
|
user=UserResponse(
|
||||||
|
id=id_token_claims.get("oid") or id_token_claims.get("sub"),
|
||||||
|
name=id_token_claims.get("name"),
|
||||||
|
email=id_token_claims.get("preferred_username"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def me(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Get current user info"""
|
||||||
|
return UserResponse(
|
||||||
|
id=current_user.get("sub"),
|
||||||
|
name=current_user.get("name"),
|
||||||
|
email=current_user.get("email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout():
|
||||||
|
"""Logout (client should clear token)"""
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def auth_status():
|
||||||
|
"""Check if authentication is configured"""
|
||||||
|
configured = all(
|
||||||
|
[
|
||||||
|
settings.ENTRA_TENANT_ID,
|
||||||
|
settings.ENTRA_CLIENT_ID,
|
||||||
|
settings.ENTRA_CLIENT_SECRET,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return {"configured": configured}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from ..middleware.auth import require_auth
|
||||||
from ..models.schemas import ChatRequest, ChatResponse, ProviderListResponse
|
from ..models.schemas import ChatRequest, ChatResponse, ProviderListResponse
|
||||||
from ..services.provider_manager import provider_manager
|
from ..services.provider_manager import provider_manager
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/chat", tags=["chat"])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=ChatResponse)
|
@router.post("/", response_model=ChatResponse)
|
||||||
async def chat(request: ChatRequest):
|
async def chat(request: ChatRequest, user: dict = Depends(require_auth)):
|
||||||
"""
|
"""
|
||||||
Non-streaming chat endpoint
|
Non-streaming chat endpoint
|
||||||
"""
|
"""
|
||||||
@@ -30,7 +31,7 @@ async def chat(request: ChatRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/stream")
|
@router.post("/stream")
|
||||||
async def chat_stream(request: ChatRequest):
|
async def chat_stream(request: ChatRequest, user: dict = Depends(require_auth)):
|
||||||
"""
|
"""
|
||||||
Streaming chat endpoint - returns SSE (Server-Sent Events)
|
Streaming chat endpoint - returns SSE (Server-Sent Events)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_REQUESTS: int = 10
|
RATE_LIMIT_REQUESTS: int = 10
|
||||||
RATE_LIMIT_WINDOW: int = 60
|
RATE_LIMIT_WINDOW: int = 60
|
||||||
|
|
||||||
|
# Microsoft Entra ID
|
||||||
|
ENTRA_TENANT_ID: Optional[str] = None
|
||||||
|
ENTRA_CLIENT_ID: Optional[str] = None
|
||||||
|
ENTRA_CLIENT_SECRET: Optional[str] = None
|
||||||
|
ENTRA_REDIRECT_URI: str = "http://localhost:3000/auth/callback"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET: str = "change-this-in-production-use-a-secure-random-string"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_EXPIRY_HOURS: int = 24
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .api import chat
|
from .api import auth, chat
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .services.provider_manager import provider_manager
|
from .services.provider_manager import provider_manager
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
|
app.include_router(auth.router)
|
||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
45
backend/app/middleware/auth.py
Normal file
45
backend/app/middleware/auth.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_jwt_token(token: str) -> dict:
|
||||||
|
"""Decode and validate JWT token"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
||||||
|
)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(request: Request):
|
||||||
|
"""Dependency to require authentication"""
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
|
if not auth_header:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authorization header",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = auth_header[7:] # Remove "Bearer " prefix
|
||||||
|
user = decode_jwt_token(token)
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
@@ -22,3 +22,23 @@ class ChatResponse(BaseModel):
|
|||||||
class ProviderListResponse(BaseModel):
|
class ProviderListResponse(BaseModel):
|
||||||
providers: List[str]
|
providers: List[str]
|
||||||
default: str
|
default: str
|
||||||
|
|
||||||
|
|
||||||
|
# Auth schemas
|
||||||
|
class AuthUrlResponse(BaseModel):
|
||||||
|
auth_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthCallbackRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthCallbackResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserResponse
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ pydantic>=2.6.0
|
|||||||
pydantic-settings>=2.1.0
|
pydantic-settings>=2.1.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
msal>=1.24.0
|
||||||
|
PyJWT>=2.8.0
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ services:
|
|||||||
- CLAUDE_MODEL=${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}
|
- CLAUDE_MODEL=${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}
|
||||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4-turbo-preview}
|
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4-turbo-preview}
|
||||||
- FRONTEND_URL=http://localhost:3000
|
- FRONTEND_URL=http://localhost:3000
|
||||||
|
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
|
||||||
|
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
|
||||||
|
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
|
||||||
|
- ENTRA_REDIRECT_URI=${ENTRA_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
|
||||||
|
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
17
index.html
17
index.html
@@ -7,8 +7,20 @@
|
|||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Welcome Screen (centered, shown initially) -->
|
<!-- Login Screen (shown when not authenticated) -->
|
||||||
<div class="welcome-screen" id="welcomeScreen">
|
<div class="login-screen" id="loginScreen">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-dev">Dev</span
|
||||||
|
><span class="logo-den">Den</span>
|
||||||
|
</div>
|
||||||
|
<button class="login-btn" id="loginBtn">
|
||||||
|
Sign in with Microsoft
|
||||||
|
</button>
|
||||||
|
<div class="hint">Authenticate to continue</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Welcome Screen (shown after auth, centered) -->
|
||||||
|
<div class="welcome-screen hidden" id="welcomeScreen">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span class="logo-dev">Dev</span
|
<span class="logo-dev">Dev</span
|
||||||
><span class="logo-den">Den</span>
|
><span class="logo-den">Den</span>
|
||||||
@@ -20,7 +32,6 @@
|
|||||||
id="welcomeInput"
|
id="welcomeInput"
|
||||||
placeholder="Ask anything..."
|
placeholder="Ask anything..."
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Press <kbd>Enter</kbd> to send</div>
|
<div class="hint">Press <kbd>Enter</kbd> to send</div>
|
||||||
|
|||||||
153
script.js
153
script.js
@@ -1,20 +1,141 @@
|
|||||||
|
const loginScreen = document.getElementById("loginScreen");
|
||||||
const welcomeScreen = document.getElementById("welcomeScreen");
|
const welcomeScreen = document.getElementById("welcomeScreen");
|
||||||
const chatScreen = document.getElementById("chatScreen");
|
const chatScreen = document.getElementById("chatScreen");
|
||||||
const chatMessages = document.getElementById("chatMessages");
|
const chatMessages = document.getElementById("chatMessages");
|
||||||
const welcomeInput = document.getElementById("welcomeInput");
|
const welcomeInput = document.getElementById("welcomeInput");
|
||||||
const chatInput = document.getElementById("chatInput");
|
const chatInput = document.getElementById("chatInput");
|
||||||
|
const loginBtn = document.getElementById("loginBtn");
|
||||||
|
|
||||||
const API_URL = "http://localhost:8000/api/chat";
|
const API_URL = "http://localhost:8000";
|
||||||
|
|
||||||
let isInChat = false;
|
let isInChat = false;
|
||||||
|
|
||||||
|
// Auth functions
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem("devden_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToken(token) {
|
||||||
|
localStorage.setItem("devden_token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken() {
|
||||||
|
localStorage.removeItem("devden_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginScreen() {
|
||||||
|
loginScreen.classList.remove("hidden");
|
||||||
|
welcomeScreen.classList.add("hidden");
|
||||||
|
chatScreen.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWelcomeScreen() {
|
||||||
|
loginScreen.classList.add("hidden");
|
||||||
|
welcomeScreen.classList.remove("hidden");
|
||||||
|
chatScreen.classList.add("hidden");
|
||||||
|
welcomeInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function switchToChat() {
|
function switchToChat() {
|
||||||
|
loginScreen.classList.add("hidden");
|
||||||
welcomeScreen.classList.add("hidden");
|
welcomeScreen.classList.add("hidden");
|
||||||
chatScreen.classList.remove("hidden");
|
chatScreen.classList.remove("hidden");
|
||||||
chatInput.focus();
|
chatInput.focus();
|
||||||
isInChat = true;
|
isInChat = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
showLoginScreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showWelcomeScreen();
|
||||||
|
} else {
|
||||||
|
clearToken();
|
||||||
|
showLoginScreen();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth check failed:", error);
|
||||||
|
showLoginScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginBtn.textContent = "Redirecting...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if auth is configured
|
||||||
|
const statusResponse = await fetch(`${API_URL}/api/auth/status`);
|
||||||
|
const statusData = await statusResponse.json();
|
||||||
|
|
||||||
|
if (!statusData.configured) {
|
||||||
|
alert(
|
||||||
|
"Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET in your .env file.",
|
||||||
|
);
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginBtn.textContent = "Sign in with Microsoft";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth URL and redirect
|
||||||
|
const response = await fetch(`${API_URL}/api/auth/login`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.auth_url) {
|
||||||
|
window.location.href = data.auth_url;
|
||||||
|
} else {
|
||||||
|
throw new Error("No auth URL returned");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
alert("Login failed: " + error.message);
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginBtn.textContent = "Sign in with Microsoft";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallback() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get("code");
|
||||||
|
|
||||||
|
if (!code) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/auth/callback`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || "Callback failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setToken(data.token);
|
||||||
|
|
||||||
|
// Clean up URL
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Callback failed:", error);
|
||||||
|
alert("Authentication failed: " + error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat functions
|
||||||
function addMessage(text, type = "user") {
|
function addMessage(text, type = "user") {
|
||||||
const msg = document.createElement("div");
|
const msg = document.createElement("div");
|
||||||
msg.className = `message ${type}`;
|
msg.className = `message ${type}`;
|
||||||
@@ -69,10 +190,12 @@ async function sendMessage(text) {
|
|||||||
showTyping();
|
showTyping();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/stream`, {
|
const token = getToken();
|
||||||
|
const response = await fetch(`${API_URL}/api/chat/stream`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: text,
|
message: text,
|
||||||
@@ -80,6 +203,13 @@ async function sendMessage(text) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
hideTyping();
|
||||||
|
clearToken();
|
||||||
|
showLoginScreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -136,7 +266,9 @@ async function sendMessage(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Welcome screen input handler
|
// Event listeners
|
||||||
|
loginBtn.addEventListener("click", handleLogin);
|
||||||
|
|
||||||
welcomeInput.addEventListener("keydown", (e) => {
|
welcomeInput.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -147,10 +279,23 @@ welcomeInput.addEventListener("keydown", (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chat input handler
|
|
||||||
chatInput.addEventListener("keydown", (e) => {
|
chatInput.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage(chatInput.value);
|
sendMessage(chatInput.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async function init() {
|
||||||
|
// Check for OAuth callback first
|
||||||
|
const callbackSuccess = await handleCallback();
|
||||||
|
|
||||||
|
if (callbackSuccess) {
|
||||||
|
showWelcomeScreen();
|
||||||
|
} else {
|
||||||
|
await checkAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|||||||
38
style.css
38
style.css
@@ -49,6 +49,44 @@ body {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== LOGIN SCREEN ==================== */
|
||||||
|
.login-screen {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 2px solid var(--ctp-surface1);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
border-color: var(--ctp-mauve);
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:active {
|
||||||
|
background: var(--ctp-surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== WELCOME SCREEN ==================== */
|
/* ==================== WELCOME SCREEN ==================== */
|
||||||
.welcome-screen {
|
.welcome-screen {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|||||||
Reference in New Issue
Block a user