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>