diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 53b2f913..87997713 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -306,7 +306,7 @@ export function setupIpcHandlers() { return runsCore.listRuns(args.cursor); }, 'oauth:connect': async (_event, args) => { - return await connectProvider(args.provider); + return await connectProvider(args.provider, args.clientId); }, 'oauth:disconnect': async (_event, args) => { return await disconnectProvider(args.provider); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 5b55e8b7..3586aaac 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -4,6 +4,12 @@ import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; +import { + clearProviderClientIdOverride, + getProviderClientIdOverride, + hasProviderClientIdOverride, + setProviderClientIdOverride, +} from '@x/core/dist/auth/provider-client-id.js'; import container from '@x/core/dist/di/container.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; @@ -76,14 +82,25 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { */ async function getProviderConfiguration(provider: string): Promise { const config = getProviderConfig(provider); + const resolveClientId = (): string => { + const override = getProviderClientIdOverride(provider); + if (override) { + return override; + } + if (config.client.mode === 'static' && config.client.clientId) { + return config.client.clientId; + } + throw new Error(`${provider} client ID not configured. Please provide a client ID.`); + }; if (config.discovery.mode === 'issuer') { if (config.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`); + const clientId = resolveClientId(); return await oauthClient.discoverConfiguration( config.discovery.issuer, - config.client.clientId + clientId ); } else { // DCR mode - check for existing registration or register new @@ -120,10 +137,11 @@ async function getProviderConfiguration(provider: string): Promise { +export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); @@ -142,6 +160,14 @@ export async function connectProvider(provider: string): Promise<{ success: bool const oauthRepo = getOAuthRepo(); const providerConfig = getProviderConfig(provider); + if (provider === 'google') { + const trimmedClientId = clientId?.trim(); + if (!trimmedClientId) { + return { success: false, error: 'Google client ID is required to connect.' }; + } + setProviderClientIdOverride(provider, trimmedClientId); + } + // Get or create OAuth configuration const config = await getProviderConfiguration(provider); @@ -256,6 +282,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b try { const oauthRepo = getOAuthRepo(); await oauthRepo.clearTokens(provider); + if (provider === 'google') { + clearProviderClientIdOverride(provider); + } return { success: true }; } catch (error) { console.error('OAuth disconnect failed:', error); @@ -269,6 +298,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b export async function isConnected(provider: string): Promise<{ isConnected: boolean }> { try { const oauthRepo = getOAuthRepo(); + if (provider === 'google' && !hasProviderClientIdOverride(provider)) { + return { isConnected: false }; + } const connected = await oauthRepo.isConnected(provider); return { isConnected: connected }; } catch (error) { @@ -325,7 +357,10 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }> try { const oauthRepo = getOAuthRepo(); const providers = await oauthRepo.getConnectedProviders(); - return { providers }; + const filteredProviders = providers.filter((provider) => + provider === 'google' ? hasProviderClientIdOverride(provider) : true + ); + return { providers: filteredProviders }; } catch (error) { console.error('Get connected providers failed:', error); return { providers: [] }; diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 1799ab75..7033a2ac 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -18,6 +18,8 @@ import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" interface ProviderState { @@ -36,6 +38,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) @@ -266,15 +269,14 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return cleanup }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) if (result.success) { // OAuth flow started - keep isConnecting state, wait for event @@ -297,6 +299,27 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) + // Disconnect from a provider const handleDisconnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -308,6 +331,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const result = await window.ipc.invoke('oauth:disconnect', { provider }) if (result.success) { + if (provider === 'google') { + clearGoogleClientId() + } const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) toast.success(`Disconnected from ${displayName}`) setProviderStates(prev => ({ @@ -395,6 +421,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return ( <> + {tooltip ? ( diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx new file mode 100644 index 00000000..5727d506 --- /dev/null +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +interface GoogleClientIdModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (clientId: string) => void + isSubmitting?: boolean +} + +export function GoogleClientIdModal({ + open, + onOpenChange, + onSubmit, + isSubmitting = false, +}: GoogleClientIdModalProps) { + const [clientId, setClientId] = useState("") + + useEffect(() => { + if (!open) { + setClientId("") + } + }, [open]) + + const trimmedClientId = clientId.trim() + const isValid = trimmedClientId.length > 0 + + const handleSubmit = () => { + if (!isValid || isSubmitting) return + onSubmit(trimmedClientId) + } + + return ( + + + + Enter Google Client ID + + This app does not store the client ID. You will be prompted each session. + + +
+ + setClientId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 074ad645..a1621d84 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -15,6 +15,8 @@ import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" interface ProviderState { @@ -37,6 +39,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) @@ -245,15 +248,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { + const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, [provider]: { ...prev[provider], isConnecting: true } })) try { - const result = await window.ipc.invoke('oauth:connect', { provider }) + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) if (!result.success) { toast.error(result.error || `Failed to connect to ${provider}`) @@ -272,6 +274,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) + const handleNext = () => { if (currentStep < 2) { setCurrentStep((prev) => (prev + 1) as Step) @@ -574,6 +597,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return ( <> + { + const connect = useCallback(async (clientId?: string) => { try { setIsConnecting(true); - const result = await window.ipc.invoke('oauth:connect', { provider }); + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }); if (result.success) { // OAuth flow started - keep isConnecting state, wait for event // Event listener will handle the actual completion diff --git a/apps/x/apps/renderer/src/lib/google-client-id-store.ts b/apps/x/apps/renderer/src/lib/google-client-id-store.ts new file mode 100644 index 00000000..78898325 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/google-client-id-store.ts @@ -0,0 +1,17 @@ +let googleClientId: string | null = null; + +export function getGoogleClientId(): string | null { + return googleClientId; +} + +export function setGoogleClientId(clientId: string): void { + const trimmed = clientId.trim(); + if (!trimmed) { + return; + } + googleClientId = trimmed; +} + +export function clearGoogleClientId(): void { + googleClientId = null; +} diff --git a/apps/x/packages/core/src/auth/provider-client-id.ts b/apps/x/packages/core/src/auth/provider-client-id.ts new file mode 100644 index 00000000..6f8f6a90 --- /dev/null +++ b/apps/x/packages/core/src/auth/provider-client-id.ts @@ -0,0 +1,23 @@ +type ProviderClientIdOverrides = Map; + +const providerClientIdOverrides: ProviderClientIdOverrides = new Map(); + +export function setProviderClientIdOverride(provider: string, clientId: string): void { + const trimmed = clientId.trim(); + if (!trimmed) { + return; + } + providerClientIdOverrides.set(provider, trimmed); +} + +export function getProviderClientIdOverride(provider: string): string | undefined { + return providerClientIdOverrides.get(provider); +} + +export function hasProviderClientIdOverride(provider: string): boolean { + return providerClientIdOverrides.has(provider); +} + +export function clearProviderClientIdOverride(provider: string): void { + providerClientIdOverrides.delete(provider); +} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index edda5447..b9acce11 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -22,7 +22,7 @@ const DiscoverySchema = z.discriminatedUnion('mode', [ const ClientSchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('static'), - clientId: z.string().min(1), + clientId: z.string().min(1).optional(), }), z.object({ mode: z.literal('dcr'), @@ -58,7 +58,6 @@ const providerConfigs: ProviderConfig = { }, client: { mode: 'static', - clientId: '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com', }, scopes: [ 'https://www.googleapis.com/auth/gmail.readonly', diff --git a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts index a56d39b5..d8c975c7 100644 --- a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts @@ -144,9 +144,13 @@ export class FirefliesClientFactory { if (providerConfig.client.mode === 'static') { // Discover endpoints, use static client ID console.log(`[Fireflies] Discovery mode: issuer with static client ID`); + const clientId = providerConfig.client.clientId; + if (!clientId) { + throw new Error('Fireflies client ID not configured.'); + } this.cache.config = await oauthClient.discoverConfiguration( providerConfig.discovery.issuer, - providerConfig.client.clientId + clientId ); } else { // DCR mode - need existing registration @@ -170,10 +174,14 @@ export class FirefliesClientFactory { } console.log(`[Fireflies] Using static endpoints (no discovery)`); + const clientId = providerConfig.client.clientId; + if (!clientId) { + throw new Error('Fireflies client ID not configured.'); + } this.cache.config = oauthClient.createStaticConfiguration( providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.tokenEndpoint, - providerConfig.client.clientId, + clientId, providerConfig.discovery.revocationEndpoint ); } diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index f32f492c..9a1240fb 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -3,6 +3,7 @@ import container from '../di/container.js'; import { IOAuthRepo } from '../auth/repo.js'; import { IClientRegistrationRepo } from '../auth/client-repo.js'; import { getProviderConfig } from '../auth/providers.js'; +import { getProviderClientIdOverride } from '../auth/provider-client-id.js'; import * as oauthClient from '../auth/oauth-client.js'; import type { Configuration } from '../auth/oauth-client.js'; import { OAuthTokens } from '../auth/types.js'; @@ -17,12 +18,22 @@ export class GoogleClientFactory { config: Configuration | null; client: OAuth2Client | null; tokens: OAuthTokens | null; + clientId: string | null; } = { config: null, client: null, tokens: null, + clientId: null, }; + private static resolveClientId(): string { + const override = getProviderClientIdOverride(this.PROVIDER_NAME); + if (!override) { + throw new Error('Google client ID not provided for this session.'); + } + return override; + } + /** * Get or create OAuth2Client, reusing cached instance when possible */ @@ -36,7 +47,13 @@ export class GoogleClientFactory { } // Initialize config cache if needed - await this.initializeConfigCache(); + try { + await this.initializeConfigCache(); + } catch (error) { + console.error("[OAuth] Failed to initialize Google OAuth configuration:", error); + this.clearCache(); + return null; + } if (!this.cache.config) { return null; } @@ -95,6 +112,10 @@ export class GoogleClientFactory { return false; } + if (!getProviderClientIdOverride(this.PROVIDER_NAME)) { + return false; + } + const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); if (!tokens) { return false; @@ -116,14 +137,21 @@ export class GoogleClientFactory { this.cache.config = null; this.cache.client = null; this.cache.tokens = null; + this.cache.clientId = null; } /** * Initialize cached configuration (called once) */ private static async initializeConfigCache(): Promise { - if (this.cache.config) { - return; // Already initialized + const clientId = this.resolveClientId(); + + if (this.cache.config && this.cache.clientId === clientId) { + return; // Already initialized for this client ID + } + + if (this.cache.clientId && this.cache.clientId !== clientId) { + this.clearCache(); } console.log(`[OAuth] Initializing Google OAuth configuration...`); @@ -135,7 +163,7 @@ export class GoogleClientFactory { console.log(`[OAuth] Discovery mode: issuer with static client ID`); this.cache.config = await oauthClient.discoverConfiguration( providerConfig.discovery.issuer, - providerConfig.client.clientId + clientId ); } else { // DCR mode - need existing registration @@ -162,11 +190,12 @@ export class GoogleClientFactory { this.cache.config = oauthClient.createStaticConfiguration( providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.tokenEndpoint, - providerConfig.client.clientId, + clientId, providerConfig.discovery.revocationEndpoint ); } + this.cache.clientId = clientId; console.log(`[OAuth] Google OAuth configuration initialized`); } @@ -174,17 +203,7 @@ export class GoogleClientFactory { * Create OAuth2Client from OAuthTokens */ private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client { - const providerConfig = getProviderConfig(this.PROVIDER_NAME); - - // Get client ID from config - let clientId: string; - if (providerConfig.client.mode === 'static') { - clientId = providerConfig.client.clientId; - } else { - // For DCR, we'd need to look up the registered client ID - // This is a fallback - normally initializeConfigCache handles this - throw new Error('Cannot create client without static client ID'); - } + const clientId = this.resolveClientId(); // Create OAuth2Client directly (PKCE flow doesn't use client secret) const client = new OAuth2Client( diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 79746516..aca2ce17 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -176,6 +176,7 @@ const ipcSchemas = { 'oauth:connect': { req: z.object({ provider: z.string(), + clientId: z.string().optional(), }), res: z.object({ success: z.boolean(),