Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 648fc8db79 | |||
| 6f2f599b1b | |||
| 4d11091c30 | |||
| a69de13f16 | |||
| 074ef02bd6 | |||
| eddff32d2d | |||
| 80574fda14 |
+3
-1
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
Reference in New Issue
Block a user