Files
speckle-connectors-dui/store/accounts.ts
T
Oğuzhan Koral 3b4aa93858 Feat: mocked bindings and logging to seq (#39)
* mocked bindings and logging to seq

* test deploy

* test deploy

* test deploy

* connectorless state

* remove logs

* remove more logs

* add flags to globalThus

* log with /api/events/raw

* log error link on prod over local account

* handle test query to distinguish self hosters

* throw again

* log again...

* sa and ra

* error policy non none

* attach server url to logs

* Add host app version

* rename name to slug

* remove useless re throw

* fix confusion on versions
2025-07-23 15:51:09 +01:00

334 lines
9.6 KiB
TypeScript

import { defineStore } from 'pinia'
import type { ApolloLink } from '@apollo/client/core'
import {
ApolloClient,
InMemoryCache,
gql,
HttpLink,
split,
from
} from '@apollo/client/core'
import { ApolloClients, provideApolloClients } from '@vue/apollo-composable'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
import { WebSocketLink } from '@apollo/client/link/ws'
import { onError, type ErrorResponse } from '@apollo/client/link/error'
import { getMainDefinition } from '@apollo/client/utilities'
import { setContext } from '@apollo/client/link/context'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import { logToSeq } from '~/lib/logger/composables/useLogger'
export type DUIAccount = {
/** account info coming from the host app */
accountInfo: Account
/** the graphql client; a bit superflous */
client: ApolloClient<unknown>
/** whether an intial serverinfo query succeeded. */
isValid: boolean
}
const accountTestQuery = gql`
query AcccountTestQuery {
serverInfo {
version
name
company
}
}
`
export const useAccountStore = defineStore('accountStore', () => {
const app = useNuxtApp()
const { $accountBinding, $configBinding } = app
const hostAppStore = useHostAppStore()
const apolloClients = {} as Record<string, ApolloClient<unknown>>
const accounts = ref<DUIAccount[]>([])
const isLoading = ref(false)
const defaultAccount = computed(
() => accounts.value.find((acc) => acc.accountInfo.isDefault) as DUIAccount
)
const userSelectedAccount = ref<DUIAccount>()
/**
* Returns either the default account or the last account the user has selected.
*/
const activeAccount = computed(() => {
return userSelectedAccount.value || accounts.value[0]
})
const removeAccount = async (acc: DUIAccount) => {
await $accountBinding.removeAccount(acc.accountInfo.id)
await hostAppStore.removeAccountModels(acc.accountInfo.id)
// TODO: post clean up for model cards that belongs to that account
await refreshAccounts()
}
const setUserSelectedAccount = (acc: DUIAccount) => {
userSelectedAccount.value = acc
try {
// NOTE: for the safe merge!
$configBinding.setUserSelectedAccountId(acc.accountInfo.id) // not need to await, fire and forget?
} catch (error) {
console.warn(error)
}
}
const testAccounts = async () => {
isLoading.value = true
for (const acc of accounts.value) {
if (!acc.client) continue
if (!acc.accountInfo.serverInfo.frontend2) continue
try {
await acc.client.query({
query: accountTestQuery,
context: {
url: acc.accountInfo.serverInfo.url
}
})
acc.isValid = true
} catch {
// TODO: properly dispose and kill this client. It's unclear how to do it.
acc.isValid = false
// NOTE: we do not want to delete the client, as we might want to "refresh" in
// case the user was not connected to the interweb.
// acc.client.disableNetworkFetches = true
// acc.client.stop()
// delete acc.client
}
}
isLoading.value = false
}
const refreshAccounts = async () => {
isLoading.value = true
const accs = (await $accountBinding?.getAccounts()) || []
const newAccs: DUIAccount[] = []
for (const acc of accs) {
const existing = accounts.value.find((a) => a.accountInfo.id === acc.id)
if (existing) {
newAccs.push(existing as DUIAccount)
continue
}
// Handle apollo client errors as top level
const errorLink = onError((res: ErrorResponse) => {
logToSeq('Error', 'Apollo GraphQL Error (DUI3)', {
operationName: res.operation?.operationName ?? 'Unknown',
serverUrl: res.operation.getContext().url as string,
graphQLErrors: res.graphQLErrors?.map((err) => ({
message: err.message,
path: err.path,
code: err.extensions?.code,
locations: err.locations
})),
networkError: res.networkError
? {
message: res.networkError.message,
name: res.networkError.name,
stack: res.networkError.stack
}
: undefined
})
if (res.graphQLErrors) {
if (
res.graphQLErrors?.some(
(err) => err.extensions?.code === 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
)
) {
hostAppStore.setNotification({
type: ToastNotificationType.Warning,
title: 'SSO Required',
description:
'Your workspace requires SSO authentication. Please sign in and try again.'
})
}
// const messages: string[] = []
// res.graphQLErrors.forEach(({ message, path }) => {
// messages.push(`${message},\n Path: ${path}`)
// })
// const notification: ToastNotification = {
// type: ToastNotificationType.Danger,
// title: 'Graphql Error',
// description: messages.join('\n')
// }
// hostAppStore.setNotification(notification)
}
// if (res.networkError) {
// const notification: ToastNotification = {
// type: ToastNotificationType.Danger,
// title: 'Network Error',
// description: res.networkError.message
// }
// hostAppStore.setNotification(notification)
// }
})
const link = splitLink(
getLinks(new URL('/graphql', acc.serverInfo.url).href, 'Bearer ' + acc.token)
)
const client = new ApolloClient({
cache: new InMemoryCache(),
link: from([errorLink, link]),
headers: {
Authorization: 'Bearer ' + acc.token
},
defaultOptions: {
query: {
errorPolicy: 'all'
},
mutate: {
errorPolicy: 'all'
},
watchQuery: {
errorPolicy: 'all'
}
}
})
// const workspacesEnabled = false
// try {
// // get workspace enabled flag and store it in account
// const res = await client.query({ query: serverInfoQuery })
// workspacesEnabled = !!res.data.serverInfo.workspaces.workspacesEnabled
// } catch (err) {
// // probably having some local account or client could not established well for some reason!
// console.log(err)
// }
apolloClients[acc.id] = client
newAccs.push({
accountInfo: acc,
client,
isValid: true
})
}
accounts.value = newAccs
isLoading.value = false
}
const getLinks = (serverUrl: string, token: string) => {
const authHeaderValue = token
const httpLink = new HttpLink({
uri: serverUrl
})
const authLink = setContext((_, { headers }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { headers: { ...headers, Authorization: authHeaderValue } }
})
const link = authLink.concat(httpLink as unknown as ApolloLink)
const wsLink = new WebSocketLink({
uri: serverUrl.replace('http', 'ws'),
options: {
reconnect: true,
connectionParams: {
Authorization: authHeaderValue
}
}
})
return { httpLink: link, wsLink }
}
const splitLink = ({
httpLink,
wsLink
}: {
httpLink: ApolloLink
wsLink: WebSocketLink
}) =>
split(
({ query }) => {
const definition = getMainDefinition(query)
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
wsLink,
httpLink
)
const isAccountExistsById = (accountId: string) => {
return !!accounts.value.find((acc) => acc.accountInfo.id === accountId)
}
const isAccountExistsByServer = (serverUrl: string) => {
return !!accounts.value.find((acc) => acc.accountInfo.serverInfo.url === serverUrl)
}
const accountWithFallback = (accountId: string, serverUrl: string) => {
const accountMatchWithId = accounts.value.find(
(acc) => acc.accountInfo.id === accountId
)
if (accountMatchWithId) return accountMatchWithId
// NOTE: we do assumption here by having first matched url.
const accountMatchWithServerUrl = accounts.value.find(
(acc) => acc.accountInfo.serverInfo.url === serverUrl
)
if (accountMatchWithServerUrl) return accountMatchWithServerUrl
return activeAccount.value
}
const accountByServerUrl = (serverUrl: string) => {
const accountMatchWithServerUrl = accounts.value.find(
(acc) => acc.accountInfo.serverInfo.url === serverUrl
)
if (accountMatchWithServerUrl) return accountMatchWithServerUrl
}
const provideClients = () => {
provideApolloClients(apolloClients)
}
watch(accounts, () => {
void testAccounts()
})
const init = async () => {
await refreshAccounts()
try {
const accountsConfig = await $configBinding.getUserSelectedAccountId()
userSelectedAccount.value = accounts.value.find(
(a) => a.accountInfo.id === accountsConfig.userSelectedAccountId
) as DUIAccount
} catch (error) {
console.warn(error)
}
}
init()
app.vueApp.provide(ApolloClients, apolloClients)
return {
isLoading,
accounts,
defaultAccount,
activeAccount,
userSelectedAccount,
setUserSelectedAccount,
removeAccount,
accountByServerUrl,
isAccountExistsById,
isAccountExistsByServer,
refreshAccounts,
accountWithFallback,
provideClients
}
})