try to fix entra
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# Use nginx alpine for minimal size
|
# Use nginx alpine for minimal size
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Copy static files to nginx html directory
|
# Copy static files to nginx html directory
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
COPY style.css /usr/share/nginx/html/
|
COPY style.css /usr/share/nginx/html/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
@@ -13,30 +14,60 @@ from ..models.schemas import (
|
|||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/app/auth.log'),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_msal_app():
|
def get_msal_app():
|
||||||
"""Create MSAL confidential client application"""
|
"""Create MSAL confidential client application"""
|
||||||
if not all(
|
logger.info("Checking MSAL configuration")
|
||||||
[
|
|
||||||
settings.ENTRA_TENANT_ID,
|
required_settings = [
|
||||||
settings.ENTRA_CLIENT_ID,
|
("ENTRA_TENANT_ID", settings.ENTRA_TENANT_ID),
|
||||||
settings.ENTRA_CLIENT_SECRET,
|
("ENTRA_CLIENT_ID", settings.ENTRA_CLIENT_ID),
|
||||||
|
("ENTRA_CLIENT_SECRET", settings.ENTRA_CLIENT_SECRET),
|
||||||
]
|
]
|
||||||
):
|
|
||||||
|
missing_settings = [name for name, value in required_settings if not value]
|
||||||
|
if missing_settings:
|
||||||
|
logger.error(f"Missing required Entra ID settings: {missing_settings}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return msal.ConfidentialClientApplication(
|
logger.info("All Entra ID settings present, creating MSAL app")
|
||||||
|
try:
|
||||||
|
msal_app = msal.ConfidentialClientApplication(
|
||||||
client_id=settings.ENTRA_CLIENT_ID,
|
client_id=settings.ENTRA_CLIENT_ID,
|
||||||
client_credential=settings.ENTRA_CLIENT_SECRET,
|
client_credential=settings.ENTRA_CLIENT_SECRET,
|
||||||
authority=f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}",
|
authority=f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}",
|
||||||
)
|
)
|
||||||
|
logger.info("MSAL application created successfully")
|
||||||
|
return msal_app
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create MSAL application: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_jwt_token(user_data: dict) -> str:
|
def create_jwt_token(user_data: dict) -> str:
|
||||||
"""Create JWT token with user data"""
|
"""Create JWT token with user data"""
|
||||||
|
logger.info("Creating JWT token", {
|
||||||
|
"user_id": user_data.get("oid") or user_data.get("sub"),
|
||||||
|
"user_name": user_data.get("name"),
|
||||||
|
"user_email": user_data.get("preferred_username")
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
"sub": user_data.get("oid") or user_data.get("sub"),
|
"sub": user_data.get("oid") or user_data.get("sub"),
|
||||||
"name": user_data.get("name"),
|
"name": user_data.get("name"),
|
||||||
@@ -44,74 +75,145 @@ def create_jwt_token(user_data: dict) -> str:
|
|||||||
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS),
|
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS),
|
||||||
"iat": datetime.now(timezone.utc),
|
"iat": datetime.now(timezone.utc),
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
|
||||||
|
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
logger.info("JWT token created successfully", {
|
||||||
|
"expires_in_hours": settings.JWT_EXPIRY_HOURS,
|
||||||
|
"algorithm": settings.JWT_ALGORITHM
|
||||||
|
})
|
||||||
|
return token
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create JWT token: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def decode_jwt_token(token: str) -> dict:
|
def decode_jwt_token(token: str) -> dict:
|
||||||
"""Decode and validate JWT token"""
|
"""Decode and validate JWT token"""
|
||||||
|
logger.info("Decoding JWT token")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
|
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
|
||||||
)
|
)
|
||||||
|
logger.info("JWT token decoded successfully", {
|
||||||
|
"user_id": payload.get("sub"),
|
||||||
|
"user_name": payload.get("name"),
|
||||||
|
"expires_at": datetime.fromtimestamp(payload.get("exp", 0), timezone.utc).isoformat()
|
||||||
|
})
|
||||||
return payload
|
return payload
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError as e:
|
||||||
|
logger.warning("JWT token expired", {"error": str(e)})
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
||||||
)
|
)
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.warning("Invalid JWT token", {"error": str(e)})
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error decoding JWT token: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token validation failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dependency to get current user from JWT token"""
|
"""Dependency to get current user from JWT token"""
|
||||||
|
logger.info("Getting current user from credentials")
|
||||||
|
|
||||||
if not credentials:
|
if not credentials:
|
||||||
|
logger.warning("No credentials provided")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Credentials found, decoding token")
|
||||||
return decode_jwt_token(credentials.credentials)
|
return decode_jwt_token(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_model=AuthUrlResponse)
|
@router.get("/login", response_model=AuthUrlResponse)
|
||||||
async def login():
|
async def login():
|
||||||
"""Get Microsoft OAuth2 authorization URL"""
|
"""Get Microsoft OAuth2 authorization URL"""
|
||||||
|
logger.info("Login endpoint called")
|
||||||
|
|
||||||
msal_app = get_msal_app()
|
msal_app = get_msal_app()
|
||||||
|
|
||||||
if not msal_app:
|
if not msal_app:
|
||||||
|
logger.error("MSAL app not available for login")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET.",
|
detail="Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Generating authorization URL", {
|
||||||
|
"scopes": ["User.Read"],
|
||||||
|
"redirect_uri": settings.ENTRA_REDIRECT_URI
|
||||||
|
})
|
||||||
auth_url = msal_app.get_authorization_request_url(
|
auth_url = msal_app.get_authorization_request_url(
|
||||||
scopes=["User.Read"], redirect_uri=settings.ENTRA_REDIRECT_URI
|
scopes=["User.Read"], redirect_uri=settings.ENTRA_REDIRECT_URI
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Authorization URL generated successfully", {
|
||||||
|
"url_length": len(auth_url),
|
||||||
|
"url_start": auth_url[:100] + "..." if len(auth_url) > 100 else auth_url
|
||||||
|
})
|
||||||
|
|
||||||
return AuthUrlResponse(auth_url=auth_url)
|
return AuthUrlResponse(auth_url=auth_url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate authorization URL: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to generate authorization URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/callback", response_model=AuthCallbackResponse)
|
@router.post("/callback", response_model=AuthCallbackResponse)
|
||||||
async def callback(request: AuthCallbackRequest):
|
async def callback(request: AuthCallbackRequest):
|
||||||
"""Exchange authorization code for tokens"""
|
"""Exchange authorization code for tokens"""
|
||||||
|
logger.info("Callback endpoint called", {
|
||||||
|
"code_length": len(request.code) if request.code else 0,
|
||||||
|
"code_start": request.code[:50] + "..." if request.code and len(request.code) > 50 else request.code
|
||||||
|
})
|
||||||
|
|
||||||
msal_app = get_msal_app()
|
msal_app = get_msal_app()
|
||||||
|
|
||||||
if not msal_app:
|
if not msal_app:
|
||||||
|
logger.error("MSAL app not available for callback")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Authentication not configured",
|
detail="Authentication not configured",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Exchanging authorization code for tokens", {
|
||||||
|
"scopes": ["User.Read"],
|
||||||
|
"redirect_uri": settings.ENTRA_REDIRECT_URI
|
||||||
|
})
|
||||||
|
|
||||||
result = msal_app.acquire_token_by_authorization_code(
|
result = msal_app.acquire_token_by_authorization_code(
|
||||||
code=request.code,
|
code=request.code,
|
||||||
scopes=["User.Read"],
|
scopes=["User.Read"],
|
||||||
redirect_uri=settings.ENTRA_REDIRECT_URI,
|
redirect_uri=settings.ENTRA_REDIRECT_URI,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Token exchange result", {
|
||||||
|
"has_access_token": "access_token" in result,
|
||||||
|
"has_id_token": "id_token" in result,
|
||||||
|
"has_error": "error" in result,
|
||||||
|
"error": result.get("error"),
|
||||||
|
"error_description": result.get("error_description")
|
||||||
|
})
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
logger.error("Token exchange failed", {
|
||||||
|
"error": result.get("error"),
|
||||||
|
"error_description": result.get("error_description"),
|
||||||
|
"correlation_id": result.get("correlation_id")
|
||||||
|
})
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Authentication failed: {result.get('error_description', result.get('error'))}",
|
detail=f"Authentication failed: {result.get('error_description', result.get('error'))}",
|
||||||
@@ -119,11 +221,17 @@ async def callback(request: AuthCallbackRequest):
|
|||||||
|
|
||||||
# Extract user info from ID token claims
|
# Extract user info from ID token claims
|
||||||
id_token_claims = result.get("id_token_claims", {})
|
id_token_claims = result.get("id_token_claims", {})
|
||||||
|
logger.info("ID token claims extracted", {
|
||||||
|
"claims_keys": list(id_token_claims.keys()),
|
||||||
|
"user_id": id_token_claims.get("oid") or id_token_claims.get("sub"),
|
||||||
|
"user_name": id_token_claims.get("name"),
|
||||||
|
"user_email": id_token_claims.get("preferred_username")
|
||||||
|
})
|
||||||
|
|
||||||
# Create our JWT token
|
# Create our JWT token
|
||||||
token = create_jwt_token(id_token_claims)
|
token = create_jwt_token(id_token_claims)
|
||||||
|
|
||||||
return AuthCallbackResponse(
|
response_data = AuthCallbackResponse(
|
||||||
token=token,
|
token=token,
|
||||||
user=UserResponse(
|
user=UserResponse(
|
||||||
id=id_token_claims.get("oid") or id_token_claims.get("sub"),
|
id=id_token_claims.get("oid") or id_token_claims.get("sub"),
|
||||||
@@ -132,10 +240,32 @@ async def callback(request: AuthCallbackRequest):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Callback completed successfully", {
|
||||||
|
"user_id": response_data.user.id,
|
||||||
|
"user_name": response_data.user.name
|
||||||
|
})
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
# Re-raise HTTP exceptions as-is
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in callback: {e}", {"traceback": str(e)})
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Internal server error during authentication"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def me(current_user: dict = Depends(get_current_user)):
|
async def me(current_user: dict = Depends(get_current_user)):
|
||||||
"""Get current user info"""
|
"""Get current user info"""
|
||||||
|
logger.info("Me endpoint called", {
|
||||||
|
"user_id": current_user.get("sub"),
|
||||||
|
"user_name": current_user.get("name")
|
||||||
|
})
|
||||||
|
|
||||||
return UserResponse(
|
return UserResponse(
|
||||||
id=current_user.get("sub"),
|
id=current_user.get("sub"),
|
||||||
name=current_user.get("name"),
|
name=current_user.get("name"),
|
||||||
@@ -146,12 +276,15 @@ async def me(current_user: dict = Depends(get_current_user)):
|
|||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout():
|
async def logout():
|
||||||
"""Logout (client should clear token)"""
|
"""Logout (client should clear token)"""
|
||||||
|
logger.info("Logout endpoint called")
|
||||||
return {"message": "Logged out successfully"}
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def auth_status():
|
async def auth_status():
|
||||||
"""Check if authentication is configured"""
|
"""Check if authentication is configured"""
|
||||||
|
logger.info("Auth status endpoint called")
|
||||||
|
|
||||||
configured = all(
|
configured = all(
|
||||||
[
|
[
|
||||||
settings.ENTRA_TENANT_ID,
|
settings.ENTRA_TENANT_ID,
|
||||||
@@ -159,4 +292,14 @@ async def auth_status():
|
|||||||
settings.ENTRA_CLIENT_SECRET,
|
settings.ENTRA_CLIENT_SECRET,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
status_info = {
|
||||||
|
"configured": configured,
|
||||||
|
"has_tenant_id": bool(settings.ENTRA_TENANT_ID),
|
||||||
|
"has_client_id": bool(settings.ENTRA_CLIENT_ID),
|
||||||
|
"has_client_secret": bool(settings.ENTRA_CLIENT_SECRET),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Auth status checked", status_info)
|
||||||
|
|
||||||
return {"configured": configured}
|
return {"configured": configured}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ from .config import settings
|
|||||||
from .services.provider_manager import provider_manager
|
from .services.provider_manager import provider_manager
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/app/devden.log'),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -30,9 +37,25 @@ app.include_router(auth.router)
|
|||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request, exc):
|
||||||
|
"""Global exception handler to log all errors"""
|
||||||
|
logger.error(f"Unhandled exception: {exc}", {
|
||||||
|
"url": str(request.url),
|
||||||
|
"method": request.method,
|
||||||
|
"headers": dict(request.headers),
|
||||||
|
"traceback": str(exc)
|
||||||
|
})
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal server error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
|
logger.info("Health check requested")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
@@ -41,6 +64,18 @@ async def health_check():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logs")
|
||||||
|
async def get_logs():
|
||||||
|
"""Get recent log entries (for debugging)"""
|
||||||
|
try:
|
||||||
|
with open('/app/devden.log', 'r') as f:
|
||||||
|
lines = f.readlines()[-50:] # Last 50 lines
|
||||||
|
return {"logs": lines}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read logs: {e}")
|
||||||
|
return {"error": "Failed to read logs"}
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
logger.info("DevDen API starting up...")
|
logger.info("DevDen API starting up...")
|
||||||
|
|||||||
24
default.conf
Normal file
24
default.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Enable SPA routing - try to serve the file, then directory, then fallback to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets for better performance
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
index.html
10
index.html
@@ -53,6 +53,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Panel (hidden by default) -->
|
||||||
|
<div id="debugPanel" style="display: none; position: fixed; top: 10px; right: 10px; background: rgba(0,0,0,0.9); color: white; padding: 10px; border-radius: 5px; z-index: 1000; font-family: monospace; font-size: 12px;">
|
||||||
|
<h4>Debug Tools</h4>
|
||||||
|
<button id="exportLogsBtn" style="margin: 5px; padding: 5px;">Export Frontend Logs</button>
|
||||||
|
<button id="clearLogsBtn" style="margin: 5px; padding: 5px;">Clear Logs</button>
|
||||||
|
<button id="viewBackendLogsBtn" style="margin: 5px; padding: 5px;">View Backend Logs</button>
|
||||||
|
<button id="closeDebugBtn" style="margin: 5px; padding: 5px;">Close</button>
|
||||||
|
<div id="debugOutput" style="margin-top: 10px; max-height: 200px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
358
script.js
358
script.js
@@ -1,3 +1,69 @@
|
|||||||
|
// Enhanced logging utility
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
this.logs = [];
|
||||||
|
this.maxLogs = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(level, message, data = null) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logEntry = {
|
||||||
|
timestamp,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
url: window.location.href
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logs.push(logEntry);
|
||||||
|
if (this.logs.length > this.maxLogs) {
|
||||||
|
this.logs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log';
|
||||||
|
console[consoleMethod](`[${timestamp}] ${level.toUpperCase()}: ${message}`, data || '');
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
try {
|
||||||
|
localStorage.setItem('devden_logs', JSON.stringify(this.logs));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save logs to localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message, data = null) { this.log('info', message, data); }
|
||||||
|
warn(message, data = null) { this.log('warn', message, data); }
|
||||||
|
error(message, data = null) { this.log('error', message, data); }
|
||||||
|
|
||||||
|
exportLogs() {
|
||||||
|
const blob = new Blob([JSON.stringify(this.logs, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `devden_logs_${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogs() {
|
||||||
|
this.logs = [];
|
||||||
|
localStorage.removeItem('devden_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
// Load existing logs from localStorage
|
||||||
|
try {
|
||||||
|
const savedLogs = localStorage.getItem('devden_logs');
|
||||||
|
if (savedLogs) {
|
||||||
|
logger.logs = JSON.parse(savedLogs);
|
||||||
|
logger.info('Loaded existing logs from localStorage', { count: logger.logs.length });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to load existing logs from localStorage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
const loginScreen = document.getElementById("loginScreen");
|
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");
|
||||||
@@ -12,91 +78,166 @@ let isInChat = false;
|
|||||||
|
|
||||||
// Auth functions
|
// Auth functions
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return localStorage.getItem("devden_token");
|
try {
|
||||||
|
const token = localStorage.getItem("devden_token");
|
||||||
|
logger.info('Retrieved token from localStorage', { hasToken: !!token });
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to retrieve token from localStorage', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToken(token) {
|
function setToken(token) {
|
||||||
|
try {
|
||||||
localStorage.setItem("devden_token", token);
|
localStorage.setItem("devden_token", token);
|
||||||
|
logger.info('Token saved to localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save token to localStorage', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearToken() {
|
function clearToken() {
|
||||||
|
try {
|
||||||
localStorage.removeItem("devden_token");
|
localStorage.removeItem("devden_token");
|
||||||
|
logger.info('Token cleared from localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to clear token from localStorage', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoginScreen() {
|
function showLoginScreen() {
|
||||||
|
try {
|
||||||
loginScreen.classList.remove("hidden");
|
loginScreen.classList.remove("hidden");
|
||||||
welcomeScreen.classList.add("hidden");
|
welcomeScreen.classList.add("hidden");
|
||||||
chatScreen.classList.add("hidden");
|
chatScreen.classList.add("hidden");
|
||||||
|
logger.info('Switched to login screen');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to show login screen', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWelcomeScreen() {
|
function showWelcomeScreen() {
|
||||||
|
try {
|
||||||
loginScreen.classList.add("hidden");
|
loginScreen.classList.add("hidden");
|
||||||
welcomeScreen.classList.remove("hidden");
|
welcomeScreen.classList.remove("hidden");
|
||||||
chatScreen.classList.add("hidden");
|
chatScreen.classList.add("hidden");
|
||||||
welcomeInput.focus();
|
welcomeInput.focus();
|
||||||
|
logger.info('Switched to welcome screen');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to show welcome screen', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToChat() {
|
function switchToChat() {
|
||||||
|
try {
|
||||||
loginScreen.classList.add("hidden");
|
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;
|
||||||
|
logger.info('Switched to chat screen');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to switch to chat screen', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
|
logger.info('Starting auth check');
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
logger.info('No token found, showing login screen');
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logger.info('Making auth check request to /api/auth/me');
|
||||||
const response = await fetch(`${API_URL}/api/auth/me`, {
|
const response = await fetch(`${API_URL}/api/auth/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info('Auth check response received', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
ok: response.ok
|
||||||
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
const userData = await response.json();
|
||||||
|
logger.info('Auth check successful', { user: userData });
|
||||||
showWelcomeScreen();
|
showWelcomeScreen();
|
||||||
} else {
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.warn('Auth check failed', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
response: errorText
|
||||||
|
});
|
||||||
clearToken();
|
clearToken();
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auth check failed:", error);
|
logger.error('Auth check request failed', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
loginBtn.disabled = true;
|
logger.info('Login button clicked, starting login process');
|
||||||
loginBtn.textContent = "Redirecting...";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginBtn.textContent = "Checking auth config...";
|
||||||
|
logger.info('Disabled login button and updated text');
|
||||||
|
|
||||||
// Check if auth is configured
|
// Check if auth is configured
|
||||||
|
logger.info('Checking auth configuration via /api/auth/status');
|
||||||
const statusResponse = await fetch(`${API_URL}/api/auth/status`);
|
const statusResponse = await fetch(`${API_URL}/api/auth/status`);
|
||||||
const statusData = await statusResponse.json();
|
const statusData = await statusResponse.json();
|
||||||
|
|
||||||
|
logger.info('Auth status response', {
|
||||||
|
status: statusResponse.status,
|
||||||
|
configured: statusData.configured
|
||||||
|
});
|
||||||
|
|
||||||
if (!statusData.configured) {
|
if (!statusData.configured) {
|
||||||
alert(
|
const errorMsg = "Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET in your .env file.";
|
||||||
"Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET in your .env file.",
|
logger.error('Auth not configured', { response: statusData });
|
||||||
);
|
alert(errorMsg);
|
||||||
loginBtn.disabled = false;
|
loginBtn.disabled = false;
|
||||||
loginBtn.textContent = "Sign in with Microsoft";
|
loginBtn.textContent = "Sign in with Microsoft";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginBtn.textContent = "Getting auth URL...";
|
||||||
|
logger.info('Auth configured, requesting login URL from /api/auth/login');
|
||||||
|
|
||||||
// Get auth URL and redirect
|
// Get auth URL and redirect
|
||||||
const response = await fetch(`${API_URL}/api/auth/login`);
|
const response = await fetch(`${API_URL}/api/auth/login`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
logger.info('Login URL response', {
|
||||||
|
status: response.status,
|
||||||
|
hasAuthUrl: !!data.auth_url,
|
||||||
|
authUrl: data.auth_url ? data.auth_url.substring(0, 100) + '...' : null
|
||||||
|
});
|
||||||
|
|
||||||
if (data.auth_url) {
|
if (data.auth_url) {
|
||||||
|
loginBtn.textContent = "Redirecting...";
|
||||||
|
logger.info('Redirecting to Microsoft OAuth URL');
|
||||||
window.location.href = data.auth_url;
|
window.location.href = data.auth_url;
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No auth URL returned");
|
throw new Error("No auth URL returned");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login failed:", error);
|
logger.error('Login process failed', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
alert("Login failed: " + error.message);
|
alert("Login failed: " + error.message);
|
||||||
loginBtn.disabled = false;
|
loginBtn.disabled = false;
|
||||||
loginBtn.textContent = "Sign in with Microsoft";
|
loginBtn.textContent = "Sign in with Microsoft";
|
||||||
@@ -104,32 +245,81 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCallback() {
|
async function handleCallback() {
|
||||||
|
logger.info('Starting OAuth callback processing', { url: window.location.href });
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const code = params.get("code");
|
const code = params.get("code");
|
||||||
|
const sessionState = params.get("session_state");
|
||||||
|
const error = params.get("error");
|
||||||
|
const errorDescription = params.get("error_description");
|
||||||
|
|
||||||
if (!code) return false;
|
logger.info('Parsed URL parameters', {
|
||||||
|
hasCode: !!code,
|
||||||
|
hasSessionState: !!sessionState,
|
||||||
|
hasError: !!error,
|
||||||
|
codeLength: code ? code.length : 0,
|
||||||
|
sessionState: sessionState,
|
||||||
|
error: error,
|
||||||
|
errorDescription: errorDescription
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for OAuth errors
|
||||||
|
if (error) {
|
||||||
|
logger.error('OAuth error in callback URL', {
|
||||||
|
error: error,
|
||||||
|
errorDescription: errorDescription
|
||||||
|
});
|
||||||
|
alert(`Authentication failed: ${error} - ${errorDescription || 'Unknown error'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
logger.warn('No authorization code found in callback URL');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logger.info('Sending authorization code to backend /api/auth/callback');
|
||||||
const response = await fetch(`${API_URL}/api/auth/callback`, {
|
const response = await fetch(`${API_URL}/api/auth/callback`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info('Callback response received', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
ok: response.ok
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(error.detail || "Callback failed");
|
logger.error('Callback request failed', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorData
|
||||||
|
});
|
||||||
|
throw new Error(errorData.detail || "Callback failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
logger.info('Callback successful', {
|
||||||
|
hasToken: !!data.token,
|
||||||
|
hasUser: !!data.user,
|
||||||
|
user: data.user
|
||||||
|
});
|
||||||
|
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
|
|
||||||
// Clean up URL
|
// Clean up URL
|
||||||
|
logger.info('Cleaning up URL (removing query parameters)');
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Callback failed:", error);
|
logger.error('Callback processing failed', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
alert("Authentication failed: " + error.message);
|
alert("Authentication failed: " + error.message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -288,14 +478,158 @@ chatInput.addEventListener("keydown", (e) => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async function init() {
|
async function init() {
|
||||||
|
logger.info('Application initialization started', {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: window.location.href,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for CSS loading
|
||||||
|
const styleLink = document.querySelector('link[rel="stylesheet"]');
|
||||||
|
if (styleLink) {
|
||||||
|
logger.info('CSS link found', { href: styleLink.href });
|
||||||
|
styleLink.addEventListener('load', () => {
|
||||||
|
logger.info('CSS loaded successfully');
|
||||||
|
});
|
||||||
|
styleLink.addEventListener('error', (e) => {
|
||||||
|
logger.error('CSS failed to load', { href: styleLink.href, error: e });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('CSS link not found in document');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DOM elements
|
||||||
|
const elements = ['loginScreen', 'welcomeScreen', 'chatScreen', 'chatMessages', 'welcomeInput', 'chatInput', 'loginBtn'];
|
||||||
|
elements.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) {
|
||||||
|
logger.error(`Required DOM element not found: ${id}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`DOM element found: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
// Check for OAuth callback first
|
// Check for OAuth callback first
|
||||||
|
logger.info('Checking for OAuth callback parameters');
|
||||||
const callbackSuccess = await handleCallback();
|
const callbackSuccess = await handleCallback();
|
||||||
|
|
||||||
if (callbackSuccess) {
|
if (callbackSuccess) {
|
||||||
|
logger.info('OAuth callback processed successfully, showing welcome screen');
|
||||||
showWelcomeScreen();
|
showWelcomeScreen();
|
||||||
} else {
|
} else {
|
||||||
|
logger.info('No OAuth callback or callback failed, checking existing auth');
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Initialization failed', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Application initialization completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add global error handler
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
logger.error('Global JavaScript error', {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
error: event.error
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
logger.error('Unhandled promise rejection', {
|
||||||
|
reason: event.reason,
|
||||||
|
promise: event.promise
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug panel functionality
|
||||||
|
const debugPanel = document.getElementById('debugPanel');
|
||||||
|
const exportLogsBtn = document.getElementById('exportLogsBtn');
|
||||||
|
const clearLogsBtn = document.getElementById('clearLogsBtn');
|
||||||
|
const viewBackendLogsBtn = document.getElementById('viewBackendLogsBtn');
|
||||||
|
const closeDebugBtn = document.getElementById('closeDebugBtn');
|
||||||
|
const debugOutput = document.getElementById('debugOutput');
|
||||||
|
|
||||||
|
function showDebugPanel() {
|
||||||
|
debugPanel.style.display = 'block';
|
||||||
|
logger.info('Debug panel opened');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDebugPanel() {
|
||||||
|
debugPanel.style.display = 'none';
|
||||||
|
logger.info('Debug panel closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDebugOutput(text) {
|
||||||
|
debugOutput.textContent = text;
|
||||||
|
debugOutput.scrollTop = debugOutput.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners for debug panel
|
||||||
|
exportLogsBtn.addEventListener('click', () => {
|
||||||
|
logger.exportLogs();
|
||||||
|
updateDebugOutput('Frontend logs exported to download');
|
||||||
|
logger.info('Logs exported via debug panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
clearLogsBtn.addEventListener('click', () => {
|
||||||
|
logger.clearLogs();
|
||||||
|
updateDebugOutput('Logs cleared');
|
||||||
|
logger.info('Logs cleared via debug panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
viewBackendLogsBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
updateDebugOutput('Loading backend logs...');
|
||||||
|
const response = await fetch(`${API_URL}/logs`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.logs) {
|
||||||
|
updateDebugOutput(data.logs.join('\n'));
|
||||||
|
} else if (data.error) {
|
||||||
|
updateDebugOutput(`Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateDebugOutput(`Failed to load backend logs: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateDebugOutput(`Error loading backend logs: ${error.message}`);
|
||||||
|
logger.error('Failed to load backend logs', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
closeDebugBtn.addEventListener('click', hideDebugPanel);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Ctrl+Shift+L: Export logs
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
||||||
|
e.preventDefault();
|
||||||
|
logger.exportLogs();
|
||||||
|
logger.info('Logs exported via keyboard shortcut');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+D: Toggle debug panel
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (debugPanel.style.display === 'none') {
|
||||||
|
showDebugPanel();
|
||||||
|
} else {
|
||||||
|
hideDebugPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: Close debug panel
|
||||||
|
if (e.key === 'Escape' && debugPanel.style.display !== 'none') {
|
||||||
|
hideDebugPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
Reference in New Issue
Block a user