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:
@@ -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;
|
||||
Reference in New Issue
Block a user