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:
@@ -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) {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
83
client/components/settings/AccessTokenCard.vue
Normal file
83
client/components/settings/AccessTokenCard.vue
Normal 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">
|
||||
,
|
||||
</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>
|
||||
143
client/components/settings/AccessTokenModal.vue
Normal file
143
client/components/settings/AccessTokenModal.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
54
client/pages/settings/access-tokens.vue
Normal file
54
client/pages/settings/access-tokens.vue
Normal 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
47
client/stores/access_tokens.js
vendored
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user