feat: Google Drive picker and enhancements

This commit is contained in:
Eric Hare 2025-09-03 14:11:32 -07:00
parent 1b4dbe66bc
commit 64edbd8eed
No known key found for this signature in database
GPG key ID: A73DF73724270AB7
8 changed files with 1041 additions and 1017 deletions

1
.gitignore vendored
View file

@ -17,3 +17,4 @@ wheels/
1001*.pdf 1001*.pdf
*.json *.json
.DS_Store

View file

@ -0,0 +1,117 @@
"use client"
import { useCallback, useState } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
// declare globals to silence TS
declare global {
interface Window { google?: any; gapi?: any }
}
const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve()
const s = document.createElement("script")
s.src = src
s.async = true
s.onload = () => resolve()
s.onerror = () => reject(new Error(`Failed to load ${src}`))
document.head.appendChild(s)
})
export type DriveSelection = { files: string[]; folders: string[] }
export function GoogleDrivePicker({
value,
onChange,
buttonLabel = "Choose in Drive",
}: {
value?: DriveSelection
onChange: (sel: DriveSelection) => void
buttonLabel?: string
}) {
const [loading, setLoading] = useState(false)
const ensureGoogleApis = useCallback(async () => {
await loadScript("https://accounts.google.com/gsi/client")
await loadScript("https://apis.google.com/js/api.js")
await new Promise<void>((res) => window.gapi?.load("picker", () => res()))
}, [])
const openPicker = useCallback(async () => {
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_API_KEY
if (!clientId || !apiKey) {
alert("Google Picker requires NEXT_PUBLIC_GOOGLE_CLIENT_ID and NEXT_PUBLIC_GOOGLE_API_KEY")
return
}
try {
setLoading(true)
await ensureGoogleApis()
const tokenClient = window.google.accounts.oauth2.initTokenClient({
client_id: clientId,
scope: "https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive.metadata.readonly",
callback: (tokenResp: any) => {
const viewDocs = new window.google.picker.DocsView()
.setIncludeFolders(true)
.setSelectFolderEnabled(true)
console.log("Picker using clientId:", clientId, "apiKey:", apiKey)
const picker = new window.google.picker.PickerBuilder()
.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
.setOAuthToken(tokenResp.access_token)
.setDeveloperKey(apiKey)
.addView(viewDocs)
.setCallback((data: any) => {
if (data.action === window.google.picker.Action.PICKED) {
const pickedFiles: string[] = []
const pickedFolders: string[] = []
for (const doc of data.docs || []) {
const id = doc.id
const isFolder = doc?.type === "folder" || doc?.mimeType === "application/vnd.google-apps.folder"
if (isFolder) pickedFolders.push(id)
else pickedFiles.push(id)
}
onChange({ files: pickedFiles, folders: pickedFolders })
}
})
.build()
picker.setVisible(true)
},
})
tokenClient.requestAccessToken()
} catch (e) {
console.error("Drive Picker error", e)
alert("Failed to open Google Drive Picker. See console.")
} finally {
setLoading(false)
}
}, [ensureGoogleApis, onChange])
const filesCount = value?.files?.length ?? 0
const foldersCount = value?.folders?.length ?? 0
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={openPicker} disabled={loading}>
{loading ? "Loading Picker…" : buttonLabel}
</Button>
{(filesCount > 0 || foldersCount > 0) && (
<Badge variant="outline">{filesCount} file(s), {foldersCount} folder(s) selected</Badge>
)}
</div>
{(filesCount > 0 || foldersCount > 0) && (
<div className="flex flex-wrap gap-1 max-h-20 overflow-auto">
{value!.files.slice(0, 6).map((id) => <Badge key={id} variant="secondary">file:{id}</Badge>)}
{filesCount > 6 && <Badge>+{filesCount - 6} more</Badge>}
{value!.folders.slice(0, 6).map((id) => <Badge key={id} variant="secondary">folder:{id}</Badge>)}
{foldersCount > 6 && <Badge>+{foldersCount - 6} more</Badge>}
</div>
)}
</div>
)
}

View file

