Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions src/ai/eval/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 7 additions & 11 deletions src/ai/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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')
}
Expand All @@ -267,7 +263,7 @@ export const aiFetchStreamingResponse = async ({
timeFormat: settings.timeFormat,
currency: settings.currency,
},
integrationStatus: getIntegrationStatus(),
integrationStatus: computeIntegrationStatusLabel(),
modeSystemPrompt,
})

Expand Down
10 changes: 5 additions & 5 deletions src/components/onboarding/onboarding-auth-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Comment thread
raivieiraadriano92 marked this conversation as resolved.
await queryClient.invalidateQueries({ queryKey: ['integrationStatus'] })
onConnectionChange(false)
} catch (error) {
console.error('Failed to disconnect:', error)
Expand Down
10 changes: 10 additions & 0 deletions src/dal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,13 @@ export {

// Devices
export { getAllDevices, getDevice, getPendingDevices, type Device } from './devices'

// Integrations
export {
deleteIntegrationCredentials,
getIntegrationCredentials,
getIntegrationStatus,
saveIntegrationCredentials,
setIntegrationEnabled,
updateIntegrationCredentials,
} from './integrations'
149 changes: 149 additions & 0 deletions src/dal/integrations.ts
Original file line number Diff line number Diff line change
@@ -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<IntegrationRow | null> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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),
}
}
4 changes: 4 additions & 0 deletions src/db/powersync/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const localOnlyTables = {
tableDefinition: tables.modelsSecretsTable,
options: { localOnly: true },
} satisfies DrizzleTableWithPowerSyncOptions,
integrations_secrets: {
tableDefinition: tables.integrationsSecretsTable,
options: { localOnly: true },
} satisfies DrizzleTableWithPowerSyncOptions,
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/db/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
Loading
Loading