mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-02 10:47:30 +00:00
195 lines
5.7 KiB
Vue
195 lines
5.7 KiB
Vue
<script lang="ts" setup>
|
|
import type { FormBuilderElement } from 'nocodb-sdk'
|
|
|
|
const props = defineProps<{
|
|
value: {
|
|
code_verifier: string
|
|
code: string
|
|
}
|
|
element: FormBuilderElement
|
|
haveValue?: boolean
|
|
formData?: Record<string, any>
|
|
}>()
|
|
|
|
const emits = defineEmits(['update:value'])
|
|
|
|
const vModel = useVModel(props, 'value', emits)
|
|
|
|
const OAuthConfig = computed(() => {
|
|
return props.element.oauthMeta!
|
|
})
|
|
|
|
const generateState = () => {
|
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
|
}
|
|
|
|
// Generate PKCE code verifier (43-128 characters)
|
|
const generateCodeVerifier = () => {
|
|
const array = new Uint8Array(32)
|
|
crypto.getRandomValues(array)
|
|
return btoa(String.fromCharCode(...array))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '')
|
|
}
|
|
|
|
// Generate PKCE code challenge (SHA256 hash of verifier)
|
|
const generateCodeChallenge = async (verifier: string) => {
|
|
const encoder = new TextEncoder()
|
|
const data = encoder.encode(verifier)
|
|
const hash = await crypto.subtle.digest('SHA-256', data)
|
|
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '')
|
|
}
|
|
|
|
const openPopup = (url: string, name: string, state: string, codeVerifier: string, width = 500, height = 600) => {
|
|
const left = window.screenX + (window.outerWidth - width) / 2
|
|
const top = window.screenY + (window.outerHeight - height) / 2.5
|
|
|
|
// add state to the URL
|
|
url += `&state=${state}`
|
|
|
|
const popup = window.open(url, name, `width=${width},height=${height},left=${left},top=${top}`)
|
|
if (!popup) throw new Error('Popup blocked')
|
|
|
|
return new Promise<{ code: string; codeVerifier: string } | null>((resolve, reject) => {
|
|
let popupClosed = false
|
|
|
|
const interval = setInterval(() => {
|
|
// First check if we already know the popup is closed
|
|
if (popupClosed) {
|
|
clearInterval(interval)
|
|
reject(new Error('Popup closed by user'))
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Try to access popup.closed, which might throw in cross-origin scenarios
|
|
popupClosed = popup.closed
|
|
if (popupClosed) {
|
|
clearInterval(interval)
|
|
reject(new Error('Popup closed by user'))
|
|
return
|
|
}
|
|
|
|
const url = popup.location.href
|
|
|
|
if (url.includes(OAuthConfig.value.redirectUri)) {
|
|
const params = new URL(url).searchParams
|
|
const code = params.get(OAuthConfig.value.codeKey || 'code')
|
|
if (code) {
|
|
clearInterval(interval)
|
|
|
|
if (params.get('state') !== state) {
|
|
reject(new Error('Invalid state please try again'))
|
|
}
|
|
|
|
popup.close()
|
|
resolve({ code, codeVerifier })
|
|
} else {
|
|
// If user clicked cancel button or code is missing and url is redirect url then we have to close the popup
|
|
clearInterval(interval)
|
|
|
|
reject(new Error('No code returned'))
|
|
|
|
popup.close()
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Handle cross-origin errors
|
|
// If we get an error accessing popup properties, check if we can detect it's closed
|
|
try {
|
|
// This might also throw, but worth trying
|
|
popupClosed = popup.closed
|
|
if (popupClosed) {
|
|
clearInterval(interval)
|
|
reject(new Error('Popup closed by user'))
|
|
}
|
|
} catch {
|
|
// Completely swallow the error - we'll keep checking
|
|
}
|
|
}
|
|
}, 500)
|
|
})
|
|
}
|
|
|
|
// Helper to get nested value from object using dot notation
|
|
const getNestedValue = (obj: any, path: string): any => {
|
|
return path.split('.').reduce((current, key) => current?.[key], obj)
|
|
}
|
|
|
|
// Resolve authUri by replacing template variables like {{config.subdomain}}
|
|
const getResolvedAuthUri = computed(() => {
|
|
let url = OAuthConfig.value.authUri
|
|
|
|
if (!url || !props.formData) return url
|
|
|
|
// Replace all {{path.to.value}} with actual values from formData
|
|
url = url.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
|
|
const value = getNestedValue(props.formData, path.trim())
|
|
return value !== undefined && value !== null ? String(value) : match
|
|
})
|
|
|
|
return url
|
|
})
|
|
|
|
const handleOAuth = async () => {
|
|
let url = getResolvedAuthUri.value
|
|
|
|
// Check if URL still has unresolved templates
|
|
if (url.includes('{{') && url.includes('}}')) {
|
|
message.error('Please fill in all required fields before authenticating')
|
|
return
|
|
}
|
|
|
|
// Check if URL is empty or invalid
|
|
if (!url || url.trim() === '') {
|
|
message.error('Please fill in all required fields before authenticating')
|
|
return
|
|
}
|
|
|
|
const codeVerifier = generateCodeVerifier()
|
|
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
|
|
|
url += `&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
|
|
|
try {
|
|
const result = await openPopup(url, `${OAuthConfig.value.provider} OAuth`, generateState(), codeVerifier, 500, 600)
|
|
|
|
if (!result) {
|
|
message.error('Failed to authenticate using OAuth')
|
|
return
|
|
}
|
|
|
|
vModel.value = {
|
|
code: result.code,
|
|
code_verifier: result.codeVerifier,
|
|
}
|
|
} catch (e: any) {
|
|
if (e?.message?.includes('Popup closed by user') || e?.message?.includes('No code returned')) {
|
|
return
|
|
}
|
|
|
|
message.error(e?.message || 'Failed to authenticate using OAuth')
|
|
console.error(e)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<NcButton type="primary" @click="handleOAuth">
|
|
<div class="flex items-center gap-2">
|
|
<div class="font-bold">Authenticate With {{ OAuthConfig.provider }}</div>
|
|
<template v-if="haveValue">
|
|
<GeneralIcon icon="circleCheckSolid" class="text-success w-6 h-6" />
|
|
</template>
|
|
</div>
|
|
</NcButton>
|
|
</div>
|
|
</template>
|
|
|
|
<style></style>
|