Skip to content
Open
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 { isSsoMode } from '@/lib/auth-mode'
import { getAuthToken } from '@/lib/auth-token'
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
7 changes: 2 additions & 5 deletions src/components/onboarding/onboarding-auth-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/dal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,12 @@ export {

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

// Integrations
export {
deleteIntegrationCredentials,
getIntegrationCredentials,
getIntegrationStatus,
saveIntegrationCredentials,
setIntegrationEnabled,
} from './integrations'
119 changes: 119 additions & 0 deletions src/dal/integrations.ts
Original file line number Diff line number Diff line change
@@ -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<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,
})
}
}

/** 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))
}

/** 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,
}
}
25 changes: 20 additions & 5 deletions src/db/powersync/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +24,22 @@ export const drizzleSchema = {
devices: tables.devicesTable,
} satisfies Record<PowerSyncTableName, unknown>

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