diff --git a/src/ai/eval/runner.ts b/src/ai/eval/runner.ts index 29d52ae4..c48bd3c4 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 12f008a6..d270f24c 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 { isSsoMode } from '@/lib/auth-mode' import { getAuthToken } from '@/lib/auth-token' @@ -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..42969355 100644 --- a/src/components/onboarding/onboarding-auth-step.tsx +++ b/src/components/onboarding/onboarding-auth-step.tsx @@ -6,7 +6,7 @@ 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' @@ -84,10 +84,7 @@ export const OnboardingAuthStep = ({ const handleDisconnect = async () => { try { - await updateSettings(db, { - [`integrations_${provider}_credentials`]: '', - [`integrations_${provider}_is_enabled`]: 'false', - }) + await deleteIntegrationCredentials(db, provider) onConnectionChange(false) } catch (error) { console.error('Failed to disconnect:', error) diff --git a/src/dal/index.ts b/src/dal/index.ts index e9c06103..349394cb 100644 --- a/src/dal/index.ts +++ b/src/dal/index.ts @@ -101,3 +101,12 @@ export { // Devices export { getAllDevices, getDevice, getPendingDevices, type Device } from './devices' + +// Integrations +export { + deleteIntegrationCredentials, + getIntegrationCredentials, + getIntegrationStatus, + saveIntegrationCredentials, + setIntegrationEnabled, +} from './integrations' diff --git a/src/dal/integrations.ts b/src/dal/integrations.ts new file mode 100644 index 00000000..9cfe7e75 --- /dev/null +++ b/src/dal/integrations.ts @@ -0,0 +1,119 @@ +/* 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, + }) + } +} + +/** 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)) +} + +/** Get connection/enabled status for all integration providers. */ +export const getIntegrationStatus = async ( + db: AnyDrizzleDatabase, +): Promise<{ + googleConnected: boolean + googleEnabled: boolean + microsoftConnected: boolean + microsoftEnabled: boolean +}> => { + 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, + microsoftConnected: !!microsoft?.credentials, + microsoftEnabled: microsoft?.enabled === 1, + } +} diff --git a/src/db/powersync/schema.ts b/src/db/powersync/schema.ts index 67a86629..214165e1 100644 --- a/src/db/powersync/schema.ts +++ b/src/db/powersync/schema.ts @@ -3,15 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { PowerSyncTableName } from '@shared/powersync-tables' -import { DrizzleAppSchema } from '@powersync/drizzle-driver' +import { DrizzleAppSchema, type DrizzleTableWithPowerSyncOptions } from '@powersync/drizzle-driver' import * as tables from '../tables' /** - * Drizzle schema for PowerSync - keys are snake_case (table names). - * Type-checked: every PowerSyncTableName must have an entry. - * The driver uses the table's config name, not our keys; snake_case keeps types in sync with shared. + * Synced tables — type-checked against PowerSyncTableName. + * Keys are snake_case (table names). The driver uses the table's config name, not our keys. */ -export const drizzleSchema = { +const syncedTables = { settings: tables.settingsTable, chat_threads: tables.chatThreadsTable, chat_messages: tables.chatMessagesTable, @@ -25,6 +24,22 @@ export const drizzleSchema = { devices: tables.devicesTable, } satisfies Record +/** Local-only tables — created in SQLite but never synced via PowerSync. */ +const localOnlyTables = { + integrations_secrets: { + tableDefinition: tables.integrationsSecretsTable, + options: { localOnly: true }, + } satisfies DrizzleTableWithPowerSyncOptions, +} + +/** + * Combined Drizzle schema for PowerSync AppSchema. + */ +export const drizzleSchema = { + ...syncedTables, + ...localOnlyTables, +} + /** * PowerSync AppSchema derived from Drizzle table definitions. */ diff --git a/src/db/tables.ts b/src/db/tables.ts index 774dd44a..896c1592 100644 --- a/src/db/tables.ts +++ b/src/db/tables.ts @@ -108,6 +108,13 @@ export const modelsTable = sqliteTable( ], ) +/** 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..ed6bf562 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 sessionStorage (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..965f4653 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,11 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: false, + googleEnabled: false, microsoftConnected: false, + microsoftEnabled: false, availableProviders: { google: false, microsoft: false, @@ -94,10 +63,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 +74,22 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: true, + googleEnabled: true, microsoftConnected: false, + microsoftEnabled: false, 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 +100,23 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: false, + googleEnabled: false, microsoftConnected: true, + microsoftEnabled: true, 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,94 +127,16 @@ describe('useIntegrationStatus', () => { }) expect(result.current.isLoading).toBe(false) - expect(result.current.data).toEqual({ googleConnected: true, + googleEnabled: true, microsoftConnected: true, + microsoftEnabled: true, 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' }), - }) - - const { result } = renderHook(() => useIntegrationStatus(), { - wrapper: createQueryTestWrapper(), - }) - - await act(async () => { - 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') }) }) @@ -268,11 +151,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 +166,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..7d7d2d46 100644 --- a/src/hooks/use-integration-status.ts +++ b/src/hooks/use-integration-status.ts @@ -3,12 +3,14 @@ * 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 microsoftConnected: boolean + microsoftEnabled: boolean availableProviders: { google: boolean microsoft: boolean @@ -25,20 +27,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..2d6445c7 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 sessionStorage (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..b09a9177 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() @@ -244,10 +244,10 @@ export const useOnboardingState = () => { }, [preferredName.value, preferredName.isLoading]) useEffect(() => { - if (integrationsGoogleIsEnabled.value && !integrationsGoogleIsEnabled.isLoading) { + if (integrationStatusData?.googleConnected) { dispatch({ type: 'SET_PROVIDER_CONNECTED', payload: true }) } - }, [integrationsGoogleIsEnabled.value, integrationsGoogleIsEnabled.isLoading]) + }, [integrationStatusData?.googleConnected]) const actions = { setCurrentStep: (step: OnboardingStep) => dispatch({ type: 'SET_CURRENT_STEP', payload: step }), diff --git a/src/integrations/google/utils.ts b/src/integrations/google/utils.ts index 35b4e828..c31e1ec3 100644 --- a/src/integrations/google/utils.ts +++ b/src/integrations/google/utils.ts @@ -4,7 +4,7 @@ import { refreshAccessToken } from '@/lib/auth' import type { HttpClient } from '@/lib/http' -import { getSettings, updateSettings } from '@/dal' +import { getIntegrationCredentials, saveIntegrationCredentials } from '@/dal' import { getDb } from '@/db/database' import type { DraftEmailParams } from './tools' @@ -137,17 +137,11 @@ export const getGoogleCredentials = async (): Promise<{ expires_at?: number }> => { const db = getDb() - const settings = await getSettings(db, { integrations_google_credentials: String }) - const credentialsStr = settings.integrationsGoogleCredentials - if (!credentialsStr) { + const row = await getIntegrationCredentials(db, 'google') + if (!row) { throw new Error('Google integration not connected') } - - try { - return JSON.parse(credentialsStr) - } catch { - throw new Error('Invalid Google credentials') - } + return row.credentials } /** @@ -181,7 +175,7 @@ export const ensureValidGoogleToken = async ( } const db = getDb() - await updateSettings(db, { integrations_google_credentials: JSON.stringify(updated) }) + await saveIntegrationCredentials(db, 'google', updated, true) return updated.access_token } diff --git a/src/integrations/microsoft/tools.ts b/src/integrations/microsoft/tools.ts index d407197a..ffed3703 100644 --- a/src/integrations/microsoft/tools.ts +++ b/src/integrations/microsoft/tools.ts @@ -4,7 +4,7 @@ // New file with Microsoft Graph tools -import { getSettings, updateSettings } from '@/dal' +import { getIntegrationCredentials, saveIntegrationCredentials } from '@/dal' import { getDb } from '@/db/database' import { llmContentCharLimit } from '@/lib/utils' import type { ToolConfig } from '@/types' @@ -146,19 +146,17 @@ const getOneDriveFileCategory = (mime: string): OneDriveFileContent['file_catego // Internal helpers // --------------------------------------------------------------------------- -const getMicrosoftCredentials = async () => { +const getMicrosoftCredentials = async (): Promise<{ + access_token: string + refresh_token: string + expires_at?: number +}> => { const db = getDb() - const settings = await getSettings(db, { integrations_microsoft_credentials: String }) - const credentialsStr = settings.integrationsMicrosoftCredentials - if (!credentialsStr) { + const row = await getIntegrationCredentials(db, 'microsoft') + if (!row) { throw new Error('Microsoft integration not connected') } - - try { - return JSON.parse(credentialsStr) - } catch { - throw new Error('Invalid Microsoft credentials') - } + return row.credentials as { access_token: string; refresh_token: string; expires_at?: number } } /** @@ -191,7 +189,7 @@ const ensureValidToken = async ( } const db = getDb() - await updateSettings(db, { integrations_microsoft_credentials: JSON.stringify(updated) }) + await saveIntegrationCredentials(db, 'microsoft', updated, true) return newTokens.access_token } diff --git a/src/lib/oauth-state.ts b/src/lib/oauth-state.ts index 70f6b7be..5651edae 100644 --- a/src/lib/oauth-state.ts +++ b/src/lib/oauth-state.ts @@ -2,14 +2,15 @@ * 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 sessionStorage (device-local, survives page reload, dies with tab). + * Never synced — PKCE verifiers and CSRF tokens are security-critical single-use values. */ type OAuthState = { state: string | null @@ -18,60 +19,27 @@ 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 +/** Gets all OAuth flow state from sessionStorage. */ +export const getOAuthState = (): OAuthState => { + const raw = sessionStorage.getItem(storageKey) + if (!raw) { + return { state: null, provider: null, verifier: null, returnContext: null } } - if (state.returnContext !== undefined) { - settings.oauth_return_context = state.returnContext + try { + return JSON.parse(raw) as OAuthState + } catch { + return { state: null, provider: null, verifier: null, returnContext: null } } +} - if (Object.keys(settings).length > 0) { - const db = getDb() - await updateSettings(db, settings) - } +/** Sets OAuth flow state in sessionStorage (merges with existing). */ +export const setOAuthState = (update: Partial): void => { + const current = getOAuthState() + const merged = { ...current, ...update } + sessionStorage.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 sessionStorage. */ +export const clearOAuthState = (): void => { + sessionStorage.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..bb0dd0f0 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' @@ -49,18 +50,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 +61,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 +74,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 +93,8 @@ 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, }, { id: 'microsoft', @@ -124,20 +102,11 @@ 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, }, ] - }, [ - 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,10 +147,8 @@ 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) } @@ -189,17 +156,18 @@ export default function IntegrationsPage() { 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 (