feat: add user-level webhooks settings page

Add a new settings page for managing user-level webhooks. Extract
webhook form into shared WebhookManager component used by both
project and user webhook settings. Add routing, translations,
and navigation entry.
This commit is contained in:
kolaente
2026-03-08 19:25:53 +01:00
parent 47a0775c73
commit 2e1648ef4c
12 changed files with 418 additions and 253 deletions

View File

@@ -0,0 +1,277 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import type {IWebhook} from '@/modelTypes/IWebhook'
import WebhookModel from '@/models/webhook'
import BaseButton from '@/components/base/BaseButton.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FormField from '@/components/input/FormField.vue'
import Expandable from '@/components/base/Expandable.vue'
import User from '@/components/misc/User.vue'
import {formatDateShort} from '@/helpers/time/formatDate'
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
const props = defineProps<{
webhooks: IWebhook[]
availableEvents: string[]
loading?: boolean
}>()
const emit = defineEmits<{
create: [webhook: IWebhook]
delete: [webhookId: number]
}>()
defineOptions({name: 'WebhookManager'})
const showNewForm = ref(false)
const showBasicAuth = ref(false)
const newWebhook = ref(new WebhookModel())
const newWebhookEvents = ref<Record<string, boolean>>({})
function initEvents(events: string[]) {
newWebhookEvents.value = Object.fromEntries(
events.map(event => [event, false]),
)
}
watch(() => props.availableEvents, (events) => {
if (events) initEvents(events)
}, {immediate: true})
const webhookTargetUrlValid = ref(true)
const selectedEventsValid = ref(true)
const showDeleteModal = ref(false)
const webhookIdToDelete = ref<number>()
function validateTargetUrl() {
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
}
function getSelectedEventsArray() {
return Object.entries(newWebhookEvents.value)
.filter(([, use]) => use)
.map(([event]) => event)
}
function validateSelectedEvents() {
const events = getSelectedEventsArray()
selectedEventsValid.value = events.length > 0
}
function create() {
validateTargetUrl()
if (!webhookTargetUrlValid.value) {
return
}
const selectedEvents = getSelectedEventsArray()
newWebhook.value.events = selectedEvents
validateSelectedEvents()
if (!selectedEventsValid.value) {
return
}
emit('create', newWebhook.value)
newWebhook.value = new WebhookModel()
initEvents(props.availableEvents)
showNewForm.value = false
}
function confirmDelete(webhookId: number) {
webhookIdToDelete.value = webhookId
showDeleteModal.value = true
}
function doDelete() {
if (webhookIdToDelete.value) {
emit('delete', webhookIdToDelete.value)
}
showDeleteModal.value = false
}
</script>
<template>
<div>
<XButton
v-if="!(webhooks?.length === 0 || showNewForm)"
icon="plus"
class="mbe-4"
@click="showNewForm = true"
>
{{ $t('project.webhooks.create') }}
</XButton>
<div
v-if="webhooks?.length === 0 || showNewForm"
class="p-4"
>
<FormField
id="targetUrl"
v-model="newWebhook.targetUrl"
:label="$t('project.webhooks.targetUrl')"
required
:placeholder="$t('project.webhooks.targetUrl')"
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
@focusout="validateTargetUrl"
/>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.secret') }}
</label>
<div class="control">
<input
id="secret"
v-model="newWebhook.secret"
class="input"
>
</div>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
</div>
<BaseButton
class="mbe-2 has-text-primary"
@click="showBasicAuth = !showBasicAuth"
>
{{ $t('project.webhooks.basicauthlink') }}
</BaseButton>
<Expandable
:open="showBasicAuth"
class="content"
>
<div class="field">
<label
class="label"
for="basicauthuser"
>
{{ $t('project.webhooks.basicauthuser') }}
</label>
<div class="control">
<input
id="basicauthuser"
v-model="newWebhook.basicauthuser"
class="input"
>
</div>
</div>
<div class="field">
<label
class="label"
for="basicauthpassword"
>
{{ $t('project.webhooks.basicauthpassword') }}
</label>
<div class="control">
<input
id="basicauthpassword"
v-model="newWebhook.basicauthpassword"
class="input"
>
</div>
</div>
</Expandable>
<div class="field">
<label
class="label"
for="events"
>
{{ $t('project.webhooks.events') }}
</label>
<p class="help">
{{ $t('project.webhooks.eventsHint') }}
</p>
<div class="control">
<FancyCheckbox
v-for="event in availableEvents"
:key="event"
v-model="newWebhookEvents[event]"
class="available-events-check"
@update:modelValue="validateSelectedEvents"
>
{{ event }}
</FancyCheckbox>
</div>
<p
v-if="!selectedEventsValid"
class="help is-danger"
>
{{ $t('project.webhooks.mustSelectEvents') }}
</p>
</div>
<XButton
icon="plus"
@click="create"
>
{{ $t('project.webhooks.create') }}
</XButton>
</div>
<table
v-if="webhooks?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<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 />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<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">
<XButton
danger
icon="trash-alt"
@click="() => confirmDelete(w.id)"
/>
</td>
</tr>
</tbody>
</table>
<Modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="doDelete()"
>
<template #header>
<span>{{ $t('project.webhooks.delete') }}</span>
</template>
<template #text>
<p>{{ $t('project.webhooks.deleteText') }}</p>
</template>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.available-events-check {
margin-inline-end: .5rem;
inline-size: 12.5rem;
}
</style>

