Files
speckle-connectors-dui/components/accounts/ExchangeTokenSignInFlow.vue
T
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

176 lines
5.3 KiB
Vue

<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>