@ -1,495 +1,14 @@
"use client" import React, { useState } from "react";
import { GoogleDrivePicker, type DriveSelection } from "./GoogleDrivePicker"
import { useState, useEffect, useCallback, Suspense } from "react" const [driveSelection, setDriveSelection] = useState<DriveSelection>({ files: [], folders: [] });
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Loader2, PlugZap, CheckCircle, XCircle, RefreshCw, Download, AlertCircle } from "lucide-react"
import { useAuth } from "@/contexts/auth-context"
import { useTask } from "@/contexts/task-context"
import { ProtectedRoute } from "@/components/protected-route"
interface Connector { // in JSX
id: string <GoogleDrivePicker value={driveSelection} onChange={setDriveSelection} />
name: string
description: string
icon: React.ReactNode
status: "not_connected" | "connecting" | "connected" | "error"
type: string
connectionId?: string // Store the active connection ID for syncing
access_token?: string // For connectors that use OAuth
}
interface SyncResult { // when calling sync:
processed?: number; const body: { file_ids: string[]; folder_ids: string[]; recursive: boolean } = {
added?: number; file_ids: driveSelection.files,
skipped?: number; folder_ids: driveSelection.folders,
errors?: number; recursive: true,
error?: string; };
message?: string; // For sync started messages
isStarted?: boolean; // For sync started state
}
interface Connection {
connection_id: string
name: string
is_active: boolean
created_at: string
last_sync?: string
}
function ConnectorsPage() {
const { isAuthenticated } = useAuth()
const { addTask, refreshTasks } = useTask()
const searchParams = useSearchParams()
const [connectors, setConnectors] = useState<Connector[]>([])
const [isConnecting, setIsConnecting] = useState<string | null>(null)
const [isSyncing, setIsSyncing] = useState<string | null>(null)
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
const [maxFiles, setMaxFiles] = useState<number>(10)
// Helper function to get connector icon
const getConnectorIcon = (iconName: string) => {
const iconMap: { [key: string]: React.ReactElement } = {
'google-drive': <div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center text-white font-bold">G</div>,
'sharepoint': <div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold">SP</div>,
'onedrive': <div className="w-8 h-8 bg-blue-400 rounded flex items-center justify-center text-white font-bold">OD</div>,
}
return iconMap[iconName] || <div className="w-8 h-8 bg-gray-500 rounded flex items-center justify-center text-white font-bold">?</div>
}
// Function definitions first
const checkConnectorStatuses = useCallback(async () => {
try {
// Fetch available connectors from backend
const connectorsResponse = await fetch('/api/connectors')
if (!connectorsResponse.ok) {
throw new Error('Failed to load connectors')
}
const connectorsResult = await connectorsResponse.json()
const connectorTypes = Object.keys(connectorsResult.connectors)
// Initialize connectors list with metadata from backend
const initialConnectors = connectorTypes
.filter(type => connectorsResult.connectors[type].available) // Only show available connectors
.map(type => ({
id: type,
name: connectorsResult.connectors[type].name,
description: connectorsResult.connectors[type].description,
icon: getConnectorIcon(connectorsResult.connectors[type].icon),
status: "not_connected" as const,
type: type
}))
setConnectors(initialConnectors)
// Check status for each connector type
for (const connectorType of connectorTypes) {
const response = await fetch(`/api/connectors/${connectorType}/status`)
if (response.ok) {
const data = await response.json()
const connections = data.connections || []
const activeConnection = connections.find((conn: Connection) => conn.is_active)
const isConnected = activeConnection !== undefined
setConnectors(prev => prev.map(c =>
c.type === connectorType
? {
...c,
status: isConnected ? "connected" : "not_connected",
connectionId: activeConnection?.connection_id
}
: c
))
}
}
} catch (error) {
console.error('Failed to check connector statuses:', error)
}
}, [setConnectors])
const handleConnect = async (connector: Connector) => {
setIsConnecting(connector.id)
setConnectors(prev => prev.map(c =>
c.id === connector.id ? { ...c, status: "connecting" } : c
))
try {
// Use the shared auth callback URL, not a separate connectors callback
const redirectUri = `${window.location.origin}/auth/callback`
const response = await fetch('/api/auth/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
connector_type: connector.type,
purpose: "data_source",
name: `${connector.name} Connection`,
redirect_uri: redirectUri
}),
})
const result = await response.json()
if (response.ok) {
// Store connector ID for callback
localStorage.setItem('connecting_connector_id', result.connection_id)
localStorage.setItem('connecting_connector_type', connector.type)
// Handle client-side OAuth with Google's library
if (result.oauth_config) {
// Use the redirect URI provided by the backend
const authUrl = `${result.oauth_config.authorization_endpoint}?` +
`client_id=${result.oauth_config.client_id}&` +
`response_type=code&` +
`scope=${result.oauth_config.scopes.join(' ')}&` +
`redirect_uri=${encodeURIComponent(result.oauth_config.redirect_uri)}&` +
`access_type=offline&` +
`prompt=consent&` +
`state=${result.connection_id}`
window.location.href = authUrl
}
} else {
throw new Error(result.error || 'Failed to initialize OAuth')
}
} catch (error) {
console.error('OAuth initialization failed:', error)
setConnectors(prev => prev.map(c =>
c.id === connector.id ? { ...c, status: "error" } : c
))
} finally {
setIsConnecting(null)
}
}
const handleSync = async (connector: Connector) => {
if (!connector.connectionId) {
console.error('No connection ID available for connector')
return
}
setIsSyncing(connector.id)
setSyncResults(prev => ({ ...prev, [connector.id]: null })) // Clear any existing progress
try {
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
max_files: maxFiles
}),
})
const result = await response.json()
if (response.status === 201 && result.task_id) {
// Task-based sync, use centralized tracking
addTask(result.task_id)
console.log(`Sync task ${result.task_id} added to central tracking for connector ${connector.id}`)
// Immediately refresh task notifications to show the new task
await refreshTasks()
// Show sync started message
setSyncResults(prev => ({
...prev,
[connector.id]: {
message: "Check task notification panel for progress",
isStarted: true
}
}))
setIsSyncing(null)
} else if (response.ok) {
// Direct sync result - still show "sync started" message
setSyncResults(prev => ({
...prev,
[connector.id]: {
message: "Check task notification panel for progress",
isStarted: true
}
}))
setIsSyncing(null)
} else {
throw new Error(result.error || 'Sync failed')
}
} catch (error) {
console.error('Sync failed:', error)
setSyncResults(prev => ({
...prev,
[connector.id]: {
error: error instanceof Error ? error.message : 'Sync failed'
}
}))
setIsSyncing(null)
}
}
const handleDisconnect = async (connector: Connector) => {
// This would call a disconnect endpoint when implemented
setConnectors(prev => prev.map(c =>
c.id === connector.id ? { ...c, status: "not_connected", connectionId: undefined } : c
))
setSyncResults(prev => ({ ...prev, [connector.id]: null }))
}
const getStatusIcon = (status: Connector['status']) => {
switch (status) {
case "connected":
return <CheckCircle className="h-4 w-4 text-green-500" />
case "connecting":
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
case "error":
return <XCircle className="h-4 w-4 text-red-500" />
default:
return <XCircle className="h-4 w-4 text-gray-400" />
}
}
const getStatusBadge = (status: Connector['status']) => {
switch (status) {
case "connected":
return <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">Connected</Badge>
case "connecting":
return <Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">Connecting...</Badge>
case "error":
return <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">Error</Badge>
default:
return <Badge variant="outline" className="bg-gray-500/10 text-gray-500 border-gray-500/20">Not Connected</Badge>
}
}
// Check connector status on mount and when returning from OAuth
useEffect(() => {
if (isAuthenticated) {
checkConnectorStatuses()
}
// If we just returned from OAuth, clear the URL parameter
if (searchParams.get('oauth_success') === 'true') {
// Clear the URL parameter without causing a page reload
const url = new URL(window.location.href)
url.searchParams.delete('oauth_success')
window.history.replaceState({}, '', url.toString())
}
}, [searchParams, isAuthenticated, checkConnectorStatuses])
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
<p className="text-muted-foreground mt-2">
Connect external services to automatically sync and index your documents
</p>
</div>
{/* Sync Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Sync Settings
</CardTitle>
<CardDescription>
Configure how many files to sync when manually triggering a sync
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<Label htmlFor="maxFiles" className="text-sm font-medium">
Max files per sync:
</Label>
<Input
id="maxFiles"
type="number"
value={maxFiles}
onChange={(e) => setMaxFiles(parseInt(e.target.value) || 10)}
className="w-24"
min="1"
max="100"
/>
<span className="text-sm text-muted-foreground">
(Leave blank or set to 0 for unlimited)
</span>
</div>
</div>
</CardContent>
</Card>
{/* Connectors Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{connectors.map((connector) => (
<Card key={connector.id} className="relative">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{connector.icon}
<div>
<CardTitle className="text-lg">{connector.name}</CardTitle>
<div className="flex items-center gap-2 mt-1">
{getStatusIcon(connector.status)}
{getStatusBadge(connector.status)}
</div>
</div>
</div>
</div>
<CardDescription className="mt-2">
{connector.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2">
{connector.status === "not_connected" && (
<Button
onClick={() => handleConnect(connector)}
disabled={isConnecting === connector.id}
className="w-full"
>
{isConnecting === connector.id ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Connecting...
</>
) : (
<>
<PlugZap className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
)}
{connector.status === "connected" && (
<>
<Button
onClick={() => handleSync(connector)}
disabled={isSyncing === connector.id}
variant="default"
className="w-full"
>
{isSyncing === connector.id ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Sync Files
</>
)}
</Button>
<Button
onClick={() => handleDisconnect(connector)}
variant="outline"
size="sm"
className="w-full"
>
Disconnect
</Button>
</>
)}
{connector.status === "error" && (
<Button
onClick={() => handleConnect(connector)}
disabled={isConnecting === connector.id}
variant="destructive"
className="w-full"
>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Connection
</Button>
)}
</div>
{/* Sync Results */}
{syncResults[connector.id] && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
{syncResults[connector.id]?.isStarted && (
<div className="text-sm">
<div className="font-medium text-blue-600 mb-1">
<RefreshCw className="inline h-3 w-3 mr-1" />
Task initiated:
</div>
<div className="text-blue-600">
{syncResults[connector.id]?.message}
</div>
</div>
)}
{syncResults[connector.id]?.error && (
<div className="text-sm">
<div className="font-medium text-red-600 mb-1">
<XCircle className="h-4 w-4 inline mr-1" />
Sync Failed
</div>
<div className="text-red-600">
{syncResults[connector.id]?.error}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Coming Soon Section */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-lg text-muted-foreground">Coming Soon</CardTitle>
<CardDescription>
Additional connectors are in development
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 opacity-50">
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold">D</div>
<div>
<div className="font-medium">Dropbox</div>
<div className="text-sm text-muted-foreground">File storage</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center text-white font-bold">O</div>
<div>
<div className="font-medium">OneDrive</div>
<div className="text-sm text-muted-foreground">Microsoft cloud storage</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-dashed">
<div className="w-8 h-8 bg-orange-600 rounded flex items-center justify-center text-white font-bold">B</div>
<div>
<div className="font-medium">Box</div>
<div className="text-sm text-muted-foreground">Enterprise file sharing</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
export default function ProtectedConnectorsPage() {
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading connectors...</div>}>
<ConnectorsPage />
</Suspense>
</ProtectedRoute>
)
}

View file

@ -12,6 +12,7 @@ import { Loader2, PlugZap, RefreshCw } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route" import { ProtectedRoute } from "@/components/protected-route"
import { useTask } from "@/contexts/task-context" import { useTask } from "@/contexts/task-context"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import { GoogleDrivePicker, type DriveSelection } from "../connectors/GoogleDrivePicker"
interface Connector { interface Connector {
@ -53,6 +54,7 @@ function KnowledgeSourcesPage() {
const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({}) const [syncResults, setSyncResults] = useState<{[key: string]: SyncResult | null}>({})
const [maxFiles, setMaxFiles] = useState<number>(10) const [maxFiles, setMaxFiles] = useState<number>(10)
const [syncAllFiles, setSyncAllFiles] = useState<boolean>(false) const [syncAllFiles, setSyncAllFiles] = useState<boolean>(false)
const [driveSelection, setDriveSelection] = useState<DriveSelection>({ files: [], folders: [] })
// Settings state // Settings state
// Note: backend internal Langflow URL is not needed on the frontend // Note: backend internal Langflow URL is not needed on the frontend
@ -210,44 +212,45 @@ function KnowledgeSourcesPage() {
const handleSync = async (connector: Connector) => { const handleSync = async (connector: Connector) => {
if (!connector.connectionId) return if (!connector.connectionId) return
setIsSyncing(connector.id) setIsSyncing(connector.id)
setSyncResults(prev => ({ ...prev, [connector.id]: null })) setSyncResults(prev => ({ ...prev, [connector.id]: null }))
try { try {
const body: any = {
connection_id: connector.connectionId,
max_files: syncAllFiles ? 0 : (maxFiles || undefined),
}
if (connector.type === "google-drive") {
body.file_ids = driveSelection.files
body.folder_ids = driveSelection.folders
body.recursive = true // or expose a checkbox if you want
}
const response = await fetch(`/api/connectors/${connector.type}/sync`, { const response = await fetch(`/api/connectors/${connector.type}/sync`, {
method: 'POST', method: "POST",
headers: { headers: { "Content-Type": "application/json" },
'Content-Type': 'application/json', body: JSON.stringify(body),
},
body: JSON.stringify({
connection_id: connector.connectionId,
max_files: syncAllFiles ? 0 : (maxFiles || undefined)
}),
}) })
const result = await response.json() const result = await response.json()
if (response.status === 201) { if (response.status === 201) {
const taskId = result.task_id const taskId = result.task_id
if (taskId) { if (taskId) {
addTask(taskId) addTask(taskId)
setSyncResults(prev => ({ setSyncResults(prev => ({
...prev, ...prev,
[connector.id]: { [connector.id]: { processed: 0, total: result.total_files || 0 }
processed: 0,
total: result.total_files || 0
}
})) }))
} }
} else if (response.ok) { } else if (response.ok) {
setSyncResults(prev => ({ ...prev, [connector.id]: result })) setSyncResults(prev => ({ ...prev, [connector.id]: result }))
// Note: Stats will auto-refresh via task completion watcher for async syncs
} else { } else {
console.error('Sync failed:', result.error) console.error("Sync failed:", result.error)
} }
} catch (error) { } catch (error) {
console.error('Sync error:', error) console.error("Sync error:", error)
} finally { } finally {
setIsSyncing(null) setIsSyncing(null)
} }
@ -433,6 +436,9 @@ function KnowledgeSourcesPage() {
<CardContent className="flex-1 flex flex-col justify-end space-y-4"> <CardContent className="flex-1 flex flex-col justify-end space-y-4">
{connector.status === "connected" ? ( {connector.status === "connected" ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-center">
<GoogleDrivePicker value={driveSelection} onChange={setDriveSelection} />
</div>
<Button <Button
onClick={() => handleSync(connector)} onClick={() => handleSync(connector)}
disabled={isSyncing === connector.id} disabled={isSyncing === connector.id}
@ -447,7 +453,7 @@ function KnowledgeSourcesPage() {
) : ( ) : (
<> <>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Sync Now Sync Files
</> </>
)} )}
</Button> </Button>

View file

@ -1,6 +1,5 @@
import os import os
import requests import requests
import asyncio
import time import time
from dotenv import load_dotenv from dotenv import load_dotenv
from opensearchpy import AsyncOpenSearch from opensearchpy import AsyncOpenSearch

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
import os import os
import json import json
import asyncio from typing import Optional
from typing import Dict, Any, Optional
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow from google_auth_oauthlib.flow import Flow
@ -25,8 +24,8 @@ class GoogleDriveOAuth:
def __init__( def __init__(
self, self,
client_id: str = None, client_id: Optional[str] = None,
client_secret: str = None, client_secret: Optional[str] = None,
token_file: str = "token.json", token_file: str = "token.json",
): ):
self.client_id = client_id self.client_id = client_id
@ -133,7 +132,7 @@ class GoogleDriveOAuth:
if not self.creds: if not self.creds:
await self.load_credentials() await self.load_credentials()
return self.creds and self.creds.valid return bool(self.creds and self.creds.valid)
def get_service(self): def get_service(self):
"""Get authenticated Google Drive service""" """Get authenticated Google Drive service"""

View file

@ -107,11 +107,27 @@ class AuthService:
auth_endpoint = oauth_class.AUTH_ENDPOINT auth_endpoint = oauth_class.AUTH_ENDPOINT
token_endpoint = oauth_class.TOKEN_ENDPOINT token_endpoint = oauth_class.TOKEN_ENDPOINT
# Get client_id from environment variable using connector's env var name # src/services/auth_service.py
client_id = os.getenv(connector_class.CLIENT_ID_ENV_VAR) client_key = getattr(connector_class, "CLIENT_ID_ENV_VAR", None)
if not client_id: secret_key = getattr(connector_class, "CLIENT_SECRET_ENV_VAR", None)
raise ValueError(
f"{connector_class.CLIENT_ID_ENV_VAR} environment variable not set" def _assert_env_key(name, val):
if not isinstance(val, str) or not val.strip():
raise RuntimeError(
f"{connector_class.__name__} misconfigured: {name} must be a non-empty string "
f"(got {val!r}). Define it as a class attribute on the connector."
)
_assert_env_key("CLIENT_ID_ENV_VAR", client_key)
_assert_env_key("CLIENT_SECRET_ENV_VAR", secret_key)
client_id = os.getenv(client_key)
client_secret = os.getenv(secret_key)
if not client_id or not client_secret:
raise RuntimeError(
f"Missing OAuth env vars for {connector_class.__name__}. "
f"Set {client_key} and {secret_key} in the environment."
) )
oauth_config = { oauth_config = {