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
This commit is contained in:
+3
-1
@@ -15,4 +15,6 @@ dist
|
|||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.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>
|
||||||
@@ -2,56 +2,30 @@
|
|||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<div v-if="isDesktopServiceAvailable">
|
<div v-if="isDesktopServiceAvailable">
|
||||||
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
|
<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">
|
<div class="flex space-x-2">
|
||||||
<FormButton
|
<FormButton full-width color="outline" @click="startAccountAddFlow()">
|
||||||
color="outline"
|
Log in (Legacy)
|
||||||
class="px-1"
|
|
||||||
:icon-left="ArrowLeftIcon"
|
|
||||||
hide-text
|
|
||||||
@click="emit('backToSignIn')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormButton full-width @click="startAccountAddFlow()">
|
|
||||||
Sign in (Legacy)
|
|
||||||
</FormButton>
|
</FormButton>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="text-sm text-center">
|
||||||
Please check your browser: waiting for authorization to complete.
|
Please check your browser: waiting for authorization to complete.
|
||||||
</div>
|
</div>
|
||||||
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></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>
|
<div class="text-sm text-center">Having trouble?</div>
|
||||||
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
|
<div class="flex justify-center">
|
||||||
<FormButton
|
<span>
|
||||||
text
|
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||||
size="sm"
|
Get in touch with us
|
||||||
full-width
|
</FormButton>
|
||||||
@click="$openUrl('https://speckle.community')"
|
</span>
|
||||||
>
|
</div>
|
||||||
Get in touch with us
|
|
||||||
</FormButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +65,10 @@ const hostApp = useHostAppStore()
|
|||||||
const app = useNuxtApp()
|
const app = useNuxtApp()
|
||||||
const { trackEvent } = useMixpanel()
|
const { trackEvent } = useMixpanel()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
serverUrl: string
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'backToSignIn'): void
|
(e: 'backToSignIn'): void
|
||||||
}>()
|
}>()
|
||||||
@@ -98,7 +76,6 @@ const emit = defineEmits<{
|
|||||||
const showCustomServerInput = ref(false)
|
const showCustomServerInput = ref(false)
|
||||||
const isAddingAccount = 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 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 showHelp = ref(false)
|
||||||
|
|
||||||
const accountCheckerIntervalFn = useIntervalFn(
|
const accountCheckerIntervalFn = useIntervalFn(
|
||||||
@@ -123,9 +100,9 @@ const startAccountAddFlow = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showHelp.value = true
|
showHelp.value = true
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
const url = customServerUrl.value
|
const url = props.serverUrl
|
||||||
? `http://localhost:29364/auth/add-account?serverUrl=${
|
? `http://localhost:29364/auth/add-account?serverUrl=${
|
||||||
new URL(customServerUrl.value).origin
|
new URL(props.serverUrl).origin
|
||||||
}`
|
}`
|
||||||
: `http://localhost:29364/auth/add-account`
|
: `http://localhost:29364/auth/add-account`
|
||||||
|
|
||||||
@@ -149,11 +126,6 @@ const startAccountAddFlow = () => {
|
|||||||
}, 30_000)
|
}, 30_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restartFlow = () => {
|
|
||||||
isAddingAccount.value = false
|
|
||||||
showHelp.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isDesktopServiceAvailable.value = await pingDesktopService()
|
isDesktopServiceAvailable.value = await pingDesktopService()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,20 +39,24 @@
|
|||||||
title="Add a new account"
|
title="Add a new account"
|
||||||
fullscreen="none"
|
fullscreen="none"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-4 p-2">
|
||||||
<AccountsSignInFlow v-if="!showLegacy" />
|
<FormTextInput
|
||||||
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
|
v-model="customServerUrl"
|
||||||
|
name="Server to sign in"
|
||||||
<FormButton
|
show-label
|
||||||
v-if="!showLegacy"
|
placeholder="https://app.speckle.systems"
|
||||||
text
|
color="foundation"
|
||||||
full-width
|
autocomplete="off"
|
||||||
size="sm"
|
show-clear
|
||||||
class="text-xs"
|
/>
|
||||||
@click="showLegacy = true"
|
<div class="space-y-2">
|
||||||
>
|
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||||
Legacy Sign in
|
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||||
</FormButton>
|
<AccountsLegacySignInFlow
|
||||||
|
v-if="!canStartAuthAccount"
|
||||||
|
:server-url="customServerUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CommonDialog>
|
</CommonDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,10 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
|
|||||||
import { useAccountStore } from '~/store/accounts'
|
import { useAccountStore } from '~/store/accounts'
|
||||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||||
|
import type { BaseBridge } from '~/lib/bridge/base'
|
||||||
|
|
||||||
const { trackEvent } = useMixpanel()
|
const { trackEvent } = useMixpanel()
|
||||||
const app = useNuxtApp()
|
const app = useNuxtApp()
|
||||||
const { pingDesktopService } = useDesktopService()
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -88,7 +100,7 @@ defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const showAddNewAccount = ref(false)
|
const showAddNewAccount = ref(false)
|
||||||
const showLegacy = ref(false)
|
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
|
||||||
|
|
||||||
const showAccountsDialog = defineModel<boolean>('open', {
|
const showAccountsDialog = defineModel<boolean>('open', {
|
||||||
required: false,
|
required: false,
|
||||||
@@ -110,8 +122,8 @@ watch(showAccountsDialog, (newVal) => {
|
|||||||
|
|
||||||
watch(showAddNewAccount, (newVal) => {
|
watch(showAddNewAccount, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
// reset the current/legacy state on every add account sub-dialog
|
// reset the sign-in mode on every add account sub-dialog
|
||||||
showLegacy.value = false
|
signInMode.value = 'default'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<FormButton
|
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||||
import type { BaseBridge } from '~/lib/bridge/base'
|
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 props = defineProps<{
|
||||||
const showCustomServerInput = ref(false)
|
serverUrl: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const accountStore = useAccountStore()
|
||||||
|
const hostAppStore = useHostAppStore()
|
||||||
const { $accountBinding } = useNuxtApp()
|
const { $accountBinding } = useNuxtApp()
|
||||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(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 { generateChallenge } = useAuthManager()
|
||||||
|
|
||||||
const logIn = () => {
|
const logIn = async () => {
|
||||||
const serverUrl = customServerUrl.value
|
const serverUrl = props.serverUrl
|
||||||
? new URL(customServerUrl.value).origin
|
? new URL(props.serverUrl).origin
|
||||||
: 'https://app.speckle.systems'
|
: 'https://app.speckle.systems'
|
||||||
const challenge = generateChallenge(serverUrl)
|
if (canStartAuthAccount) {
|
||||||
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
|
const acc = await $accountBinding.authenticateAccount(serverUrl)
|
||||||
window.location.href = authUrl
|
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>
|
</script>
|
||||||
|
|||||||
@@ -6,18 +6,22 @@
|
|||||||
Welcome to Speckle
|
Welcome to Speckle
|
||||||
</h1>
|
</h1>
|
||||||
<div v-if="isDesktopServiceAvailable || canAddAccount">
|
<div v-if="isDesktopServiceAvailable || canAddAccount">
|
||||||
<AccountsSignInFlow v-if="!showLegacy" />
|
<div class="flex flex-col space-y-4 p-2">
|
||||||
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
|
<FormTextInput
|
||||||
<FormButton
|
v-model="customServerUrl"
|
||||||
v-if="!showLegacy"
|
name="Server to sign in"
|
||||||
text
|
:show-label="false"
|
||||||
full-width
|
placeholder="https://app.speckle.systems"
|
||||||
size="sm"
|
color="foundation"
|
||||||
class="text-xs"
|
autocomplete="off"
|
||||||
@click="showLegacy = true"
|
show-clear
|
||||||
>
|
/>
|
||||||
Legacy Sign in
|
<div class="space-y-2">
|
||||||
</FormButton>
|
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||||
|
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||||
|
<AccountsLegacySignInFlow :server-url="customServerUrl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="text-foreground-2 mt-2 mb-4">
|
<div class="text-foreground-2 mt-2 mb-4">
|
||||||
@@ -53,13 +57,13 @@ import type { BaseBridge } from '~/lib/bridge/base'
|
|||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
const { pingDesktopService } = useDesktopService()
|
const { pingDesktopService } = useDesktopService()
|
||||||
|
|
||||||
|
const customServerUrl = ref<string>('https://app.speckle.systems')
|
||||||
|
|
||||||
const { $accountBinding } = useNuxtApp()
|
const { $accountBinding } = useNuxtApp()
|
||||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(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.
|
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -1,29 +1,81 @@
|
|||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
||||||
const CHALLENGE_KEY = 'speckle_challenge'
|
const CHALLENGE_KEY = 'speckle_challenge'
|
||||||
const CHALLENGE_URL_KEY = 'speckle_url_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() {
|
export function useAuthManager() {
|
||||||
const generateChallenge = (url: string): string => {
|
/**
|
||||||
let result = ''
|
* Generates a PKCE code_verifier + code_challenge pair and persists to localStorage.
|
||||||
for (let i = 0; i < 12; i++) {
|
* Used by redirect-based sign-in flows (SignInFlow) that need to
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
* recover the values after the browser navigates away and back.
|
||||||
}
|
*/
|
||||||
localStorage.setItem(CHALLENGE_KEY, result) // <-- persist it
|
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)
|
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 => {
|
const getChallenge = (): string | null => {
|
||||||
return localStorage.getItem(CHALLENGE_KEY)
|
return localStorage.getItem(CHALLENGE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCodeVerifier = (): string | null => {
|
||||||
|
return localStorage.getItem(CODE_VERIFIER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
const getChallengeUrl = (): string | null => {
|
const getChallengeUrl = (): string | null => {
|
||||||
return localStorage.getItem(CHALLENGE_URL_KEY)
|
return localStorage.getItem(CHALLENGE_URL_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getChallenge,
|
getChallenge,
|
||||||
|
getCodeVerifier,
|
||||||
getChallengeUrl,
|
getChallengeUrl,
|
||||||
generateChallenge
|
generateChallenge,
|
||||||
|
generateLocalChallenge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>
|
getAccounts: () => Promise<Account[]>
|
||||||
addAccount: (accountId: string, account: Account) => Promise<void>
|
addAccount: (accountId: string, account: Account) => Promise<void>
|
||||||
removeAccount: (accountId: string) => 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.
|
// 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)
|
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) {
|
public async removeAccount(accountId: string) {
|
||||||
return await console.log('no way dude', accountId)
|
return await console.log('no way dude', accountId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -574,6 +574,8 @@ export type AutomateAuthCodePayloadTest = {
|
|||||||
|
|
||||||
/** Additional resources to validate user access to. */
|
/** Additional resources to validate user access to. */
|
||||||
export type AutomateAuthCodeResources = {
|
export type AutomateAuthCodeResources = {
|
||||||
|
automationId?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
projectId?: InputMaybe<Scalars['String']['input']>;
|
||||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1249,6 +1251,18 @@ export type CreateDashboardTokenReturn = {
|
|||||||
tokenMetadata: DashboardToken;
|
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 = {
|
export type CreateEmbedTokenReturn = {
|
||||||
__typename?: 'CreateEmbedTokenReturn';
|
__typename?: 'CreateEmbedTokenReturn';
|
||||||
token: Scalars['String']['output'];
|
token: Scalars['String']['output'];
|
||||||
@@ -2680,6 +2694,7 @@ export type Mutation = {
|
|||||||
serverInviteBatchCreate: Scalars['Boolean']['output'];
|
serverInviteBatchCreate: Scalars['Boolean']['output'];
|
||||||
/** Invite a new user to the speckle server and return the invite ID */
|
/** Invite a new user to the speckle server and return the invite ID */
|
||||||
serverInviteCreate: Scalars['Boolean']['output'];
|
serverInviteCreate: Scalars['Boolean']['output'];
|
||||||
|
sharingMutations: SharingMutations;
|
||||||
/**
|
/**
|
||||||
* Request access to a specific stream
|
* Request access to a specific stream
|
||||||
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.create instead.
|
* @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']>;
|
description?: Maybe<Scalars['String']['output']>;
|
||||||
/** Public project-level configuration for embedded viewer */
|
/** Public project-level configuration for embedded viewer */
|
||||||
embedOptions: ProjectEmbedOptions;
|
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;
|
embedTokens: EmbedTokenCollection;
|
||||||
/** @deprecated Use specific auth policies instead */
|
/** @deprecated Use specific auth policies instead */
|
||||||
hasAccessToFeature: Scalars['Boolean']['output'];
|
hasAccessToFeature: Scalars['Boolean']['output'];
|
||||||
@@ -3283,6 +3299,7 @@ export type Project = {
|
|||||||
/** Same as savedView(), but won't throw if view isn't found */
|
/** Same as savedView(), but won't throw if view isn't found */
|
||||||
savedViewIfExists?: Maybe<SavedView>;
|
savedViewIfExists?: Maybe<SavedView>;
|
||||||
savedViews: SavedViewCollection;
|
savedViews: SavedViewCollection;
|
||||||
|
shareTokens: ShareTokenCollection;
|
||||||
/** Source apps used in any models of this project */
|
/** Source apps used in any models of this project */
|
||||||
sourceApps: Array<Scalars['String']['output']>;
|
sourceApps: Array<Scalars['String']['output']>;
|
||||||
team: Array<ProjectCollaborator>;
|
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 = {
|
export type ProjectUngroupedViewGroupArgs = {
|
||||||
input: GetUngroupedViewGroupInput;
|
input: GetUngroupedViewGroupInput;
|
||||||
};
|
};
|
||||||
@@ -4032,6 +4056,7 @@ export type ProjectMutations = {
|
|||||||
batchDelete: Scalars['Boolean']['output'];
|
batchDelete: Scalars['Boolean']['output'];
|
||||||
/** Create new project */
|
/** Create new project */
|
||||||
create: 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;
|
createEmbedToken: CreateEmbedTokenReturn;
|
||||||
/** Delete an existing project */
|
/** Delete an existing project */
|
||||||
delete: Scalars['Boolean']['output'];
|
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 a project. Only possible if you're not the last remaining owner. */
|
||||||
leave: Scalars['Boolean']['output'];
|
leave: Scalars['Boolean']['output'];
|
||||||
modelIngestionMutations: ProjectModelIngestionMutations;
|
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'];
|
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'];
|
revokeEmbedTokens: Scalars['Boolean']['output'];
|
||||||
savedViewMutations: SavedViewMutations;
|
savedViewMutations: SavedViewMutations;
|
||||||
/** Updates an existing project */
|
/** Updates an existing project */
|
||||||
@@ -4147,16 +4174,19 @@ export type ProjectPermissionChecks = {
|
|||||||
canLeave: PermissionCheckResult;
|
canLeave: PermissionCheckResult;
|
||||||
canListAutomations: PermissionCheckResult;
|
canListAutomations: PermissionCheckResult;
|
||||||
canListIssues: PermissionCheckResult;
|
canListIssues: PermissionCheckResult;
|
||||||
|
canListShareTokens: PermissionCheckResult;
|
||||||
canListUsers: PermissionCheckResult;
|
canListUsers: PermissionCheckResult;
|
||||||
canLoad: PermissionCheckResult;
|
canLoad: PermissionCheckResult;
|
||||||
canMoveToWorkspace: PermissionCheckResult;
|
canMoveToWorkspace: PermissionCheckResult;
|
||||||
canPublish: PermissionCheckResult;
|
canPublish: PermissionCheckResult;
|
||||||
canRead: PermissionCheckResult;
|
canRead: PermissionCheckResult;
|
||||||
canReadAccIntegrationSettings: 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;
|
canReadEmbedTokens: PermissionCheckResult;
|
||||||
canReadSettings: PermissionCheckResult;
|
canReadSettings: PermissionCheckResult;
|
||||||
canReadWebhooks: PermissionCheckResult;
|
canReadWebhooks: PermissionCheckResult;
|
||||||
canRequestRender: 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;
|
canRevokeEmbedTokens: PermissionCheckResult;
|
||||||
canUpdate: PermissionCheckResult;
|
canUpdate: PermissionCheckResult;
|
||||||
canUpdateAllowPublicComments: PermissionCheckResult;
|
canUpdateAllowPublicComments: PermissionCheckResult;
|
||||||
@@ -4237,6 +4267,11 @@ export enum ProjectSavedViewsUpdatedMessageType {
|
|||||||
Updated = 'UPDATED'
|
Updated = 'UPDATED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectShareTokensFilter = {
|
||||||
|
createdByUserId?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
sourceType?: InputMaybe<ShareSourceType>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectTestAutomationCreateInput = {
|
export type ProjectTestAutomationCreateInput = {
|
||||||
modelId: Scalars['String']['input'];
|
modelId: Scalars['String']['input'];
|
||||||
name: Scalars['String']['input'];
|
name: Scalars['String']['input'];
|
||||||
@@ -4615,6 +4650,22 @@ export type RequestWorkspaceSupportAccessInput = {
|
|||||||
workspaceId: Scalars['ID']['input'];
|
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 = {
|
export type ResourceIdentifier = {
|
||||||
__typename?: 'ResourceIdentifier';
|
__typename?: 'ResourceIdentifier';
|
||||||
resourceId: Scalars['String']['output'];
|
resourceId: Scalars['String']['output'];
|
||||||
@@ -5141,6 +5192,72 @@ export type SetPrimaryUserEmailInput = {
|
|||||||
id: Scalars['ID']['input'];
|
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 = {
|
export type SmartTextEditorValue = {
|
||||||
__typename?: 'SmartTextEditorValue';
|
__typename?: 'SmartTextEditorValue';
|
||||||
/** File attachments, if any */
|
/** File attachments, if any */
|
||||||
@@ -5823,6 +5940,12 @@ export type UpdateServerRegionInput = {
|
|||||||
name?: InputMaybe<Scalars['String']['input']>;
|
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 */
|
/** Only non-null values will be updated */
|
||||||
export type UpdateVersionInput = {
|
export type UpdateVersionInput = {
|
||||||
message?: InputMaybe<Scalars['String']['input']>;
|
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. */
|
/** 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>;
|
seatType?: Maybe<WorkspaceSeatType>;
|
||||||
seats?: Maybe<WorkspaceSubscriptionSeats>;
|
seats?: Maybe<WorkspaceSubscriptionSeats>;
|
||||||
|
shareTokens: ShareTokenCollection;
|
||||||
slug: Scalars['String']['output'];
|
slug: Scalars['String']['output'];
|
||||||
/** Information about the workspace's SSO configuration and the current user's SSO session, if present */
|
/** Information about the workspace's SSO configuration and the current user's SSO session, if present */
|
||||||
sso?: Maybe<WorkspaceSso>;
|
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 = {
|
export type WorkspaceSupportSessionsArgs = {
|
||||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||||
filter?: InputMaybe<WorkspaceSupportSessionFilter>;
|
filter?: InputMaybe<WorkspaceSupportSessionFilter>;
|
||||||
@@ -7146,6 +7277,7 @@ export type WorkspacePermissionChecks = {
|
|||||||
canInvite: PermissionCheckResult;
|
canInvite: PermissionCheckResult;
|
||||||
canLeave: PermissionCheckResult;
|
canLeave: PermissionCheckResult;
|
||||||
canListDashboards: PermissionCheckResult;
|
canListDashboards: PermissionCheckResult;
|
||||||
|
canListShareTokens: PermissionCheckResult;
|
||||||
canMakeWorkspaceExclusive: PermissionCheckResult;
|
canMakeWorkspaceExclusive: PermissionCheckResult;
|
||||||
canManageDomainBasedSecurityPolicies: PermissionCheckResult;
|
canManageDomainBasedSecurityPolicies: PermissionCheckResult;
|
||||||
canManageInvites: PermissionCheckResult;
|
canManageInvites: PermissionCheckResult;
|
||||||
@@ -7398,6 +7530,12 @@ export type WorkspaceSeatsByType = {
|
|||||||
viewers?: Maybe<WorkspaceSeatCollection>;
|
viewers?: Maybe<WorkspaceSeatCollection>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceShareTokensFilter = {
|
||||||
|
createdByUserId?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
projectId?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
sourceType?: InputMaybe<ShareSourceType>;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceSso = {
|
export type WorkspaceSso = {
|
||||||
__typename?: 'WorkspaceSso';
|
__typename?: 'WorkspaceSso';
|
||||||
/** If null, the workspace does not have SSO configured */
|
/** If null, the workspace does not have SSO configured */
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { md5 } from '@speckle/shared'
|
|
||||||
import { ToastNotificationType } from '@speckle/ui-components'
|
import { ToastNotificationType } from '@speckle/ui-components'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
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'
|
import { useHostAppStore } from '~/store/hostApp'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { getChallenge, getChallengeUrl } = useAuthManager()
|
const { getChallenge, getCodeVerifier, getChallengeUrl } = useAuthManager()
|
||||||
const { $accountBinding } = useNuxtApp()
|
const { exchangeAccessCode } = useTokenExchange()
|
||||||
const hostApp = useHostAppStore()
|
const hostApp = useHostAppStore()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -22,69 +21,11 @@ onMounted(async () => {
|
|||||||
const accessCode = route.query.access_code as string | undefined
|
const accessCode = route.query.access_code as string | undefined
|
||||||
if (accessCode && origin) {
|
if (accessCode && origin) {
|
||||||
const challenge = getChallenge()
|
const challenge = getChallenge()
|
||||||
const body = {
|
if (!challenge) {
|
||||||
appId: 'sdui',
|
throw new Error('No challenge found in storage.')
|
||||||
appSecret: 'sdui',
|
|
||||||
accessCode,
|
|
||||||
challenge
|
|
||||||
}
|
}
|
||||||
|
const codeVerifier = getCodeVerifier() ?? undefined
|
||||||
// Exchange the access code for a real token (optional)
|
await exchangeAccessCode(origin, accessCode, challenge, codeVerifier)
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No access code is found.')
|
throw new Error('No access code is found.')
|
||||||
}
|
}
|
||||||
@@ -92,7 +33,7 @@ onMounted(async () => {
|
|||||||
hostApp.setNotification({
|
hostApp.setNotification({
|
||||||
type: ToastNotificationType.Danger,
|
type: ToastNotificationType.Danger,
|
||||||
title: 'Failed to add your Speckle account.',
|
title: 'Failed to add your Speckle account.',
|
||||||
description: error as string
|
description: error instanceof Error ? error.message : (error as string)
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
|
|||||||
Reference in New Issue
Block a user