From 2e1648ef4c7b1d1a05542567cd2a682f1038b03c Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 8 Mar 2026 19:25:53 +0100 Subject: [PATCH] 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. --- .../src/components/misc/WebhookManager.vue | 277 ++++++++++++++++++ frontend/src/i18n/lang/en.json | 4 + frontend/src/modelTypes/IUserSettings.ts | 1 - frontend/src/modelTypes/IWebhook.ts | 5 +- frontend/src/models/userSettings.ts | 2 + frontend/src/models/webhook.ts | 1 + frontend/src/router/index.ts | 5 + frontend/src/services/webhook.ts | 26 ++ frontend/src/stores/config.ts | 2 + .../settings/ProjectSettingsWebhooks.vue | 276 ++--------------- frontend/src/views/user/Settings.vue | 6 + frontend/src/views/user/settings/Webhooks.vue | 66 +++++ 12 files changed, 418 insertions(+), 253 deletions(-) create mode 100644 frontend/src/components/misc/WebhookManager.vue create mode 100644 frontend/src/views/user/settings/Webhooks.vue diff --git a/frontend/src/components/misc/WebhookManager.vue b/frontend/src/components/misc/WebhookManager.vue new file mode 100644 index 000000000..4bff44320 --- /dev/null +++ b/frontend/src/components/misc/WebhookManager.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 90b19a1ab..b7f4890a9 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -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.", diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index 8edbd438f..246321d74 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -1,4 +1,3 @@ - import type {IAbstract} from './IAbstract' import type {IProject} from './IProject' import type {PrefixMode} from '@/modules/parseTaskText' diff --git a/frontend/src/modelTypes/IWebhook.ts b/frontend/src/modelTypes/IWebhook.ts index 268d7260b..137a386c5 100644 --- a/frontend/src/modelTypes/IWebhook.ts +++ b/frontend/src/modelTypes/IWebhook.ts @@ -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 diff --git a/frontend/src/models/userSettings.ts b/frontend/src/models/userSettings.ts index 7b61a50ed..a4fcc91fb 100644 --- a/frontend/src/models/userSettings.ts +++ b/frontend/src/models/userSettings.ts @@ -25,11 +25,13 @@ export default class UserSettingsModel extends AbstractModel 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', diff --git a/frontend/src/models/webhook.ts b/frontend/src/models/webhook.ts index a92d4a6f5..e86c7bdec 100644 --- a/frontend/src/models/webhook.ts +++ b/frontend/src/models/webhook.ts @@ -5,6 +5,7 @@ import UserModel from '@/models/user' export default class WebhookModel extends AbstractModel implements IWebhook { id = 0 projectId = 0 + userId = 0 secret = '' basicauthuser = '' basicauthpassword = '' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ceacc0b74..c19b4642d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/services/webhook.ts b/frontend/src/services/webhook.ts index f2830c889..2c906bcc8 100644 --- a/frontend/src/services/webhook.ts +++ b/frontend/src/services/webhook.ts @@ -27,3 +27,29 @@ export default class WebhookService extends AbstractService { } } } + +export class UserWebhookService extends AbstractService { + 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 { + const cancel = this.setLoading() + + try { + const response = await this.http.get('/user/settings/webhooks/events') + return response.data + } finally { + cancel() + } + } +} diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index f759b07df..e5f70d54f 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -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, diff --git a/frontend/src/views/project/settings/ProjectSettingsWebhooks.vue b/frontend/src/views/project/settings/ProjectSettingsWebhooks.vue index f745b97a8..6159cd513 100644 --- a/frontend/src/views/project/settings/ProjectSettingsWebhooks.vue +++ b/frontend/src/views/project/settings/ProjectSettingsWebhooks.vue @@ -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() 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() +const webhooks = ref([]) const webhookService = new WebhookService() -const availableEvents = ref() +const availableEvents = ref([]) +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() +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 -} - - diff --git a/frontend/src/views/user/Settings.vue b/frontend/src/views/user/Settings.vue index 81e21409d..155316f37 100644 --- a/frontend/src/views/user/Settings.vue +++ b/frontend/src/views/user/Settings.vue @@ -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', diff --git a/frontend/src/views/user/settings/Webhooks.vue b/frontend/src/views/user/settings/Webhooks.vue new file mode 100644 index 000000000..2a087caa4 --- /dev/null +++ b/frontend/src/views/user/settings/Webhooks.vue @@ -0,0 +1,66 @@ + + +