Zapier integration (#491)

* create zapier app

* install sanctum

* move OAuthProviderController

* make `api-external` middleware

* add zapier endpoints

* add tests

* token management

* zapier event handler

* add policy

* use `slug` instead of `id`

* wip

* check policies

* change api prefix to `external`

* ui tweaks

* validate token abilities

* open zapier URL

* zapier ui tweaks

* update zap

* Fix linting

* Added sample endpoints + minor UI changes

* Run PHP code linter

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Boris Lepikhin
2024-08-12 02:14:02 -07:00
committed by GitHub
parent 7ad62fb3ea
commit 517bccc695
61 changed files with 5799 additions and 51 deletions

View File

@@ -7,6 +7,7 @@
<v-checkbox
:id="id ? id : name"
v-model="compVal"
:value="value"
:disabled="disabled ? true : null"
:name="name"
:color="color"
@@ -48,6 +49,7 @@ export default {
components: { InputWrapper, VCheckbox },
props: {
...inputProps,
value: { type: [Boolean, String, Number, Object], required: false },
},
setup(props, context) {

View File

@@ -3,6 +3,7 @@
<input
:id="id || name"
v-model="internalValue"
:value="value"
:name="name"
type="checkbox"
class="rounded border-gray-500 w-10 h-10 cursor-pointer checkbox"
@@ -32,6 +33,7 @@ const props = defineProps({
id: { type: String, default: null },
name: { type: String, default: "checkbox" },
modelValue: { type: [Boolean, String], default: false },
value: { type: [Boolean, String, Number, Object], required: false },
disabled: { type: Boolean, default: false },
theme: {
type: Object, default: () => {

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-wrap sm:flex-nowrap my-4 w-full">
<div
class="w-full sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100"
class="flex-1 truncate sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100"
>
<p class="select-all text-gray-900">
{{ content }}
@@ -11,7 +11,7 @@
<v-button
color="light-gray"
class="w-full"
@click="copyToClipboard"
@click.prevent="copyToClipboard"
>
<slot name="icon">
<svg

View File

@@ -92,6 +92,7 @@
</v-button>
</template>
<a
v-if="integrationTypeInfo.is_editable !== false"
v-track.edit_form_integration_click="{
form_slug: form.slug,
form_integration_id: integration.id,
@@ -106,6 +107,21 @@
/>
Edit
</a>
<a
v-else-if="integrationTypeInfo.url"
v-track.edit_form_integration_click="{
form_slug: form.slug,
form_integration_id: integration.id,
}"
:href="integrationTypeInfo.url"
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"
>
<Icon
name="heroicons:pencil"
class="w-5 h-5 mr-2"
/>
Edit on {{ integrationTypeInfo.name }}
</a>
<a
v-track.past_events_form_integration_click="{
form_slug: form.slug,

View File

@@ -1,7 +1,7 @@
<template>
<UTooltip
:text="tooltipText"
:prevent="!unavailable"
:prevent="!unavailable || !tooltipText"
>
<div
v-track.new_integration_click="{ name: integration.id }"
@@ -32,6 +32,11 @@
>
(coming soon)</span>
</div>
<Icon
v-if="integration.is_external"
class="inline h-4 w-4 ml-1 inline text-gray-500"
name="heroicons:arrow-top-right-on-square-20-solid"
/>
</div>
<pro-tag
v-if="integration?.is_pro === true"
@@ -42,8 +47,10 @@
</template>
<script setup>
const emit = defineEmits(["select"])
import { computed } from 'vue'
import { useWorkspacesStore } from '@/stores/workspaces'
const emit = defineEmits(["select"])
const props = defineProps({
integration: {
type: Object,
@@ -51,22 +58,25 @@ const props = defineProps({
},
})
const workspacesStore = useWorkspacesStore()
const currentWorkspace = computed(() => workspacesStore.getCurrent)
const unavailable = computed(() => {
return (
props.integration.coming_soon || props.integration.requires_subscription
props.integration.coming_soon ||
(props.integration.requires_subscription && !currentWorkspace.value.is_pro)
)
})
const tooltipText = computed(() => {
if (props.integration.coming_soon) return "This integration is coming soon"
if (props.integration.requires_subscription)
if (props.integration.requires_subscription && !currentWorkspace.value.is_pro )
return "You need a subscription to use this integration."
return ""
return null
})
const onClick = () => {
if (props.integration.coming_soon || props.integration.requires_subscription)
return
if (unavailable.value) return
emit("select", props.integration.id)
}
</script>
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div
class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center"
>
<div class="flex-grow flex items-center">
<div>
<div class="flex space-x-3 font-semibold mr-2">
{{ token.name }}
</div>
<div class="">
<span
v-for="(ability, index) in token.abilities"
:key="index"
>
{{ accessTokenStore.getAbility(ability).title }}
<template v-if="index !== token.abilities.length - 1">
,&nbsp;
</template>
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<dropdown
class="inline"
>
<template #trigger="{ toggle }">
<v-button
color="white"
@click="toggle"
>
<Icon
name="heroicons:ellipsis-horizontal"
class="w-4 h-4 -mt-1"
/>
</v-button>
</template>
<a
href="#"
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center"
@click.prevent="destroy"
>
<Icon
name="heroicons:trash"
class="w-5 h-5 mr-2"
/>
Delete
</a>
</dropdown>
</div>
</div>
</template>
<script setup>
const props = defineProps({
token: Object
})
const accessTokenStore = useAccessTokenStore()
const alert = useAlert()
function destroy() {
alert.confirm("Do you really want to delete this token?", () => {
opnFetch(`/settings/tokens/${props.token.id}`, {
method: 'DELETE'
})
.then(() => {
accessTokenStore.remove(props.token.id)
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while disconnecting an account")
}
})
})
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<modal
:show="show"
max-width="lg"
@close="emit('close')"
>
<template #icon>
<svg
class="w-8 h-8"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<template #title>
Create an access token
</template>
<div class="px-4">
<form
@submit.prevent="createToken"
@keydown="form.onKeydown($event)"
>
<div v-if="!token">
<text-input
name="name"
class="mt-4"
:form="form"
:required="true"
label="Name"
/>
<div>
<label class="input-label">Abilities</label>
<div class="mt-2">
<checkbox-input
v-for="ability in abilities"
:key="ability.name"
v-model="form.abilities"
:value="ability.name"
:label="ability.title"
disabled
/>
</div>
</div>
</div>
<UAlert
v-if="token"
icon="i-heroicons-key-20-solid"
color="green"
variant="subtle"
title="Copy your access token"
description="Your token will only be shown once. Make sure to save it safely."
/>
<div class="flex">
<copy-content
v-if="token"
:content="token"
>
<template #icon>
<Icon
name="heroicons:link"
class="w-4 h-4 -mt-1 text-blue-600 mr-3"
/>
</template>
Copy Token
</copy-content>
</div>
<div class="w-full mt-6">
<v-button
v-if="!token"
:loading="form.busy"
class="w-full my-3"
>
Save
</v-button>
<v-button
v-else
class="w-full my-3"
@click.prevent="emit('close')"
>
Close
</v-button>
</div>
</form>
</div>
</modal>
</template>
<script setup>
import CopyContent from "../open/forms/components/CopyContent.vue"
const props = defineProps({
show: Boolean
})
const emit = defineEmits(['close'])
const accessTokenStore = useAccessTokenStore()
const abilities = computed(() => accessTokenStore.abilities)
const form = useForm({
name: "",
abilities: abilities.value.map(ability => ability.name),
})
const token = ref('')
function createToken() {
form.post("/settings/tokens").then((data) => {
// workspacesStore.save(data.workspace)
// workspacesStore.currentId = data.workspace.id
// workspaceModal.value = false
token.value = data.token
accessTokenStore.fetchTokens()
useAlert().success(
"Access token successfully created!",
)
})
}
watch(() => props.show, () => {
form.name = ''
form.abilities = abilities.value.map(ability => ability.name),
token.value = ''
})
</script>

View File

@@ -31,17 +31,19 @@
"webhook": {
"name": "Webhook Notification",
"icon": "material-symbols:webhook",
"section_name": "Notifications",
"section_name": "Automation",
"file_name": "WebhookIntegration",
"is_pro": false
},
"zapier": {
"name": "Zapier Integration",
"name": "Zapier",
"icon": "cib:zapier",
"section_name": "Notifications",
"section_name": "Automation",
"file_name": "ZapierIntegration",
"is_pro": true,
"coming_soon": true
"is_pro": false,
"is_external": true,
"is_editable": false,
"url": "https://zapier.com/app/zaps"
},
"google_sheets": {
"name": "Google Sheets",

View File

@@ -49,7 +49,7 @@
v-for="(sectionItem, sectionItemKey) in section"
:key="sectionItemKey"
:integration="sectionItem"
@select="openIntegrationModal"
@select="openIntegration"
/>
</div>
</div>
@@ -107,11 +107,22 @@ onMounted(() => {
oAuthProvidersStore.fetchOAuthProviders(props.form.workspace_id)
})
const openIntegrationModal = (itemKey) => {
if (!itemKey || !integrations.value.has(itemKey))
const openIntegration = (itemKey) => {
if (!itemKey || !integrations.value.has(itemKey)) {
return alert.error("Integration not found")
if (integrations.value.get(itemKey).coming_soon)
}
const integration = integrations.value.get(itemKey)
if (integration.coming_soon) {
return alert.warning("This integration is not available yet")
}
if(integration.is_external && integration.url) {
window.open(integration.url, '_blank')
return
}
selectedIntegrationKey.value = itemKey
selectedIntegration.value = integrations.value.get(
selectedIntegrationKey.value,

View File

@@ -66,6 +66,10 @@ const tabsList = computed(() => {
name: "Workspace Settings",
route: "settings-workspace",
},
{
name: "Access Tokens",
route: "settings-access-tokens",
},
{
name: "Connections",
route: "settings-connections",

View File

@@ -0,0 +1,54 @@
<template>
<div>
<div class="flex items-center gap-y-4 flex-wrap-reverse">
<div class="flex-grow">
<h3 class="font-semibold text-2xl text-gray-900">
Access Tokens
</h3>
<small class="text-gray-600">Manage your access tokens keys.</small>
</div>
<UButton
label="Create new token"
icon="i-heroicons-plus"
:loading="loading"
@click="accessTokenModal = true"
/>
</div>
<div
v-if="loading"
class="w-full text-blue-500 text-center"
>
<Loader class="h-10 w-10 p-5" />
</div>
<div class="py-6">
<SettingsAccessTokenCard
v-for="token in tokens"
:key="token.id"
:token="token"
/>
</div>
<SettingsAccessTokenModal
:show="accessTokenModal"
@close="accessTokenModal = false"
/>
</div>
</template>
<script setup>
useOpnSeoMeta({
title: "Access Tokens",
})
const accessTokenModal = ref(false)
const accessTokenStore = useAccessTokenStore()
const tokens = computed(() => accessTokenStore.getAll)
const loading = computed(() => accessTokenStore.loading)
onMounted(() => {
accessTokenStore.fetchTokens()
})
</script>

47
client/stores/access_tokens.js vendored Normal file
View File

@@ -0,0 +1,47 @@
import { defineStore } from "pinia"
import { useContentStore } from "~/composables/stores/useContentStore.js"
export const useAccessTokenStore = defineStore("access_tokens", () => {
const contentStore = useContentStore()
const abilities = [
{
title: 'Manage integrations',
name: 'manage-integrations',
},
{
title: 'List forms',
name: 'list-forms',
},
{
title: 'List workspaces',
name: 'list-workspaces',
},
]
const fetchTokens = () => {
contentStore.resetState()
contentStore.startLoading()
return opnFetch('/settings/tokens').then(
(data) => {
contentStore.save(data)
contentStore.stopLoading()
},
)
}
const tokens = computed(() => contentStore.getAll.value)
const getAbility = (name) => {
return abilities.find(ability => ability.name == name)
}
return {
...contentStore,
fetchTokens,
tokens,
abilities,
getAbility
}
})