@nuxtagentic/google 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +21 -2
- package/module.ts +21 -0
- package/package.json +43 -3
- package/runtime/components/ConnectionSettings.vue +89 -0
- package/runtime/composables/useGoogleConnection.ts +112 -0
- package/runtime/server/api/integrations/google/[service]/connection.post.ts +43 -0
- package/runtime/server/api/integrations/google/[service]/status.get.ts +21 -0
- package/runtime/server/utils/google.ts +222 -0
- package/runtime/shared/agent.ts +5 -0
- package/runtime/shared/google.ts +13 -0
- package/schema.ts +24 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Proprietary software. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Use of Nuxt Agentic packages and starter materials is governed by the applicable
|
|
4
|
+
commercial agreement supplied with a purchase. This repository does not grant
|
|
5
|
+
an open-source license or permission to redistribute the packages as a competing
|
|
6
|
+
template or module library.
|
|
7
|
+
|
|
8
|
+
Third-party dependencies retain their respective licenses. This notice does not
|
|
9
|
+
replace or modify those licenses.
|
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Google connections
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@nuxtagentic/google` owns the shared Google connection lifecycle and authenticated
|
|
4
|
+
server access. It installs Identity and Prompt. Include `googleModels` from
|
|
5
|
+
`@nuxtagentic/google/schema` in the host config to store service preferences.
|
|
6
|
+
|
|
7
|
+
Workspace features use these Google connections. Integrations (Gmail, Calendar,
|
|
8
|
+
Drive, Analytics, Search Console, Ads) connect through CloudConfig Connect instead,
|
|
9
|
+
with a Google grant separate from sign-in. This shared service remains in the agentic
|
|
10
|
+
starter even though the standalone Calendar, Files, and Emails dashboard modules have
|
|
11
|
+
moved out.
|
|
12
|
+
|
|
13
|
+
Configure the Google OAuth client with `pnpm exec cloudconfig auth add google`.
|
|
14
|
+
Register `/api/auth/callback/google` for each app origin. Account linking expects
|
|
15
|
+
the same Google email as the signed-in account. Enable the Google provider APIs
|
|
16
|
+
required by the integration actions you choose, such as Gmail, Calendar, or Drive.
|
|
17
|
+
OAuth client secrets and refresh tokens stay on the server.
|
|
18
|
+
|
|
19
|
+
Use [Integrations](../integrations/README.md) to connect Google accounts for agents,
|
|
20
|
+
choose permission scopes and actions, and [Automations](../automations/README.md) to
|
|
21
|
+
configure triggers. Connecting a service requests its permissions without replacing the
|
|
22
|
+
sign-in session; disconnecting disables the service while retaining Google sign-in.
|
package/module.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
2
|
+
import { registerWorkspaceModule } from '@nuxtagentic/kit'
|
|
3
|
+
|
|
4
|
+
export default defineNuxtModule({
|
|
5
|
+
meta: { name: '@nuxtagentic/google' },
|
|
6
|
+
moduleDependencies: {
|
|
7
|
+
'@nuxtagentic/identity': {},
|
|
8
|
+
'@nuxtagentic/prompt': {},
|
|
9
|
+
},
|
|
10
|
+
setup(_options, nuxt) {
|
|
11
|
+
const resolver = createResolver(import.meta.url)
|
|
12
|
+
registerWorkspaceModule(
|
|
13
|
+
{
|
|
14
|
+
id: 'google',
|
|
15
|
+
runtime: resolver.resolve('./runtime'),
|
|
16
|
+
metadata: resolver.resolve('./runtime/shared/agent.ts'),
|
|
17
|
+
},
|
|
18
|
+
nuxt,
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxtagentic/google",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"module.ts",
|
|
7
|
+
"runtime",
|
|
8
|
+
"schema.ts",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./module.ts",
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./schema": "./schema.ts",
|
|
16
|
+
"./runtime/*.vue": "./runtime/*.vue",
|
|
17
|
+
"./runtime/*.css": "./runtime/*.css",
|
|
18
|
+
"./runtime/*.json": "./runtime/*.json",
|
|
19
|
+
"./runtime/*.svg": "./runtime/*.svg",
|
|
20
|
+
"./runtime/*": "./runtime/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@cloudconfig/core": "0.2.0-beta.2",
|
|
24
|
+
"@nuxt/kit": "4.6.1",
|
|
25
|
+
"h3": "1.15.11",
|
|
26
|
+
"@nuxtagentic/prompt": "0.1.0",
|
|
27
|
+
"@nuxtagentic/kit": "0.1.0",
|
|
28
|
+
"@nuxtagentic/identity": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"nuxt": "^4.6.1",
|
|
32
|
+
"vue": "^3.5.43"
|
|
33
|
+
},
|
|
34
|
+
"cloudconfig": {
|
|
35
|
+
"models": "googleModels"
|
|
36
|
+
},
|
|
37
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
38
|
+
"description": "Google connection lifecycle for Nuxt Agentic.",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
|
45
|
+
}
|
|
6
46
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useGoogleConnection } from '../composables/useGoogleConnection'
|
|
3
|
+
import { useRoute } from '#imports'
|
|
4
|
+
import { ref } from 'vue'
|
|
5
|
+
|
|
6
|
+
import { getIntegrationErrorMessage, type GoogleService } from '../shared/google'
|
|
7
|
+
|
|
8
|
+
const { service, name, icon, description } = defineProps<{
|
|
9
|
+
service: GoogleService
|
|
10
|
+
name: string
|
|
11
|
+
icon: string
|
|
12
|
+
description: string
|
|
13
|
+
}>()
|
|
14
|
+
const connection = useGoogleConnection(() => service)
|
|
15
|
+
const route = useRoute()
|
|
16
|
+
const error = ref('')
|
|
17
|
+
async function disconnect() {
|
|
18
|
+
error.value = ''
|
|
19
|
+
try {
|
|
20
|
+
await connection.disconnect()
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
error.value = getIntegrationErrorMessage(cause)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<SettingsSection
|
|
29
|
+
:title="name"
|
|
30
|
+
:description="description"
|
|
31
|
+
>
|
|
32
|
+
<div class="flex items-center gap-3 py-2">
|
|
33
|
+
<UIcon
|
|
34
|
+
:name="icon"
|
|
35
|
+
class="size-5 shrink-0 text-muted"
|
|
36
|
+
/>
|
|
37
|
+
<p class="min-w-0 flex-1 text-[13px] text-muted">
|
|
38
|
+
{{
|
|
39
|
+
connection.connected.value ? connection.data.value?.email : 'Connect your Google account.'
|
|
40
|
+
}}
|
|
41
|
+
</p>
|
|
42
|
+
<UDropdownMenu
|
|
43
|
+
v-if="connection.connected.value"
|
|
44
|
+
:items="[
|
|
45
|
+
{
|
|
46
|
+
label: 'Reconnect',
|
|
47
|
+
icon: 'i-lucide-refresh-cw',
|
|
48
|
+
onSelect: () => connection.connect('/dashboard/settings#connections'),
|
|
49
|
+
},
|
|
50
|
+
{ label: 'Disconnect', icon: 'i-lucide-unplug', onSelect: disconnect },
|
|
51
|
+
]"
|
|
52
|
+
>
|
|
53
|
+
<UButton
|
|
54
|
+
label="Connected"
|
|
55
|
+
trailing-icon="i-lucide-chevron-down"
|
|
56
|
+
variant="outline"
|
|
57
|
+
size="sm"
|
|
58
|
+
:loading="connection.connecting.value"
|
|
59
|
+
/>
|
|
60
|
+
</UDropdownMenu>
|
|
61
|
+
<UButton
|
|
62
|
+
v-else
|
|
63
|
+
:label="connection.data.value?.available === false ? 'Invite only' : 'Connect'"
|
|
64
|
+
size="sm"
|
|
65
|
+
:disabled="
|
|
66
|
+
connection.data.value?.available === false || connection.data.value?.configured === false
|
|
67
|
+
"
|
|
68
|
+
:loading="connection.connecting.value"
|
|
69
|
+
@click="connection.connect('/dashboard/settings#connections')"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
<p
|
|
73
|
+
v-if="error"
|
|
74
|
+
role="alert"
|
|
75
|
+
class="text-sm text-error"
|
|
76
|
+
>
|
|
77
|
+
{{ error }}
|
|
78
|
+
</p>
|
|
79
|
+
<p
|
|
80
|
+
v-if="route.query.googleError === service"
|
|
81
|
+
role="alert"
|
|
82
|
+
class="text-sm text-error"
|
|
83
|
+
>
|
|
84
|
+
Google could not connect this account. Use the Google account matching your sign-in email and
|
|
85
|
+
grant the requested permissions.
|
|
86
|
+
</p>
|
|
87
|
+
<slot />
|
|
88
|
+
</SettingsSection>
|
|
89
|
+
</template>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCurrentUser } from '@nuxtagentic/identity/runtime/composables/useCurrentUser'
|
|
2
|
+
import {
|
|
3
|
+
useCloudConfigAuth,
|
|
4
|
+
useRequestFetch,
|
|
5
|
+
useRoute,
|
|
6
|
+
useToast,
|
|
7
|
+
useAsyncData,
|
|
8
|
+
clearNuxtData,
|
|
9
|
+
useState,
|
|
10
|
+
} from '#imports'
|
|
11
|
+
import { ref, computed, toValue, type MaybeRefOrGetter } from 'vue'
|
|
12
|
+
import {
|
|
13
|
+
type GoogleService,
|
|
14
|
+
type GoogleConnection,
|
|
15
|
+
getIntegrationErrorMessage,
|
|
16
|
+
} from '../shared/google'
|
|
17
|
+
import { GOOGLE_SERVICES } from '#workspace/client'
|
|
18
|
+
|
|
19
|
+
export function useGoogleConnection(service: MaybeRefOrGetter<GoogleService>) {
|
|
20
|
+
const settings = computed(() => {
|
|
21
|
+
const serviceId = toValue(service)
|
|
22
|
+
const configuration = GOOGLE_SERVICES[serviceId]
|
|
23
|
+
if (!configuration) {
|
|
24
|
+
throw new Error(`Google connection "${serviceId}" is not enabled in this application.`)
|
|
25
|
+
}
|
|
26
|
+
return { ...configuration, serviceId }
|
|
27
|
+
})
|
|
28
|
+
const user = useCurrentUser()
|
|
29
|
+
const auth = useCloudConfigAuth()
|
|
30
|
+
const fetch = useRequestFetch()
|
|
31
|
+
const route = useRoute()
|
|
32
|
+
const toast = useToast()
|
|
33
|
+
const connecting = ref(false)
|
|
34
|
+
const resource = useAsyncData(
|
|
35
|
+
computed(() => `google:${settings.value.serviceId}:connection:${user.value?.id ?? ''}`),
|
|
36
|
+
() =>
|
|
37
|
+
user.value
|
|
38
|
+
? fetch<GoogleConnection>(`/api/integrations/google/${settings.value.serviceId}/status`)
|
|
39
|
+
: Promise.resolve<GoogleConnection>({
|
|
40
|
+
configured: false,
|
|
41
|
+
connected: false,
|
|
42
|
+
needsConsent: true,
|
|
43
|
+
}),
|
|
44
|
+
{ lazy: true, deep: false, dedupe: 'defer' },
|
|
45
|
+
)
|
|
46
|
+
const connected = computed(() => resource.data.value?.connected === true && !resource.error.value)
|
|
47
|
+
|
|
48
|
+
async function connect(callbackURL = route.fullPath) {
|
|
49
|
+
const { serviceId: selectedService, scopes: requestedScopes } = settings.value
|
|
50
|
+
const reauthorize = connected.value
|
|
51
|
+
connecting.value = true
|
|
52
|
+
try {
|
|
53
|
+
const enabled = await fetch<{ connected: boolean }>(
|
|
54
|
+
`/api/integrations/google/${selectedService}/connection`,
|
|
55
|
+
{ method: 'POST', body: { enabled: true } },
|
|
56
|
+
)
|
|
57
|
+
if (enabled.connected && !reauthorize && !resource.error.value) {
|
|
58
|
+
await resource.refresh()
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
const accounts = await auth.listAccounts()
|
|
62
|
+
if (accounts.error) {
|
|
63
|
+
throw new Error(accounts.error.message)
|
|
64
|
+
}
|
|
65
|
+
const scopes = [
|
|
66
|
+
...new Set([
|
|
67
|
+
...(accounts.data?.find((account) => account.providerId === 'google')?.scopes ?? []),
|
|
68
|
+
...requestedScopes,
|
|
69
|
+
]),
|
|
70
|
+
]
|
|
71
|
+
const result = await auth.linkSocial({
|
|
72
|
+
provider: 'google',
|
|
73
|
+
scopes,
|
|
74
|
+
callbackURL,
|
|
75
|
+
errorCallbackURL: `/dashboard/settings?googleError=${selectedService}#connections`,
|
|
76
|
+
additionalParams: {
|
|
77
|
+
prompt: 'consent',
|
|
78
|
+
access_type: 'offline',
|
|
79
|
+
include_granted_scopes: 'true',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
if (result.error) {
|
|
83
|
+
throw new Error(result.error.message ?? 'Could not connect Google.')
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
toast.add({
|
|
88
|
+
title: 'Could not connect Google',
|
|
89
|
+
description: getIntegrationErrorMessage(cause),
|
|
90
|
+
color: 'error',
|
|
91
|
+
})
|
|
92
|
+
return false
|
|
93
|
+
} finally {
|
|
94
|
+
connecting.value = false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function disconnect() {
|
|
99
|
+
const { serviceId: selectedService, disconnect: cleanup } = settings.value
|
|
100
|
+
await fetch(`/api/integrations/google/${selectedService}/connection`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
body: { enabled: false },
|
|
103
|
+
})
|
|
104
|
+
await resource.refresh()
|
|
105
|
+
const prefixes = cleanup?.dataPrefixes ?? []
|
|
106
|
+
clearNuxtData((key) => prefixes.some((prefix) => key.startsWith(prefix)))
|
|
107
|
+
for (const prefix of cleanup?.userStatePrefixes ?? []) {
|
|
108
|
+
useState(`${prefix}${user.value?.id ?? ''}`, () => null).value = null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { ...resource, connected, connecting, connect, disconnect }
|
|
112
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
|
|
2
|
+
import { googleDisconnectHooks } from '#workspace/server'
|
|
3
|
+
import {
|
|
4
|
+
getGoogleIntegration,
|
|
5
|
+
assertGoogleMutation,
|
|
6
|
+
getGoogleService,
|
|
7
|
+
} from '../../../../utils/google'
|
|
8
|
+
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
assertGoogleMutation(event)
|
|
11
|
+
const service = getGoogleService(getRouterParam(event, 'service'))
|
|
12
|
+
const body = await readBody<{ enabled?: boolean }>(event)
|
|
13
|
+
if (typeof body?.enabled !== 'boolean') {
|
|
14
|
+
throw createError({
|
|
15
|
+
statusCode: 400,
|
|
16
|
+
statusMessage: 'Choose whether to enable this connection.',
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
const { enabled } = body
|
|
20
|
+
const { available, unavailableReason, user, record, api, granted } = await getGoogleIntegration(
|
|
21
|
+
event,
|
|
22
|
+
service,
|
|
23
|
+
)
|
|
24
|
+
if (enabled && !available) {
|
|
25
|
+
throw createError({ statusCode: 403, statusMessage: unavailableReason })
|
|
26
|
+
}
|
|
27
|
+
const input = { enabled, ownerId: user.id }
|
|
28
|
+
if (record) {
|
|
29
|
+
await api.googleIntegration.update(record.id, input)
|
|
30
|
+
} else {
|
|
31
|
+
await api.googleIntegration.create({
|
|
32
|
+
...input,
|
|
33
|
+
key: `${user.id}:${service}`,
|
|
34
|
+
service,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
if (!enabled) {
|
|
38
|
+
for (const disconnect of googleDisconnectHooks) {
|
|
39
|
+
await disconnect({ event, service, user })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { connected: enabled && granted }
|
|
43
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineEventHandler, getRouterParam } from 'h3'
|
|
2
|
+
import {
|
|
3
|
+
getGoogleService,
|
|
4
|
+
getGoogleIntegration,
|
|
5
|
+
isGoogleConfigured,
|
|
6
|
+
} from '../../../../utils/google'
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
const service = getGoogleService(getRouterParam(event, 'service'))
|
|
9
|
+
const { connected, granted, available, account, user } = await getGoogleIntegration(
|
|
10
|
+
event,
|
|
11
|
+
service,
|
|
12
|
+
)
|
|
13
|
+
return {
|
|
14
|
+
configured: isGoogleConfigured(event),
|
|
15
|
+
connected,
|
|
16
|
+
needsConsent: !granted,
|
|
17
|
+
available,
|
|
18
|
+
accountId: account?.accountId,
|
|
19
|
+
email: user.email,
|
|
20
|
+
}
|
|
21
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createError, getHeader, getRequestURL, setHeader, type H3Event } from 'h3'
|
|
2
|
+
import { useRuntimeConfig } from '#imports'
|
|
3
|
+
import { useCloudConfigServer } from '#cloudconfig/server'
|
|
4
|
+
import type { GoogleConfig } from '@nuxtagentic/google/schema'
|
|
5
|
+
import type { GoogleService } from '../../shared/google'
|
|
6
|
+
import { GOOGLE_SERVICES, googleAvailabilityHooks, getWorkspaceMetadata } from '#workspace/server'
|
|
7
|
+
|
|
8
|
+
interface GoogleAccount {
|
|
9
|
+
id: string
|
|
10
|
+
accountId: string
|
|
11
|
+
providerId: string
|
|
12
|
+
scopes: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getGoogleAuthPath(event: H3Event) {
|
|
16
|
+
return (
|
|
17
|
+
(useRuntimeConfig(event).public.cloudconfig as { authBasePath?: string })?.authBasePath ??
|
|
18
|
+
'/api/auth'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isGoogleConfigured(event: H3Event) {
|
|
23
|
+
const env = event.context.cloudflare?.env as Record<string, unknown> | undefined
|
|
24
|
+
return !!(
|
|
25
|
+
(env?.GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID) &&
|
|
26
|
+
(env?.GOOGLE_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET)
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getGoogleContext(event: H3Event) {
|
|
31
|
+
const cached = event.context.googleConnectionContext as
|
|
32
|
+
ReturnType<typeof createGoogleContext> | undefined
|
|
33
|
+
if (cached) {
|
|
34
|
+
return cached
|
|
35
|
+
}
|
|
36
|
+
const context = createGoogleContext(event)
|
|
37
|
+
event.context.googleConnectionContext = context
|
|
38
|
+
return context
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function createGoogleContext(event: H3Event) {
|
|
42
|
+
setHeader(event, 'Cache-Control', 'private, no-store')
|
|
43
|
+
const session = await event.$fetch<{ user?: { id: string; email: string } } | null>(
|
|
44
|
+
`${getGoogleAuthPath(event)}/get-session`,
|
|
45
|
+
{ query: { disableCookieCache: true } },
|
|
46
|
+
)
|
|
47
|
+
if (!session?.user) {
|
|
48
|
+
throw createError({ statusCode: 401, statusMessage: 'Sign in to connect Google.' })
|
|
49
|
+
}
|
|
50
|
+
const accounts = await event.$fetch<GoogleAccount[]>(`${getGoogleAuthPath(event)}/list-accounts`)
|
|
51
|
+
const api = await useCloudConfigServer<GoogleConfig>(event)
|
|
52
|
+
return {
|
|
53
|
+
api,
|
|
54
|
+
user: { ...session.user, id: Number(session.user.id) },
|
|
55
|
+
account: accounts.find((account) => account.providerId === 'google'),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function getGoogleIntegration(event: H3Event, service: GoogleService) {
|
|
60
|
+
const settings = getWorkspaceMetadata(event.context.workspaceModules as string[] | undefined)
|
|
61
|
+
.GOOGLE_SERVICES[service]
|
|
62
|
+
if (!settings) {
|
|
63
|
+
throw createError({ statusCode: 400, statusMessage: 'Choose an enabled Google connection.' })
|
|
64
|
+
}
|
|
65
|
+
const context = await getGoogleContext(event)
|
|
66
|
+
const rows = await context.api.googleIntegration.list({
|
|
67
|
+
pageSize: 1,
|
|
68
|
+
filters: {
|
|
69
|
+
type: 'group',
|
|
70
|
+
logic: 'and',
|
|
71
|
+
children: [
|
|
72
|
+
{ type: 'condition', field: 'key', operator: 'eq', value: `${context.user.id}:${service}` },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
const record = rows.data[0]
|
|
77
|
+
const granted =
|
|
78
|
+
!!context.account && settings.scopes.every((scope) => context.account!.scopes.includes(scope))
|
|
79
|
+
let unavailableReason: string | undefined
|
|
80
|
+
for (const checkAvailability of googleAvailabilityHooks) {
|
|
81
|
+
unavailableReason = await checkAvailability({ event, service, user: context.user })
|
|
82
|
+
if (unavailableReason) {
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const available = !unavailableReason
|
|
87
|
+
return {
|
|
88
|
+
...context,
|
|
89
|
+
record,
|
|
90
|
+
granted,
|
|
91
|
+
available,
|
|
92
|
+
unavailableReason,
|
|
93
|
+
connected: available && granted && record?.enabled !== false,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getGoogleAccessToken(event: H3Event, accountId: string) {
|
|
98
|
+
const tokens = (event.context.googleAccessTokens ??= new Map<string, Promise<string>>()) as Map<
|
|
99
|
+
string,
|
|
100
|
+
Promise<string>
|
|
101
|
+
>
|
|
102
|
+
const cached = tokens.get(accountId)
|
|
103
|
+
if (cached) {
|
|
104
|
+
return cached
|
|
105
|
+
}
|
|
106
|
+
const token = fetchGoogleAccessToken(event, accountId)
|
|
107
|
+
tokens.set(accountId, token)
|
|
108
|
+
return token
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchGoogleAccessToken(event: H3Event, accountId: string) {
|
|
112
|
+
try {
|
|
113
|
+
const result = await event.$fetch<{ accessToken: string }>(
|
|
114
|
+
`${getGoogleAuthPath(event)}/get-access-token`,
|
|
115
|
+
{ method: 'POST', body: { accountId } },
|
|
116
|
+
)
|
|
117
|
+
if (!result.accessToken) {
|
|
118
|
+
throw new Error('Missing token')
|
|
119
|
+
}
|
|
120
|
+
return result.accessToken
|
|
121
|
+
} catch {
|
|
122
|
+
throw createError({
|
|
123
|
+
statusCode: 401,
|
|
124
|
+
statusMessage: 'Your Google connection expired. Reconnect to continue.',
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function getGoogleClient(event: H3Event, service: GoogleService) {
|
|
130
|
+
const context = await getGoogleIntegration(event, service)
|
|
131
|
+
if (!context.available) {
|
|
132
|
+
throw createError({ statusCode: 403, statusMessage: context.unavailableReason })
|
|
133
|
+
}
|
|
134
|
+
if (!context.connected || !context.account) {
|
|
135
|
+
throw createError({
|
|
136
|
+
statusCode: 409,
|
|
137
|
+
statusMessage: `Connect ${GOOGLE_SERVICES[service]?.label ?? service} to continue.`,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
const accessToken = await getGoogleAccessToken(event, context.account.id)
|
|
141
|
+
async function request<T>(
|
|
142
|
+
url: string,
|
|
143
|
+
options: {
|
|
144
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
|
145
|
+
body?: Record<string, unknown> | string
|
|
146
|
+
query?: Record<string, string | number | boolean | string[]>
|
|
147
|
+
headers?: Record<string, string>
|
|
148
|
+
responseType?: 'json' | 'arrayBuffer' | 'text'
|
|
149
|
+
} = {},
|
|
150
|
+
): Promise<T> {
|
|
151
|
+
try {
|
|
152
|
+
return (await $fetch<T>(url, {
|
|
153
|
+
...options,
|
|
154
|
+
headers: { Authorization: `Bearer ${accessToken}`, ...options.headers },
|
|
155
|
+
retry: 0,
|
|
156
|
+
timeout: 30_000,
|
|
157
|
+
})) as T
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
const error = cause as { statusCode?: number; status?: number }
|
|
160
|
+
const status = error.statusCode ?? error.status ?? 502
|
|
161
|
+
const message = googleErrorMessages[status] ?? 'Could not reach Google. Please try again.'
|
|
162
|
+
throw createError({ statusCode: status, statusMessage: message })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { ...context, request }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const googleErrorMessages: Record<number, string> = {
|
|
169
|
+
401: 'Your Google connection expired. Reconnect to continue.',
|
|
170
|
+
403: 'Google denied access. Check the account permissions and reconnect.',
|
|
171
|
+
404: 'This item is no longer available in Google.',
|
|
172
|
+
412: 'This item changed in Google. Refresh it before saving.',
|
|
173
|
+
429: 'Google is busy. Wait a moment and try again.',
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function assertGoogleMutation(event: H3Event) {
|
|
177
|
+
const origin = getHeader(event, 'origin')
|
|
178
|
+
if (origin && origin !== getRequestURL(event).origin) {
|
|
179
|
+
throw createError({ statusCode: 403, statusMessage: 'Open Google actions from this app.' })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function getGoogleService(value: unknown): GoogleService {
|
|
184
|
+
if (typeof value !== 'string' || !Object.hasOwn(GOOGLE_SERVICES, value)) {
|
|
185
|
+
throw createError({ statusCode: 400, statusMessage: 'Choose a valid Google connection.' })
|
|
186
|
+
}
|
|
187
|
+
return value as GoogleService
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function assertGoogleId(value: unknown) {
|
|
191
|
+
if (typeof value !== 'string' || !/^[\w-]{1,2048}$/.test(value)) {
|
|
192
|
+
throw createError({ statusCode: 400, statusMessage: 'Choose a valid Google item.' })
|
|
193
|
+
}
|
|
194
|
+
return value
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The unique key index also bounds user-owned job listings. */
|
|
198
|
+
export function getGoogleOwnerKeys(userId: number) {
|
|
199
|
+
return [
|
|
200
|
+
{ type: 'condition' as const, field: 'key', operator: 'gte' as const, value: `${userId}:` },
|
|
201
|
+
{ type: 'condition' as const, field: 'key', operator: 'lt' as const, value: `${userId};` },
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Keep large pages from issuing a burst of Google requests. */
|
|
206
|
+
export async function mapWithConcurrency<T, R>(
|
|
207
|
+
items: T[],
|
|
208
|
+
map: (item: T) => Promise<R>,
|
|
209
|
+
concurrency = 4,
|
|
210
|
+
): Promise<R[]> {
|
|
211
|
+
const results = new Array<R>(items.length)
|
|
212
|
+
let cursor = 0
|
|
213
|
+
await Promise.all(
|
|
214
|
+
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
215
|
+
while (cursor < items.length) {
|
|
216
|
+
const index = cursor++
|
|
217
|
+
results[index] = await map(items[index]!)
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
return results
|
|
222
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type GoogleService = 'drive' | 'calendar' | 'gmail'
|
|
2
|
+
export interface GoogleConnection {
|
|
3
|
+
configured: boolean
|
|
4
|
+
connected: boolean
|
|
5
|
+
needsConsent: boolean
|
|
6
|
+
available?: boolean
|
|
7
|
+
accountId?: string
|
|
8
|
+
email?: string
|
|
9
|
+
}
|
|
10
|
+
export function getIntegrationErrorMessage(cause: unknown) {
|
|
11
|
+
const error = cause as { data?: { statusMessage?: string; message?: string }; message?: string }
|
|
12
|
+
return error?.data?.statusMessage ?? error?.data?.message ?? error?.message ?? 'Please try again.'
|
|
13
|
+
}
|
package/schema.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { model, t } from '@cloudconfig/core'
|
|
2
|
+
|
|
3
|
+
export const googleModels = {
|
|
4
|
+
googleIntegration: model(
|
|
5
|
+
{
|
|
6
|
+
key: t.text().required().unique().serverOnly(),
|
|
7
|
+
service: t.select(['drive', 'calendar', 'gmail']).required(),
|
|
8
|
+
enabled: t.boolean().default(true).serverOnly(),
|
|
9
|
+
settings: t.json().serverOnly(),
|
|
10
|
+
ownerId: t.number().required().serverOnly(),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
access: {
|
|
14
|
+
list: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
15
|
+
read: ({ user, where }) => (user ? where('ownerId', 'eq', user.id) : false),
|
|
16
|
+
create: ({ user, input }) => !!user && input?.ownerId === user.id,
|
|
17
|
+
update: ({ user, input, where }) =>
|
|
18
|
+
user && input?.ownerId === user.id ? where('ownerId', 'eq', user.id) : false,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type GoogleConfig = { models: typeof googleModels }
|