feat(webhooks): add webhook management form

This commit is contained in:
kolaente 2023-10-18 20:12:29 +02:00
parent df09bca010
commit 3d2fe4cf65
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
8 changed files with 315 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import {
faArrowUpFromBracket,
faBars,
faBell,
faBolt,
faCalendar,
faCheck,
faCheckDouble,
@ -144,6 +145,7 @@ library.add(faUsers)
library.add(faArrowUpFromBracket)
library.add(faX)
library.add(faAnglesUp)
library.add(faBolt)
// overwriting the wrong types
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes

View File

@ -72,6 +72,12 @@
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
:to="{ name: 'project.settings.webhooks', params: { projectId: project.id } }"
icon="bolt"
>
{{ $t('project.webhooks.title') }}
</dropdown-item>
<dropdown-item
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"

View File

@ -359,6 +359,20 @@
"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).",
"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": {
@ -480,6 +494,7 @@
"custom": "Custom",
"id": "ID",
"created": "Created at",
"createdBy": "Created by {0}",
"actions": "Actions",
"cannotBeUndone": "This cannot be undone!"
},

View 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
View 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)
}
}

View File

@ -46,6 +46,7 @@ const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.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 ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
@ -286,6 +287,14 @@ const router = createRouter({
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/webhooks',
name: 'project.settings.webhooks',
component: ProjectSettingWebhooks,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/delete',
name: 'project.settings.delete',

29
src/services/webhook.ts Normal file
View 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()
}
}
}

View File

@ -0,0 +1,215 @@
<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'
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() {
const selectedEvents = Object.entries(newWebhookEvents.value)
.filter(([event, use]) => use)
.map(([event]) => event)
newWebhook.value.events = selectedEvents
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() {
}
</script>
<template>
<create-edit
:title="$t('project.webhooks.title')"
:has-primary-action="false"
>
<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"
/>
</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="mr-2"
v-model="newWebhookEvents[event]"
>
{{ event }}
</fancycheckbox>
</div>
</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>