feat: webhooks (#3783)
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/3783
This commit is contained in:
commit
5d991e539b
@ -8,6 +8,7 @@ import {
|
|||||||
faArrowUpFromBracket,
|
faArrowUpFromBracket,
|
||||||
faBars,
|
faBars,
|
||||||
faBell,
|
faBell,
|
||||||
|
faBolt,
|
||||||
faCalendar,
|
faCalendar,
|
||||||
faCheck,
|
faCheck,
|
||||||
faCheckDouble,
|
faCheckDouble,
|
||||||
@ -144,6 +145,7 @@ library.add(faUsers)
|
|||||||
library.add(faArrowUpFromBracket)
|
library.add(faArrowUpFromBracket)
|
||||||
library.add(faX)
|
library.add(faX)
|
||||||
library.add(faAnglesUp)
|
library.add(faAnglesUp)
|
||||||
|
library.add(faBolt)
|
||||||
|
|
||||||
// overwriting the wrong types
|
// overwriting the wrong types
|
||||||
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes
|
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes
|
@ -72,6 +72,12 @@
|
|||||||
@update:model-value="setSubscriptionInStore"
|
@update:model-value="setSubscriptionInStore"
|
||||||
type="dropdown"
|
type="dropdown"
|
||||||
/>
|
/>
|
||||||
|
<dropdown-item
|
||||||
|
:to="{ name: 'project.settings.webhooks', params: { projectId: project.id } }"
|
||||||
|
icon="bolt"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.title') }}
|
||||||
|
</dropdown-item>
|
||||||
<dropdown-item
|
<dropdown-item
|
||||||
v-if="level < 2"
|
v-if="level < 2"
|
||||||
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
|
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
|
||||||
|
11
src/helpers/isValidHttpUrl.ts
Normal file
11
src/helpers/isValidHttpUrl.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export function isValidHttpUrl(urlToCheck: string): boolean {
|
||||||
|
let url
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(urlToCheck)
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||||
|
}
|
@ -359,6 +359,21 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Favorites"
|
"title": "Favorites"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"webhooks": {
|
||||||
|
"title": "Webhooks",
|
||||||
|
"targetUrl": "Target URL",
|
||||||
|
"targetUrlInvalid": "Please provide a valid URL.",
|
||||||
|
"events": "Events",
|
||||||
|
"eventsHint": "Select all events this webhook should recieve updates for (within the current project).",
|
||||||
|
"mustSelectEvents": "You must select at least one event.",
|
||||||
|
"delete": "Delete this webhook",
|
||||||
|
"deleteText": "Are you sure you want to delete this webhook? External targets will not be notified of its events anymore.",
|
||||||
|
"deleteSuccess": "The webhook was successfully deleted.",
|
||||||
|
"create": "Create webhook",
|
||||||
|
"secret": "Secret",
|
||||||
|
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
|
||||||
|
"secretDocs": "Check out the docs for more details about how to use secrets."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
@ -480,6 +495,7 @@
|
|||||||
"custom": "Custom",
|
"custom": "Custom",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"created": "Created at",
|
"created": "Created at",
|
||||||
|
"createdBy": "Created by {0}",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"cannotBeUndone": "This cannot be undone!"
|
"cannotBeUndone": "This cannot be undone!"
|
||||||
},
|
},
|
||||||
|
14
src/modelTypes/IWebhook.ts
Normal file
14
src/modelTypes/IWebhook.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type {IAbstract} from './IAbstract'
|
||||||
|
import type {IUser} from '@/modelTypes/IUser'
|
||||||
|
|
||||||
|
export interface IWebhook extends IAbstract {
|
||||||
|
id: number
|
||||||
|
projectId: number
|
||||||
|
secret: string
|
||||||
|
targetUrl: string
|
||||||
|
events: string[]
|
||||||
|
createdBy: IUser
|
||||||
|
|
||||||
|
created: Date
|
||||||
|
updated: Date
|
||||||
|
}
|
25
src/models/webhook.ts
Normal file
25
src/models/webhook.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import AbstractModel from '@/models/abstractModel'
|
||||||
|
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||||
|
import UserModel from '@/models/user'
|
||||||
|
|
||||||
|
export default class WebhookModel extends AbstractModel<IWebhook> implements IWebhook {
|
||||||
|
id = 0
|
||||||
|
projectId = 0
|
||||||
|
secret = ''
|
||||||
|
targetUrl = ''
|
||||||
|
events = []
|
||||||
|
createdBy = null
|
||||||
|
|
||||||
|
created: Date
|
||||||
|
updated: Date
|
||||||
|
|
||||||
|
constructor(data: Partial<IWebhook> = {}) {
|
||||||
|
super()
|
||||||
|
this.assignData(data)
|
||||||
|
|
||||||
|
this.createdBy = new UserModel(this.createdBy)
|
||||||
|
|
||||||
|
this.created = new Date(this.created)
|
||||||
|
this.updated = new Date(this.updated)
|
||||||
|
}
|
||||||
|
}
|
@ -46,6 +46,7 @@ const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
|
|||||||
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
|
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
|
||||||
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue')
|
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue')
|
||||||
const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
|
const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
|
||||||
|
const ProjectSettingWebhooks = () => import('@/views/project/settings/webhooks.vue')
|
||||||
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
|
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
|
||||||
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
|
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
|
||||||
|
|
||||||
@ -286,6 +287,14 @@ const router = createRouter({
|
|||||||
showAsModal: true,
|
showAsModal: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:projectId/settings/webhooks',
|
||||||
|
name: 'project.settings.webhooks',
|
||||||
|
component: ProjectSettingWebhooks,
|
||||||
|
meta: {
|
||||||
|
showAsModal: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/projects/:projectId/settings/delete',
|
path: '/projects/:projectId/settings/delete',
|
||||||
name: 'project.settings.delete',
|
name: 'project.settings.delete',
|
||||||
|
29
src/services/webhook.ts
Normal file
29
src/services/webhook.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import AbstractService from '@/services/abstractService'
|
||||||
|
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||||
|
import WebhookModel from '@/models/webhook'
|
||||||
|
|
||||||
|
export default class WebhookService extends AbstractService<IWebhook> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
getAll: '/projects/{projectId}/webhooks',
|
||||||
|
create: '/projects/{projectId}/webhooks',
|
||||||
|
update: '/projects/{projectId}/webhooks/{id}',
|
||||||
|
delete: '/projects/{projectId}/webhooks/{id}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
modelFactory(data) {
|
||||||
|
return new WebhookModel(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableEvents(): Promise<string[]> {
|
||||||
|
const cancel = this.setLoading()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.http.get('/webhooks/events')
|
||||||
|
return response.data
|
||||||
|
} finally {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
255
src/views/project/settings/webhooks.vue
Normal file
255
src/views/project/settings/webhooks.vue
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export default {name: 'project-setting-webhooks'}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {ref, computed, watchEffect} from 'vue'
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import {useTitle} from '@vueuse/core'
|
||||||
|
|
||||||
|
import ProjectService from '@/services/project'
|
||||||
|
import ProjectModel from '@/models/project'
|
||||||
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
|
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||||
|
|
||||||
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||||
|
import WebhookService from '@/services/webhook'
|
||||||
|
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||||
|
import User from '@/components/misc/user.vue'
|
||||||
|
import WebhookModel from '@/models/webhook'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
|
import {success} from '@/message'
|
||||||
|
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
|
||||||
|
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
|
const project = ref<IProject>()
|
||||||
|
useTitle(t('project.webhooks.title'))
|
||||||
|
|
||||||
|
const showNewForm = ref(false)
|
||||||
|
|
||||||
|
async function loadProject(projectId: number) {
|
||||||
|
const projectService = new ProjectService()
|
||||||
|
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||||
|
await useBaseStore().handleSetCurrentProject({project: newProject})
|
||||||
|
project.value = newProject
|
||||||
|
await loadWebhooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const projectId = computed(() => route.params.projectId !== undefined
|
||||||
|
? parseInt(route.params.projectId as string)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||||
|
|
||||||
|
const webhooks = ref<IWebhook[]>()
|
||||||
|
const webhookService = new WebhookService()
|
||||||
|
const availableEvents = ref<string[]>()
|
||||||
|
|
||||||
|
async function loadWebhooks() {
|
||||||
|
webhooks.value = await webhookService.getAll({projectId: project.value.id})
|
||||||
|
availableEvents.value = await webhookService.getAvailableEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDeleteModal = ref(false)
|
||||||
|
const webhookIdToDelete = ref<number>()
|
||||||
|
|
||||||
|
async function deleteWebhook() {
|
||||||
|
await webhookService.delete({
|
||||||
|
id: webhookIdToDelete.value,
|
||||||
|
projectId: project.value.id,
|
||||||
|
})
|
||||||
|
showDeleteModal.value = false
|
||||||
|
success({message: t('project.webhooks.deleteSuccess')})
|
||||||
|
await loadWebhooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWebhook = ref(new WebhookModel())
|
||||||
|
const newWebhookEvents = ref({})
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
|
||||||
|
validateTargetUrl()
|
||||||
|
if (!webhookTargetUrlValid.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEvents = getSelectedEventsArray()
|
||||||
|
newWebhook.value.events = selectedEvents
|
||||||
|
|
||||||
|
validateSelectedEvents()
|
||||||
|
if (!selectedEventsValid.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newWebhook.value.projectId = project.value.id
|
||||||
|
const created = await webhookService.create(newWebhook.value)
|
||||||
|
webhooks.value.push(created)
|
||||||
|
newWebhook.value = new WebhookModel()
|
||||||
|
showNewForm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookTargetUrlValid = ref(true)
|
||||||
|
|
||||||
|
function validateTargetUrl() {
|
||||||
|
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEventsValid = ref(true)
|
||||||
|
|
||||||
|
function getSelectedEventsArray() {
|
||||||
|
return Object.entries(newWebhookEvents.value)
|
||||||
|
.filter(([_, use]) => use)
|
||||||
|
.map(([event]) => event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSelectedEvents() {
|
||||||
|
const events = getSelectedEventsArray()
|
||||||
|
if (events.length === 0) {
|
||||||
|
selectedEventsValid.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<create-edit
|
||||||
|
:title="$t('project.webhooks.title')"
|
||||||
|
:has-primary-action="false"
|
||||||
|
:wide="true"
|
||||||
|
>
|
||||||
|
<x-button
|
||||||
|
v-if="!(webhooks?.length === 0 || showNewForm)"
|
||||||
|
@click="showNewForm = true"
|
||||||
|
icon="plus"
|
||||||
|
class="mb-4">
|
||||||
|
{{ $t('project.webhooks.create') }}
|
||||||
|
</x-button>
|
||||||
|
|
||||||
|
<div class="p-4" v-if="webhooks?.length === 0 || showNewForm">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="targetUrl">
|
||||||
|
{{ $t('project.webhooks.targetUrl') }}
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
id="targetUrl"
|
||||||
|
class="input"
|
||||||
|
:placeholder="$t('project.webhooks.targetUrl')"
|
||||||
|
v-model="newWebhook.targetUrl"
|
||||||
|
@focusout="validateTargetUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="help is-danger" v-if="!webhookTargetUrlValid">
|
||||||
|
{{ $t('project.webhooks.targetUrlInvalid') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="secret">
|
||||||
|
{{ $t('project.webhooks.secret') }}
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
id="secret"
|
||||||
|
class="input"
|
||||||
|
v-model="newWebhook.secret"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
{{ $t('project.webhooks.secretHint') }}
|
||||||
|
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
||||||
|
{{ $t('project.webhooks.secretDocs') }}
|
||||||
|
</BaseButton>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="secret">
|
||||||
|
{{ $t('project.webhooks.events') }}
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
{{ $t('project.webhooks.eventsHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="control">
|
||||||
|
<fancycheckbox
|
||||||
|
v-for="event in availableEvents"
|
||||||
|
:key="event"
|
||||||
|
class="available-events-check"
|
||||||
|
v-model="newWebhookEvents[event]"
|
||||||
|
@update:model-value="validateSelectedEvents"
|
||||||
|
>
|
||||||
|
{{ event }}
|
||||||
|
</fancycheckbox>
|
||||||
|
</div>
|
||||||
|
<p class="help is-danger" v-if="!selectedEventsValid">
|
||||||
|
{{ $t('project.webhooks.mustSelectEvents') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<x-button @click="create" icon="plus">
|
||||||
|
{{ $t('project.webhooks.create') }}
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table
|
||||||
|
class="table has-actions is-striped is-hoverable is-fullwidth"
|
||||||
|
v-if="webhooks"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('project.webhooks.targetUrl') }}</th>
|
||||||
|
<th>{{ $t('project.webhooks.events') }}</th>
|
||||||
|
<th>{{ $t('misc.created') }}</th>
|
||||||
|
<th>{{ $t('misc.createdBy') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :key="w.id" v-for="w in webhooks">
|
||||||
|
<td>{{ w.targetUrl }}</td>
|
||||||
|
<td>{{ w.events.join(', ') }}</td>
|
||||||
|
<td>{{ formatDateShort(w.created) }}</td>
|
||||||
|
<td>
|
||||||
|
<User
|
||||||
|
:avatar-size="25"
|
||||||
|
:user="w.createdBy"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="actions">
|
||||||
|
<x-button
|
||||||
|
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
|
||||||
|
class="is-danger"
|
||||||
|
icon="trash-alt"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<modal
|
||||||
|
:enabled="showDeleteModal"
|
||||||
|
@close="showDeleteModal = false"
|
||||||
|
@submit="deleteWebhook()"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ $t('project.webhooks.delete') }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #text>
|
||||||
|
<p>{{ $t('project.webhooks.deleteText') }}</p>
|
||||||
|
</template>
|
||||||
|
</modal>
|
||||||
|
</create-edit>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.available-events-check {
|
||||||
|
margin-right: .5rem;
|
||||||
|
width: 12.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
x
Reference in New Issue
Block a user