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:
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { ClerkProvider, useAuth, useUser } from '@clerk/clerk-react';
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import Wizard from './components/OnboardingWizard/Wizard';
|
||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||
@@ -11,6 +12,7 @@ import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||
|
||||
import { apiClient } from './api/client';
|
||||
|
||||
@@ -171,51 +173,50 @@ const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if CopilotKit API key is available
|
||||
const copilotApiKey = process.env.REACT_APP_COPILOTKIT_API_KEY;
|
||||
|
||||
// If no CopilotKit API key, render without CopilotKit wrapper
|
||||
if (!copilotApiKey) {
|
||||
// Get environment variables with fallbacks
|
||||
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||
const copilotApiKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||
|
||||
// Show error if required keys are missing
|
||||
if (!clerkPublishableKey) {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<InitialRouteHandler />} />
|
||||
<Route path="/onboarding" element={<Wizard />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<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>
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="h6">
|
||||
Missing Clerk Publishable Key
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopilotKit
|
||||
publicApiKey={copilotApiKey}
|
||||
showDevConsole={false}
|
||||
onError={(e) => console.error("CopilotKit Error:", e)}
|
||||
|
||||
>
|
||||
<Router>
|
||||
<ConditionalCopilotKit>
|
||||
<Routes>
|
||||
<Route path="/" element={<InitialRouteHandler />} />
|
||||
<Route path="/onboarding" element={<Wizard />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<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>
|
||||
</ConditionalCopilotKit>
|
||||
</Router>
|
||||
</CopilotKit>
|
||||
<ClerkProvider publishableKey={clerkPublishableKey}>
|
||||
<CopilotKit
|
||||
publicApiKey={copilotApiKey}
|
||||
showDevConsole={false}
|
||||
onError={(e) => console.error("CopilotKit Error:", e)}
|
||||
|
||||
>
|
||||
<Router>
|
||||
<ConditionalCopilotKit>
|
||||
<Routes>
|
||||
<Route path="/" element={<InitialRouteHandler />} />
|
||||
<Route path="/onboarding" element={<Wizard />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||
</Routes>
|
||||
</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}
|
||||
margin="normal"
|
||||
required
|
||||
helperText={`${formData.business_description.length}/1000 characters`}
|
||||
helperText={`${formData.business_description?.length || 0}/1000 characters`}
|
||||
inputProps={{ maxLength: 1000 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -101,7 +101,7 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
helperText={`${formData.industry.length}/100 characters`}
|
||||
helperText={`${formData.industry?.length || 0}/100 characters`}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth, useUser, SignInButton, SignOutButton } from '@clerk/clerk-react';
|
||||
|
||||
// Shared components
|
||||
import { DashboardContainer, GlassCard } from '../shared/styled';
|
||||
@@ -19,6 +20,9 @@ import { SEOCopilotKitProvider, SEOCopilotSuggestions } from './index';
|
||||
// Removed SEOCopilotTest
|
||||
import useSEOCopilotStore from '../../stores/seoCopilotStore';
|
||||
|
||||
// GSC Components
|
||||
import GSCLoginButton from './components/GSCLoginButton';
|
||||
|
||||
// Zustand store
|
||||
import { useSEODashboardStore } from '../../stores/seoDashboardStore';
|
||||
|
||||
@@ -29,6 +33,10 @@ import { userDataAPI } from '../../api/userData';
|
||||
const SEODashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Clerk authentication hooks
|
||||
const { isSignedIn, isLoaded } = useAuth();
|
||||
const { user } = useUser();
|
||||
|
||||
// Zustand store hooks
|
||||
const {
|
||||
loading,
|
||||
@@ -141,6 +149,52 @@ const SEODashboard: React.FC = () => {
|
||||
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 (
|
||||
<SEOCopilotKitProvider enableDebugMode={false}>
|
||||
<DashboardContainer>
|
||||
@@ -161,7 +215,38 @@ const SEODashboard: React.FC = () => {
|
||||
AI-powered insights and actionable recommendations
|
||||
</Typography>
|
||||
</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 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>
|
||||
|
||||
{/* GSC Connection Section */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<GSCLoginButton />
|
||||
</Box>
|
||||
|
||||
{/* CopilotKit Test Panel removed */}
|
||||
|
||||
{/* 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user