View File

@@ -186,6 +186,10 @@
"backgroundBrightness": {
"title": "Background brightness"
},
"webhooks": {
"title": "Webhook Notifications",
"description": "Configure webhook URLs to receive POST requests when reminder or overdue events fire. These webhooks receive events from all your projects."
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",

View File

@@ -1,4 +1,3 @@
import type {IAbstract} from './IAbstract'
import type {IProject} from './IProject'
import type {PrefixMode} from '@/modules/parseTaskText'

View File

@@ -4,9 +4,10 @@ import type {IUser} from '@/modelTypes/IUser'
export interface IWebhook extends IAbstract {
id: number
projectId: number
secret: string
userId: number
secret: string
basicauthuser: string
basicauthpassword: string
basicauthpassword: string
targetUrl: string
events: string[]
createdBy: IUser

View File

@@ -25,11 +25,13 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
quickAddMagicMode: PrefixMode.Default,
colorSchema: 'auto',
allowIconChanges: true,
filterIdUsedOnOverview: null,
defaultView: DEFAULT_PROJECT_VIEW_SETTINGS.FIRST,
minimumPriority: PRIORITIES.MEDIUM,
dateDisplay: DATE_DISPLAY.RELATIVE,
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: null,
alwaysShowBucketTaskCount: false,
sidebarWidth: null,
commentSortOrder: 'asc',

View File

@@ -5,6 +5,7 @@ import UserModel from '@/models/user'
export default class WebhookModel extends AbstractModel<IWebhook> implements IWebhook {
id = 0
projectId = 0
userId = 0
secret = ''
basicauthuser = ''
basicauthpassword = ''

View File

@@ -144,6 +144,11 @@ const router = createRouter({
name: 'user.settings.sessions',
component: () => import('@/views/user/settings/Sessions.vue'),
},
{
path: '/user/settings/webhooks',
name: 'user.settings.webhooks',
component: () => import('@/views/user/settings/Webhooks.vue'),
},
{
path: '/user/settings/migrate',
name: 'migrate.start',

View File

@@ -27,3 +27,29 @@ export default class WebhookService extends AbstractService<IWebhook> {
}
}
}
export class UserWebhookService extends AbstractService<IWebhook> {
constructor() {
super({
getAll: '/user/settings/webhooks',
create: '/user/settings/webhooks',
update: '/user/settings/webhooks/{id}',
delete: '/user/settings/webhooks/{id}',
})
}
modelFactory(data) {
return new WebhookModel(data)
}
async getAvailableEvents(): Promise<string[]> {
const cancel = this.setLoading()
try {
const response = await this.http.get('/user/settings/webhooks/events')
return response.data
} finally {
cancel()
}
}
}

View File

@@ -28,6 +28,7 @@ export interface ConfigState {
userDeletionEnabled: boolean,
taskCommentsEnabled: boolean,
demoModeEnabled: boolean,
webhooksEnabled: boolean,
auth: {
local: {
enabled: boolean,
@@ -66,6 +67,7 @@ export const useConfigStore = defineStore('config', () => {
userDeletionEnabled: true,
taskCommentsEnabled: true,
demoModeEnabled: false,
webhooksEnabled: false,
auth: {
local: {
enabled: true,

View File

@@ -7,21 +7,14 @@ import {useTitle} from '@vueuse/core'
import ProjectService from '@/services/project'
import ProjectModel from '@/models/project'
import type {IProject} from '@/modelTypes/IProject'
import type {IWebhook} from '@/modelTypes/IWebhook'
import CreateEdit from '@/components/misc/CreateEdit.vue'
import WebhookManager from '@/components/misc/WebhookManager.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 FormField from '@/components/input/FormField.vue'
import Expandable from '@/components/base/Expandable.vue'
import {success} from '@/message'
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
defineOptions({name: 'ProjectSettingWebhooks'})
@@ -30,9 +23,6 @@ const {t} = useI18n({useScope: 'global'})
const project = ref<IProject>()
useTitle(t('project.webhooks.title'))
const showNewForm = ref(false)
const showBasicAuth = ref(false)
async function loadProject(projectId: number) {
const projectService = new ProjectService()
const newProject = await projectService.get(new ProjectModel({id: projectId}))
@@ -49,75 +39,35 @@ const projectId = computed(() => route.params.projectId !== undefined
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
const webhooks = ref<IWebhook[]>()
const webhooks = ref<IWebhook[]>([])
const webhookService = new WebhookService()
const availableEvents = ref<string[]>()
const availableEvents = ref<string[]>([])
const loading = ref(false)
async function loadWebhooks() {
webhooks.value = await webhookService.getAll({projectId: project.value.id})
availableEvents.value = await webhookService.getAvailableEvents()
// Initialize all events to false to avoid undefined modelValue errors
newWebhookEvents.value = Object.fromEntries(
availableEvents.value.map(event => [event, false]),
)
loading.value = true
try {
webhooks.value = await webhookService.getAll({projectId: project.value.id})
availableEvents.value = await webhookService.getAvailableEvents()
} finally {
loading.value = false
}
}
const showDeleteModal = ref(false)
const webhookIdToDelete = ref<number>()
async function handleCreate(webhook: IWebhook) {
webhook.projectId = project.value.id
const created = await webhookService.create(webhook)
webhooks.value.push(created)
}
async function deleteWebhook() {
async function handleDelete(webhookId: number) {
await webhookService.delete({
id: webhookIdToDelete.value,
id: webhookId,
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()
selectedEventsValid.value = events.length > 0
}
</script>
<template>
@@ -126,186 +76,12 @@ function validateSelectedEvents() {
:has-primary-action="false"
:wide="true"
>
<XButton
v-if="!(webhooks?.length === 0 || showNewForm)"
icon="plus"
class="mbe-4"
@click="showNewForm = true"
>
{{ $t('project.webhooks.create') }}
</XButton>
<div
v-if="webhooks?.length === 0 || showNewForm"
class="p-4"
>
<FormField
id="targetUrl"
v-model="newWebhook.targetUrl"
:label="$t('project.webhooks.targetUrl')"
required
:placeholder="$t('project.webhooks.targetUrl')"
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
@focusout="validateTargetUrl"
/>
<div class="field">
<label
class="label"
for="secret"
>
{{ $t('project.webhooks.secret') }}
</label>
<div class="control">
<input
id="secret"
v-model="newWebhook.secret"
class="input"
>
</div>
<p class="help">
{{ $t('project.webhooks.secretHint') }}
<BaseButton href="https://vikunja.io/docs/webhooks/">
{{ $t('project.webhooks.secretDocs') }}
</BaseButton>
</p>
</div>
<BaseButton
class="mbe-2 has-text-primary"
@click="showBasicAuth = !showBasicAuth"
>
{{ $t('project.webhooks.basicauthlink') }}
</BaseButton>
<Expandable
:open="showBasicAuth"
class="content"
>
<div class="field">
<label
class="label"
for="basicauthuser"
>
{{ $t('project.webhooks.basicauthuser') }}
</label>
<div class="control">
<input
id="basicauthuser"
v-model="newWebhook.basicauthuser"
class="input"
>
</div>
</div>
<div class="field">
<label
class="label"
for="basicauthpassword"
>
{{ $t('project.webhooks.basicauthpassword') }}
</label>
<div class="control">
<input
id="basicauthpassword"
v-model="newWebhook.basicauthpassword"
class="input"
>
</div>
</div>
</Expandable>
<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"
v-model="newWebhookEvents[event]"
class="available-events-check"
@update:modelValue="validateSelectedEvents"
>
{{ event }}
</FancyCheckbox>
</div>
<p
v-if="!selectedEventsValid"
class="help is-danger"
>
{{ $t('project.webhooks.mustSelectEvents') }}
</p>
</div>
<XButton
icon="plus"
@click="create"
>
{{ $t('project.webhooks.create') }}
</XButton>
</div>
<div
v-if="webhooks?.length > 0"
class="has-horizontal-overflow"
>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<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 />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<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">
<XButton
danger
icon="trash-alt"
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
/>
</td>
</tr>
</tbody>
</table>
</div>
<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>
<WebhookManager
:webhooks="webhooks"
:available-events="availableEvents"
:loading="loading"
@create="handleCreate"
@delete="handleDelete"
/>
</CreateEdit>
</template>
<style lang="scss" scoped>
.available-events-check {
margin-inline-end: .5rem;
inline-size: 12.5rem;
}
</style>

View File

@@ -64,6 +64,7 @@ const caldavEnabled = computed(() => configStore.caldavEnabled)
const migratorsEnabled = computed(() => configStore.migratorsEnabled)
const isLocalUser = computed(() => authStore.info?.isLocalUser)
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
const webhooksEnabled = computed(() => configStore.webhooksEnabled)
const navigationItems = computed(() => {
const items = [
@@ -112,6 +113,11 @@ const navigationItems = computed(() => {
title: t('user.settings.sessions.title'),
routeName: 'user.settings.sessions',
},
{
title: t('user.settings.webhooks.title'),
routeName: 'user.settings.webhooks',
condition: webhooksEnabled.value,
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',

View File

@@ -0,0 +1,66 @@
<script lang="ts" setup>
import {ref, onMounted} from 'vue'
import {useI18n} from 'vue-i18n'
import Card from '@/components/misc/Card.vue'
import WebhookManager from '@/components/misc/WebhookManager.vue'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {UserWebhookService} from '@/services/webhook'
import type {IWebhook} from '@/modelTypes/IWebhook'
defineOptions({name: 'UserSettingsWebhooks'})
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.webhooks.title')} - ${t('user.settings.title')}`)
const service = new UserWebhookService()
const webhooks = ref<IWebhook[]>([])
const availableEvents = ref<string[]>([])
const loading = ref(false)
async function loadWebhooks() {
loading.value = true
try {
webhooks.value = await service.getAll()
availableEvents.value = await service.getAvailableEvents()
} finally {
loading.value = false
}
}
async function handleCreate(webhook: IWebhook) {
const created = await service.create(webhook)
webhooks.value.push(created)
}
async function handleDelete(webhookId: number) {
await service.delete({id: webhookId})
success({message: t('project.webhooks.deleteSuccess')})
await loadWebhooks()
}
onMounted(() => {
loadWebhooks()
})
</script>
<template>
<Card
:title="$t('user.settings.webhooks.title')"
:loading="loading"
>
<p class="mb-4">
{{ $t('user.settings.webhooks.description') }}
</p>
<WebhookManager
:webhooks="webhooks"
:available-events="availableEvents"
:loading="loading"
@create="handleCreate"
@delete="handleDelete"
/>
</Card>
</template>