diff --git a/src/ai/eval/runner.ts b/src/ai/eval/runner.ts index d9fc2f62..b4b0b325 100644 --- a/src/ai/eval/runner.ts +++ b/src/ai/eval/runner.ts @@ -59,10 +59,6 @@ const logVerbosePrompt = async (scenario: EvalScenario, modeSystemPrompt: string time_format: '12h', currency: 'USD', integrations_do_not_ask_again: false, - integrations_google_credentials: '', - integrations_google_is_enabled: false, - integrations_microsoft_credentials: '', - integrations_microsoft_is_enabled: false, }) const systemPrompt = createPrompt({ diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index d263446b..f927d3dd 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -12,7 +12,7 @@ import { isFinalStep, shouldRetry, } from '@/ai/step-logic' -import { getModel, getModelProfile, getSettings } from '@/dal' +import { getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' import { getDb } from '@/db/database' import { getLocalSetting } from '@/stores/local-settings-store' import { isSsoMode } from '@/lib/auth-mode' @@ -194,12 +194,10 @@ export const aiFetchStreamingResponse = async ({ time_format: '12h', currency: 'USD', integrations_do_not_ask_again: false, - integrations_google_credentials: '', - integrations_google_is_enabled: false, - integrations_microsoft_credentials: '', - integrations_microsoft_is_enabled: false, }) + const integrationStatus = await getIntegrationStatus(db) + const model = await getModel(db, modelId) if (!model) { @@ -232,17 +230,15 @@ export const aiFetchStreamingResponse = async ({ } // Compute integration status for the model (can return multiple statuses) - const getIntegrationStatus = (): string => { + const computeIntegrationStatusLabel = (): string => { const statuses: string[] = [] - // Check for disabled integrations (connected but turned off) - if (settings.integrationsGoogleCredentials && !settings.integrationsGoogleIsEnabled) { + if (integrationStatus.googleConnected && !integrationStatus.googleEnabled) { statuses.push('GOOGLE_DISABLED') } - if (settings.integrationsMicrosoftCredentials && !settings.integrationsMicrosoftIsEnabled) { + if (integrationStatus.microsoftConnected && !integrationStatus.microsoftEnabled) { statuses.push('MICROSOFT_DISABLED') } - // Check if user chose "Don't ask again" if (settings.integrationsDoNotAskAgain) { statuses.push('PROMPTS_DISABLED') } @@ -267,7 +263,7 @@ export const aiFetchStreamingResponse = async ({ timeFormat: settings.timeFormat, currency: settings.currency, }, - integrationStatus: getIntegrationStatus(), + integrationStatus: computeIntegrationStatusLabel(), modeSystemPrompt, }) diff --git a/src/components/onboarding/onboarding-auth-step.tsx b/src/components/onboarding/onboarding-auth-step.tsx index b884d8d8..cb89bc65 100644 --- a/src/components/onboarding/onboarding-auth-step.tsx +++ b/src/components/onboarding/onboarding-auth-step.tsx @@ -6,10 +6,11 @@ import { ConnectProviderButton } from '@/components/connect-provider-button' import { GoogleLogo } from '@/components/ui/google-logo' import { MicrosoftLogo } from '@/components/ui/microsoft-logo' import { useDatabase } from '@/contexts' -import { updateSettings } from '@/dal' +import { deleteIntegrationCredentials } from '@/dal' import { useOAuthConnect } from '@/hooks/use-oauth-connect' import type { UseOAuthConnectResult } from '@/hooks/use-oauth-connect' import { type OAuthProvider } from '@/lib/auth' +import { useQueryClient } from '@tanstack/react-query' import { Calendar, File, Mail } from 'lucide-react' import { useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router' @@ -35,6 +36,7 @@ export const OnboardingAuthStep = ({ const db = useDatabase() const location = useLocation() const navigate = useNavigate() + const queryClient = useQueryClient() // Determine which provider to use for this step (first in list) const provider = providers[0] @@ -84,10 +86,8 @@ export const OnboardingAuthStep = ({ const handleDisconnect = async () => { try { - await updateSettings(db, { - [`integrations_${provider}_credentials`]: '', - [`integrations_${provider}_is_enabled`]: 'false', - }) + await deleteIntegrationCredentials(db, provider) + await queryClient.invalidateQueries({ queryKey: ['integrationStatus'] }) onConnectionChange(false) } catch (error) { console.error('Failed to disconnect:', error) diff --git a/src/dal/index.ts b/src/dal/index.ts index e9c06103..b2bf842c 100644 --- a/src/dal/index.ts +++ b/src/dal/index.ts @@ -101,3 +101,13 @@ export { // Devices export { getAllDevices, getDevice, getPendingDevices, type Device } from './devices' + +// Integrations +export { + deleteIntegrationCredentials, + getIntegrationCredentials, + getIntegrationStatus, + saveIntegrationCredentials, + setIntegrationEnabled, + updateIntegrationCredentials, +} from './integrations' diff --git a/src/dal/integrations.ts b/src/dal/integrations.ts new file mode 100644 index 00000000..f052f33c --- /dev/null +++ b/src/dal/integrations.ts @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { eq } from 'drizzle-orm' +import type { AnyDrizzleDatabase } from '../db/database-interface' +import { integrationsSecretsTable } from '../db/tables' +import type { OAuthProvider } from '../lib/auth' + +type IntegrationCredentials = { + access_token: string + refresh_token?: string + expires_at?: number + profile?: { + email: string + name: string + picture?: string + } +} + +type IntegrationRow = { + credentials: IntegrationCredentials + enabled: boolean +} + +/** Get credentials and enabled flag for a provider. Returns null if no row exists. */ +export const getIntegrationCredentials = async ( + db: AnyDrizzleDatabase, + provider: OAuthProvider, +): Promise => { + const row = await db + .select() + .from(integrationsSecretsTable) + .where(eq(integrationsSecretsTable.provider, provider)) + .get() + + if (!row?.credentials) { + return null + } + + try { + return { + credentials: JSON.parse(row.credentials) as IntegrationCredentials, + enabled: row.enabled === 1, + } + } catch { + return null + } +} + +/** + * Save credentials for a provider (insert or update). + * Uses SELECT-then-INSERT-or-UPDATE because PowerSync local-only tables are views that don't support UPSERT. + */ +export const saveIntegrationCredentials = async ( + db: AnyDrizzleDatabase, + provider: OAuthProvider, + credentials: IntegrationCredentials, + enabled: boolean, +): Promise => { + const json = JSON.stringify(credentials) + const existing = await db + .select() + .from(integrationsSecretsTable) + .where(eq(integrationsSecretsTable.provider, provider)) + .get() + + if (existing) { + await db + .update(integrationsSecretsTable) + .set({ credentials: json, enabled: enabled ? 1 : 0 }) + .where(eq(integrationsSecretsTable.provider, provider)) + } else { + await db.insert(integrationsSecretsTable).values({ + provider, + credentials: json, + enabled: enabled ? 1 : 0, + }) + } +} + +/** + * Update credentials for a provider without changing the enabled flag. + * No-op if the provider has no existing row (only callable after a connect). + */ +export const updateIntegrationCredentials = async ( + db: AnyDrizzleDatabase, + provider: OAuthProvider, + credentials: IntegrationCredentials, +): Promise => { + await db + .update(integrationsSecretsTable) + .set({ credentials: JSON.stringify(credentials) }) + .where(eq(integrationsSecretsTable.provider, provider)) +} + +/** Toggle the enabled flag for a provider without changing credentials. */ +export const setIntegrationEnabled = async ( + db: AnyDrizzleDatabase, + provider: OAuthProvider, + enabled: boolean, +): Promise => { + await db + .update(integrationsSecretsTable) + .set({ enabled: enabled ? 1 : 0 }) + .where(eq(integrationsSecretsTable.provider, provider)) +} + +/** Delete credentials for a provider (disconnect). */ +export const deleteIntegrationCredentials = async (db: AnyDrizzleDatabase, provider: OAuthProvider): Promise => { + await db.delete(integrationsSecretsTable).where(eq(integrationsSecretsTable.provider, provider)) +} + +const parseEmail = (raw: string | null | undefined): string | null => { + if (!raw) { + return null + } + try { + return (JSON.parse(raw) as IntegrationCredentials).profile?.email ?? null + } catch { + return null + } +} + +/** Get connection/enabled status for all integration providers. */ +export const getIntegrationStatus = async ( + db: AnyDrizzleDatabase, +): Promise<{ + googleConnected: boolean + googleEnabled: boolean + googleEmail: string | null + microsoftConnected: boolean + microsoftEnabled: boolean + microsoftEmail: string | null +}> => { + const rows = await db.select().from(integrationsSecretsTable).all() + + const google = rows.find((r) => r.provider === 'google') + const microsoft = rows.find((r) => r.provider === 'microsoft') + + return { + googleConnected: !!google?.credentials, + googleEnabled: google?.enabled === 1, + googleEmail: parseEmail(google?.credentials), + microsoftConnected: !!microsoft?.credentials, + microsoftEnabled: microsoft?.enabled === 1, + microsoftEmail: parseEmail(microsoft?.credentials), + } +} diff --git a/src/db/powersync/schema.ts b/src/db/powersync/schema.ts index de780a27..c512b987 100644 --- a/src/db/powersync/schema.ts +++ b/src/db/powersync/schema.ts @@ -30,6 +30,10 @@ const localOnlyTables = { tableDefinition: tables.modelsSecretsTable, options: { localOnly: true }, } satisfies DrizzleTableWithPowerSyncOptions, + integrations_secrets: { + tableDefinition: tables.integrationsSecretsTable, + options: { localOnly: true }, + } satisfies DrizzleTableWithPowerSyncOptions, } /** diff --git a/src/db/tables.ts b/src/db/tables.ts index 9c73c78f..89c92fd4 100644 --- a/src/db/tables.ts +++ b/src/db/tables.ts @@ -113,6 +113,13 @@ export const modelsSecretsTable = sqliteTable('models_secrets', { apiKey: text('api_key'), }) +/** Local-only table for integration credentials (Google, Microsoft OAuth tokens). Never synced via PowerSync. */ +export const integrationsSecretsTable = sqliteTable('integrations_secrets', { + provider: text('id').primaryKey(), // 'google' | 'microsoft' + credentials: text('credentials'), // JSON blob (OAuth tokens) + enabled: integer('enabled').default(0), +}) + export const mcpServersTable = sqliteTable( 'mcp_servers', { diff --git a/src/hooks/use-deep-link-listener.test.ts b/src/hooks/use-deep-link-listener.test.ts index daf52960..f6c37062 100644 --- a/src/hooks/use-deep-link-listener.test.ts +++ b/src/hooks/use-deep-link-listener.test.ts @@ -7,7 +7,7 @@ import { createElement, type ReactNode } from 'react' import { BrowserRouter } from 'react-router' import { afterAll, beforeAll, describe, expect, it } from 'bun:test' import { setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import type { ReturnContext } from '@/lib/oauth-state' +import { setOAuthState, type ReturnContext } from '@/lib/oauth-state' import { getClock } from '@/testing-library' import { createQueryTestWrapper } from '@/test-utils/react-query' import { @@ -368,7 +368,6 @@ describe('useDeepLinkListener hook', () => { it('does not set up listeners when not running in Tauri', () => { const getCurrent = () => Promise.resolve(null) const onOpenUrl = () => Promise.resolve(() => {}) - const getSettings = () => Promise.resolve({ oauthReturnContext: null }) renderHook( () => @@ -376,7 +375,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => false, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -396,7 +394,6 @@ describe('useDeepLinkListener hook', () => { callback = cb return Promise.resolve(async () => {}) } - const getSettings = () => Promise.resolve({ oauthReturnContext: null }) const customHandler = async (urls: string[]) => { customHandlerCalled = true @@ -409,7 +406,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -439,7 +435,7 @@ describe('useDeepLinkListener hook', () => { callback = cb return Promise.resolve(async () => {}) } - const getSettings = () => Promise.resolve({ oauthReturnContext: '/chats/test' }) + setOAuthState({ returnContext: '/chats/test' }) const customHandler = async (_urls: string[]) => { customHandlerCalled = true @@ -451,7 +447,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -481,7 +476,6 @@ describe('useDeepLinkListener hook', () => { callback = cb return Promise.resolve(async () => {}) } - const getSettings = () => Promise.resolve({ oauthReturnContext: null }) const customHandler = async (_urls: string[]) => { customHandlerCalled = true @@ -493,7 +487,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -528,7 +521,6 @@ describe('useDeepLinkListener hook', () => { callback = cb return Promise.resolve(async () => {}) } - const getSettings = () => Promise.resolve({ oauthReturnContext: null }) const customHandler = async (urls: string[]) => { customHandlerCallCount++ @@ -541,7 +533,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -577,7 +568,7 @@ describe('useDeepLinkListener hook', () => { callback = cb return Promise.resolve(async () => {}) } - const getSettings = () => Promise.resolve({ oauthReturnContext: '/chats/test' }) + setOAuthState({ returnContext: '/chats/test' }) const customHandler = async (urls: string[]) => { receivedUrls = urls @@ -589,7 +580,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -614,7 +604,6 @@ describe('useDeepLinkListener hook', () => { const getCurrent = () => Promise.resolve(mockUrls) const onOpenUrl = () => Promise.resolve(() => {}) - const getSettings = () => Promise.resolve({ oauthReturnContext: null }) // Should not throw error renderHook( @@ -623,7 +612,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) @@ -645,7 +633,7 @@ describe('useDeepLinkListener hook', () => { storedCallback = callback return Promise.resolve(async () => {}) } - const getSettings = () => Promise.resolve({ oauthReturnContext: '/chats/active' }) + setOAuthState({ returnContext: '/chats/active' }) renderHook( () => @@ -653,7 +641,6 @@ describe('useDeepLinkListener hook', () => { isTauri: () => true, getCurrent, onOpenUrl, - getSettings, }), { wrapper }, ) diff --git a/src/hooks/use-deep-link-listener.ts b/src/hooks/use-deep-link-listener.ts index 76d023fa..6909e3a0 100644 --- a/src/hooks/use-deep-link-listener.ts +++ b/src/hooks/use-deep-link-listener.ts @@ -2,9 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useDatabase } from '@/contexts' -import { getSettings } from '@/dal' -import type { ReturnContext } from '@/lib/oauth-state' +import { getOAuthState, type ReturnContext } from '@/lib/oauth-state' import { isTauri } from '@/lib/platform' import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link' import { useEffect } from 'react' @@ -99,7 +97,6 @@ type DeepLinkDependencies = { isTauri?: typeof isTauri getCurrent?: typeof getCurrent onOpenUrl?: typeof onOpenUrl - getSettings?: typeof getSettings } /** @@ -110,7 +107,6 @@ type DeepLinkDependencies = { * @param dependencies Optional dependencies for testing (uses real implementations by default) */ export const useDeepLinkListener = (handler?: DeepLinkHandler, dependencies?: DeepLinkDependencies) => { - const db = useDatabase() const navigate = useNavigate() // Use injected dependencies or fall back to real implementations @@ -118,7 +114,6 @@ export const useDeepLinkListener = (handler?: DeepLinkHandler, dependencies?: De isTauri: checkIsTauri = isTauri, getCurrent: getCurrentUrls = getCurrent, onOpenUrl: listenToOpenUrl = onOpenUrl, - getSettings: getSettingsData = getSettings, } = dependencies || {} useEffect(() => { @@ -151,9 +146,9 @@ export const useDeepLinkListener = (handler?: DeepLinkHandler, dependencies?: De // Handle OAuth callback deep links const oauthData = parseOAuthCallback(url) if (oauthData) { - // Get the return context from SQLite settings (where mobile flow stores it) - const settings = await getSettingsData(db, { oauth_return_context: String }) - const target = determineNavigationTarget(settings.oauthReturnContext as ReturnContext | null, oauthData) + // Get the return context from oauth-state (where mobile flow stores it) + const oauthState = getOAuthState() + const target = determineNavigationTarget(oauthState.returnContext, oauthData) navigate(target.path, { state: { oauth: target.oauth }, diff --git a/src/hooks/use-handle-integration-completion.test.ts b/src/hooks/use-handle-integration-completion.test.ts index 04919f38..d8d87391 100644 --- a/src/hooks/use-handle-integration-completion.test.ts +++ b/src/hooks/use-handle-integration-completion.test.ts @@ -13,7 +13,7 @@ import { getDb } from '@/db/database' import { chatThreadsTable } from '@/db/tables' import { v7 as uuidv7 } from 'uuid' import { saveMessagesWithContextUpdate, getMessage } from '@/dal/chat-messages' -import { updateSettings } from '@/dal/settings' +import { saveIntegrationCredentials } from '@/dal' import type { ThunderboltUIMessage } from '@/types' import { getClock } from '@/testing-library' @@ -122,10 +122,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) + // No integration credentials — local-only table is empty by default renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper(), @@ -149,10 +146,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) + // No integration credentials — local-only table is empty by default const { unmount } = renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper(), @@ -178,10 +172,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) + // No integration credentials — local-only table is empty by default renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper(), @@ -208,10 +199,7 @@ describe('useHandleIntegrationCompletion', () => { // Use the real store and hydrate it with test data (id is null - no session created) resetStore() - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) + // No integration credentials — local-only table is empty by default renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper(), @@ -270,10 +258,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: '', - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper({ @@ -359,10 +344,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: '', - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper({ @@ -437,10 +419,7 @@ describe('useHandleIntegrationCompletion', () => { }) // Start with no credentials - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) + // No integration credentials — local-only table is empty by default renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper({ @@ -469,7 +448,7 @@ describe('useHandleIntegrationCompletion', () => { expect(mockSaveMessages).not.toHaveBeenCalled() // Now add credentials to simulate connection - await updateSettings(getDb(), { integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }) }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) await act(async () => { await getClock().runAllAsync() @@ -498,10 +477,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: '', - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) const originalWarn = console.warn const consoleWarnSpy = mock(() => {}) @@ -565,10 +541,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: '', - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) const originalWarn = console.warn const consoleWarnSpy = mock(() => {}) @@ -645,10 +618,7 @@ describe('useHandleIntegrationCompletion', () => { triggerData: null, }) - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: '', - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) renderHook(() => useHandleIntegrationCompletion({ saveMessages: mockSaveMessages }), { wrapper: createQueryTestWrapper({ diff --git a/src/hooks/use-integration-status.test.ts b/src/hooks/use-integration-status.test.ts index 8e299d65..86b2f2b7 100644 --- a/src/hooks/use-integration-status.test.ts +++ b/src/hooks/use-integration-status.test.ts @@ -6,9 +6,9 @@ import { act, renderHook } from '@testing-library/react' import { afterAll, beforeAll, afterEach, describe, expect, it } from 'bun:test' import { setupTestDatabase, teardownTestDatabase, resetTestDatabase } from '@/dal/test-utils' import { getDb } from '@/db/database' +import { saveIntegrationCredentials } from '@/dal' import { createQueryTestWrapper } from '@/test-utils/react-query' import { useIntegrationStatus } from './use-integration-status' -import { updateSettings } from '@/dal/settings' import { getClock } from '@/testing-library' describe('useIntegrationStatus', () => { @@ -37,39 +37,7 @@ describe('useIntegrationStatus', () => { }) describe('No providers connected', () => { - it('should return both providers as not connected when credentials are empty', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) - - const { result } = renderHook(() => useIntegrationStatus(), { - wrapper: createQueryTestWrapper(), - }) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(result.current.isLoading).toBe(false) - - expect(result.current.data).toEqual({ - googleConnected: false, - microsoftConnected: false, - availableProviders: { - google: false, - microsoft: false, - }, - }) - expect(result.current.error).toBeNull() - }) - - it('should return both providers as not connected when credentials are null', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: null, - integrations_microsoft_credentials: null, - }) - + it('should return both providers as not connected when no credentials exist', async () => { const { result } = renderHook(() => useIntegrationStatus(), { wrapper: createQueryTestWrapper(), }) @@ -79,10 +47,13 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: false, + googleEnabled: false, + googleEmail: null, microsoftConnected: false, + microsoftEnabled: false, + microsoftEmail: null, availableProviders: { google: false, microsoft: false, @@ -94,10 +65,7 @@ describe('useIntegrationStatus', () => { describe('Google provider connected', () => { it('should return Google as connected when credentials exist', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: '', - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'test_token' }, true) const { result } = renderHook(() => useIntegrationStatus(), { wrapper: createQueryTestWrapper(), @@ -108,25 +76,24 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: true, + googleEnabled: true, + googleEmail: null, microsoftConnected: false, + microsoftEnabled: false, + microsoftEmail: null, availableProviders: { google: true, microsoft: false, }, }) - expect(result.current.error).toBeNull() }) }) describe('Microsoft provider connected', () => { it('should return Microsoft as connected when credentials exist', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: JSON.stringify({ access_token: 'test_token' }), - }) + await saveIntegrationCredentials(getDb(), 'microsoft', { access_token: 'test_token' }, true) const { result } = renderHook(() => useIntegrationStatus(), { wrapper: createQueryTestWrapper(), @@ -137,25 +104,25 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: false, + googleEnabled: false, + googleEmail: null, microsoftConnected: true, + microsoftEnabled: true, + microsoftEmail: null, availableProviders: { google: false, microsoft: true, }, }) - expect(result.current.error).toBeNull() }) }) describe('Both providers connected', () => { it('should return both providers as connected when both credentials exist', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'google_token' }), - integrations_microsoft_credentials: JSON.stringify({ access_token: 'microsoft_token' }), - }) + await saveIntegrationCredentials(getDb(), 'google', { access_token: 'google_token' }, true) + await saveIntegrationCredentials(getDb(), 'microsoft', { access_token: 'microsoft_token' }, true) const { result } = renderHook(() => useIntegrationStatus(), { wrapper: createQueryTestWrapper(), @@ -166,78 +133,35 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: true, + googleEnabled: true, + googleEmail: null, microsoftConnected: true, + microsoftEnabled: true, + microsoftEmail: null, availableProviders: { google: true, microsoft: true, }, }) - expect(result.current.error).toBeNull() }) }) - describe('Edge cases', () => { - it('should treat empty string as not connected', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) - - const { result } = renderHook(() => useIntegrationStatus(), { - wrapper: createQueryTestWrapper(), - }) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(result.current.isLoading).toBe(false) - - expect(result.current.data?.googleConnected).toBe(false) - expect(result.current.data?.microsoftConnected).toBe(false) - }) - - it('should correctly identify connection status for different credential states', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) - - const { result: result1 } = renderHook(() => useIntegrationStatus(), { - wrapper: createQueryTestWrapper(), - }) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(result1.current.isLoading).toBe(false) - expect(result1.current.data?.googleConnected).toBe(false) - expect(result1.current.data?.microsoftConnected).toBe(false) - - await updateSettings(getDb(), { integrations_google_credentials: JSON.stringify({ access_token: 'new_token' }) }) - - const { result: result2 } = renderHook(() => useIntegrationStatus(), { - wrapper: createQueryTestWrapper(), - }) - - await act(async () => { - await getClock().runAllAsync() - }) - - expect(result2.current.isLoading).toBe(false) - expect(result2.current.data?.googleConnected).toBe(true) - expect(result2.current.data?.microsoftConnected).toBe(false) - }) - - it('should return data structure with correct types', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: JSON.stringify({ access_token: 'test_token' }), - integrations_microsoft_credentials: JSON.stringify({ access_token: 'test_token' }), - }) + describe('Email surfacing', () => { + it('should surface profile.email from stored credentials', async () => { + await saveIntegrationCredentials( + getDb(), + 'google', + { access_token: 'g', profile: { email: 'user@example.com', name: 'User' } }, + true, + ) + await saveIntegrationCredentials( + getDb(), + 'microsoft', + { access_token: 'm', profile: { email: 'user@outlook.com', name: 'User' } }, + true, + ) const { result } = renderHook(() => useIntegrationStatus(), { wrapper: createQueryTestWrapper(), @@ -247,13 +171,8 @@ describe('useIntegrationStatus', () => { await getClock().runAllAsync() }) - expect(result.current.isLoading).toBe(false) - - expect(result.current.data).toBeDefined() - expect(typeof result.current.data?.googleConnected).toBe('boolean') - expect(typeof result.current.data?.microsoftConnected).toBe('boolean') - expect(typeof result.current.data?.availableProviders.google).toBe('boolean') - expect(typeof result.current.data?.availableProviders.microsoft).toBe('boolean') + expect(result.current.data?.googleEmail).toBe('user@example.com') + expect(result.current.data?.microsoftEmail).toBe('user@outlook.com') }) }) @@ -268,11 +187,6 @@ describe('useIntegrationStatus', () => { }) it('should handle query completion successfully', async () => { - await updateSettings(getDb(), { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) - const { result } = renderHook(() => useIntegrationStatus(), { wrapper: createQueryTestWrapper({ defaultOptions: { @@ -288,7 +202,6 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toBeDefined() expect(result.current.error).toBeNull() }) diff --git a/src/hooks/use-integration-status.ts b/src/hooks/use-integration-status.ts index eed8cb9f..f14eb3ef 100644 --- a/src/hooks/use-integration-status.ts +++ b/src/hooks/use-integration-status.ts @@ -3,12 +3,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useDatabase } from '@/contexts' -import { getSettings } from '@/dal' +import { getIntegrationStatus } from '@/dal' import { useQuery } from '@tanstack/react-query' export type IntegrationStatus = { googleConnected: boolean + googleEnabled: boolean + googleEmail: string | null microsoftConnected: boolean + microsoftEnabled: boolean + microsoftEmail: string | null availableProviders: { google: boolean microsoft: boolean @@ -25,20 +29,13 @@ export const useIntegrationStatus = (): { const query = useQuery({ queryKey: ['integrationStatus'], queryFn: async (): Promise => { - const { integrationsGoogleCredentials, integrationsMicrosoftCredentials } = await getSettings(db, { - integrations_google_credentials: '', - integrations_microsoft_credentials: '', - }) - - const googleConnected = !!integrationsGoogleCredentials && integrationsGoogleCredentials !== '' - const microsoftConnected = !!integrationsMicrosoftCredentials && integrationsMicrosoftCredentials !== '' + const status = await getIntegrationStatus(db) return { - googleConnected, - microsoftConnected, + ...status, availableProviders: { - google: googleConnected, - microsoft: microsoftConnected, + google: status.googleConnected, + microsoft: status.microsoftConnected, }, } }, diff --git a/src/hooks/use-oauth-connect.test.tsx b/src/hooks/use-oauth-connect.test.tsx index 9806a0bd..3bfb8357 100644 --- a/src/hooks/use-oauth-connect.test.tsx +++ b/src/hooks/use-oauth-connect.test.tsx @@ -2,9 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { getSettings, updateSettings } from '@/dal' +import { getOAuthState, setOAuthState } from '@/lib/oauth-state' import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' -import { getDb } from '@/db/database' import { cleanupSessionStorage, mockOAuthCallbackData, mockOAuthErrorCallbackData } from '@/test-utils/oauth' import { createQueryTestWrapper } from '@/test-utils/react-query' import { act, renderHook } from '@testing-library/react' @@ -19,10 +18,10 @@ const createMockDependencies = (): OAuthDependencies => ({ }, redirectOAuthFlow: async (_httpClient, provider) => { // Simulate what the real redirectOAuthFlow does before redirecting - await updateSettings(getDb(), { - oauth_state: 'mock_state_12345', - oauth_provider: provider, - oauth_verifier: 'mock_verifier_67890', + setOAuthState({ + state: 'mock_state_12345', + provider, + verifier: 'mock_verifier_67890', }) // Throw to simulate the redirect throw new Error('Redirecting for OAuth') @@ -175,20 +174,20 @@ describe('useOAuthConnect', () => { } }) - // Verify return context was stored in sqlite - const settings = await getSettings(getDb(), { oauth_return_context: String }) - expect(settings.oauthReturnContext).toBe('onboarding') + // Verify return context was stored in sessionStorage + const oauthState = getOAuthState() + expect(oauthState.returnContext).toBe('onboarding') }) }) describe('processCallback', () => { it('should handle successful OAuth callback', async () => { const callbackData = mockOAuthCallbackData() - // Setup sqlite settings - await updateSettings(getDb(), { - oauth_state: callbackData.state!, - oauth_provider: 'google', - oauth_verifier: 'mock_verifier_67890', + // Setup OAuth state in sessionStorage + setOAuthState({ + state: callbackData.state!, + provider: 'google', + verifier: 'mock_verifier_67890', }) const onSuccess = mock() @@ -218,11 +217,11 @@ describe('useOAuthConnect', () => { it('should handle state mismatch', async () => { const callbackData = mockOAuthCallbackData() - // Setup sqlite settings with mismatched state - await updateSettings(getDb(), { - oauth_state: 'different_state', - oauth_provider: 'google', - oauth_verifier: 'mock_verifier_67890', + // Setup sessionStorage with mismatched state + setOAuthState({ + state: 'different_state', + provider: 'google', + verifier: 'mock_verifier_67890', }) const onSuccess = mock() @@ -319,9 +318,9 @@ describe('useOAuthConnect', () => { } }) - // Verify return context was stored in sqlite - const settings = await getSettings(getDb(), { oauth_return_context: String }) - expect(settings.oauthReturnContext).toBe('onboarding') + // Verify return context was stored in sessionStorage + const oauthState = getOAuthState() + expect(oauthState.returnContext).toBe('onboarding') }) }) diff --git a/src/hooks/use-oauth-connect.ts b/src/hooks/use-oauth-connect.ts index 15d59b6e..5d57fca4 100644 --- a/src/hooks/use-oauth-connect.ts +++ b/src/hooks/use-oauth-connect.ts @@ -3,13 +3,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useDatabase, useHttpClient } from '@/contexts' -import { deleteSetting, getSettings, updateSettings } from '@/dal' +import { saveIntegrationCredentials, updateSettings } from '@/dal' import { buildAuthUrl, exchangeCodeForTokens, getUserInfo, redirectOAuthFlow, type OAuthProvider } from '@/lib/auth' import { startOAuthFlowLoopback } from '@/lib/oauth-loopback' +import { clearOAuthState, getOAuthState, setOAuthState } from '@/lib/oauth-state' import { generateCodeChallenge, generateCodeVerifier } from '@/lib/pkce' import type { ReturnContext } from '@/lib/oauth-state' import { isMobile, isTauri } from '@/lib/platform' import { openUrl } from '@tauri-apps/plugin-opener' +import { useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { v4 as uuidv4 } from 'uuid' @@ -92,6 +94,7 @@ const getInitialConnectingState = (key: string | undefined): boolean => { export const useOAuthConnect = (options: UseOAuthConnectOptions = {}): UseOAuthConnectResult => { const db = useDatabase() const httpClient = useHttpClient() + const queryClient = useQueryClient() const { connectingKey, onSuccess, @@ -147,10 +150,8 @@ export const useOAuthConnect = (options: UseOAuthConnectOptions = {}): UseOAuthC }, } - await updateSettings(db, { - [`integrations_${provider}_credentials`]: JSON.stringify(credentials), - [`integrations_${provider}_is_enabled`]: 'true', - }) + await saveIntegrationCredentials(db, provider, credentials, true) + await queryClient.invalidateQueries({ queryKey: ['integrationStatus'] }) if (setPreferredName && userInfo.name) { await updateSettings(db, { preferred_name: userInfo.name }) @@ -204,11 +205,11 @@ export const useOAuthConnect = (options: UseOAuthConnectOptions = {}): UseOAuthC const codeChallenge = await generateCodeChallenge(codeVerifier) // Store OAuth state for callback validation - await updateSettings(db, { - oauth_state: state, - oauth_provider: provider, - oauth_verifier: codeVerifier, - oauth_return_context: returnContext, + setOAuthState({ + state, + provider, + verifier: codeVerifier, + returnContext, }) const authUrl = await buildAuthUrl(httpClient, provider, state, codeChallenge) @@ -240,7 +241,7 @@ export const useOAuthConnect = (options: UseOAuthConnectOptions = {}): UseOAuthC } } else { // For web: Use redirect flow - await updateSettings(db, { oauth_return_context: returnContext }) + setOAuthState({ returnContext }) await redirect(httpClient, provider) } } catch (e: unknown) { @@ -265,16 +266,11 @@ export const useOAuthConnect = (options: UseOAuthConnectOptions = {}): UseOAuthC const { code, state: returnedState, error: oauthError } = callbackData - // Get OAuth state from sqlite settings (needed for both success and error cleanup) - const settings = await getSettings(db, { - oauth_state: String, - oauth_provider: String, - oauth_verifier: String, - }) - - const storedState = settings.oauthState - const provider = settings.oauthProvider as OAuthProvider | null - const codeVerifier = settings.oauthVerifier + // Get OAuth state from oauth-state (needed for both success and error cleanup) + const oauthState = getOAuthState() + const storedState = oauthState.state + const provider = oauthState.provider + const codeVerifier = oauthState.verifier // Helper to cleanup connecting state - uses connectingKey if available, otherwise provider const cleanup = () => { @@ -311,13 +307,7 @@ export const useOAuthConnect = (options: UseOAuthConnectOptions = {}): UseOAuthC await saveCredentials(provider, tokens, userInfo) - // Cleanup OAuth state from sqlite - await Promise.all([ - deleteSetting(db, 'oauth_state'), - deleteSetting(db, 'oauth_provider'), - deleteSetting(db, 'oauth_verifier'), - deleteSetting(db, 'oauth_return_context'), - ]) + clearOAuthState() cleanup() onSuccess?.() diff --git a/src/hooks/use-onboarding-state.ts b/src/hooks/use-onboarding-state.ts index b5a1bfb5..516e3eb6 100644 --- a/src/hooks/use-onboarding-state.ts +++ b/src/hooks/use-onboarding-state.ts @@ -5,6 +5,7 @@ import { extractCountryFromLocation } from '@/lib/country-utils' import { useEffect, useReducer } from 'react' import { useCountryUnits } from './use-country-units' +import { useIntegrationStatus } from './use-integration-status' import { useSettings } from './use-settings' type OnboardingStep = 1 | 2 | 3 | 4 | 5 @@ -211,7 +212,6 @@ export const useOnboardingState = () => { dateFormat, timeFormat, currency, - integrationsGoogleIsEnabled, } = useSettings({ preferred_name: '', location_name: '', @@ -222,8 +222,8 @@ export const useOnboardingState = () => { date_format: 'MM/DD/YYYY', time_format: '12h', currency: 'USD', - integrations_google_is_enabled: false, }) + const { data: integrationStatusData } = useIntegrationStatus() const { fetchCountryUnits } = useCountryUnits() @@ -243,11 +243,7 @@ export const useOnboardingState = () => { } }, [preferredName.value, preferredName.isLoading]) - useEffect(() => { - if (integrationsGoogleIsEnabled.value && !integrationsGoogleIsEnabled.isLoading) { - dispatch({ type: 'SET_PROVIDER_CONNECTED', payload: true }) - } - }, [integrationsGoogleIsEnabled.value, integrationsGoogleIsEnabled.isLoading]) + const isProviderConnected = state.isProviderConnected || (integrationStatusData?.googleConnected ?? false) const actions = { setCurrentStep: (step: OnboardingStep) => dispatch({ type: 'SET_CURRENT_STEP', payload: step }), @@ -336,7 +332,7 @@ export const useOnboardingState = () => { }, } - return { state, actions } + return { state: { ...state, isProviderConnected }, actions } } export type { OnboardingAction, OnboardingState } diff --git a/src/integrations/google/tools.ts b/src/integrations/google/tools.ts index f2a15a84..41c4f7f2 100644 --- a/src/integrations/google/tools.ts +++ b/src/integrations/google/tools.ts @@ -6,15 +6,8 @@ import { llmContentCharLimit, truncateText } from '@/lib/utils' import type { ToolConfig } from '@/types' import { http, type HttpClient } from '@/lib/http' import { z } from 'zod' -import { - buildRawMessage, - ensureValidGoogleToken, - extractBody, - getGoogleCredentials, - getHeader, - parseEmailAddress, - transformDriveQuery, -} from './utils' +import { buildRawMessage, extractBody, getHeader, parseEmailAddress, transformDriveQuery } from './utils' +import { ensureValidOAuthToken, getOAuthCredentials, type OAuthCredentials } from '@/integrations/oauth-credentials' // ============================================================================= // DEPENDENCY INJECTION @@ -22,13 +15,13 @@ import { /** Inject-able auth dependencies for testing (DB + HTTP side effects) */ export type GoogleAuthDeps = { - getCredentials: typeof getGoogleCredentials - ensureToken: typeof ensureValidGoogleToken + getCredentials: () => Promise + ensureToken: (httpClient: HttpClient, credentials: OAuthCredentials) => Promise } const defaultAuthDeps: GoogleAuthDeps = { - getCredentials: getGoogleCredentials, - ensureToken: ensureValidGoogleToken, + getCredentials: () => getOAuthCredentials('google'), + ensureToken: (httpClient, credentials) => ensureValidOAuthToken(httpClient, 'google', credentials), } // ============================================================================= diff --git a/src/integrations/google/utils.ts b/src/integrations/google/utils.ts index 35b4e828..8c2a0284 100644 --- a/src/integrations/google/utils.ts +++ b/src/integrations/google/utils.ts @@ -2,10 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { refreshAccessToken } from '@/lib/auth' -import type { HttpClient } from '@/lib/http' -import { getSettings, updateSettings } from '@/dal' -import { getDb } from '@/db/database' import type { DraftEmailParams } from './tools' // ============================================================================= @@ -123,69 +119,6 @@ export const buildRawMessage = (params: DraftEmailParams): string => { .replace(/\//g, '_') } -// ============================================================================= -// AUTH UTILITY FUNCTIONS -// ============================================================================= - -/** - * Retrieve stored Google OAuth credentials from settings. - * Throws if the integration has not been connected yet or the stored value is malformed. - */ -export const getGoogleCredentials = async (): Promise<{ - access_token: string - refresh_token?: string - expires_at?: number -}> => { - const db = getDb() - const settings = await getSettings(db, { integrations_google_credentials: String }) - const credentialsStr = settings.integrationsGoogleCredentials - if (!credentialsStr) { - throw new Error('Google integration not connected') - } - - try { - return JSON.parse(credentialsStr) - } catch { - throw new Error('Invalid Google credentials') - } -} - -/** - * Ensure that we have a valid Google OAuth access token, refreshing it if necessary. - * If the token is refreshed, the stored credentials are updated automatically. - */ -export const ensureValidGoogleToken = async ( - httpClient: HttpClient, - credentials: { - access_token: string - refresh_token?: string - expires_at?: number - }, -): Promise => { - const now = Date.now() - // If the token is still valid for at least 1 minute, reuse it - if (credentials.expires_at && credentials.expires_at - 60_000 > now) { - return credentials.access_token - } - - if (!credentials.refresh_token) { - throw new Error('Access token expired and no refresh token available') - } - - const newTokens = await refreshAccessToken(httpClient, 'google', credentials.refresh_token) - - const updated = { - ...credentials, - access_token: newTokens.access_token, - expires_at: Date.now() + newTokens.expires_in * 1000, - } - - const db = getDb() - await updateSettings(db, { integrations_google_credentials: JSON.stringify(updated) }) - - return updated.access_token -} - // ============================================================================= // DRIVE UTILITY FUNCTIONS // ============================================================================= diff --git a/src/integrations/microsoft/tools.ts b/src/integrations/microsoft/tools.ts index d407197a..7b9f95da 100644 --- a/src/integrations/microsoft/tools.ts +++ b/src/integrations/microsoft/tools.ts @@ -4,8 +4,7 @@ // New file with Microsoft Graph tools -import { getSettings, updateSettings } from '@/dal' -import { getDb } from '@/db/database' +import { ensureValidOAuthToken, getOAuthCredentials, type OAuthCredentials } from '@/integrations/oauth-credentials' import { llmContentCharLimit } from '@/lib/utils' import type { ToolConfig } from '@/types' import { http, type HttpClient } from '@/lib/http' @@ -146,55 +145,10 @@ const getOneDriveFileCategory = (mime: string): OneDriveFileContent['file_catego // Internal helpers // --------------------------------------------------------------------------- -const getMicrosoftCredentials = async () => { - const db = getDb() - const settings = await getSettings(db, { integrations_microsoft_credentials: String }) - const credentialsStr = settings.integrationsMicrosoftCredentials - if (!credentialsStr) { - throw new Error('Microsoft integration not connected') - } - - try { - return JSON.parse(credentialsStr) - } catch { - throw new Error('Invalid Microsoft credentials') - } -} +const getMicrosoftCredentials = (): Promise => getOAuthCredentials('microsoft') -/** - * Check whether a token is still valid with a 60-second safety buffer. - * Returns true when the token can be reused without refreshing. - */ -export const isTokenFresh = (expiresAt: number | undefined, now: number): boolean => - expiresAt !== undefined && expiresAt - 60_000 > now - -/** Refresh access token if needed */ -const ensureValidToken = async ( - httpClient: HttpClient, - credentials: { access_token: string; refresh_token: string; expires_at?: number }, -) => { - const now = Date.now() - if (isTokenFresh(credentials.expires_at, now)) { - return credentials.access_token - } - - if (!credentials.refresh_token) { - throw new Error('Access token expired and no refresh token available') - } - - const { refreshAccessToken } = await import('@/lib/auth') - const newTokens = await refreshAccessToken(httpClient, 'microsoft', credentials.refresh_token) - const updated = { - ...credentials, - access_token: newTokens.access_token, - expires_at: Date.now() + newTokens.expires_in * 1000, - } - - const db = getDb() - await updateSettings(db, { integrations_microsoft_credentials: JSON.stringify(updated) }) - - return newTokens.access_token -} +const ensureValidToken = (httpClient: HttpClient, credentials: OAuthCredentials): Promise => + ensureValidOAuthToken(httpClient, 'microsoft', credentials) // --------------------------------------------------------------------------- // Public API diff --git a/src/integrations/microsoft/tools.test.ts b/src/integrations/oauth-credentials.test.ts similarity index 96% rename from src/integrations/microsoft/tools.test.ts rename to src/integrations/oauth-credentials.test.ts index 076b35fc..751f9ce9 100644 --- a/src/integrations/microsoft/tools.test.ts +++ b/src/integrations/oauth-credentials.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'bun:test' -import { isTokenFresh } from './tools' +import { isTokenFresh } from './oauth-credentials' describe('isTokenFresh', () => { const now = 1_700_000_000_000 diff --git a/src/integrations/oauth-credentials.ts b/src/integrations/oauth-credentials.ts new file mode 100644 index 00000000..87a20ece --- /dev/null +++ b/src/integrations/oauth-credentials.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getIntegrationCredentials, updateIntegrationCredentials } from '@/dal' +import { getDb } from '@/db/database' +import { refreshAccessToken, type OAuthProvider } from '@/lib/auth' +import type { HttpClient } from '@/lib/http' + +export type OAuthCredentials = { + access_token: string + refresh_token?: string + expires_at?: number +} + +/** + * Retrieve stored OAuth credentials for the given provider. + * Throws if the integration has not been connected yet. + */ +export const getOAuthCredentials = async (provider: OAuthProvider): Promise => { + const db = getDb() + const row = await getIntegrationCredentials(db, provider) + if (!row) { + throw new Error(`${provider} integration not connected`) + } + return row.credentials +} + +/** + * Check whether a token is still valid with a 60-second safety buffer. + * Returns true when the token can be reused without refreshing. + */ +export const isTokenFresh = (expiresAt: number | undefined, now: number = Date.now()): boolean => + expiresAt !== undefined && expiresAt - 60_000 > now + +/** + * Ensure that we have a valid OAuth access token, refreshing it if necessary. + * If refreshed, the stored credentials are updated automatically. + */ +export const ensureValidOAuthToken = async ( + httpClient: HttpClient, + provider: OAuthProvider, + credentials: OAuthCredentials, +): Promise => { + if (isTokenFresh(credentials.expires_at)) { + return credentials.access_token + } + + if (!credentials.refresh_token) { + throw new Error('Access token expired and no refresh token available') + } + + const newTokens = await refreshAccessToken(httpClient, provider, credentials.refresh_token) + const updated: OAuthCredentials = { + ...credentials, + access_token: newTokens.access_token, + expires_at: Date.now() + newTokens.expires_in * 1000, + } + + const db = getDb() + await updateIntegrationCredentials(db, provider, updated) + + return updated.access_token +} diff --git a/src/lib/oauth-state.ts b/src/lib/oauth-state.ts index 70f6b7be..c1a074e3 100644 --- a/src/lib/oauth-state.ts +++ b/src/lib/oauth-state.ts @@ -2,14 +2,21 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { getSettings, updateSettings, deleteSetting } from '@/dal' -import { getDb } from '@/db/database' import type { OAuthProvider } from './auth' export type ReturnContext = 'onboarding' | 'integrations' | `/${string}` +const storageKey = 'oauth_flow_state' + /** - * OAuth state stored in sqlite settings + * OAuth state stored in localStorage (device-local, never synced). + * + * localStorage (not sessionStorage) because on Tauri mobile the OS may + * terminate the app while the user is in the system browser completing + * OAuth — sessionStorage would be wiped on relaunch, breaking the + * deep-link callback validation. The IdP enforces code expiry server-side, + * so no client-side TTL is needed; the next flow's setOAuthState + * overwrites any abandoned entry. */ type OAuthState = { state: string | null @@ -18,60 +25,34 @@ type OAuthState = { returnContext: ReturnContext | null } -/** - * Gets all OAuth state from sqlite settings - */ -export const getOAuthState = async (): Promise => { - const db = getDb() - const settings = await getSettings(db, { - oauth_state: String, - oauth_provider: String, - oauth_verifier: String, - oauth_return_context: String, - }) - - return { - state: settings.oauthState, - provider: settings.oauthProvider as OAuthProvider | null, - verifier: settings.oauthVerifier, - returnContext: settings.oauthReturnContext as ReturnContext | null, - } -} - -/** - * Sets OAuth state in sqlite settings - */ -export const setOAuthState = async (state: Partial): Promise => { - const settings: Record = {} - - if (state.state !== undefined) { - settings.oauth_state = state.state - } - if (state.provider !== undefined) { - settings.oauth_provider = state.provider - } - if (state.verifier !== undefined) { - settings.oauth_verifier = state.verifier +const emptyState = (): OAuthState => ({ + state: null, + provider: null, + verifier: null, + returnContext: null, +}) + +/** Gets all OAuth flow state from localStorage. */ +export const getOAuthState = (): OAuthState => { + const raw = localStorage.getItem(storageKey) + if (!raw) { + return emptyState() } - if (state.returnContext !== undefined) { - settings.oauth_return_context = state.returnContext + try { + return JSON.parse(raw) as OAuthState + } catch { + return emptyState() } +} - if (Object.keys(settings).length > 0) { - const db = getDb() - await updateSettings(db, settings) - } +/** Sets OAuth flow state in localStorage (merges with existing). */ +export const setOAuthState = (update: Partial): void => { + const current = getOAuthState() + const merged = { ...current, ...update } + localStorage.setItem(storageKey, JSON.stringify(merged)) } -/** - * Clears OAuth state from sqlite settings - */ -export const clearOAuthState = async (): Promise => { - const db = getDb() - await Promise.all([ - deleteSetting(db, 'oauth_state'), - deleteSetting(db, 'oauth_provider'), - deleteSetting(db, 'oauth_verifier'), - deleteSetting(db, 'oauth_return_context'), - ]) +/** Clears all OAuth flow state from localStorage. */ +export const clearOAuthState = (): void => { + localStorage.removeItem(storageKey) } diff --git a/src/lib/tools.ts b/src/lib/tools.ts index be0a9e49..142e6a97 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { HttpClient } from '@/contexts' -import { getSettings } from '@/dal' +import { getIntegrationStatus, getSettings } from '@/dal' import { getDb } from '@/db/database' import * as tasksTools from '@/extensions/tasks/tools' import { createConfigs as createGoogleConfigs } from '@/integrations/google/tools' @@ -21,17 +21,11 @@ export const getAvailableTools = async ( // Check Thunderbolt Pro access and integration enabled state const db = getDb() const proEnabled = await hasProAccess() - const { - experimentalFeatureTasks, - integrationsProIsEnabled, - integrationsGoogleIsEnabled, - integrationsMicrosoftIsEnabled, - } = await getSettings(db, { + const { experimentalFeatureTasks, integrationsProIsEnabled } = await getSettings(db, { experimental_feature_tasks: false, integrations_pro_is_enabled: false, - integrations_google_is_enabled: false, - integrations_microsoft_is_enabled: false, }) + const integrationStatus = await getIntegrationStatus(db) const baseTools: ToolConfig[] = experimentalFeatureTasks ? [...Object.values(tasksTools)] : [] @@ -41,11 +35,11 @@ export const getAvailableTools = async ( baseTools.push(...createProConfigs(httpClient, sourceCollector)) } - if (integrationsGoogleIsEnabled) { + if (integrationStatus.googleEnabled) { baseTools.push(...createGoogleConfigs(httpClient)) } - if (integrationsMicrosoftIsEnabled) { + if (integrationStatus.microsoftEnabled) { baseTools.push(...createMicrosoftConfigs(httpClient)) } diff --git a/src/settings/integrations.tsx b/src/settings/integrations.tsx index dc70b294..3ba2046f 100644 --- a/src/settings/integrations.tsx +++ b/src/settings/integrations.tsx @@ -16,10 +16,11 @@ import { configs as proToolConfigs } from '@/integrations/thunderbolt-pro/tools' import { getProStatus } from '@/integrations/thunderbolt-pro/utils' import { type OAuthProvider } from '@/lib/auth' import { useDatabase } from '@/contexts' -import { updateSettings } from '@/dal' +import { deleteIntegrationCredentials, setIntegrationEnabled, updateSettings } from '@/dal' +import { useIntegrationStatus } from '@/hooks/use-integration-status' import { useOAuthConnect } from '@/hooks/use-oauth-connect' import { useSettings } from '@/hooks/use-settings' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useLocation, useNavigate } from 'react-router' @@ -32,15 +33,6 @@ type Integration = { isEnabled: boolean isConnected: boolean userEmail?: string - credentials?: { - access_token: string - refresh_token: string - expires_at: number - profile?: { - email?: string - name?: string - } - } } const ThunderboltProIcon = () => ( @@ -49,18 +41,6 @@ const ThunderboltProIcon = () => ( ) -const parseCredentials = (credentialsJson: string): Integration['credentials'] | undefined => { - if (!credentialsJson) { - return undefined - } - try { - return JSON.parse(credentialsJson) as Integration['credentials'] - } catch (e) { - console.error('Failed to parse credentials:', e) - return undefined - } -} - export default function IntegrationsPage() { const db = useDatabase() const location = useLocation() @@ -72,13 +52,11 @@ export default function IntegrationsPage() { return !!oauth }) + const queryClient = useQueryClient() const integrationSettings = useSettings({ integrations_pro_is_enabled: false, - integrations_google_is_enabled: false, - integrations_google_credentials: '', - integrations_microsoft_is_enabled: false, - integrations_microsoft_credentials: '', }) + const { data: integrationStatusData } = useIntegrationStatus() const { data: proStatus, isLoading: proStatusLoading } = useQuery({ queryKey: ['proStatus'], @@ -87,13 +65,6 @@ export default function IntegrationsPage() { const integrations = useMemo((): Integration[] => { const proEnabled = integrationSettings.integrationsProIsEnabled.value - const googleEnabled = integrationSettings.integrationsGoogleIsEnabled.value - const googleCredentials = integrationSettings.integrationsGoogleCredentials.value ?? '' - const microsoftEnabled = integrationSettings.integrationsMicrosoftIsEnabled.value - const microsoftCredentials = integrationSettings.integrationsMicrosoftCredentials.value ?? '' - - const gParsed = parseCredentials(googleCredentials) - const mParsed = parseCredentials(microsoftCredentials) const isProUser = proStatus?.isProUser ?? false return [ @@ -113,10 +84,9 @@ export default function IntegrationsPage() { provider: 'google', connectLabel: 'Connect Google', icon: , - isEnabled: googleEnabled, - isConnected: !!gParsed, - userEmail: gParsed?.profile?.email, - credentials: gParsed, + isEnabled: integrationStatusData?.googleEnabled ?? false, + isConnected: integrationStatusData?.googleConnected ?? false, + userEmail: integrationStatusData?.googleEmail ?? undefined, }, { id: 'microsoft', @@ -124,20 +94,12 @@ export default function IntegrationsPage() { provider: 'microsoft', connectLabel: 'Connect Microsoft', icon: , - isEnabled: microsoftEnabled, - isConnected: !!mParsed, - userEmail: mParsed?.profile?.email, - credentials: mParsed, + isEnabled: integrationStatusData?.microsoftEnabled ?? false, + isConnected: integrationStatusData?.microsoftConnected ?? false, + userEmail: integrationStatusData?.microsoftEmail ?? undefined, }, ] - }, [ - integrationSettings.integrationsProIsEnabled.value, - integrationSettings.integrationsGoogleIsEnabled.value, - integrationSettings.integrationsGoogleCredentials.value, - integrationSettings.integrationsMicrosoftIsEnabled.value, - integrationSettings.integrationsMicrosoftCredentials.value, - proStatus?.isProUser, - ]) + }, [integrationSettings.integrationsProIsEnabled.value, integrationStatusData, proStatus?.isProUser]) const { processCallback } = useOAuthConnect({ onError: (err) => { @@ -178,28 +140,28 @@ export default function IntegrationsPage() { const handleDisconnect = async (integration: Integration) => { try { - await updateSettings(db, { - [`integrations_${integration.provider}_credentials`]: '', - [`integrations_${integration.provider}_is_enabled`]: 'false', - }) + await deleteIntegrationCredentials(db, integration.provider as OAuthProvider) + await queryClient.invalidateQueries({ queryKey: ['integrationStatus'] }) } catch (err) { console.error('Failed to disconnect integration', err) + setError(err instanceof Error ? err.message : 'Failed to disconnect integration') } } const handleToggleEnabled = async (integration: Integration, enabled: boolean) => { try { - const settingKey = - integration.provider === 'thunderbolt-pro' - ? 'integrations_pro_is_enabled' - : `integrations_${integration.provider}_is_enabled` - await updateSettings(db, { [settingKey]: enabled.toString() }) + if (integration.provider === 'thunderbolt-pro') { + await updateSettings(db, { integrations_pro_is_enabled: enabled.toString() }) + } else { + await setIntegrationEnabled(db, integration.provider as OAuthProvider, enabled) + await queryClient.invalidateQueries({ queryKey: ['integrationStatus'] }) + } } catch (err) { console.error('Failed to update integration', err) } } - const loading = integrationSettings.integrationsProIsEnabled.isLoading || proStatusLoading + const loading = integrationSettings.integrationsProIsEnabled.isLoading || proStatusLoading || !integrationStatusData if (loading) { return (