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:
Om-Singh1808
2025-09-06 02:43:50 +05:30
committed by ي
parent aeb7751d48
commit 0a7d9bfd21
19 changed files with 1912 additions and 87 deletions

18
frontend/env_template.txt Normal file
View 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

View File

@@ -8,6 +8,7 @@
"name": "alwrity-frontend",
"version": "1.0.0",
"dependencies": {
"@clerk/clerk-react": "^5.46.1",
"@copilotkit/react-core": "^1.10.3",
"@copilotkit/react-ui": "^1.10.3",
"@copilotkit/shared": "^1.10.3",
@@ -2133,6 +2134,66 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"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": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.10.3.tgz",
@@ -12913,6 +12974,15 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -19869,6 +19939,12 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -20507,6 +20583,19 @@
"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": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

View File

@@ -4,6 +4,7 @@
"description": "Alwrity React Frontend",
"private": true,
"dependencies": {
"@clerk/clerk-react": "^5.46.1",
"@copilotkit/react-core": "^1.10.3",
"@copilotkit/react-ui": "^1.10.3",
"@copilotkit/shared": "^1.10.3",

View File

@@ -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
View 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();

View File

@@ -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}
/>

View File

@@ -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 */}

View File

@@ -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;

View File

@@ -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;

View 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;
}
};

View File

@@ -19,6 +19,7 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"typeRoots": [
"./node_modules/@types",
"./src/types"
@@ -26,5 +27,8 @@
},
"include": [
"src"
],
"exclude": [
"node_modules"
]
}