Compare commits

...

7 Commits

Author SHA1 Message Date
Björn Steinhagen b2f0c37b4c feat(dui): adds version check to isDisableCacheSupported 2026-04-01 15:59:04 +02:00
Björn Steinhagen a6533be095 chore(dui): adds todo 2026-04-01 14:32:29 +02:00
Björn Steinhagen 6d6055fa6e fix(dui): excludes non-sharp dui connectors manually with slug check 2026-04-01 13:44:07 +02:00
Björn Steinhagen bf7f8f6992 feat(dui): adds disable cache setting 2026-04-01 09:35:46 +02:00
Björn Steinhagen 8fc81b0b4e fix(dui): stale load settings to existing model card (#94)
Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-03-27 19:29:33 +03:00
Björn Steinhagen 6f2f599b1b fix: redirect to workspace on sso session error (#97) 2026-03-27 19:15:54 +03:00
Oğuzhan Koral a69de13f16 feat: refactor auth flow and enable exchange token flow (#95)
* feat: refactor auth flow and enable exchange token flow

* fix: do not cache to local storage for exchange token

* chore: remove logging

* chore: lint

* feat: pkce alignment with oauth endpoint

* feat: default log in via accountBinding.authenticateAccount if available

* feat: do not show legacy sign in if connectors has accountBinding.authenticateAccount flow

* fix: base64url safe
2026-03-25 17:21:07 +03:00
16 changed files with 698 additions and 198 deletions
+3 -1
View File
@@ -15,4 +15,6 @@ dist
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.yarn/versions
.claude
@@ -0,0 +1,175 @@
<template>
<div v-if="!hidden" class="flex flex-col space-y-2">
<!-- idle: server URL + sign in button -->
<template v-if="state === 'idle'">
<div class="flex space-x-2">
<FormButton
v-if="canAddAccount"
full-width
color="outline"
@click="openBrowserAuth()"
>
Log in with OAuth token
</FormButton>
</div>
</template>
<!-- waiting: instructions + code input -->
<template v-if="state === 'waiting' || state === 'submitting'">
<div class="text-foreground-2 space-y-2 border rounded-lg p-2">
<div class="text-sm text-center">
Check your browser: authorize the app, then copy the exchange code and paste
it below.
</div>
<div class="py-2"><CommonLoadingBar :loading="state === 'waiting'" /></div>
<FormTextInput
v-model="exchangeCode"
name="exchangeCode"
:show-label="false"
placeholder="Paste exchange code here"
color="foundation"
autocomplete="off"
:disabled="state === 'submitting'"
/>
<FormButton
full-width
:disabled="!exchangeCode?.trim() || state === 'submitting'"
@click="submitCode()"
>
{{ state === 'submitting' ? 'Signing in...' : 'Submit' }}
</FormButton>
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
<div class="text-sm text-center">Having trouble?</div>
<div class="flex justify-center">
<span>
<FormButton size="sm" text @click="retryFlow()">Retry</FormButton>
or
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
Get in touch with us
</FormButton>
</span>
</div>
</div>
</div>
</template>
<!-- error -->
<template v-if="state === 'error'">
<div class="text-foreground-2 space-y-2">
<div class="text-sm text-center text-red-500">
{{ errorMessage }}
</div>
<FormButton full-width @click="retryFlow()">Try again</FormButton>
<FormButton text size="sm" full-width @click="emit('backToSignIn')">
Back
</FormButton>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import { useTokenExchange, supportsOAuthToken } from '~/lib/authn/useTokenExchange'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useAccountStore } from '~/store/accounts'
import type { BaseBridge } from '~/lib/bridge/base'
const props = defineProps<{
serverUrl: string
}>()
const emit = defineEmits<{
(e: 'backToSignIn'): void
}>()
const app = useNuxtApp()
const { generateLocalChallenge } = useAuthManager()
const { exchangeAccessCode } = useTokenExchange()
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const state = ref<'idle' | 'waiting' | 'submitting' | 'error'>('idle')
const exchangeCode = ref<string | undefined>()
const errorMessage = ref('')
const showHelp = ref(false)
const hidden = ref(false)
const checkServerSupport = async (url: string) => {
const serverUrl = url ? new URL(url).origin : 'https://app.speckle.systems'
hidden.value = !(await supportsOAuthToken(serverUrl))
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => checkServerSupport(props.serverUrl))
watch(
() => props.serverUrl,
(url) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => checkServerSupport(url), 500)
}
)
let currentCodeVerifier = ''
let currentCodeChallenge = ''
let currentServerUrl = ''
const openBrowserAuth = async () => {
currentServerUrl = props.serverUrl
? new URL(props.serverUrl).origin
: 'https://app.speckle.systems'
const { codeVerifier, codeChallenge } = await generateLocalChallenge()
currentCodeVerifier = codeVerifier
currentCodeChallenge = codeChallenge
const authUrl = `${currentServerUrl}/authn/verify/sdui/${codeChallenge}?returnExchangeToken=true&code_challenge_method=S256`
app.$openUrl(authUrl)
state.value = 'waiting'
exchangeCode.value = undefined
showHelp.value = false
setTimeout(() => {
if (state.value === 'waiting') {
showHelp.value = true
}
}, 10_000)
}
const submitCode = async () => {
const code = exchangeCode.value?.trim()
if (!code || !currentCodeChallenge || !currentServerUrl) return
state.value = 'submitting'
try {
await exchangeAccessCode(
currentServerUrl,
code,
currentCodeChallenge,
currentCodeVerifier
)
void trackEvent('DUI Account Added')
// Refresh accounts so the watcher in Menu.vue detects the new account and closes the dialog
await accountStore.refreshAccounts()
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : 'Failed to sign in. Please try again.'
state.value = 'error'
}
}
const retryFlow = () => {
state.value = 'idle'
exchangeCode.value = undefined
errorMessage.value = ''
showHelp.value = false
}
</script>
+20 -48
View File
@@ -2,56 +2,30 @@
<div class="flex flex-col space-y-2">
<div v-if="isDesktopServiceAvailable">
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<div class="flex space-x-2">
<FormButton
color="outline"
class="px-1"
:icon-left="ArrowLeftIcon"
hide-text
@click="emit('backToSignIn')"
/>
<FormButton full-width @click="startAccountAddFlow()">
Sign in (Legacy)
<FormButton full-width color="outline" @click="startAccountAddFlow()">
Log in (Legacy)
</FormButton>
</div>
</div>
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
<div
v-show="isAddingAccount"
class="text-foreground-2 mt-2 mb-4 space-y-2 border rounded-lg p-2"
>
<div class="text-sm text-center">
Please check your browser: waiting for authorization to complete.
</div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
<div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
<FormButton
text
size="sm"
full-width
@click="$openUrl('https://speckle.community')"
>
Get in touch with us
</FormButton>
<div class="flex justify-center">
<span>
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
Get in touch with us
</FormButton>
</span>
</div>
</div>
</div>
</div>
@@ -91,6 +65,10 @@ const hostApp = useHostAppStore()
const app = useNuxtApp()
const { trackEvent } = useMixpanel()
const props = defineProps<{
serverUrl: string
}>()
const emit = defineEmits<{
(e: 'backToSignIn'): void
}>()
@@ -98,7 +76,6 @@ const emit = defineEmits<{
const showCustomServerInput = ref(false)
const isAddingAccount = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showHelp = ref(false)
const accountCheckerIntervalFn = useIntervalFn(
@@ -123,9 +100,9 @@ const startAccountAddFlow = () => {
setTimeout(() => {
showHelp.value = true
}, 10_000)
const url = customServerUrl.value
const url = props.serverUrl
? `http://localhost:29364/auth/add-account?serverUrl=${
new URL(customServerUrl.value).origin
new URL(props.serverUrl).origin
}`
: `http://localhost:29364/auth/add-account`
@@ -149,11 +126,6 @@ const startAccountAddFlow = () => {
}, 30_000)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
}
onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService()
})
+29 -17
View File
@@ -39,20 +39,24 @@
title="Add a new account"
fullscreen="none"
>
<div class="flex flex-col space-y-2">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
<div class="flex flex-col space-y-4 p-2">
<FormTextInput
v-model="customServerUrl"
name="Server to sign in"
show-label
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
/>
<div class="space-y-2">
<AccountsSignInFlow :server-url="customServerUrl" />
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
<AccountsLegacySignInFlow
v-if="!canStartAuthAccount"
:server-url="customServerUrl"
/>
</div>
</div>
</CommonDialog>
</div>
@@ -68,10 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { BaseBridge } from '~/lib/bridge/base'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { pingDesktopService } = useDesktopService()
const { $accountBinding } = useNuxtApp()
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
(name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const customServerUrl = ref<string>('https://app.speckle.systems')
const props = withDefaults(
defineProps<{
@@ -88,7 +100,7 @@ defineEmits<{
}>()
const showAddNewAccount = ref(false)
const showLegacy = ref(false)
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
const showAccountsDialog = defineModel<boolean>('open', {
required: false,
@@ -110,8 +122,8 @@ watch(showAccountsDialog, (newVal) => {
watch(showAddNewAccount, (newVal) => {
if (newVal) {
// reset the current/legacy state on every add account sub-dialog
showLegacy.value = false
// reset the sign-in mode on every add account sub-dialog
signInMode.value = 'default'
}
})
+33 -31
View File
@@ -1,51 +1,53 @@
<template>
<div class="flex flex-col space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<FormButton v-if="canAddAccount" full-width @click="logIn()">Sign in</FormButton>
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</FormButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { BaseBridge } from '~/lib/bridge/base'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showCustomServerInput = ref(false)
const props = defineProps<{
serverUrl: string
}>()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
(name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const { generateChallenge } = useAuthManager()
const logIn = () => {
const serverUrl = customServerUrl.value
? new URL(customServerUrl.value).origin
const logIn = async () => {
const serverUrl = props.serverUrl
? new URL(props.serverUrl).origin
: 'https://app.speckle.systems'
const challenge = generateChallenge(serverUrl)
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
window.location.href = authUrl
if (canStartAuthAccount) {
const acc = await $accountBinding.authenticateAccount(serverUrl)
if (acc.token) {
await accountStore.refreshAccounts()
} else {
hostAppStore.setNotification({
title: 'Log In',
type: ToastNotificationType.Info,
description:
"Log in could not completed. Make sure you have logged in successfully, otherwise try 'Log in with OAuth token'"
})
}
} else {
const { codeChallenge } = await generateChallenge(serverUrl)
const authUrl = `${serverUrl}/authn/verify/sdui/${codeChallenge}?code_challenge_method=S256`
window.location.href = authUrl
}
}
</script>
+47 -2
View File
@@ -36,6 +36,23 @@
{{ isDarkTheme ? 'Light theme' : 'Dark theme' }}
</div>
</MenuItem>
<MenuItem
v-if="isDisableCacheSupported"
v-slot="{ active }"
@click="toggleCache"
>
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-2xs flex justify-between px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
<span>Disable Cache</span>
<span v-if="isCacheDisabled" class="text-primary font-bold ml-2"></span>
</div>
</MenuItem>
<div class="border-t border-outline-3 mt-1">
<MenuItem v-if="app.$revitMapperBinding" v-slot="{ active }">
<button
@@ -120,12 +137,40 @@ import { storeToRefs } from 'pinia'
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useConfigStore } from '~/store/config'
import { useHostAppStore } from '~/store/hostApp'
const app = useNuxtApp()
const uiConfigStore = useConfigStore()
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
const { isDarkTheme, hasConfigBindings, isDevMode, isCacheDisabled } =
storeToRefs(uiConfigStore)
const { toggleTheme, toggleCache } = uiConfigStore
const hostAppStore = useHostAppStore()
const { hostAppName, connectorVersion } = storeToRefs(hostAppStore)
const isDisableCacheSupported = computed(() => {
const appName = hostAppName.value
const version = connectorVersion.value
if (!appName || !version) return false
// excludes non-sharp connectors (assuming they don't have backend cache bypass)
const nonSharpApps = ['sketchup', 'archicad', 'vectorworks']
if (nonSharpApps.includes(appName.toLowerCase())) return false
// always show in dev environments
if (version.includes('dev') || version.includes('local')) return true
// for sharp connectors, check if version is >= 3.18.0
const targetVersion = '3.18.0'
return (
version.localeCompare(targetVersion, undefined, {
numeric: true,
sensitivity: 'base'
}) >= 0
)
})
const { $showDevTools, $openUrl } = useNuxtApp()
const showAccountsDialog = ref(false)
+18 -14
View File
@@ -6,18 +6,22 @@
Welcome to Speckle
</h1>
<div v-if="isDesktopServiceAvailable || canAddAccount">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
<div class="flex flex-col space-y-4 p-2">
<FormTextInput
v-model="customServerUrl"
name="Server to sign in"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
/>
<div class="space-y-2">
<AccountsSignInFlow :server-url="customServerUrl" />
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
<AccountsLegacySignInFlow :server-url="customServerUrl" />
</div>
</div>
</div>
<div v-else>
<div class="text-foreground-2 mt-2 mb-4">
@@ -53,13 +57,13 @@ import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService()
const customServerUrl = ref<string>('https://app.speckle.systems')
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const showLegacy = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
onMounted(async () => {
+10 -6
View File
@@ -178,21 +178,25 @@ const selectVersionAndAddModel = async (
if (existingModel) {
emit('close')
// Patch the existing model card with new versions!
await hostAppStore.patchModel(existingModel.modelCardId, {
const patchPayload: Record<string, unknown> = {
selectedVersionId: version.id,
selectedVersionSourceApp: version.sourceApplication,
selectedVersionUserId: version.authorUser?.id,
latestVersionId: latestVersion.id,
latestVersionSourceApp: latestVersion.sourceApplication,
latestVersionUserId: latestVersion.authorUser?.id
})
}
// apply new settings to the existing model card if they were changed
if (settingsWereChanged.value && receieveSettings.value) {
patchPayload.settings = receieveSettings.value
}
// patch the existing model card with new versions and settings
await hostAppStore.patchModel(existingModel.modelCardId, patchPayload)
await hostAppStore.receiveModel(existingModel.modelCardId, 'Wizard')
return
}
// We were tracking the source host app wrong before `getHostAppFromString`
// i.e. we were having `Revit 2023` instead of `revit`
const selectedVersionSourceApp = getSlugFromHostAppNameAndVersion(
version.sourceApplication as string
)
+7
View File
@@ -591,6 +591,13 @@ const upgradePlanButtonAction = () => {
)
return
}
// catch SSO session expired / any other unhandled permission flags
// redirecting to the workspace root will trigger the standard web authentication flow.
if (selectedWorkspace.value?.slug) {
$openUrl(
`${account.value.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.value?.slug}`
)
}
}
const loadMore = () => {
+61 -9
View File
@@ -1,29 +1,81 @@
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const CHALLENGE_KEY = 'speckle_challenge'
const CHALLENGE_URL_KEY = 'speckle_url_challenge'
const CODE_VERIFIER_KEY = 'speckle_code_verifier'
function toBase64Url(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Generates a cryptographically random base64url-encoded string.
* 32 bytes → 43 characters after base64url encoding (within the RFC 7636 range of 43-128).
*/
function createCodeVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return toBase64Url(bytes)
}
/**
* Computes SHA-256 hash of a string and returns it as base64url.
* This is the PKCE code_challenge derivation from a code_verifier.
*/
async function computeS256Challenge(codeVerifier: string): Promise<string> {
const data = new TextEncoder().encode(codeVerifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return toBase64Url(new Uint8Array(digest))
}
export interface ChallengeData {
/** The raw random string (code_verifier in PKCE terms) */
codeVerifier: string
/** SHA-256 base64url hash of codeVerifier (code_challenge for S256 method) */
codeChallenge: string
}
export function useAuthManager() {
const generateChallenge = (url: string): string => {
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
localStorage.setItem(CHALLENGE_KEY, result) // <-- persist it
/**
* Generates a PKCE code_verifier + code_challenge pair and persists to localStorage.
* Used by redirect-based sign-in flows (SignInFlow) that need to
* recover the values after the browser navigates away and back.
*/
const generateChallenge = async (url: string): Promise<ChallengeData> => {
const codeVerifier = createCodeVerifier()
const codeChallenge = await computeS256Challenge(codeVerifier)
localStorage.setItem(CHALLENGE_KEY, codeChallenge)
localStorage.setItem(CODE_VERIFIER_KEY, codeVerifier)
localStorage.setItem(CHALLENGE_URL_KEY, url)
return result
return { codeVerifier, codeChallenge }
}
/**
* Generates a PKCE code_verifier + code_challenge pair without persisting to localStorage.
* Used by flows that keep values in memory (ExchangeTokenSignInFlow)
* so they don't overwrite the redirect flow's stored data.
*/
const generateLocalChallenge = async (): Promise<ChallengeData> => {
const codeVerifier = createCodeVerifier()
const codeChallenge = await computeS256Challenge(codeVerifier)
return { codeVerifier, codeChallenge }
}
const getChallenge = (): string | null => {
return localStorage.getItem(CHALLENGE_KEY)
}
const getCodeVerifier = (): string | null => {
return localStorage.getItem(CODE_VERIFIER_KEY)
}
const getChallengeUrl = (): string | null => {
return localStorage.getItem(CHALLENGE_URL_KEY)
}
return {
getChallenge,
getCodeVerifier,
getChallengeUrl,
generateChallenge
generateChallenge,
generateLocalChallenge
}
}
+114
View File
@@ -0,0 +1,114 @@
import { md5 } from '@speckle/shared'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
/**
* Checks if the server supports the new /oauth/token endpoint.
* The server exposes GET /oauth/token returning 'supported' when available.
*/
export async function supportsOAuthToken(serverUrl: string): Promise<boolean> {
try {
const res = await fetch(new URL('/oauth/token', serverUrl), { method: 'GET' })
return res.ok
} catch {
return false
}
}
export function useTokenExchange() {
const { $accountBinding } = useNuxtApp()
const exchangeAccessCode = async (
rawServerUrl: string,
accessCode: string,
challenge: string,
codeVerifier?: string
): Promise<void> => {
// Normalize to origin (strips trailing slash, path, etc.)
// so account IDs stay consistent with connectors
const serverUrl = new URL(rawServerUrl).origin
const tokenHeaders = { 'Content-Type': 'application/json' }
let tokenResponse: Response
// If we have a codeVerifier, try the new PKCE-based /oauth/token endpoint first
if (codeVerifier && (await supportsOAuthToken(serverUrl))) {
tokenResponse = await fetch(new URL('/oauth/token', serverUrl), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({
appId: 'sdui',
appSecret: 'sdui',
accessCode,
codeVerifier
})
})
} else {
// Fall back to legacy /auth/token with plain challenge
tokenResponse = await fetch(new URL('/auth/token', serverUrl), {
method: 'POST',
headers: tokenHeaders,
body: JSON.stringify({
appId: 'sdui',
appSecret: 'sdui',
accessCode,
challenge
})
})
}
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
throw new Error(
`Token exchange failed with status ${tokenResponse.status}: ${errorText}`
)
}
const { token, refreshToken } = (await tokenResponse.json()) as {
token: string
refreshToken: string
}
// Query user and server info
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', serverUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(graphqlQuery)
})
if (!userAndServerInfoResponse.ok) {
throw new Error(
`Failed to fetch user info with status ${userAndServerInfoResponse.status}`
)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + serverUrl
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: serverUrl, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
}
return { exchangeAccessCode }
}
@@ -9,6 +9,7 @@ export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
getAccounts: () => Promise<Account[]>
addAccount: (accountId: string, account: Account) => Promise<void>
removeAccount: (accountId: string) => Promise<void>
authenticateAccount: (serverUrl: string) => Promise<Account>
}
// An almost 1-1 mapping of what we need from the Core accounts class.
@@ -60,6 +61,25 @@ export class MockedAccountBinding implements IAccountBinding {
return await console.log('no way dude', accountId, account)
}
public async authenticateAccount(serverUrl: string) {
const config = useRuntimeConfig()
return (await {
id: 'whatever',
isDefault: true,
token: config.public.speckleToken,
serverInfo: {
name: 'test',
url: serverUrl,
frontend2: true
},
userInfo: {
id: 'whatever',
avatar: 'whatever',
email: ''
}
}) as Account
}
public async removeAccount(accountId: string) {
return await console.log('no way dude', accountId)
}
+2 -1
View File
@@ -31,6 +31,7 @@ export type GlobalConfig = {
export type ConnectorConfig = {
darkTheme: boolean
disableCache?: boolean
}
export type AccountsConfig = {
@@ -48,7 +49,7 @@ export class MockedConfigBinding implements IConfigBinding {
}
public async getConfig() {
return await { darkTheme: false }
return await { darkTheme: false, disableCache: false }
}
public async getGlobalConfig() {
+138
View File
@@ -574,6 +574,8 @@ export type AutomateAuthCodePayloadTest = {
/** Additional resources to validate user access to. */
export type AutomateAuthCodeResources = {
automationId?: InputMaybe<Scalars['String']['input']>;
projectId?: InputMaybe<Scalars['String']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
@@ -1249,6 +1251,18 @@ export type CreateDashboardTokenReturn = {
tokenMetadata: DashboardToken;
};
export type CreateEmbedShareTokenInput = {
expiresAt?: InputMaybe<Scalars['DateTime']['input']>;
label?: InputMaybe<Scalars['String']['input']>;
password?: InputMaybe<Scalars['String']['input']>;
projectId: Scalars['String']['input'];
/**
* The model(s) and version(s) string used in the embed url.
* Format: 'modelId1,modelId2@versionId'
*/
resourceIdString: Scalars['String']['input'];
};
export type CreateEmbedTokenReturn = {
__typename?: 'CreateEmbedTokenReturn';
token: Scalars['String']['output'];
@@ -2680,6 +2694,7 @@ export type Mutation = {
serverInviteBatchCreate: Scalars['Boolean']['output'];
/** Invite a new user to the speckle server and return the invite ID */
serverInviteCreate: Scalars['Boolean']['output'];
sharingMutations: SharingMutations;
/**
* Request access to a specific stream
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.create instead.
@@ -3235,6 +3250,7 @@ export type Project = {
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
embedTokens: EmbedTokenCollection;
/** @deprecated Use specific auth policies instead */
hasAccessToFeature: Scalars['Boolean']['output'];
@@ -3283,6 +3299,7 @@ export type Project = {
/** Same as savedView(), but won't throw if view isn't found */
savedViewIfExists?: Maybe<SavedView>;
savedViews: SavedViewCollection;
shareTokens: ShareTokenCollection;
/** Source apps used in any models of this project */
sourceApps: Array<Scalars['String']['output']>;
team: Array<ProjectCollaborator>;
@@ -3480,6 +3497,13 @@ export type ProjectSavedViewsArgs = {
};
export type ProjectShareTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<ProjectShareTokensFilter>;
limit?: Scalars['Int']['input'];
};
export type ProjectUngroupedViewGroupArgs = {
input: GetUngroupedViewGroupInput;
};
@@ -4032,6 +4056,7 @@ export type ProjectMutations = {
batchDelete: Scalars['Boolean']['output'];
/** Create new project */
create: Project;
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
createEmbedToken: CreateEmbedTokenReturn;
/** Delete an existing project */
delete: Scalars['Boolean']['output'];
@@ -4041,7 +4066,9 @@ export type ProjectMutations = {
/** Leave a project. Only possible if you're not the last remaining owner. */
leave: Scalars['Boolean']['output'];
modelIngestionMutations: ProjectModelIngestionMutations;
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
revokeEmbedToken: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Field will be deleted on October 1st, 2026. */
revokeEmbedTokens: Scalars['Boolean']['output'];
savedViewMutations: SavedViewMutations;
/** Updates an existing project */
@@ -4147,16 +4174,19 @@ export type ProjectPermissionChecks = {
canLeave: PermissionCheckResult;
canListAutomations: PermissionCheckResult;
canListIssues: PermissionCheckResult;
canListShareTokens: PermissionCheckResult;
canListUsers: PermissionCheckResult;
canLoad: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
canPublish: PermissionCheckResult;
canRead: PermissionCheckResult;
canReadAccIntegrationSettings: PermissionCheckResult;
/** @deprecated Part of the old API surface and will be removed in the future. Use canListShareTokens. Field will be deleted on October 1st, 2026. */
canReadEmbedTokens: PermissionCheckResult;
canReadSettings: PermissionCheckResult;
canReadWebhooks: PermissionCheckResult;
canRequestRender: PermissionCheckResult;
/** @deprecated Part of the old API surface and will be removed in the future. Use canRevoke on ShareToken. Field will be deleted on October 1st, 2026. */
canRevokeEmbedTokens: PermissionCheckResult;
canUpdate: PermissionCheckResult;
canUpdateAllowPublicComments: PermissionCheckResult;
@@ -4237,6 +4267,11 @@ export enum ProjectSavedViewsUpdatedMessageType {
Updated = 'UPDATED'
}
export type ProjectShareTokensFilter = {
createdByUserId?: InputMaybe<Scalars['String']['input']>;
sourceType?: InputMaybe<ShareSourceType>;
};
export type ProjectTestAutomationCreateInput = {
modelId: Scalars['String']['input'];
name: Scalars['String']['input'];
@@ -4615,6 +4650,22 @@ export type RequestWorkspaceSupportAccessInput = {
workspaceId: Scalars['ID']['input'];
};
export type ResourceAccessRule = {
__typename?: 'ResourceAccessRule';
modelId?: Maybe<Scalars['String']['output']>;
projectId?: Maybe<Scalars['String']['output']>;
type: ResourceAccessRuleType;
versionId?: Maybe<Scalars['String']['output']>;
workspaceId?: Maybe<Scalars['String']['output']>;
};
export enum ResourceAccessRuleType {
Model = 'model',
Project = 'project',
Version = 'version',
Workspace = 'workspace'
}
export type ResourceIdentifier = {
__typename?: 'ResourceIdentifier';
resourceId: Scalars['String']['output'];
@@ -5141,6 +5192,72 @@ export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
export enum ShareSourceType {
Dashboard = 'dashboard',
Embed = 'embed',
SavedViewGroup = 'savedViewGroup'
}
export type ShareToken = {
__typename?: 'ShareToken';
createdAt: Scalars['DateTime']['output'];
createdBy: LimitedUser;
expiresAt?: Maybe<Scalars['DateTime']['output']>;
hasPassword: Scalars['Boolean']['output'];
id: Scalars['String']['output'];
label?: Maybe<Scalars['String']['output']>;
lastUsed?: Maybe<Scalars['DateTime']['output']>;
permissions: ShareTokenPermissionChecks;
resourceAccessRules: Array<ResourceAccessRule>;
sourceId: Scalars['String']['output'];
sourceType: ShareSourceType;
/** The full token string. Only returned on creation. */
token?: Maybe<Scalars['String']['output']>;
};
export type ShareTokenCollection = {
__typename?: 'ShareTokenCollection';
cursor?: Maybe<Scalars['String']['output']>;
items: Array<ShareToken>;
totalCount: Scalars['Int']['output'];
};
export type ShareTokenPermissionChecks = {
__typename?: 'ShareTokenPermissionChecks';
canRevoke: PermissionCheckResult;
canUpdate: PermissionCheckResult;
};
export type SharingMutations = {
__typename?: 'SharingMutations';
createEmbedShareToken: ShareToken;
revokeProjectShareTokens: Scalars['Boolean']['output'];
revokeShareToken: Scalars['Boolean']['output'];
updateShareToken: ShareToken;
};
export type SharingMutationsCreateEmbedShareTokenArgs = {
input: CreateEmbedShareTokenInput;
};
export type SharingMutationsRevokeProjectShareTokensArgs = {
projectId: Scalars['String']['input'];
sourceType: ShareSourceType;
};
export type SharingMutationsRevokeShareTokenArgs = {
tokenId: Scalars['String']['input'];
};
export type SharingMutationsUpdateShareTokenArgs = {
input: UpdateShareTokenInput;
tokenId: Scalars['String']['input'];
};
export type SmartTextEditorValue = {
__typename?: 'SmartTextEditorValue';
/** File attachments, if any */
@@ -5823,6 +5940,12 @@ export type UpdateServerRegionInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type UpdateShareTokenInput = {
expiresAt?: InputMaybe<Scalars['DateTime']['input']>;
label?: InputMaybe<Scalars['String']['input']>;
password?: InputMaybe<Scalars['String']['input']>;
};
/** Only non-null values will be updated */
export type UpdateVersionInput = {
message?: InputMaybe<Scalars['String']['input']>;
@@ -6591,6 +6714,7 @@ export type Workspace = {
/** Active user's seat type for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
seatType?: Maybe<WorkspaceSeatType>;
seats?: Maybe<WorkspaceSubscriptionSeats>;
shareTokens: ShareTokenCollection;
slug: Scalars['String']['output'];
/** Information about the workspace's SSO configuration and the current user's SSO session, if present */
sso?: Maybe<WorkspaceSso>;
@@ -6650,6 +6774,13 @@ export type WorkspaceProjectsArgs = {
};
export type WorkspaceShareTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceShareTokensFilter>;
limit?: Scalars['Int']['input'];
};
export type WorkspaceSupportSessionsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceSupportSessionFilter>;
@@ -7146,6 +7277,7 @@ export type WorkspacePermissionChecks = {
canInvite: PermissionCheckResult;
canLeave: PermissionCheckResult;
canListDashboards: PermissionCheckResult;
canListShareTokens: PermissionCheckResult;
canMakeWorkspaceExclusive: PermissionCheckResult;
canManageDomainBasedSecurityPolicies: PermissionCheckResult;
canManageInvites: PermissionCheckResult;
@@ -7398,6 +7530,12 @@ export type WorkspaceSeatsByType = {
viewers?: Maybe<WorkspaceSeatCollection>;
};
export type WorkspaceShareTokensFilter = {
createdByUserId?: InputMaybe<Scalars['String']['input']>;
projectId?: InputMaybe<Scalars['String']['input']>;
sourceType?: InputMaybe<ShareSourceType>;
};
export type WorkspaceSso = {
__typename?: 'WorkspaceSso';
/** If null, the workspace does not have SSO configured */
+8 -67
View File
@@ -3,17 +3,16 @@
</template>
<script setup lang="ts">
import { md5 } from '@speckle/shared'
import { ToastNotificationType } from '@speckle/ui-components'
import { useRoute, useRouter } from 'vue-router'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
import { useTokenExchange } from '~/lib/authn/useTokenExchange'
import { useHostAppStore } from '~/store/hostApp'
const route = useRoute()
const router = useRouter()
const { getChallenge, getChallengeUrl } = useAuthManager()
const { $accountBinding } = useNuxtApp()
const { getChallenge, getCodeVerifier, getChallengeUrl } = useAuthManager()
const { exchangeAccessCode } = useTokenExchange()
const hostApp = useHostAppStore()
onMounted(async () => {
@@ -22,69 +21,11 @@ onMounted(async () => {
const accessCode = route.query.access_code as string | undefined
if (accessCode && origin) {
const challenge = getChallenge()
const body = {
appId: 'sdui',
appSecret: 'sdui',
accessCode,
challenge
if (!challenge) {
throw new Error('No challenge found in storage.')
}
// Exchange the access code for a real token (optional)
const response = await fetch(new URL('/auth/token', origin), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
hostApp.setNotification({
title: 'Log In',
type: ToastNotificationType.Danger,
description: `Token exchange failed with status ${response.status}: ${errorText}`
})
// Stop processing and redirect immediately on failure
return router.replace('/')
}
const { token, refreshToken } = (await response.json()) as {
token: string
refreshToken: string
}
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', origin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` // Add the token as a Bearer token
},
body: JSON.stringify(graphqlQuery)
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + origin
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: origin, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
const codeVerifier = getCodeVerifier() ?? undefined
await exchangeAccessCode(origin, accessCode, challenge, codeVerifier)
} else {
throw new Error('No access code is found.')
}
@@ -92,7 +33,7 @@ onMounted(async () => {
hostApp.setNotification({
type: ToastNotificationType.Danger,
title: 'Failed to add your Speckle account.',
description: error as string
description: error instanceof Error ? error.message : (error as string)
})
} finally {
router.replace('/')
+13 -2
View File
@@ -8,11 +8,14 @@ export const useConfigStore = defineStore('configStore', () => {
const userSelectedWorkspaceId = ref<string>()
const config = ref<ConnectorConfig>({ darkTheme: true })
const config = ref<ConnectorConfig>({ darkTheme: true, disableCache: false })
const isDarkTheme = computed(() => {
return config.value?.darkTheme
})
const isCacheDisabled = computed(() => {
return config.value?.disableCache || false
})
const isDevMode = ref(false)
const toggleTheme = () => {
@@ -20,6 +23,11 @@ export const useConfigStore = defineStore('configStore', () => {
$configBinding.updateConfig(config.value)
}
const toggleCache = () => {
config.value.disableCache = !config.value.disableCache
$configBinding.updateConfig(config.value)
}
const setUserSelectedWorkspace = (workspaceId: string) => {
userSelectedWorkspaceId.value = workspaceId
try {
@@ -33,7 +41,8 @@ export const useConfigStore = defineStore('configStore', () => {
const init = async () => {
if (!$configBinding) return
config.value = await $configBinding.getConfig()
const fetchedConfig = await $configBinding.getConfig()
config.value = { disableCache: false, ...fetchedConfig }
const workspacesConfig = await $configBinding.getWorkspacesConfig()
if (workspacesConfig && workspacesConfig.userSelectedWorkspaceId) {
userSelectedWorkspaceId.value = workspacesConfig.userSelectedWorkspaceId
@@ -51,9 +60,11 @@ export const useConfigStore = defineStore('configStore', () => {
config,
hasConfigBindings,
isDarkTheme,
isCacheDisabled,
isDevMode,
userSelectedWorkspaceId,
toggleTheme,
toggleCache,
setUserSelectedWorkspace
}
})