chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ConnectedAccount,
|
||||
disconnectOAuthContract,
|
||||
listConnectedAccountsContract,
|
||||
listOAuthConnectionsContract,
|
||||
type OAuthAccountSummary,
|
||||
type OAuthConnection,
|
||||
} from '@/lib/api/contracts/oauth-connections'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
|
||||
|
||||
const logger = createLogger('OAuthConnectionsQuery')
|
||||
|
||||
export const OAUTH_CONNECTIONS_STALE_TIME = 30 * 1000
|
||||
export const OAUTH_CONNECTED_ACCOUNTS_STALE_TIME = 60 * 1000
|
||||
|
||||
/**
|
||||
* Query key factory for OAuth connection queries.
|
||||
* Provides hierarchical cache keys for connections and provider-specific accounts.
|
||||
*/
|
||||
export const oauthConnectionsKeys = {
|
||||
all: ['oauthConnections'] as const,
|
||||
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
|
||||
accounts: () => [...oauthConnectionsKeys.all, 'accounts'] as const,
|
||||
account: (provider: string) => [...oauthConnectionsKeys.accounts(), provider] as const,
|
||||
}
|
||||
|
||||
/** OAuth service with connection status and linked accounts. */
|
||||
export interface ServiceInfo extends OAuthServiceConfig {
|
||||
id: string
|
||||
isConnected: boolean
|
||||
lastConnected?: string
|
||||
accounts?: OAuthAccountSummary[]
|
||||
}
|
||||
|
||||
type OAuthConnectionResponse = OAuthConnection
|
||||
|
||||
function defineServices(): ServiceInfo[] {
|
||||
const servicesList: ServiceInfo[] = []
|
||||
|
||||
Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => {
|
||||
Object.entries(provider.services).forEach(([serviceKey, service]) => {
|
||||
servicesList.push({
|
||||
...service,
|
||||
id: serviceKey,
|
||||
isConnected: false,
|
||||
scopes: service.scopes || [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return servicesList
|
||||
}
|
||||
|
||||
async function fetchOAuthConnections(signal?: AbortSignal): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
const serviceDefinitions = defineServices()
|
||||
|
||||
const data = await requestJson(listOAuthConnectionsContract, { signal })
|
||||
const connections = data.connections || []
|
||||
|
||||
const updatedServices = serviceDefinitions.map((service) => {
|
||||
const connection = connections.find(
|
||||
(conn: OAuthConnectionResponse) => conn.provider === service.providerId
|
||||
)
|
||||
|
||||
if (connection) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: (connection.accounts?.length ?? 0) > 0,
|
||||
accounts: connection.accounts || [],
|
||||
lastConnected: connection.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
const connectionWithScopes = connections.find((conn: OAuthConnectionResponse) => {
|
||||
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (conn.scopes && service.scopes) {
|
||||
const connScopes = conn.scopes
|
||||
return service.scopes.every((scope) => connScopes.includes(scope))
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (connectionWithScopes) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: (connectionWithScopes.accounts?.length ?? 0) > 0,
|
||||
accounts: connectionWithScopes.accounts || [],
|
||||
lastConnected: connectionWithScopes.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
})
|
||||
|
||||
return updatedServices
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return defineServices()
|
||||
}
|
||||
logger.error('Error fetching OAuth connections:', error)
|
||||
return defineServices()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all OAuth service connections with their status.
|
||||
* Returns service definitions merged with connection data.
|
||||
*/
|
||||
export function useOAuthConnections() {
|
||||
return useQuery({
|
||||
queryKey: oauthConnectionsKeys.connections(),
|
||||
queryFn: ({ signal }) => fetchOAuthConnections(signal),
|
||||
staleTime: OAUTH_CONNECTIONS_STALE_TIME,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
interface ConnectServiceParams {
|
||||
providerId: string
|
||||
callbackURL: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates OAuth connection flow for a service.
|
||||
* Redirects the user to the provider's authorization page.
|
||||
*/
|
||||
export function useConnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => {
|
||||
if (providerId === 'trello') {
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (providerId === 'shopify') {
|
||||
const returnUrl = encodeURIComponent(callbackURL)
|
||||
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('OAuth connection error:', error)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DisconnectServiceParams {
|
||||
provider: string
|
||||
providerId?: string
|
||||
serviceId: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects an OAuth service account.
|
||||
* Performs optimistic update and rolls back on failure.
|
||||
*/
|
||||
export function useDisconnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => {
|
||||
return requestJson(disconnectOAuthContract, {
|
||||
body: {
|
||||
provider,
|
||||
providerId,
|
||||
accountId,
|
||||
},
|
||||
})
|
||||
},
|
||||
onMutate: async ({ serviceId, accountId }) => {
|
||||
await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
|
||||
const previousServices = queryClient.getQueryData<ServiceInfo[]>(
|
||||
oauthConnectionsKeys.connections()
|
||||
)
|
||||
|
||||
if (previousServices) {
|
||||
queryClient.setQueryData<ServiceInfo[]>(
|
||||
oauthConnectionsKeys.connections(),
|
||||
previousServices.map((svc) => {
|
||||
if (svc.id === serviceId) {
|
||||
const updatedAccounts =
|
||||
accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : []
|
||||
return {
|
||||
...svc,
|
||||
accounts: updatedAccounts,
|
||||
isConnected: updatedAccounts.length > 0,
|
||||
}
|
||||
}
|
||||
return svc
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { previousServices }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousServices) {
|
||||
queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices)
|
||||
}
|
||||
logger.error('Failed to disconnect service')
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Connected OAuth account for a specific provider. */
|
||||
export type { ConnectedAccount }
|
||||
|
||||
async function fetchConnectedAccounts(
|
||||
provider: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ConnectedAccount[]> {
|
||||
const data = await requestJson(listConnectedAccountsContract, {
|
||||
query: { provider },
|
||||
signal,
|
||||
})
|
||||
return data.accounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches connected accounts for a specific OAuth provider.
|
||||
* @param provider - The provider ID (e.g., 'slack', 'google')
|
||||
* @param options - Query options including enabled flag
|
||||
*/
|
||||
export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: oauthConnectionsKeys.account(provider),
|
||||
queryFn: ({ signal }) => fetchConnectedAccounts(provider, signal),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: OAUTH_CONNECTED_ACCOUNTS_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listOAuthCredentialsContract } from '@/lib/api/contracts'
|
||||
import type { Credential } from '@/lib/oauth'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
|
||||
export const OAUTH_CREDENTIAL_LIST_STALE_TIME = 60 * 1000
|
||||
export const OAUTH_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000
|
||||
|
||||
export const oauthCredentialKeys = {
|
||||
all: ['oauthCredentials'] as const,
|
||||
lists: () => [...oauthCredentialKeys.all, 'list'] as const,
|
||||
list: (providerId?: string, workspaceId?: string, workflowId?: string) =>
|
||||
[
|
||||
...oauthCredentialKeys.lists(),
|
||||
providerId ?? 'none',
|
||||
workspaceId ?? 'none',
|
||||
workflowId ?? 'none',
|
||||
] as const,
|
||||
details: () => [...oauthCredentialKeys.all, 'detail'] as const,
|
||||
detail: (credentialId?: string, workflowId?: string) =>
|
||||
[...oauthCredentialKeys.details(), credentialId ?? 'none', workflowId ?? 'none'] as const,
|
||||
}
|
||||
|
||||
interface FetchOAuthCredentialsParams {
|
||||
providerId: string
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentials(
|
||||
params: FetchOAuthCredentialsParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<Credential[]> {
|
||||
const { providerId, workspaceId, workflowId } = params
|
||||
if (!providerId) return []
|
||||
const data = await requestJson(listOAuthCredentialsContract, {
|
||||
signal,
|
||||
query: {
|
||||
provider: providerId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
},
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentialDetail(
|
||||
credentialId: string,
|
||||
workflowId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Credential[]> {
|
||||
if (!credentialId) return []
|
||||
const data = await requestJson(listOAuthCredentialsContract, {
|
||||
signal,
|
||||
query: {
|
||||
credentialId,
|
||||
workflowId,
|
||||
},
|
||||
})
|
||||
return data.credentials ?? []
|
||||
}
|
||||
|
||||
interface UseOAuthCredentialsOptions {
|
||||
enabled?: boolean
|
||||
workspaceId?: string
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
function resolveOptions(
|
||||
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
|
||||
): Required<UseOAuthCredentialsOptions> {
|
||||
if (typeof enabledOrOptions === 'boolean') {
|
||||
return {
|
||||
enabled: enabledOrOptions,
|
||||
workspaceId: '',
|
||||
workflowId: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: enabledOrOptions?.enabled ?? true,
|
||||
workspaceId: enabledOrOptions?.workspaceId ?? '',
|
||||
workflowId: enabledOrOptions?.workflowId ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function useOAuthCredentials(
|
||||
providerId?: string,
|
||||
enabledOrOptions?: boolean | UseOAuthCredentialsOptions
|
||||
) {
|
||||
const { enabled, workspaceId, workflowId } = resolveOptions(enabledOrOptions)
|
||||
|
||||
return useQuery<Credential[]>({
|
||||
queryKey: oauthCredentialKeys.list(providerId, workspaceId, workflowId),
|
||||
queryFn: ({ signal }) =>
|
||||
fetchOAuthCredentials(
|
||||
{
|
||||
providerId: providerId ?? '',
|
||||
workspaceId: workspaceId || undefined,
|
||||
workflowId: workflowId || undefined,
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: Boolean(providerId) && enabled,
|
||||
staleTime: OAUTH_CREDENTIAL_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOAuthCredentialDetail(
|
||||
credentialId?: string,
|
||||
workflowId?: string,
|
||||
enabled = true
|
||||
) {
|
||||
return useQuery<Credential[]>({
|
||||
queryKey: oauthCredentialKeys.detail(credentialId, workflowId),
|
||||
queryFn: ({ signal }) => fetchOAuthCredentialDetail(credentialId ?? '', workflowId, signal),
|
||||
enabled: Boolean(credentialId) && enabled,
|
||||
staleTime: OAUTH_CREDENTIAL_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCredentialName(
|
||||
credentialId?: string,
|
||||
providerId?: string,
|
||||
workflowId?: string,
|
||||
workspaceId?: string
|
||||
) {
|
||||
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
|
||||
providerId,
|
||||
{
|
||||
enabled: Boolean(providerId),
|
||||
workspaceId,
|
||||
workflowId,
|
||||
}
|
||||
)
|
||||
|
||||
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
|
||||
|
||||
const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
|
||||
|
||||
const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
|
||||
shouldFetchDetail ? credentialId : undefined,
|
||||
workflowId,
|
||||
shouldFetchDetail
|
||||
)
|
||||
|
||||
// Fallback for credential blocks that have no serviceId/providerId — look up by ID directly
|
||||
const { data: workspaceCredential, isFetching: workspaceCredentialLoading } =
|
||||
useWorkspaceCredential(!providerId ? credentialId : undefined)
|
||||
|
||||
const detailCredential = foreignCredentials[0]
|
||||
const hasForeignMeta = foreignCredentials.length > 0
|
||||
|
||||
const displayName =
|
||||
selectedCredential?.name ?? detailCredential?.name ?? workspaceCredential?.displayName ?? null
|
||||
|
||||
return {
|
||||
displayName,
|
||||
isLoading: credentialsLoading || foreignLoading || workspaceCredentialLoading,
|
||||
hasForeignMeta,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user