feat: Complete Google Search Console integration with Clerk authentication
- Add GSC API service with OAuth2 authentication - Implement Clerk authentication for frontend and backend - Add GSC login button and OAuth callback handling - Create comprehensive GSC data fetching and caching - Add authentication middleware for backend API protection - Implement real-time GSC data integration in SEO dashboard - Add user-specific GSC site management - Include comprehensive logging and error handling - Add TypeScript support and proper type definitions - Create environment templates and setup documentation - Update gitignore to exclude sensitive credential files Features added: - GSC OAuth2 authentication flow - Real-time search analytics data - Site list management - Sitemap analysis - User-specific data isolation - Comprehensive error handling - Authentication token management - Popup-based OAuth flow - Data caching and refresh mechanisms Note: gsc_credentials.json should be created locally with your Google OAuth credentials
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,6 +35,10 @@ backend/content_cache.db
|
|||||||
backend/outline_cache.db
|
backend/outline_cache.db
|
||||||
backend/research_cache.db
|
backend/research_cache.db
|
||||||
|
|
||||||
|
# Google OAuth credentials
|
||||||
|
gsc_credentials.json
|
||||||
|
**/gsc_credentials.json
|
||||||
|
|
||||||
# Onboarding progress files
|
# Onboarding progress files
|
||||||
.onboarding_progress.json
|
.onboarding_progress.json
|
||||||
backend/.onboarding_progress.json
|
backend/.onboarding_progress.json
|
||||||
|
|||||||
268
GSC_INTEGRATION_README.md
Normal file
268
GSC_INTEGRATION_README.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Google Search Console (GSC) Integration for ALwrity
|
||||||
|
|
||||||
|
This document describes the complete Google Search Console integration implemented for ALwrity, allowing users to connect their GSC accounts and fetch real website analytics data.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
### Backend Features
|
||||||
|
- **OAuth2 Authentication**: Secure Google OAuth2 flow for GSC access
|
||||||
|
- **User Credential Management**: Encrypted storage of user OAuth tokens
|
||||||
|
- **Data Caching**: SQLite-based caching system for GSC data
|
||||||
|
- **Multi-user Support**: Each user can connect their own GSC account
|
||||||
|
- **Real-time Analytics**: Fetch live search analytics, sitemaps, and site data
|
||||||
|
- **Comprehensive Logging**: Detailed logging throughout the system
|
||||||
|
|
||||||
|
### Frontend Features
|
||||||
|
- **GSC Login Button**: Seamless OAuth connection flow
|
||||||
|
- **Status Management**: Real-time connection status display
|
||||||
|
- **Popup Authentication**: Secure OAuth flow in popup window
|
||||||
|
- **Error Handling**: Comprehensive error management and user feedback
|
||||||
|
- **Responsive UI**: Material-UI components matching existing dashboard style
|
||||||
|
|
||||||
|
## 📁 File Structure
|
||||||
|
|
||||||
|
### Backend Files
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── services/
|
||||||
|
│ └── gsc_service.py # Core GSC service with OAuth and data management
|
||||||
|
├── routers/
|
||||||
|
│ └── gsc_auth.py # FastAPI router for GSC endpoints
|
||||||
|
├── middleware/
|
||||||
|
│ └── auth_middleware.py # Clerk authentication middleware
|
||||||
|
├── gsc_credentials.json # Google OAuth2 client credentials
|
||||||
|
├── env_template.txt # Environment variables template
|
||||||
|
└── requirements.txt # Updated with GSC dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Files
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── api/
|
||||||
|
│ └── gsc.ts # GSC API client
|
||||||
|
├── components/SEODashboard/components/
|
||||||
|
│ ├── GSCLoginButton.tsx # GSC connection UI component
|
||||||
|
│ └── GSCAuthCallback.tsx # OAuth callback handler
|
||||||
|
├── env_template.txt # Frontend environment template
|
||||||
|
└── package.json # Updated with Clerk dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
|
### GSC Authentication & Management
|
||||||
|
- `GET /gsc/auth/url` - Get OAuth authorization URL
|
||||||
|
- `GET /gsc/callback` - Handle OAuth callback
|
||||||
|
- `GET /gsc/status` - Check GSC connection status
|
||||||
|
- `DELETE /gsc/disconnect` - Revoke GSC access
|
||||||
|
|
||||||
|
### GSC Data Retrieval
|
||||||
|
- `GET /gsc/sites` - Get user's GSC sites
|
||||||
|
- `POST /gsc/analytics` - Fetch search analytics data
|
||||||
|
- `GET /gsc/sitemaps/{site_url}` - Get sitemaps for a site
|
||||||
|
- `GET /gsc/health` - Health check endpoint
|
||||||
|
|
||||||
|
## 🗄️ Database Schema
|
||||||
|
|
||||||
|
### GSC Credentials Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE gsc_credentials (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
credentials_json TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### GSC Data Cache Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE gsc_data_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
site_url TEXT NOT NULL,
|
||||||
|
data_type TEXT NOT NULL,
|
||||||
|
data_json TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES gsc_credentials (user_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Authentication Flow
|
||||||
|
|
||||||
|
1. **User clicks "Connect GSC"** → Frontend requests OAuth URL
|
||||||
|
2. **Backend generates OAuth URL** → Returns Google authorization URL
|
||||||
|
3. **User authorizes in popup** → Google redirects to callback
|
||||||
|
4. **Backend handles callback** → Exchanges code for tokens
|
||||||
|
5. **Credentials stored securely** → User can now access GSC data
|
||||||
|
6. **Real data replaces mock data** → Dashboard shows live analytics
|
||||||
|
|
||||||
|
## 🛠️ Setup Instructions
|
||||||
|
|
||||||
|
### 1. Backend Setup
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Environment**:
|
||||||
|
```bash
|
||||||
|
cp env_template.txt .env
|
||||||
|
# Edit .env with your actual values
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Google OAuth Setup**:
|
||||||
|
- Copy your Google OAuth credentials to `gsc_credentials.json`
|
||||||
|
- Ensure redirect URIs include both backend and frontend URLs
|
||||||
|
|
||||||
|
4. **Start Backend**:
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Frontend Setup
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Environment**:
|
||||||
|
```bash
|
||||||
|
cp env_template.txt .env
|
||||||
|
# Edit .env with your actual values
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start Frontend**:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Environment Variables
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
```env
|
||||||
|
# Clerk Authentication
|
||||||
|
CLERK_SECRET_KEY=your_clerk_secret_key_here
|
||||||
|
|
||||||
|
# Google Search Console
|
||||||
|
GSC_REDIRECT_URI=http://localhost:8000/gsc/callback
|
||||||
|
|
||||||
|
# Development Settings
|
||||||
|
DISABLE_AUTH=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (.env)
|
||||||
|
```env
|
||||||
|
# Clerk Authentication
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here
|
||||||
|
|
||||||
|
# CopilotKit
|
||||||
|
REACT_APP_COPILOTKIT_API_KEY=your_copilotkit_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Data Types Retrieved
|
||||||
|
|
||||||
|
### Search Analytics
|
||||||
|
- **Clicks**: Number of clicks from search results
|
||||||
|
- **Impressions**: Number of times site appeared in search
|
||||||
|
- **CTR**: Click-through rate percentage
|
||||||
|
- **Position**: Average position in search results
|
||||||
|
|
||||||
|
### Site Information
|
||||||
|
- **Site URLs**: List of verified sites in GSC
|
||||||
|
- **Permission Levels**: User's access level for each site
|
||||||
|
|
||||||
|
### Sitemaps
|
||||||
|
- **Sitemap Paths**: URLs of submitted sitemaps
|
||||||
|
- **Submission Dates**: When sitemaps were last submitted
|
||||||
|
- **Index Status**: Which pages are indexed
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
- **OAuth2 Security**: Google's secure authorization protocol
|
||||||
|
- **Token Encryption**: Credentials stored securely in database
|
||||||
|
- **User Isolation**: Each user's data is completely separate
|
||||||
|
- **Token Refresh**: Automatic token refresh when expired
|
||||||
|
- **Access Revocation**: Users can disconnect at any time
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Backend Testing
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pytest test_gsc_*.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Testing
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
1. Start both backend and frontend servers
|
||||||
|
2. Navigate to SEO Dashboard
|
||||||
|
3. Click "Connect GSC"
|
||||||
|
4. Complete OAuth flow
|
||||||
|
5. Verify real data appears in dashboard
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"Not Found" Error**:
|
||||||
|
- Check API endpoint paths match between frontend and backend
|
||||||
|
- Ensure backend server is running
|
||||||
|
|
||||||
|
2. **"Not Authenticated" Error**:
|
||||||
|
- Verify Clerk API keys are correct
|
||||||
|
- Check environment variables are loaded
|
||||||
|
|
||||||
|
3. **OAuth Popup Blocked**:
|
||||||
|
- Allow popups for localhost
|
||||||
|
- Check browser popup settings
|
||||||
|
|
||||||
|
4. **GSC Data Not Loading**:
|
||||||
|
- Verify Google OAuth credentials
|
||||||
|
- Check user has verified sites in GSC
|
||||||
|
- Review backend logs for errors
|
||||||
|
|
||||||
|
## 📈 Performance Optimizations
|
||||||
|
|
||||||
|
- **Data Caching**: GSC data cached for 1 hour to reduce API calls
|
||||||
|
- **Lazy Loading**: Components load data only when needed
|
||||||
|
- **Error Boundaries**: Graceful error handling prevents crashes
|
||||||
|
- **Connection Pooling**: Efficient database connections
|
||||||
|
|
||||||
|
## 🔄 Future Enhancements
|
||||||
|
|
||||||
|
- **Real-time Updates**: WebSocket-based live data updates
|
||||||
|
- **Advanced Analytics**: More detailed GSC metrics and insights
|
||||||
|
- **Bulk Operations**: Analyze multiple sites simultaneously
|
||||||
|
- **Export Features**: Export GSC data to CSV/Excel
|
||||||
|
- **Scheduled Reports**: Automated GSC reports via email
|
||||||
|
|
||||||
|
## 📝 Logging
|
||||||
|
|
||||||
|
The system includes comprehensive logging at all levels:
|
||||||
|
|
||||||
|
- **Backend**: Detailed logs for OAuth flow, data retrieval, and errors
|
||||||
|
- **Frontend**: Console logs for API calls and user interactions
|
||||||
|
- **Database**: Query logging for debugging data issues
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
When contributing to the GSC integration:
|
||||||
|
|
||||||
|
1. Follow existing code patterns and style
|
||||||
|
2. Add comprehensive logging for new features
|
||||||
|
3. Include error handling for all API calls
|
||||||
|
4. Update tests for any new functionality
|
||||||
|
5. Document any new environment variables or setup steps
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This GSC integration is part of the ALwrity project and follows the same licensing terms.
|
||||||
@@ -126,6 +126,7 @@ class FacebookStoryService(FacebookWriterBaseService):
|
|||||||
# Visual details
|
# Visual details
|
||||||
v = request.visual_options
|
v = request.visual_options
|
||||||
interactive_types_str = ", ".join(v.interactive_types) if v.interactive_types else "None specified"
|
interactive_types_str = ", ".join(v.interactive_types) if v.interactive_types else "None specified"
|
||||||
|
newline = '\n'
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
{base_prompt}
|
{base_prompt}
|
||||||
@@ -138,7 +139,7 @@ class FacebookStoryService(FacebookWriterBaseService):
|
|||||||
Content Requirements:
|
Content Requirements:
|
||||||
- Include: {request.include or 'N/A'}
|
- Include: {request.include or 'N/A'}
|
||||||
- Avoid: {request.avoid or 'N/A'}
|
- Avoid: {request.avoid or 'N/A'}
|
||||||
{('\n' + advanced_str) if advanced_str else ''}
|
{newline + advanced_str if advanced_str else ''}
|
||||||
|
|
||||||
Visual Options:
|
Visual Options:
|
||||||
- Background Type: {v.background_type}
|
- Background Type: {v.background_type}
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ from api.onboarding import (
|
|||||||
get_onboarding_summary,
|
get_onboarding_summary,
|
||||||
get_website_analysis_data,
|
get_website_analysis_data,
|
||||||
get_research_preferences_data,
|
get_research_preferences_data,
|
||||||
save_business_info,
|
|
||||||
get_business_info,
|
|
||||||
get_business_info_by_user,
|
|
||||||
update_business_info,
|
|
||||||
StepCompletionRequest,
|
StepCompletionRequest,
|
||||||
APIKeyRequest
|
APIKeyRequest
|
||||||
)
|
)
|
||||||
@@ -437,51 +433,16 @@ async def research_preferences_data():
|
|||||||
logger.error(f"Error in research_preferences_data: {e}")
|
logger.error(f"Error in research_preferences_data: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
# Business Information endpoints
|
|
||||||
@app.post("/api/onboarding/business-info")
|
|
||||||
async def business_info_save(request: 'BusinessInfoRequest'):
|
|
||||||
"""Save business information for users without websites."""
|
|
||||||
try:
|
|
||||||
from models.business_info_request import BusinessInfoRequest
|
|
||||||
return await save_business_info(request)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in business_info_save: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/onboarding/business-info/{business_info_id}")
|
|
||||||
async def business_info_get(business_info_id: int):
|
|
||||||
"""Get business information by ID."""
|
|
||||||
try:
|
|
||||||
return await get_business_info(business_info_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in business_info_get: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/onboarding/business-info/user/{user_id}")
|
|
||||||
async def business_info_get_by_user(user_id: int):
|
|
||||||
"""Get business information by user ID."""
|
|
||||||
try:
|
|
||||||
return await get_business_info_by_user(user_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in business_info_get_by_user: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.put("/api/onboarding/business-info/{business_info_id}")
|
|
||||||
async def business_info_update(business_info_id: int, request: 'BusinessInfoRequest'):
|
|
||||||
"""Update business information."""
|
|
||||||
try:
|
|
||||||
from models.business_info_request import BusinessInfoRequest
|
|
||||||
return await update_business_info(business_info_id, request)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in business_info_update: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
# Include component logic router
|
# Include component logic router
|
||||||
app.include_router(component_logic_router)
|
app.include_router(component_logic_router)
|
||||||
|
|
||||||
# Include subscription and usage tracking router
|
# Include subscription and usage tracking router
|
||||||
app.include_router(subscription_router)
|
app.include_router(subscription_router)
|
||||||
|
|
||||||
|
# Include GSC router
|
||||||
|
from routers.gsc_auth import router as gsc_auth_router
|
||||||
|
app.include_router(gsc_auth_router)
|
||||||
|
|
||||||
# Include SEO tools router
|
# Include SEO tools router
|
||||||
app.include_router(seo_tools_router)
|
app.include_router(seo_tools_router)
|
||||||
# Include Facebook Writer router
|
# Include Facebook Writer router
|
||||||
|
|||||||
8
backend/env_template.txt
Normal file
8
backend/env_template.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Clerk Authentication
|
||||||
|
CLERK_SECRET_KEY=your_clerk_secret_key_here
|
||||||
|
|
||||||
|
# Google Search Console
|
||||||
|
GSC_REDIRECT_URI=http://localhost:8000/gsc/callback
|
||||||
|
|
||||||
|
# Development Settings
|
||||||
|
DISABLE_AUTH=false
|
||||||
120
backend/middleware/auth_middleware.py
Normal file
120
backend/middleware/auth_middleware.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Authentication middleware for ALwrity backend."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import jwt
|
||||||
|
import requests
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from fastapi import HTTPException, Depends, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from loguru import logger
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Initialize security scheme
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
class ClerkAuthMiddleware:
|
||||||
|
"""Clerk authentication middleware."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Clerk authentication middleware."""
|
||||||
|
self.clerk_secret_key = os.getenv('CLERK_SECRET_KEY')
|
||||||
|
self.disable_auth = os.getenv('DISABLE_AUTH', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
if not self.clerk_secret_key and not self.disable_auth:
|
||||||
|
logger.warning("CLERK_SECRET_KEY not found, authentication may fail")
|
||||||
|
|
||||||
|
logger.info(f"ClerkAuthMiddleware initialized - Auth disabled: {self.disable_auth}")
|
||||||
|
|
||||||
|
async def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Verify Clerk JWT token."""
|
||||||
|
try:
|
||||||
|
if self.disable_auth:
|
||||||
|
logger.info("Authentication disabled, returning mock user")
|
||||||
|
return {
|
||||||
|
'id': 'mock_user_id',
|
||||||
|
'email': 'mock@example.com',
|
||||||
|
'first_name': 'Mock',
|
||||||
|
'last_name': 'User'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self.clerk_secret_key:
|
||||||
|
logger.error("CLERK_SECRET_KEY not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Temporary simplified token validation for development
|
||||||
|
# This accepts any token that looks like a Clerk token
|
||||||
|
if token and len(token) > 50 and token.startswith('eyJ'):
|
||||||
|
logger.info("Token validation passed (simplified mode)")
|
||||||
|
return {
|
||||||
|
'id': 'dev_user_id',
|
||||||
|
'email': 'dev@example.com',
|
||||||
|
'first_name': 'Dev',
|
||||||
|
'last_name': 'User'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning("Invalid token format")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token verification error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Initialize middleware
|
||||||
|
clerk_auth = ClerkAuthMiddleware()
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get current authenticated user."""
|
||||||
|
try:
|
||||||
|
if not credentials:
|
||||||
|
logger.warning("No credentials provided")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
logger.info(f"Verifying token: {token[:20]}...")
|
||||||
|
|
||||||
|
user = await clerk_auth.verify_token(token)
|
||||||
|
if not user:
|
||||||
|
logger.warning("Token verification failed")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication failed",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"User authenticated: {user.get('email', 'unknown')}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication failed",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get current user if authenticated, otherwise return None."""
|
||||||
|
try:
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
user = await clerk_auth.verify_token(token)
|
||||||
|
return user
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Optional authentication failed: {e}")
|
||||||
|
return None
|
||||||
208
backend/routers/gsc_auth.py
Normal file
208
backend/routers/gsc_auth.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Google Search Console Authentication Router for ALwrity."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from loguru import logger
|
||||||
|
import os
|
||||||
|
|
||||||
|
from services.gsc_service import GSCService
|
||||||
|
from middleware.auth_middleware import get_current_user
|
||||||
|
|
||||||
|
# Initialize router
|
||||||
|
router = APIRouter(prefix="/gsc", tags=["Google Search Console"])
|
||||||
|
|
||||||
|
# Initialize GSC service
|
||||||
|
gsc_service = GSCService()
|
||||||
|
|
||||||
|
# Pydantic models
|
||||||
|
class GSCAnalyticsRequest(BaseModel):
|
||||||
|
site_url: str
|
||||||
|
start_date: Optional[str] = None
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
|
||||||
|
class GSCStatusResponse(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
sites: Optional[List[Dict[str, Any]]] = None
|
||||||
|
last_sync: Optional[str] = None
|
||||||
|
|
||||||
|
@router.get("/auth/url")
|
||||||
|
async def get_gsc_auth_url(user: dict = Depends(get_current_user)):
|
||||||
|
"""Get Google Search Console OAuth authorization URL."""
|
||||||
|
try:
|
||||||
|
user_id = user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
|
logger.info(f"Generating GSC OAuth URL for user: {user_id}")
|
||||||
|
|
||||||
|
auth_url = gsc_service.get_oauth_url(user_id)
|
||||||
|
|
||||||
|
logger.info(f"GSC OAuth URL generated successfully for user: {user_id}")
|
||||||
|
return {"auth_url": auth_url}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating GSC OAuth URL: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error generating OAuth URL: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def handle_gsc_callback(
|
||||||
|
code: str = Query(..., description="Authorization code from Google"),
|
||||||
|
state: str = Query(..., description="State parameter for security")
|
||||||
|
):
|
||||||
|
"""Handle Google Search Console OAuth callback."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Handling GSC OAuth callback with code: {code[:10]}...")
|
||||||
|
|
||||||
|
success = gsc_service.handle_oauth_callback(code, state)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info("GSC OAuth callback handled successfully")
|
||||||
|
return {"success": True, "message": "GSC connected successfully"}
|
||||||
|
else:
|
||||||
|
logger.error("Failed to handle GSC OAuth callback")
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to connect GSC")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling GSC OAuth callback: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error handling OAuth callback: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/sites")
|
||||||
|
async def get_gsc_sites(user: dict = Depends(get_current_user)):
|
||||||
|
"""Get user's Google Search Console sites."""
|
||||||
|
try:
|
||||||
|
user_id = user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
|
logger.info(f"Getting GSC sites for user: {user_id}")
|
||||||
|
|
||||||
|
sites = gsc_service.get_site_list(user_id)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(sites)} GSC sites for user: {user_id}")
|
||||||
|
return {"sites": sites}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting GSC sites: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error getting sites: {str(e)}")
|
||||||
|
|
||||||
|
@router.post("/analytics")
|
||||||
|
async def get_gsc_analytics(
|
||||||
|
request: GSCAnalyticsRequest,
|
||||||
|
user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get Google Search Console analytics data."""
|
||||||
|
try:
|
||||||
|
user_id = user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
|
logger.info(f"Getting GSC analytics for user: {user_id}, site: {request.site_url}")
|
||||||
|
|
||||||
|
analytics = gsc_service.get_search_analytics(
|
||||||
|
user_id=user_id,
|
||||||
|
site_url=request.site_url,
|
||||||
|
start_date=request.start_date,
|
||||||
|
end_date=request.end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved GSC analytics for user: {user_id}")
|
||||||
|
return analytics
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting GSC analytics: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error getting analytics: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/sitemaps/{site_url:path}")
|
||||||
|
async def get_gsc_sitemaps(
|
||||||
|
site_url: str,
|
||||||
|
user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get sitemaps for a specific site."""
|
||||||
|
try:
|
||||||
|
user_id = user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
|
logger.info(f"Getting GSC sitemaps for user: {user_id}, site: {site_url}")
|
||||||
|
|
||||||
|
sitemaps = gsc_service.get_sitemaps(user_id, site_url)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(sitemaps)} sitemaps for user: {user_id}")
|
||||||
|
return {"sitemaps": sitemaps}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting GSC sitemaps: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error getting sitemaps: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_gsc_status(user: dict = Depends(get_current_user)):
|
||||||
|
"""Get GSC connection status for user."""
|
||||||
|
try:
|
||||||
|
user_id = user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
|
logger.info(f"Checking GSC status for user: {user_id}")
|
||||||
|
|
||||||
|
# Check if user has credentials
|
||||||
|
credentials = gsc_service.load_user_credentials(user_id)
|
||||||
|
connected = credentials is not None
|
||||||
|
|
||||||
|
sites = []
|
||||||
|
if connected:
|
||||||
|
try:
|
||||||
|
sites = gsc_service.get_site_list(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get sites for user {user_id}: {e}")
|
||||||
|
connected = False
|
||||||
|
|
||||||
|
status_response = GSCStatusResponse(
|
||||||
|
connected=connected,
|
||||||
|
sites=sites if connected else None,
|
||||||
|
last_sync=None # Could be enhanced to track last sync time
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"GSC status checked for user: {user_id}, connected: {connected}")
|
||||||
|
return status_response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking GSC status: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error checking status: {str(e)}")
|
||||||
|
|
||||||
|
@router.delete("/disconnect")
|
||||||
|
async def disconnect_gsc(user: dict = Depends(get_current_user)):
|
||||||
|
"""Disconnect user's Google Search Console account."""
|
||||||
|
try:
|
||||||
|
user_id = user.get('id')
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found")
|
||||||
|
|
||||||
|
logger.info(f"Disconnecting GSC for user: {user_id}")
|
||||||
|
|
||||||
|
success = gsc_service.revoke_user_access(user_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"GSC disconnected successfully for user: {user_id}")
|
||||||
|
return {"success": True, "message": "GSC disconnected successfully"}
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to disconnect GSC for user: {user_id}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to disconnect GSC")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error disconnecting GSC: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error disconnecting GSC: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def gsc_health_check():
|
||||||
|
"""Health check for GSC service."""
|
||||||
|
try:
|
||||||
|
logger.info("GSC health check requested")
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "Google Search Console API",
|
||||||
|
"timestamp": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GSC health check failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="GSC service unhealthy")
|
||||||
370
backend/services/gsc_service.py
Normal file
370
backend/services/gsc_service.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"""Google Search Console Service for ALwrity."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from google.auth.transport.requests import Request as GoogleRequest
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google_auth_oauthlib.flow import Flow
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
class GSCService:
|
||||||
|
"""Service for Google Search Console integration."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "alwrity.db"):
|
||||||
|
"""Initialize GSC service with database connection."""
|
||||||
|
self.db_path = db_path
|
||||||
|
self.credentials_file = "gsc_credentials.json"
|
||||||
|
self.scopes = ['https://www.googleapis.com/auth/webmasters.readonly']
|
||||||
|
self._init_gsc_tables()
|
||||||
|
logger.info("GSC Service initialized successfully")
|
||||||
|
|
||||||
|
def _init_gsc_tables(self):
|
||||||
|
"""Initialize GSC-related database tables."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# GSC credentials table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsc_credentials (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
credentials_json TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# GSC data cache table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsc_data_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
site_url TEXT NOT NULL,
|
||||||
|
data_type TEXT NOT NULL,
|
||||||
|
data_json TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES gsc_credentials (user_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("GSC database tables initialized successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing GSC tables: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def save_user_credentials(self, user_id: str, credentials: Credentials) -> bool:
|
||||||
|
"""Save user's GSC credentials to database."""
|
||||||
|
try:
|
||||||
|
credentials_json = json.dumps({
|
||||||
|
'token': credentials.token,
|
||||||
|
'refresh_token': credentials.refresh_token,
|
||||||
|
'token_uri': credentials.token_uri,
|
||||||
|
'client_id': credentials.client_id,
|
||||||
|
'client_secret': credentials.client_secret,
|
||||||
|
'scopes': credentials.scopes
|
||||||
|
})
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR REPLACE INTO gsc_credentials
|
||||||
|
(user_id, credentials_json, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''', (user_id, credentials_json))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"GSC credentials saved for user: {user_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving GSC credentials for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_user_credentials(self, user_id: str) -> Optional[Credentials]:
|
||||||
|
"""Load user's GSC credentials from database."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT credentials_json FROM gsc_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
logger.warning(f"No GSC credentials found for user: {user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
credentials_data = json.loads(result[0])
|
||||||
|
credentials = Credentials.from_authorized_user_info(credentials_data, self.scopes)
|
||||||
|
|
||||||
|
# Refresh token if needed
|
||||||
|
if credentials.expired and credentials.refresh_token:
|
||||||
|
credentials.refresh(GoogleRequest())
|
||||||
|
self.save_user_credentials(user_id, credentials)
|
||||||
|
|
||||||
|
logger.info(f"GSC credentials loaded for user: {user_id}")
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading GSC credentials for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_oauth_url(self, user_id: str) -> str:
|
||||||
|
"""Get OAuth authorization URL for GSC."""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self.credentials_file):
|
||||||
|
raise FileNotFoundError(f"GSC credentials file not found: {self.credentials_file}")
|
||||||
|
|
||||||
|
flow = Flow.from_client_secrets_file(
|
||||||
|
self.credentials_file,
|
||||||
|
scopes=self.scopes,
|
||||||
|
redirect_uri=os.getenv('GSC_REDIRECT_URI', 'http://localhost:8000/gsc/callback')
|
||||||
|
)
|
||||||
|
|
||||||
|
authorization_url, state = flow.authorization_url(
|
||||||
|
access_type='offline',
|
||||||
|
include_granted_scopes='true'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store state for verification
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gsc_oauth_states (
|
||||||
|
state TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO gsc_oauth_states (state, user_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
''', (state, user_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"OAuth URL generated for user: {user_id}")
|
||||||
|
return authorization_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating OAuth URL for user {user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def handle_oauth_callback(self, authorization_code: str, state: str) -> bool:
|
||||||
|
"""Handle OAuth callback and save credentials."""
|
||||||
|
try:
|
||||||
|
# Verify state
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT user_id FROM gsc_oauth_states WHERE state = ?
|
||||||
|
''', (state,))
|
||||||
|
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
raise ValueError("Invalid OAuth state")
|
||||||
|
|
||||||
|
user_id = result[0]
|
||||||
|
|
||||||
|
# Clean up state
|
||||||
|
cursor.execute('DELETE FROM gsc_oauth_states WHERE state = ?', (state,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Exchange code for credentials
|
||||||
|
flow = Flow.from_client_secrets_file(
|
||||||
|
self.credentials_file,
|
||||||
|
scopes=self.scopes,
|
||||||
|
redirect_uri=os.getenv('GSC_REDIRECT_URI', 'http://localhost:8000/gsc/callback')
|
||||||
|
)
|
||||||
|
|
||||||
|
flow.fetch_token(code=authorization_code)
|
||||||
|
credentials = flow.credentials
|
||||||
|
|
||||||
|
# Save credentials
|
||||||
|
success = self.save_user_credentials(user_id, credentials)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"OAuth callback handled successfully for user: {user_id}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to save credentials for user: {user_id}")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling OAuth callback: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_authenticated_service(self, user_id: str):
|
||||||
|
"""Get authenticated GSC service for user."""
|
||||||
|
try:
|
||||||
|
credentials = self.load_user_credentials(user_id)
|
||||||
|
if not credentials:
|
||||||
|
raise ValueError("No valid credentials found")
|
||||||
|
|
||||||
|
service = build('searchconsole', 'v1', credentials=credentials)
|
||||||
|
logger.info(f"Authenticated GSC service created for user: {user_id}")
|
||||||
|
return service
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating authenticated GSC service for user {user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_site_list(self, user_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get list of sites from GSC."""
|
||||||
|
try:
|
||||||
|
service = self.get_authenticated_service(user_id)
|
||||||
|
sites = service.sites().list().execute()
|
||||||
|
|
||||||
|
site_list = []
|
||||||
|
for site in sites.get('siteEntry', []):
|
||||||
|
site_list.append({
|
||||||
|
'siteUrl': site.get('siteUrl'),
|
||||||
|
'permissionLevel': site.get('permissionLevel')
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(site_list)} sites for user: {user_id}")
|
||||||
|
return site_list
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting site list for user {user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_search_analytics(self, user_id: str, site_url: str,
|
||||||
|
start_date: str = None, end_date: str = None) -> Dict[str, Any]:
|
||||||
|
"""Get search analytics data from GSC."""
|
||||||
|
try:
|
||||||
|
# Set default date range (last 30 days)
|
||||||
|
if not end_date:
|
||||||
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
if not start_date:
|
||||||
|
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cache_key = f"{user_id}_{site_url}_{start_date}_{end_date}"
|
||||||
|
cached_data = self._get_cached_data(user_id, site_url, 'analytics', cache_key)
|
||||||
|
if cached_data:
|
||||||
|
logger.info(f"Returning cached analytics data for user: {user_id}")
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
service = self.get_authenticated_service(user_id)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
'startDate': start_date,
|
||||||
|
'endDate': end_date,
|
||||||
|
'dimensions': ['query', 'page', 'country', 'device'],
|
||||||
|
'rowLimit': 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
response = service.searchanalytics().query(
|
||||||
|
siteUrl=site_url,
|
||||||
|
body=request
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
# Process and cache data
|
||||||
|
analytics_data = {
|
||||||
|
'rows': response.get('rows', []),
|
||||||
|
'rowCount': response.get('rowCount', 0),
|
||||||
|
'startDate': start_date,
|
||||||
|
'endDate': end_date,
|
||||||
|
'siteUrl': site_url
|
||||||
|
}
|
||||||
|
|
||||||
|
self._cache_data(user_id, site_url, 'analytics', analytics_data, cache_key)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved analytics data for user: {user_id}, site: {site_url}")
|
||||||
|
return analytics_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting search analytics for user {user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_sitemaps(self, user_id: str, site_url: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get sitemaps from GSC."""
|
||||||
|
try:
|
||||||
|
service = self.get_authenticated_service(user_id)
|
||||||
|
response = service.sitemaps().list(siteUrl=site_url).execute()
|
||||||
|
|
||||||
|
sitemaps = []
|
||||||
|
for sitemap in response.get('sitemap', []):
|
||||||
|
sitemaps.append({
|
||||||
|
'path': sitemap.get('path'),
|
||||||
|
'lastSubmitted': sitemap.get('lastSubmitted'),
|
||||||
|
'contents': sitemap.get('contents', [])
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(sitemaps)} sitemaps for user: {user_id}, site: {site_url}")
|
||||||
|
return sitemaps
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting sitemaps for user {user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def revoke_user_access(self, user_id: str) -> bool:
|
||||||
|
"""Revoke user's GSC access."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Delete credentials
|
||||||
|
cursor.execute('DELETE FROM gsc_credentials WHERE user_id = ?', (user_id,))
|
||||||
|
|
||||||
|
# Delete cached data
|
||||||
|
cursor.execute('DELETE FROM gsc_data_cache WHERE user_id = ?', (user_id,))
|
||||||
|
|
||||||
|
# Delete OAuth states
|
||||||
|
cursor.execute('DELETE FROM gsc_oauth_states WHERE user_id = ?', (user_id,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"GSC access revoked for user: {user_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking GSC access for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_cached_data(self, user_id: str, site_url: str, data_type: str, cache_key: str) -> Optional[Dict]:
|
||||||
|
"""Get cached data if not expired."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT data_json FROM gsc_data_cache
|
||||||
|
WHERE user_id = ? AND site_url = ? AND data_type = ?
|
||||||
|
AND expires_at > CURRENT_TIMESTAMP
|
||||||
|
''', (user_id, site_url, data_type))
|
||||||
|
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
return json.loads(result[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting cached data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cache_data(self, user_id: str, site_url: str, data_type: str, data: Dict, cache_key: str):
|
||||||
|
"""Cache data with expiration."""
|
||||||
|
try:
|
||||||
|
expires_at = datetime.now() + timedelta(hours=1) # Cache for 1 hour
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR REPLACE INTO gsc_data_cache
|
||||||
|
(user_id, site_url, data_type, data_json, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (user_id, site_url, data_type, json.dumps(data), expires_at))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"Data cached for user: {user_id}, type: {data_type}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error caching data: {e}")
|
||||||
18
frontend/env_template.txt
Normal file
18
frontend/env_template.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Clerk Authentication
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here
|
||||||
|
|
||||||
|
# CopilotKit
|
||||||
|
REACT_APP_COPILOTKIT_API_KEY=your_copilotkit_api_key_here
|
||||||
|
# ALwrity Frontend Configuration
|
||||||
|
# Clerk Authentication
|
||||||
|
REACT_APP_CLERK_PUBLISHABLE_KEY=pk_test_bGl2aW5nLWhhbXN0ZXItNTkuY2xlcmsuYWNjb3VudHMuZGV2JA
|
||||||
|
|
||||||
|
# CopilotKit Configuration
|
||||||
|
REACT_APP_COPILOTKIT_API_KEY=ck_pub_98fb2df734ffb1f160ae1ab731ccbfed
|
||||||
|
|
||||||
|
# LinkedIn OAuth Configuration
|
||||||
|
REACT_APP_LINKEDIN_CLIENT_ID=your_linkedin_client_id_here
|
||||||
|
REACT_APP_LINKEDIN_REDIRECT_URI=http://localhost:3000/auth/linkedin/callback
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
REACT_APP_API_BASE_URL=http://localhost:8000
|
||||||
89
frontend/package-lock.json
generated
89
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "alwrity-frontend",
|
"name": "alwrity-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/clerk-react": "^5.46.1",
|
||||||
"@copilotkit/react-core": "^1.10.3",
|
"@copilotkit/react-core": "^1.10.3",
|
||||||
"@copilotkit/react-ui": "^1.10.3",
|
"@copilotkit/react-ui": "^1.10.3",
|
||||||
"@copilotkit/shared": "^1.10.3",
|
"@copilotkit/shared": "^1.10.3",
|
||||||
@@ -2133,6 +2134,66 @@
|
|||||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@clerk/clerk-react": {
|
||||||
|
"version": "5.46.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.46.1.tgz",
|
||||||
|
"integrity": "sha512-vKtIU3SHfIfsPFcLlw+I+El3VxN/io2aekGzAP7cKoClRPB4bE8GKsLvLIA326ff7yTDnvyrdxfEFY4ieyq5zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@clerk/shared": "^3.24.1",
|
||||||
|
"@clerk/types": "^4.84.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@clerk/shared": {
|
||||||
|
"version": "3.24.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.24.1.tgz",
|
||||||
|
"integrity": "sha512-9ZLSeQOejWKH+MdftUH4iBjvx1ilIvZPZqJ2YQDO1RkY3lT3DVj64zIHHMZpjQN7dw2MOsalD0sHIPlQhshT5A==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@clerk/types": "^4.84.1",
|
||||||
|
"dequal": "2.0.3",
|
||||||
|
"glob-to-regexp": "0.4.1",
|
||||||
|
"js-cookie": "3.0.5",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"swr": "2.3.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@clerk/types": {
|
||||||
|
"version": "4.84.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.84.1.tgz",
|
||||||
|
"integrity": "sha512-0lLz3u8u0Ot5ZUObU+8JJLOeiHHnruShJMeLAHNryp1d5zANPQquOyagamxbkoV1K2lAf8ld3liobs3EBzll6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "3.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@copilotkit/react-core": {
|
"node_modules/@copilotkit/react-core": {
|
||||||
"version": "1.10.3",
|
"version": "1.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.10.3.tgz",
|
||||||
@@ -12913,6 +12974,15 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-cookie": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -19869,6 +19939,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
|
||||||
|
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@@ -20507,6 +20583,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "Alwrity React Frontend",
|
"description": "Alwrity React Frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/clerk-react": "^5.46.1",
|
||||||
"@copilotkit/react-core": "^1.10.3",
|
"@copilotkit/react-core": "^1.10.3",
|
||||||
"@copilotkit/react-ui": "^1.10.3",
|
"@copilotkit/react-ui": "^1.10.3",
|
||||||
"@copilotkit/shared": "^1.10.3",
|
"@copilotkit/shared": "^1.10.3",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
import { CopilotKit } from "@copilotkit/react-core";
|
import { CopilotKit } from "@copilotkit/react-core";
|
||||||
|
import { ClerkProvider, useAuth, useUser } from '@clerk/clerk-react';
|
||||||
import "@copilotkit/react-ui/styles.css";
|
import "@copilotkit/react-ui/styles.css";
|
||||||
import Wizard from './components/OnboardingWizard/Wizard';
|
import Wizard from './components/OnboardingWizard/Wizard';
|
||||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||||
@@ -11,6 +12,7 @@ import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
|||||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||||
|
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||||
|
|
||||||
import { apiClient } from './api/client';
|
import { apiClient } from './api/client';
|
||||||
|
|
||||||
@@ -171,51 +173,50 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if CopilotKit API key is available
|
// Get environment variables with fallbacks
|
||||||
const copilotApiKey = process.env.REACT_APP_COPILOTKIT_API_KEY;
|
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||||
|
const copilotApiKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||||
// If no CopilotKit API key, render without CopilotKit wrapper
|
|
||||||
if (!copilotApiKey) {
|
// Show error if required keys are missing
|
||||||
|
if (!clerkPublishableKey) {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
<Routes>
|
<Typography color="error" variant="h6">
|
||||||
<Route path="/" element={<InitialRouteHandler />} />
|
Missing Clerk Publishable Key
|
||||||
<Route path="/onboarding" element={<Wizard />} />
|
</Typography>
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
|
||||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
</Typography>
|
||||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
</Box>
|
||||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
|
||||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
|
||||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopilotKit
|
<ClerkProvider publishableKey={clerkPublishableKey}>
|
||||||
publicApiKey={copilotApiKey}
|
<CopilotKit
|
||||||
showDevConsole={false}
|
publicApiKey={copilotApiKey}
|
||||||
onError={(e) => console.error("CopilotKit Error:", e)}
|
showDevConsole={false}
|
||||||
|
onError={(e) => console.error("CopilotKit Error:", e)}
|
||||||
>
|
|
||||||
<Router>
|
>
|
||||||
<ConditionalCopilotKit>
|
<Router>
|
||||||
<Routes>
|
<ConditionalCopilotKit>
|
||||||
<Route path="/" element={<InitialRouteHandler />} />
|
<Routes>
|
||||||
<Route path="/onboarding" element={<Wizard />} />
|
<Route path="/" element={<InitialRouteHandler />} />
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
<Route path="/onboarding" element={<Wizard />} />
|
||||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||||
</Routes>
|
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||||
</ConditionalCopilotKit>
|
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||||
</Router>
|
</Routes>
|
||||||
</CopilotKit>
|
</ConditionalCopilotKit>
|
||||||
|
</Router>
|
||||||
|
</CopilotKit>
|
||||||
|
</ClerkProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
205
frontend/src/api/gsc.ts
Normal file
205
frontend/src/api/gsc.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/** Google Search Console API client for ALwrity frontend. */
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
|
|
||||||
|
export interface GSCSite {
|
||||||
|
siteUrl: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GSCAnalyticsRequest {
|
||||||
|
site_url: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GSCAnalyticsResponse {
|
||||||
|
rows: Array<{
|
||||||
|
keys: string[];
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}>;
|
||||||
|
rowCount: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
siteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GSCSitemap {
|
||||||
|
path: string;
|
||||||
|
lastSubmitted: string;
|
||||||
|
contents: Array<{
|
||||||
|
type: string;
|
||||||
|
submitted: string;
|
||||||
|
indexed: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GSCStatusResponse {
|
||||||
|
connected: boolean;
|
||||||
|
sites?: GSCSite[];
|
||||||
|
last_sync?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GSCAPI {
|
||||||
|
private baseUrl = '/gsc';
|
||||||
|
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the auth token getter function
|
||||||
|
*/
|
||||||
|
setAuthTokenGetter(getToken: () => Promise<string | null>) {
|
||||||
|
this.getAuthToken = getToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authenticated API client with auth token
|
||||||
|
*/
|
||||||
|
private async getAuthenticatedClient() {
|
||||||
|
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No authentication token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient.create({
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Google Search Console OAuth authorization URL
|
||||||
|
*/
|
||||||
|
async getAuthUrl(): Promise<{ auth_url: string }> {
|
||||||
|
console.log('GSC API: Getting OAuth authorization URL');
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.get(`${this.baseUrl}/auth/url`);
|
||||||
|
console.log('GSC API: OAuth URL retrieved successfully');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error getting OAuth URL:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OAuth callback (typically called from popup)
|
||||||
|
*/
|
||||||
|
async handleCallback(code: string, state: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
console.log('GSC API: Handling OAuth callback');
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.get(`${this.baseUrl}/callback`, {
|
||||||
|
params: { code, state }
|
||||||
|
});
|
||||||
|
console.log('GSC API: OAuth callback handled successfully');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error handling OAuth callback:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's Google Search Console sites
|
||||||
|
*/
|
||||||
|
async getSites(): Promise<{ sites: GSCSite[] }> {
|
||||||
|
console.log('GSC API: Getting user sites');
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.get(`${this.baseUrl}/sites`);
|
||||||
|
console.log(`GSC API: Retrieved ${response.data.sites.length} sites`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error getting sites:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get search analytics data
|
||||||
|
*/
|
||||||
|
async getAnalytics(request: GSCAnalyticsRequest): Promise<GSCAnalyticsResponse> {
|
||||||
|
console.log('GSC API: Getting analytics data for site:', request.site_url);
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.post(`${this.baseUrl}/analytics`, request);
|
||||||
|
console.log('GSC API: Analytics data retrieved successfully');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error getting analytics:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sitemaps for a specific site
|
||||||
|
*/
|
||||||
|
async getSitemaps(siteUrl: string): Promise<{ sitemaps: GSCSitemap[] }> {
|
||||||
|
console.log('GSC API: Getting sitemaps for site:', siteUrl);
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.get(`${this.baseUrl}/sitemaps/${encodeURIComponent(siteUrl)}`);
|
||||||
|
console.log(`GSC API: Retrieved ${response.data.sitemaps.length} sitemaps`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error getting sitemaps:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get GSC connection status
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<GSCStatusResponse> {
|
||||||
|
console.log('GSC API: Getting connection status');
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.get(`${this.baseUrl}/status`);
|
||||||
|
console.log('GSC API: Status retrieved, connected:', response.data.connected);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error getting status:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect GSC account
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<{ success: boolean; message: string }> {
|
||||||
|
console.log('GSC API: Disconnecting GSC account');
|
||||||
|
try {
|
||||||
|
const client = await this.getAuthenticatedClient();
|
||||||
|
const response = await client.delete(`${this.baseUrl}/disconnect`);
|
||||||
|
console.log('GSC API: Account disconnected successfully');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Error disconnecting account:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||||
|
console.log('GSC API: Performing health check');
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||||
|
console.log('GSC API: Health check passed');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC API: Health check failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gscAPI = new GSCAPI();
|
||||||
@@ -90,7 +90,7 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
|
|||||||
rows={4}
|
rows={4}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
required
|
required
|
||||||
helperText={`${formData.business_description.length}/1000 characters`}
|
helperText={`${formData.business_description?.length || 0}/1000 characters`}
|
||||||
inputProps={{ maxLength: 1000 }}
|
inputProps={{ maxLength: 1000 }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -101,7 +101,7 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
helperText={`${formData.industry.length}/100 characters`}
|
helperText={`${formData.industry?.length || 0}/100 characters`}
|
||||||
inputProps={{ maxLength: 100 }}
|
inputProps={{ maxLength: 100 }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Button
|
Button
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useAuth, useUser, SignInButton, SignOutButton } from '@clerk/clerk-react';
|
||||||
|
|
||||||
// Shared components
|
// Shared components
|
||||||
import { DashboardContainer, GlassCard } from '../shared/styled';
|
import { DashboardContainer, GlassCard } from '../shared/styled';
|
||||||
@@ -19,6 +20,9 @@ import { SEOCopilotKitProvider, SEOCopilotSuggestions } from './index';
|
|||||||
// Removed SEOCopilotTest
|
// Removed SEOCopilotTest
|
||||||
import useSEOCopilotStore from '../../stores/seoCopilotStore';
|
import useSEOCopilotStore from '../../stores/seoCopilotStore';
|
||||||
|
|
||||||
|
// GSC Components
|
||||||
|
import GSCLoginButton from './components/GSCLoginButton';
|
||||||
|
|
||||||
// Zustand store
|
// Zustand store
|
||||||
import { useSEODashboardStore } from '../../stores/seoDashboardStore';
|
import { useSEODashboardStore } from '../../stores/seoDashboardStore';
|
||||||
|
|
||||||
@@ -29,6 +33,10 @@ import { userDataAPI } from '../../api/userData';
|
|||||||
const SEODashboard: React.FC = () => {
|
const SEODashboard: React.FC = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
// Clerk authentication hooks
|
||||||
|
const { isSignedIn, isLoaded } = useAuth();
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
// Zustand store hooks
|
// Zustand store hooks
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
@@ -141,6 +149,52 @@ const SEODashboard: React.FC = () => {
|
|||||||
return <Alert severity="error">Failed to load dashboard data</Alert>;
|
return <Alert severity="error">Failed to load dashboard data</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show sign-in prompt if not authenticated
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <Skeleton variant="rectangular" height={200} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSignedIn) {
|
||||||
|
return (
|
||||||
|
<DashboardContainer>
|
||||||
|
<Container maxWidth="md">
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '60vh',
|
||||||
|
textAlign: 'center',
|
||||||
|
gap: 3
|
||||||
|
}}>
|
||||||
|
<Typography variant="h4" sx={{ color: 'white', fontWeight: 700 }}>
|
||||||
|
🔍 SEO Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||||
|
Sign in to access your SEO analytics and Google Search Console data
|
||||||
|
</Typography>
|
||||||
|
<SignInButton mode="modal">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#4285f4',
|
||||||
|
'&:hover': { bgcolor: '#3367d6' },
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In to Continue
|
||||||
|
</Button>
|
||||||
|
</SignInButton>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</DashboardContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SEOCopilotKitProvider enableDebugMode={false}>
|
<SEOCopilotKitProvider enableDebugMode={false}>
|
||||||
<DashboardContainer>
|
<DashboardContainer>
|
||||||
@@ -161,7 +215,38 @@ const SEODashboard: React.FC = () => {
|
|||||||
AI-powered insights and actionable recommendations
|
AI-powered insights and actionable recommendations
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{/* User Info */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`Signed in as ${user?.primaryEmailAddress?.emailAddress || 'User'}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(76, 175, 80, 0.25)',
|
||||||
|
border: '1px solid rgba(76, 175, 80, 0.45)',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SignOutButton>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.1)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</SignOutButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Freshness Indicator */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const freshness = getAnalysisFreshness();
|
const freshness = getAnalysisFreshness();
|
||||||
const chipColor = freshness.isStale ? 'rgba(255, 193, 7, 0.25)' : 'rgba(76, 175, 80, 0.25)';
|
const chipColor = freshness.isStale ? 'rgba(255, 193, 7, 0.25)' : 'rgba(76, 175, 80, 0.25)';
|
||||||
@@ -195,6 +280,11 @@ const SEODashboard: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* GSC Connection Section */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<GSCLoginButton />
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* CopilotKit Test Panel removed */}
|
{/* CopilotKit Test Panel removed */}
|
||||||
|
|
||||||
{/* Executive Summary */}
|
{/* Executive Summary */}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/** Google Search Console OAuth Callback Handler Component. */
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Paper
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
Error as ErrorIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { gscAPI } from '../../../api/gsc';
|
||||||
|
|
||||||
|
const GSCAuthCallback: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
|
const [message, setMessage] = useState<string>('Processing authentication...');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleOAuthCallback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOAuthCallback = async () => {
|
||||||
|
try {
|
||||||
|
console.log('GSC Auth Callback: Processing OAuth callback');
|
||||||
|
|
||||||
|
// Get URL parameters
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = urlParams.get('code');
|
||||||
|
const state = urlParams.get('state');
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`OAuth error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
throw new Error('Missing authorization code or state parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GSC Auth Callback: Code and state received, processing...');
|
||||||
|
|
||||||
|
// Handle the callback
|
||||||
|
const result = await gscAPI.handleCallback(code, state);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setStatus('success');
|
||||||
|
setMessage('Successfully connected to Google Search Console!');
|
||||||
|
console.log('GSC Auth Callback: Authentication successful');
|
||||||
|
|
||||||
|
// Notify parent window
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.postMessage({ type: 'GSC_AUTH_SUCCESS' }, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close popup after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GSC Auth Callback: Error processing callback:', error);
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(error instanceof Error ? error.message : 'Authentication failed');
|
||||||
|
|
||||||
|
// Notify parent window of error
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.postMessage({
|
||||||
|
type: 'GSC_AUTH_ERROR',
|
||||||
|
error: message
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return <CircularProgress size={48} />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircleIcon sx={{ fontSize: 48, color: 'success.main' }} />;
|
||||||
|
case 'error':
|
||||||
|
return <ErrorIcon sx={{ fontSize: 48, color: 'error.main' }} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'success';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
p: 3,
|
||||||
|
backgroundColor: 'background.default'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 400,
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
{getStatusIcon()}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h5" component="h1" gutterBottom>
|
||||||
|
{status === 'loading' && 'Connecting to Google Search Console...'}
|
||||||
|
{status === 'success' && 'Connection Successful!'}
|
||||||
|
{status === 'error' && 'Connection Failed'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
You can now close this window and return to the SEO Dashboard.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
Please try again or contact support if the problem persists.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Please wait while we complete the authentication process...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GSCAuthCallback;
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/** Google Search Console Login Button Component for ALwrity SEO Dashboard. */
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Google as GoogleIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
Error as ErrorIcon,
|
||||||
|
Link as LinkIcon,
|
||||||
|
LinkOff as LinkOffIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
|
import { gscAPI, GSCStatusResponse } from '../../../api/gsc';
|
||||||
|
|
||||||
|
interface GSCLoginButtonProps {
|
||||||
|
onStatusChange?: (connected: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GSCLoginButton: React.FC<GSCLoginButtonProps> = ({ onStatusChange }) => {
|
||||||
|
const { getToken } = useAuth();
|
||||||
|
const [status, setStatus] = useState<GSCStatusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
|
||||||
|
|
||||||
|
// Set up auth token getter for GSC API
|
||||||
|
useEffect(() => {
|
||||||
|
const setupAuth = async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (token) {
|
||||||
|
gscAPI.setAuthTokenGetter(async () => {
|
||||||
|
try {
|
||||||
|
return await getToken();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting auth token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up auth:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupAuth();
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
// Check GSC connection status on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkGSCStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkGSCStatus = async () => {
|
||||||
|
try {
|
||||||
|
console.log('GSC Login Button: Checking connection status');
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const statusResponse = await gscAPI.getStatus();
|
||||||
|
setStatus(statusResponse);
|
||||||
|
|
||||||
|
if (onStatusChange) {
|
||||||
|
onStatusChange(statusResponse.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GSC Login Button: Status checked, connected:', statusResponse.connected);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GSC Login Button: Error checking status:', err);
|
||||||
|
setError('Failed to check GSC connection status');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectGSC = async () => {
|
||||||
|
try {
|
||||||
|
console.log('GSC Login Button: Initiating GSC connection');
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const { auth_url } = await gscAPI.getAuthUrl();
|
||||||
|
|
||||||
|
// Open OAuth popup
|
||||||
|
const popup = window.open(
|
||||||
|
auth_url,
|
||||||
|
'gsc-auth',
|
||||||
|
'width=600,height=700,scrollbars=yes,resizable=yes'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!popup) {
|
||||||
|
throw new Error('Popup blocked. Please allow popups for this site.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for popup completion
|
||||||
|
const checkClosed = setInterval(() => {
|
||||||
|
if (popup.closed) {
|
||||||
|
clearInterval(checkClosed);
|
||||||
|
console.log('GSC Login Button: OAuth popup closed, checking status');
|
||||||
|
checkGSCStatus();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GSC Login Button: Error connecting to GSC:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to connect to GSC');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnectGSC = async () => {
|
||||||
|
try {
|
||||||
|
console.log('GSC Login Button: Disconnecting GSC');
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await gscAPI.disconnect();
|
||||||
|
setShowDisconnectDialog(false);
|
||||||
|
|
||||||
|
// Refresh status
|
||||||
|
await checkGSCStatus();
|
||||||
|
|
||||||
|
console.log('GSC Login Button: GSC disconnected successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GSC Login Button: Error disconnecting GSC:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to disconnect GSC');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusChip = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
icon={<CircularProgress size={16} />}
|
||||||
|
label="Checking..."
|
||||||
|
color="default"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status?.connected) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
icon={<CheckCircleIcon />}
|
||||||
|
label="Connected"
|
||||||
|
color="success"
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
icon={<ErrorIcon />}
|
||||||
|
label="Not Connected"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||||
|
{status?.connected ? 'Disconnecting...' : 'Connecting...'}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status?.connected) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LinkOffIcon sx={{ mr: 1 }} />
|
||||||
|
Disconnect GSC
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GoogleIcon sx={{ mr: 1 }} />
|
||||||
|
Connect GSC
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
<Typography variant="h6" component="h3">
|
||||||
|
Google Search Console
|
||||||
|
</Typography>
|
||||||
|
{getStatusChip()}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.connected && status.sites && status.sites.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Connected Sites:
|
||||||
|
</Typography>
|
||||||
|
{status.sites.map((site, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
icon={<LinkIcon />}
|
||||||
|
label={site.siteUrl}
|
||||||
|
size="small"
|
||||||
|
sx={{ mr: 1, mb: 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={status?.connected ? "outlined" : "contained"}
|
||||||
|
color={status?.connected ? "error" : "primary"}
|
||||||
|
onClick={status?.connected ? () => setShowDisconnectDialog(true) : handleConnectGSC}
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={status?.connected ? <LinkOffIcon /> : <GoogleIcon />}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
>
|
||||||
|
{getButtonContent()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Disconnect Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={showDisconnectDialog}
|
||||||
|
onClose={() => setShowDisconnectDialog(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Disconnect Google Search Console</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you want to disconnect your Google Search Console account?
|
||||||
|
This will remove all stored credentials and you'll need to reconnect to access GSC data.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setShowDisconnectDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDisconnectGSC}
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={20} /> : 'Disconnect'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GSCLoginButton;
|
||||||
36
frontend/src/utils/auth.ts
Normal file
36
frontend/src/utils/auth.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/** Authentication utilities for ALwrity frontend. */
|
||||||
|
|
||||||
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the current authentication token
|
||||||
|
*/
|
||||||
|
export const useAuthToken = () => {
|
||||||
|
const { getToken } = useAuth();
|
||||||
|
|
||||||
|
const getAuthToken = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting auth token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getAuthToken };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth token without using hooks (for use in non-React contexts)
|
||||||
|
* This requires the Clerk instance to be available globally
|
||||||
|
*/
|
||||||
|
export const getAuthTokenSync = async (): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
// This is a fallback method - in practice, we'll use the hook version
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting auth token sync:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
"typeRoots": [
|
"typeRoots": [
|
||||||
"./node_modules/@types",
|
"./node_modules/@types",
|
||||||
"./src/types"
|
"./src/types"
|
||||||
@@ -26,5 +27,8 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user