feat: auth in dui (#71)
* feat: auth in dui * feat: enable auth with registered app * feat: handle exceptions
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
@clear="showCustomServerInput = false"
|
||||
/>
|
||||
</div>
|
||||
<FormButton full-width @click="startAccountAddFlow()">Sign In</FormButton>
|
||||
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</FormButton>
|
||||
<FormButton v-else full-width @click="startAccountAddFlow()">Sign in</FormButton>
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
@@ -52,6 +53,8 @@ import { useAccountStore } from '~~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const hostApp = useHostAppStore()
|
||||
@@ -63,6 +66,11 @@ const isAddingAccount = ref(false)
|
||||
const showHelp = ref(false)
|
||||
const showCustomServerInput = ref(false)
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const accountCheckerIntervalFn = useIntervalFn(
|
||||
async () => {
|
||||
const previousAccountCount = accountStore.accounts.length
|
||||
@@ -79,6 +87,17 @@ const accountCheckerIntervalFn = useIntervalFn(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const { generateChallenge } = useAuthManager()
|
||||
|
||||
const logIn = () => {
|
||||
const challenge = generateChallenge()
|
||||
const serverUrl = customServerUrl.value
|
||||
? new URL(customServerUrl.value).origin
|
||||
: 'https://app.speckle.systems'
|
||||
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
|
||||
window.location.href = authUrl
|
||||
}
|
||||
|
||||
const startAccountAddFlow = () => {
|
||||
isAddingAccount.value = true
|
||||
accountCheckerIntervalFn.resume()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
Welcome to Speckle
|
||||
</h1>
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<div v-if="isDesktopServiceAvailable || canAddAccount">
|
||||
<AccountsSignInFlow />
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -37,10 +37,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const CHALLENGE_KEY = 'speckle_challenge'
|
||||
|
||||
export function useAuthManager() {
|
||||
const generateChallenge = (): 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
|
||||
return result
|
||||
}
|
||||
|
||||
const getChallenge = (): string | null => {
|
||||
return localStorage.getItem(CHALLENGE_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
getChallenge,
|
||||
generateChallenge
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const IAccountBindingKey = 'accountsBinding'
|
||||
|
||||
export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
|
||||
getAccounts: () => Promise<Account[]>
|
||||
addAccount: (accountId: string, account: Account) => Promise<void>
|
||||
removeAccount: (accountId: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ export type Account = {
|
||||
id: string
|
||||
isDefault: boolean
|
||||
token: string
|
||||
refreshToken: string
|
||||
serverInfo: {
|
||||
name: string
|
||||
url: string
|
||||
@@ -54,6 +56,10 @@ export class MockedAccountBinding implements IAccountBinding {
|
||||
]) as Account[]
|
||||
}
|
||||
|
||||
public async addAccount(accountId: string, account: Account) {
|
||||
return await console.log('no way dude', accountId, account)
|
||||
}
|
||||
|
||||
public async removeAccount(accountId: string) {
|
||||
return await console.log('no way dude', accountId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center"><InfiniteLoading /></div>
|
||||
</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 { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { getChallenge } = useAuthManager()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const hostApp = useHostAppStore()
|
||||
const origin = window.location.origin
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const accessCode = route.query.access_code as string | undefined
|
||||
if (accessCode) {
|
||||
const challenge = getChallenge()
|
||||
const body = {
|
||||
appId: 'sdui',
|
||||
appSecret: 'sdui',
|
||||
accessCode,
|
||||
challenge
|
||||
}
|
||||
|
||||
// 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)
|
||||
} else {
|
||||
throw new Error('No access code is found.')
|
||||
}
|
||||
} catch (error) {
|
||||
hostApp.setNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to add your Speckle account.',
|
||||
description: error as string
|
||||
})
|
||||
} finally {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user