From 2173f14708ff3b35d7821fc9b6dcb5fcd06b8494 Mon Sep 17 00:00:00 2001
From: KN4CK3R <admin@oldschoolhack.me>
Date: Fri, 10 Mar 2023 15:28:32 +0100
Subject: [PATCH] Add user webhooks (#21563)

Currently we can add webhooks for organizations but not for users. This
PR adds the latter. You can access it from the current users settings.


![grafik](https://user-images.githubusercontent.com/1666336/197391408-15dfdc23-b476-4d0c-82f7-9bc9b065988f.png)
---
 .../doc/developers/oauth2-provider.en-us.md   |   1 +
 models/auth/token_scope.go                    |  10 +-
 models/auth/token_scope_test.go               |   4 +-
 models/fixtures/webhook.yml                   |   2 +-
 models/migrations/migrations.go               |   2 +
 models/migrations/v1_20/v245.go               |  74 +++++++++
 models/webhook/webhook.go                     |  24 +--
 models/webhook/webhook_system.go              |  10 +-
 models/webhook/webhook_test.go                |  24 +--
 options/locale/locale_en-US.ini               |   2 +
 routers/api/v1/admin/hooks.go                 |   5 +-
 routers/api/v1/api.go                         |   7 +
 routers/api/v1/org/hook.go                    |  79 +++------
 routers/api/v1/repo/hook.go                   |   6 +-
 routers/api/v1/user/hook.go                   | 154 ++++++++++++++++++
 routers/api/v1/utils/hook.go                  |  89 +++++++---
 routers/web/org/setting.go                    |   8 +-
 routers/web/repo/webhook.go                   |  67 ++++----
 routers/web/user/setting/webhooks.go          |  48 ++++++
 routers/web/web.go                            | 123 ++++++--------
 services/repository/hooks.go                  |   2 +-
 services/webhook/webhook.go                   |  10 +-
 templates/repo/settings/webhook/history.tmpl  |   2 +-
 templates/swagger/v1_json.tmpl                | 146 +++++++++++++++++
 templates/user/settings/applications.tmpl     |   6 +
 templates/user/settings/hook_new.tmpl         |  53 ++++++
 templates/user/settings/hooks.tmpl            |   8 +
 templates/user/settings/navbar.tmpl           |   5 +
 28 files changed, 737 insertions(+), 234 deletions(-)
 create mode 100644 models/migrations/v1_20/v245.go
 create mode 100644 routers/api/v1/user/hook.go
 create mode 100644 routers/web/user/setting/webhooks.go
 create mode 100644 templates/user/settings/hook_new.tmpl
 create mode 100644 templates/user/settings/hooks.tmpl

diff --git a/docs/content/doc/developers/oauth2-provider.en-us.md b/docs/content/doc/developers/oauth2-provider.en-us.md
index 17c12d22f2..1ef30a7f0e 100644
--- a/docs/content/doc/developers/oauth2-provider.en-us.md
+++ b/docs/content/doc/developers/oauth2-provider.en-us.md
@@ -60,6 +60,7 @@ Gitea supports the following scopes for tokens:
 | &nbsp;&nbsp;&nbsp; **write:public_key** | Grant read/write access to public keys |
 | &nbsp;&nbsp;&nbsp; **read:public_key** | Grant read-only access to public keys |
 | **admin:org_hook** | Grants full access to organizational-level hooks |
+| **admin:user_hook** | Grants full access to user-level hooks |
 | **notification** | Grants full access to notifications |
 | **user** | Grants full access to user profile info |
 | &nbsp;&nbsp;&nbsp; **read:user** | Grants read access to user's profile |
diff --git a/models/auth/token_scope.go b/models/auth/token_scope.go
index 38733a1c8f..06c89fecc2 100644
--- a/models/auth/token_scope.go
+++ b/models/auth/token_scope.go
@@ -32,6 +32,8 @@ const (
 
 	AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook"
 
+	AccessTokenScopeAdminUserHook AccessTokenScope = "admin:user_hook"
+
 	AccessTokenScopeNotification AccessTokenScope = "notification"
 
 	AccessTokenScopeUser       AccessTokenScope = "user"
@@ -64,7 +66,7 @@ type AccessTokenScopeBitmap uint64
 const (
 	// AccessTokenScopeAllBits is the bitmap of all access token scopes, except `sudo`.
 	AccessTokenScopeAllBits AccessTokenScopeBitmap = AccessTokenScopeRepoBits |
-		AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits |
+		AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits | AccessTokenScopeAdminUserHookBits |
 		AccessTokenScopeNotificationBits | AccessTokenScopeUserBits | AccessTokenScopeDeleteRepoBits |
 		AccessTokenScopePackageBits | AccessTokenScopeAdminGPGKeyBits | AccessTokenScopeAdminApplicationBits
 
@@ -86,6 +88,8 @@ const (
 
 	AccessTokenScopeAdminOrgHookBits AccessTokenScopeBitmap = 1 << iota
 
+	AccessTokenScopeAdminUserHookBits AccessTokenScopeBitmap = 1 << iota
+
 	AccessTokenScopeNotificationBits AccessTokenScopeBitmap = 1 << iota
 
 	AccessTokenScopeUserBits       AccessTokenScopeBitmap = 1<<iota | AccessTokenScopeReadUserBits | AccessTokenScopeUserEmailBits | AccessTokenScopeUserFollowBits
@@ -123,6 +127,7 @@ var allAccessTokenScopes = []AccessTokenScope{
 	AccessTokenScopeAdminPublicKey, AccessTokenScopeWritePublicKey, AccessTokenScopeReadPublicKey,
 	AccessTokenScopeAdminRepoHook, AccessTokenScopeWriteRepoHook, AccessTokenScopeReadRepoHook,
 	AccessTokenScopeAdminOrgHook,
+	AccessTokenScopeAdminUserHook,
 	AccessTokenScopeNotification,
 	AccessTokenScopeUser, AccessTokenScopeReadUser, AccessTokenScopeUserEmail, AccessTokenScopeUserFollow,
 	AccessTokenScopeDeleteRepo,
@@ -147,6 +152,7 @@ var allAccessTokenScopeBits = map[AccessTokenScope]AccessTokenScopeBitmap{
 	AccessTokenScopeWriteRepoHook:    AccessTokenScopeWriteRepoHookBits,
 	AccessTokenScopeReadRepoHook:     AccessTokenScopeReadRepoHookBits,
 	AccessTokenScopeAdminOrgHook:     AccessTokenScopeAdminOrgHookBits,
+	AccessTokenScopeAdminUserHook:    AccessTokenScopeAdminUserHookBits,
 	AccessTokenScopeNotification:     AccessTokenScopeNotificationBits,
 	AccessTokenScopeUser:             AccessTokenScopeUserBits,
 	AccessTokenScopeReadUser:         AccessTokenScopeReadUserBits,
@@ -263,7 +269,7 @@ func (bitmap AccessTokenScopeBitmap) ToScope() AccessTokenScope {
 	scope := AccessTokenScope(strings.Join(scopes, ","))
 	scope = AccessTokenScope(strings.ReplaceAll(
 		string(scope),
-		"repo,admin:org,admin:public_key,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application",
+		"repo,admin:org,admin:public_key,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application",
 		"all",
 	))
 	return scope
diff --git a/models/auth/token_scope_test.go b/models/auth/token_scope_test.go
index 1d7f4794a4..b96a5fd469 100644
--- a/models/auth/token_scope_test.go
+++ b/models/auth/token_scope_test.go
@@ -40,8 +40,8 @@ func TestAccessTokenScope_Normalize(t *testing.T) {
 		{"admin:gpg_key,write:gpg_key,user", "user,admin:gpg_key", nil},
 		{"admin:application,write:application,user", "user,admin:application", nil},
 		{"all", "all", nil},
-		{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", "all", nil},
-		{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application,sudo", "all,sudo", nil},
+		{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application", "all", nil},
+		{"repo,admin:org,admin:public_key,admin:repo_hook,admin:org_hook,admin:user_hook,notification,user,delete_repo,package,admin:gpg_key,admin:application,sudo", "all,sudo", nil},
 	}
 
 	for _, test := range tests {
diff --git a/models/fixtures/webhook.yml b/models/fixtures/webhook.yml
index 5563dcada7..f62bae1f31 100644
--- a/models/fixtures/webhook.yml
+++ b/models/fixtures/webhook.yml
@@ -16,7 +16,7 @@
 
 -
   id: 3
-  org_id: 3
+  owner_id: 3
   repo_id: 3
   url: www.example.com/url3
   content_type: 1 # json
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 585457e474..4cbcd95d20 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -467,6 +467,8 @@ var migrations = []Migration{
 
 	// v244 -> v245
 	NewMigration("Add NeedApproval to actions tables", v1_20.AddNeedApprovalToActionRun),
+	// v245 -> v246
+	NewMigration("Rename Webhook org_id to owner_id", v1_20.RenameWebhookOrgToOwner),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_20/v245.go b/models/migrations/v1_20/v245.go
new file mode 100644
index 0000000000..466f21c239
--- /dev/null
+++ b/models/migrations/v1_20/v245.go
@@ -0,0 +1,74 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_20 //nolint
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/migrations/base"
+	"code.gitea.io/gitea/modules/setting"
+
+	"xorm.io/xorm"
+)
+
+func RenameWebhookOrgToOwner(x *xorm.Engine) error {
+	type Webhook struct {
+		OrgID int64 `xorm:"INDEX"`
+	}
+
+	// This migration maybe rerun so that we should check if it has been run
+	ownerExist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "webhook", "owner_id")
+	if err != nil {
+		return err
+	}
+
+	if ownerExist {
+		orgExist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "webhook", "org_id")
+		if err != nil {
+			return err
+		}
+		if !orgExist {
+			return nil
+		}
+	}
+
+	sess := x.NewSession()
+	defer sess.Close()
+	if err := sess.Begin(); err != nil {
+		return err
+	}
+
+	if err := sess.Sync2(new(Webhook)); err != nil {
+		return err
+	}
+
+	if ownerExist {
+		if err := base.DropTableColumns(sess, "webhook", "owner_id"); err != nil {
+			return err
+		}
+	}
+
+	switch {
+	case setting.Database.Type.IsMySQL():
+		inferredTable, err := x.TableInfo(new(Webhook))
+		if err != nil {
+			return err
+		}
+		sqlType := x.Dialect().SQLType(inferredTable.GetColumn("org_id"))
+		if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `webhook` CHANGE org_id owner_id %s", sqlType)); err != nil {
+			return err
+		}
+	case setting.Database.Type.IsMSSQL():
+		if _, err := sess.Exec("sp_rename 'webhook.org_id', 'owner_id', 'COLUMN'"); err != nil {
+			return err
+		}
+	default:
+		if _, err := sess.Exec("ALTER TABLE `webhook` RENAME COLUMN org_id TO owner_id"); err != nil {
+			return err
+		}
+	}
+
+	return sess.Commit()
+}
diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go
index 64119f1494..e3f6b593d9 100644
--- a/models/webhook/webhook.go
+++ b/models/webhook/webhook.go
@@ -122,7 +122,7 @@ func IsValidHookContentType(name string) bool {
 type Webhook struct {
 	ID                        int64 `xorm:"pk autoincr"`
 	RepoID                    int64 `xorm:"INDEX"` // An ID of 0 indicates either a default or system webhook
-	OrgID                     int64 `xorm:"INDEX"`
+	OwnerID                   int64 `xorm:"INDEX"`
 	IsSystemWebhook           bool
 	URL                       string `xorm:"url TEXT"`
 	HTTPMethod                string `xorm:"http_method"`
@@ -412,11 +412,11 @@ func GetWebhookByRepoID(repoID, id int64) (*Webhook, error) {
 	})
 }
 
-// GetWebhookByOrgID returns webhook of organization by given ID.
-func GetWebhookByOrgID(orgID, id int64) (*Webhook, error) {
+// GetWebhookByOwnerID returns webhook of a user or organization by given ID.
+func GetWebhookByOwnerID(ownerID, id int64) (*Webhook, error) {
 	return getWebhook(&Webhook{
-		ID:    id,
-		OrgID: orgID,
+		ID:      id,
+		OwnerID: ownerID,
 	})
 }
 
@@ -424,7 +424,7 @@ func GetWebhookByOrgID(orgID, id int64) (*Webhook, error) {
 type ListWebhookOptions struct {
 	db.ListOptions
 	RepoID   int64
-	OrgID    int64
+	OwnerID  int64
 	IsActive util.OptionalBool
 }
 
@@ -433,8 +433,8 @@ func (opts *ListWebhookOptions) toCond() builder.Cond {
 	if opts.RepoID != 0 {
 		cond = cond.And(builder.Eq{"webhook.repo_id": opts.RepoID})
 	}
-	if opts.OrgID != 0 {
-		cond = cond.And(builder.Eq{"webhook.org_id": opts.OrgID})
+	if opts.OwnerID != 0 {
+		cond = cond.And(builder.Eq{"webhook.owner_id": opts.OwnerID})
 	}
 	if !opts.IsActive.IsNone() {
 		cond = cond.And(builder.Eq{"webhook.is_active": opts.IsActive.IsTrue()})
@@ -503,10 +503,10 @@ func DeleteWebhookByRepoID(repoID, id int64) error {
 	})
 }
 
-// DeleteWebhookByOrgID deletes webhook of organization by given ID.
-func DeleteWebhookByOrgID(orgID, id int64) error {
+// DeleteWebhookByOwnerID deletes webhook of a user or organization by given ID.
+func DeleteWebhookByOwnerID(ownerID, id int64) error {
 	return deleteWebhook(&Webhook{
-		ID:    id,
-		OrgID: orgID,
+		ID:      id,
+		OwnerID: ownerID,
 	})
 }
diff --git a/models/webhook/webhook_system.go b/models/webhook/webhook_system.go
index 21dc0406a0..2e89f9547b 100644
--- a/models/webhook/webhook_system.go
+++ b/models/webhook/webhook_system.go
@@ -15,7 +15,7 @@ import (
 func GetDefaultWebhooks(ctx context.Context) ([]*Webhook, error) {
 	webhooks := make([]*Webhook, 0, 5)
 	return webhooks, db.GetEngine(ctx).
-		Where("repo_id=? AND org_id=? AND is_system_webhook=?", 0, 0, false).
+		Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, false).
 		Find(&webhooks)
 }
 
@@ -23,7 +23,7 @@ func GetDefaultWebhooks(ctx context.Context) ([]*Webhook, error) {
 func GetSystemOrDefaultWebhook(ctx context.Context, id int64) (*Webhook, error) {
 	webhook := &Webhook{ID: id}
 	has, err := db.GetEngine(ctx).
-		Where("repo_id=? AND org_id=?", 0, 0).
+		Where("repo_id=? AND owner_id=?", 0, 0).
 		Get(webhook)
 	if err != nil {
 		return nil, err
@@ -38,11 +38,11 @@ func GetSystemWebhooks(ctx context.Context, isActive util.OptionalBool) ([]*Webh
 	webhooks := make([]*Webhook, 0, 5)
 	if isActive.IsNone() {
 		return webhooks, db.GetEngine(ctx).
-			Where("repo_id=? AND org_id=? AND is_system_webhook=?", 0, 0, true).
+			Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, true).
 			Find(&webhooks)
 	}
 	return webhooks, db.GetEngine(ctx).
-		Where("repo_id=? AND org_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, true, isActive.IsTrue()).
+		Where("repo_id=? AND owner_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, true, isActive.IsTrue()).
 		Find(&webhooks)
 }
 
@@ -50,7 +50,7 @@ func GetSystemWebhooks(ctx context.Context, isActive util.OptionalBool) ([]*Webh
 func DeleteDefaultSystemWebhook(ctx context.Context, id int64) error {
 	return db.WithTx(ctx, func(ctx context.Context) error {
 		count, err := db.GetEngine(ctx).
-			Where("repo_id=? AND org_id=?", 0, 0).
+			Where("repo_id=? AND owner_id=?", 0, 0).
 			Delete(&Webhook{ID: id})
 		if err != nil {
 			return err
diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go
index c368fc620e..74f7aeaa03 100644
--- a/models/webhook/webhook_test.go
+++ b/models/webhook/webhook_test.go
@@ -109,13 +109,13 @@ func TestGetWebhookByRepoID(t *testing.T) {
 	assert.True(t, IsErrWebhookNotExist(err))
 }
 
-func TestGetWebhookByOrgID(t *testing.T) {
+func TestGetWebhookByOwnerID(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
-	hook, err := GetWebhookByOrgID(3, 3)
+	hook, err := GetWebhookByOwnerID(3, 3)
 	assert.NoError(t, err)
 	assert.Equal(t, int64(3), hook.ID)
 
-	_, err = GetWebhookByOrgID(unittest.NonexistentID, unittest.NonexistentID)
+	_, err = GetWebhookByOwnerID(unittest.NonexistentID, unittest.NonexistentID)
 	assert.Error(t, err)
 	assert.True(t, IsErrWebhookNotExist(err))
 }
@@ -140,9 +140,9 @@ func TestGetWebhooksByRepoID(t *testing.T) {
 	}
 }
 
-func TestGetActiveWebhooksByOrgID(t *testing.T) {
+func TestGetActiveWebhooksByOwnerID(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
-	hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OrgID: 3, IsActive: util.OptionalBoolTrue})
+	hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OwnerID: 3, IsActive: util.OptionalBoolTrue})
 	assert.NoError(t, err)
 	if assert.Len(t, hooks, 1) {
 		assert.Equal(t, int64(3), hooks[0].ID)
@@ -150,9 +150,9 @@ func TestGetActiveWebhooksByOrgID(t *testing.T) {
 	}
 }
 
-func TestGetWebhooksByOrgID(t *testing.T) {
+func TestGetWebhooksByOwnerID(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
-	hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OrgID: 3})
+	hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OwnerID: 3})
 	assert.NoError(t, err)
 	if assert.Len(t, hooks, 1) {
 		assert.Equal(t, int64(3), hooks[0].ID)
@@ -181,13 +181,13 @@ func TestDeleteWebhookByRepoID(t *testing.T) {
 	assert.True(t, IsErrWebhookNotExist(err))
 }
 
-func TestDeleteWebhookByOrgID(t *testing.T) {
+func TestDeleteWebhookByOwnerID(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
-	unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 3, OrgID: 3})
-	assert.NoError(t, DeleteWebhookByOrgID(3, 3))
-	unittest.AssertNotExistsBean(t, &Webhook{ID: 3, OrgID: 3})
+	unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 3, OwnerID: 3})
+	assert.NoError(t, DeleteWebhookByOwnerID(3, 3))
+	unittest.AssertNotExistsBean(t, &Webhook{ID: 3, OwnerID: 3})
 
-	err := DeleteWebhookByOrgID(unittest.NonexistentID, unittest.NonexistentID)
+	err := DeleteWebhookByOwnerID(unittest.NonexistentID, unittest.NonexistentID)
 	assert.Error(t, err)
 	assert.True(t, IsErrWebhookNotExist(err))
 }
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 6f0d06a6e8..095257b365 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -821,6 +821,8 @@ remove_account_link = Remove Linked Account
 remove_account_link_desc = Removing a linked account will revoke its access to your Gitea account. Continue?
 remove_account_link_success = The linked account has been removed.
 
+hooks.desc = Add webhooks which will be triggered for <strong>all repositories</strong> owned by this user.
+
 orgs_none = You are not a member of any organizations.
 repos_none = You do not own any repositories
 
diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go
index 2aed4139f3..8264503c9d 100644
--- a/routers/api/v1/admin/hooks.go
+++ b/routers/api/v1/admin/hooks.go
@@ -105,10 +105,7 @@ func CreateHook(ctx *context.APIContext) {
 	//     "$ref": "#/responses/Hook"
 
 	form := web.GetForm(ctx).(*api.CreateHookOption)
-	// TODO in body params
-	if !utils.CheckCreateHookOption(ctx, form) {
-		return
-	}
+
 	utils.AddSystemHook(ctx, form)
 }
 
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 1d2f8b18e0..735939a551 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -835,6 +835,13 @@ func Routes(ctx gocontext.Context) *web.Route {
 			m.Get("/stopwatches", reqToken(auth_model.AccessTokenScopeRepo), repo.GetStopwatches)
 			m.Get("/subscriptions", reqToken(auth_model.AccessTokenScopeRepo), user.GetMyWatchedRepos)
 			m.Get("/teams", reqToken(auth_model.AccessTokenScopeRepo), org.ListUserTeams)
+			m.Group("/hooks", func() {
+				m.Combo("").Get(user.ListHooks).
+					Post(bind(api.CreateHookOption{}), user.CreateHook)
+				m.Combo("/{id}").Get(user.GetHook).
+					Patch(bind(api.EditHookOption{}), user.EditHook).
+					Delete(user.DeleteHook)
+			}, reqToken(auth_model.AccessTokenScopeAdminUserHook), reqWebhooksEnabled())
 		}, reqToken(""))
 
 		// Repositories
diff --git a/routers/api/v1/org/hook.go b/routers/api/v1/org/hook.go
index 4e435c9599..a6ea618a7d 100644
--- a/routers/api/v1/org/hook.go
+++ b/routers/api/v1/org/hook.go
@@ -6,7 +6,6 @@ package org
 import (
 	"net/http"
 
-	webhook_model "code.gitea.io/gitea/models/webhook"
 	"code.gitea.io/gitea/modules/context"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/web"
@@ -39,34 +38,10 @@ func ListHooks(ctx *context.APIContext) {
 	//   "200":
 	//     "$ref": "#/responses/HookList"
 
-	opts := &webhook_model.ListWebhookOptions{
-		ListOptions: utils.GetListOptions(ctx),
-		OrgID:       ctx.Org.Organization.ID,
-	}
-
-	count, err := webhook_model.CountWebhooksByOpts(opts)
-	if err != nil {
-		ctx.InternalServerError(err)
-		return
-	}
-
-	orgHooks, err := webhook_model.ListWebhooksByOpts(ctx, opts)
-	if err != nil {
-		ctx.InternalServerError(err)
-		return
-	}
-
-	hooks := make([]*api.Hook, len(orgHooks))
-	for i, hook := range orgHooks {
-		hooks[i], err = webhook_service.ToHook(ctx.Org.Organization.AsUser().HomeLink(), hook)
-		if err != nil {
-			ctx.InternalServerError(err)
-			return
-		}
-	}
-
-	ctx.SetTotalCountHeader(count)
-	ctx.JSON(http.StatusOK, hooks)
+	utils.ListOwnerHooks(
+		ctx,
+		ctx.ContextUser,
+	)
 }
 
 // GetHook get an organization's hook by id
@@ -92,14 +67,12 @@ func GetHook(ctx *context.APIContext) {
 	//   "200":
 	//     "$ref": "#/responses/Hook"
 
-	org := ctx.Org.Organization
-	hookID := ctx.ParamsInt64(":id")
-	hook, err := utils.GetOrgHook(ctx, org.ID, hookID)
+	hook, err := utils.GetOwnerHook(ctx, ctx.ContextUser.ID, ctx.ParamsInt64("id"))
 	if err != nil {
 		return
 	}
 
-	apiHook, err := webhook_service.ToHook(org.AsUser().HomeLink(), hook)
+	apiHook, err := webhook_service.ToHook(ctx.ContextUser.HomeLink(), hook)
 	if err != nil {
 		ctx.InternalServerError(err)
 		return
@@ -131,15 +104,14 @@ func CreateHook(ctx *context.APIContext) {
 	//   "201":
 	//     "$ref": "#/responses/Hook"
 
-	form := web.GetForm(ctx).(*api.CreateHookOption)
-	// TODO in body params
-	if !utils.CheckCreateHookOption(ctx, form) {
-		return
-	}
-	utils.AddOrgHook(ctx, form)
+	utils.AddOwnerHook(
+		ctx,
+		ctx.ContextUser,
+		web.GetForm(ctx).(*api.CreateHookOption),
+	)
 }
 
-// EditHook modify a hook of a repository
+// EditHook modify a hook of an organization
 func EditHook(ctx *context.APIContext) {
 	// swagger:operation PATCH /orgs/{org}/hooks/{id} organization orgEditHook
 	// ---
@@ -168,11 +140,12 @@ func EditHook(ctx *context.APIContext) {
 	//   "200":
 	//     "$ref": "#/responses/Hook"
 
-	form := web.GetForm(ctx).(*api.EditHookOption)
-
-	// TODO in body params
-	hookID := ctx.ParamsInt64(":id")
-	utils.EditOrgHook(ctx, form, hookID)
+	utils.EditOwnerHook(
+		ctx,
+		ctx.ContextUser,
+		web.GetForm(ctx).(*api.EditHookOption),
+		ctx.ParamsInt64("id"),
+	)
 }
 
 // DeleteHook delete a hook of an organization
@@ -198,15 +171,9 @@ func DeleteHook(ctx *context.APIContext) {
 	//   "204":
 	//     "$ref": "#/responses/empty"
 
-	org := ctx.Org.Organization
-	hookID := ctx.ParamsInt64(":id")
-	if err := webhook_model.DeleteWebhookByOrgID(org.ID, hookID); err != nil {
-		if webhook_model.IsErrWebhookNotExist(err) {
-			ctx.NotFound()
-		} else {
-			ctx.Error(http.StatusInternalServerError, "DeleteWebhookByOrgID", err)
-		}
-		return
-	}
-	ctx.Status(http.StatusNoContent)
+	utils.DeleteOwnerHook(
+		ctx,
+		ctx.ContextUser,
+		ctx.ParamsInt64("id"),
+	)
 }
diff --git a/routers/api/v1/repo/hook.go b/routers/api/v1/repo/hook.go
index fd54d1f740..39d83912b0 100644
--- a/routers/api/v1/repo/hook.go
+++ b/routers/api/v1/repo/hook.go
@@ -223,12 +223,8 @@ func CreateHook(ctx *context.APIContext) {
 	// responses:
 	//   "201":
 	//     "$ref": "#/responses/Hook"
-	form := web.GetForm(ctx).(*api.CreateHookOption)
 
-	if !utils.CheckCreateHookOption(ctx, form) {
-		return
-	}
-	utils.AddRepoHook(ctx, form)
+	utils.AddRepoHook(ctx, web.GetForm(ctx).(*api.CreateHookOption))
 }
 
 // EditHook modify a hook of a repository
diff --git a/routers/api/v1/user/hook.go b/routers/api/v1/user/hook.go
new file mode 100644
index 0000000000..50be519c81
--- /dev/null
+++ b/routers/api/v1/user/hook.go
@@ -0,0 +1,154 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"net/http"
+
+	"code.gitea.io/gitea/modules/context"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/web"
+	"code.gitea.io/gitea/routers/api/v1/utils"
+	webhook_service "code.gitea.io/gitea/services/webhook"
+)
+
+// ListHooks list the authenticated user's webhooks
+func ListHooks(ctx *context.APIContext) {
+	// swagger:operation GET /user/hooks user userListHooks
+	// ---
+	// summary: List the authenticated user's webhooks
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/HookList"
+
+	utils.ListOwnerHooks(
+		ctx,
+		ctx.Doer,
+	)
+}
+
+// GetHook get the authenticated user's hook by id
+func GetHook(ctx *context.APIContext) {
+	// swagger:operation GET /user/hooks/{id} user userGetHook
+	// ---
+	// summary: Get a hook
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: id
+	//   in: path
+	//   description: id of the hook to get
+	//   type: integer
+	//   format: int64
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/Hook"
+
+	hook, err := utils.GetOwnerHook(ctx, ctx.Doer.ID, ctx.ParamsInt64("id"))
+	if err != nil {
+		return
+	}
+
+	apiHook, err := webhook_service.ToHook(ctx.Doer.HomeLink(), hook)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	ctx.JSON(http.StatusOK, apiHook)
+}
+
+// CreateHook create a hook for the authenticated user
+func CreateHook(ctx *context.APIContext) {
+	// swagger:operation POST /user/hooks user userCreateHook
+	// ---
+	// summary: Create a hook
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: body
+	//   in: body
+	//   required: true
+	//   schema:
+	//     "$ref": "#/definitions/CreateHookOption"
+	// responses:
+	//   "201":
+	//     "$ref": "#/responses/Hook"
+
+	utils.AddOwnerHook(
+		ctx,
+		ctx.Doer,
+		web.GetForm(ctx).(*api.CreateHookOption),
+	)
+}
+
+// EditHook modify a hook of the authenticated user
+func EditHook(ctx *context.APIContext) {
+	// swagger:operation PATCH /user/hooks/{id} user userEditHook
+	// ---
+	// summary: Update a hook
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: id
+	//   in: path
+	//   description: id of the hook to update
+	//   type: integer
+	//   format: int64
+	//   required: true
+	// - name: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/EditHookOption"
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/Hook"
+
+	utils.EditOwnerHook(
+		ctx,
+		ctx.Doer,
+		web.GetForm(ctx).(*api.EditHookOption),
+		ctx.ParamsInt64("id"),
+	)
+}
+
+// DeleteHook delete a hook of the authenticated user
+func DeleteHook(ctx *context.APIContext) {
+	// swagger:operation DELETE /user/hooks/{id} user userDeleteHook
+	// ---
+	// summary: Delete a hook
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: id
+	//   in: path
+	//   description: id of the hook to delete
+	//   type: integer
+	//   format: int64
+	//   required: true
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+
+	utils.DeleteOwnerHook(
+		ctx,
+		ctx.Doer,
+		ctx.ParamsInt64("id"),
+	)
+}
diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go
index f6aaf74aff..44625cc9b8 100644
--- a/routers/api/v1/utils/hook.go
+++ b/routers/api/v1/utils/hook.go
@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"strings"
 
+	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/models/webhook"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/json"
@@ -18,15 +19,46 @@ import (
 	webhook_service "code.gitea.io/gitea/services/webhook"
 )
 
-// GetOrgHook get an organization's webhook. If there is an error, write to
-// `ctx` accordingly and return the error
-func GetOrgHook(ctx *context.APIContext, orgID, hookID int64) (*webhook.Webhook, error) {
-	w, err := webhook.GetWebhookByOrgID(orgID, hookID)
+// ListOwnerHooks lists the webhooks of the provided owner
+func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) {
+	opts := &webhook.ListWebhookOptions{
+		ListOptions: GetListOptions(ctx),
+		OwnerID:     owner.ID,
+	}
+
+	count, err := webhook.CountWebhooksByOpts(opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	hooks, err := webhook.ListWebhooksByOpts(ctx, opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	apiHooks := make([]*api.Hook, len(hooks))
+	for i, hook := range hooks {
+		apiHooks[i], err = webhook_service.ToHook(owner.HomeLink(), hook)
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+	}
+
+	ctx.SetTotalCountHeader(count)
+	ctx.JSON(http.StatusOK, apiHooks)
+}
+
+// GetOwnerHook gets an user or organization webhook. Errors are written to ctx.
+func GetOwnerHook(ctx *context.APIContext, ownerID, hookID int64) (*webhook.Webhook, error) {
+	w, err := webhook.GetWebhookByOwnerID(ownerID, hookID)
 	if err != nil {
 		if webhook.IsErrWebhookNotExist(err) {
 			ctx.NotFound()
 		} else {
-			ctx.Error(http.StatusInternalServerError, "GetWebhookByOrgID", err)
+			ctx.Error(http.StatusInternalServerError, "GetWebhookByOwnerID", err)
 		}
 		return nil, err
 	}
@@ -48,9 +80,9 @@ func GetRepoHook(ctx *context.APIContext, repoID, hookID int64) (*webhook.Webhoo
 	return w, nil
 }
 
-// CheckCreateHookOption check if a CreateHookOption form is valid. If invalid,
+// checkCreateHookOption check if a CreateHookOption form is valid. If invalid,
 // write the appropriate error to `ctx`. Return whether the form is valid
-func CheckCreateHookOption(ctx *context.APIContext, form *api.CreateHookOption) bool {
+func checkCreateHookOption(ctx *context.APIContext, form *api.CreateHookOption) bool {
 	if !webhook_service.IsValidHookTaskType(form.Type) {
 		ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Invalid hook type: %s", form.Type))
 		return false
@@ -81,14 +113,13 @@ func AddSystemHook(ctx *context.APIContext, form *api.CreateHookOption) {
 	}
 }
 
-// AddOrgHook add a hook to an organization. Writes to `ctx` accordingly
-func AddOrgHook(ctx *context.APIContext, form *api.CreateHookOption) {
-	org := ctx.Org.Organization
-	hook, ok := addHook(ctx, form, org.ID, 0)
+// AddOwnerHook adds a hook to an user or organization
+func AddOwnerHook(ctx *context.APIContext, owner *user_model.User, form *api.CreateHookOption) {
+	hook, ok := addHook(ctx, form, owner.ID, 0)
 	if !ok {
 		return
 	}
-	apiHook, ok := toAPIHook(ctx, org.AsUser().HomeLink(), hook)
+	apiHook, ok := toAPIHook(ctx, owner.HomeLink(), hook)
 	if !ok {
 		return
 	}
@@ -128,14 +159,18 @@ func pullHook(events []string, event string) bool {
 	return util.SliceContainsString(events, event, true) || util.SliceContainsString(events, string(webhook_module.HookEventPullRequest), true)
 }
 
-// addHook add the hook specified by `form`, `orgID` and `repoID`. If there is
+// addHook add the hook specified by `form`, `ownerID` and `repoID`. If there is
 // an error, write to `ctx` accordingly. Return (webhook, ok)
-func addHook(ctx *context.APIContext, form *api.CreateHookOption, orgID, repoID int64) (*webhook.Webhook, bool) {
+func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoID int64) (*webhook.Webhook, bool) {
+	if !checkCreateHookOption(ctx, form) {
+		return nil, false
+	}
+
 	if len(form.Events) == 0 {
 		form.Events = []string{"push"}
 	}
 	w := &webhook.Webhook{
-		OrgID:       orgID,
+		OwnerID:     ownerID,
 		RepoID:      repoID,
 		URL:         form.Config["url"],
 		ContentType: webhook.ToHookContentType(form.Config["content_type"]),
@@ -234,21 +269,20 @@ func EditSystemHook(ctx *context.APIContext, form *api.EditHookOption, hookID in
 	ctx.JSON(http.StatusOK, h)
 }
 
-// EditOrgHook edit webhook `w` according to `form`. Writes to `ctx` accordingly
-func EditOrgHook(ctx *context.APIContext, form *api.EditHookOption, hookID int64) {
-	org := ctx.Org.Organization
-	hook, err := GetOrgHook(ctx, org.ID, hookID)
+// EditOwnerHook updates a webhook of an user or organization
+func EditOwnerHook(ctx *context.APIContext, owner *user_model.User, form *api.EditHookOption, hookID int64) {
+	hook, err := GetOwnerHook(ctx, owner.ID, hookID)
 	if err != nil {
 		return
 	}
 	if !editHook(ctx, form, hook) {
 		return
 	}
-	updated, err := GetOrgHook(ctx, org.ID, hookID)
+	updated, err := GetOwnerHook(ctx, owner.ID, hookID)
 	if err != nil {
 		return
 	}
-	apiHook, ok := toAPIHook(ctx, org.AsUser().HomeLink(), updated)
+	apiHook, ok := toAPIHook(ctx, owner.HomeLink(), updated)
 	if !ok {
 		return
 	}
@@ -362,3 +396,16 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh
 	}
 	return true
 }
+
+// DeleteOwnerHook deletes the hook owned by the owner.
+func DeleteOwnerHook(ctx *context.APIContext, owner *user_model.User, hookID int64) {
+	if err := webhook.DeleteWebhookByOwnerID(owner.ID, hookID); err != nil {
+		if webhook.IsErrWebhookNotExist(err) {
+			ctx.NotFound()
+		} else {
+			ctx.Error(http.StatusInternalServerError, "DeleteWebhookByOwnerID", err)
+		}
+		return
+	}
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go
index f713d09663..b57ebfbcda 100644
--- a/routers/web/org/setting.go
+++ b/routers/web/org/setting.go
@@ -218,9 +218,9 @@ func Webhooks(ctx *context.Context) {
 	ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks"
 	ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc")
 
-	ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OrgID: ctx.Org.Organization.ID})
+	ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID})
 	if err != nil {
-		ctx.ServerError("GetWebhooksByOrgId", err)
+		ctx.ServerError("ListWebhooksByOpts", err)
 		return
 	}
 
@@ -230,8 +230,8 @@ func Webhooks(ctx *context.Context) {
 
 // DeleteWebhook response for delete webhook
 func DeleteWebhook(ctx *context.Context) {
-	if err := webhook.DeleteWebhookByOrgID(ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil {
-		ctx.Flash.Error("DeleteWebhookByOrgID: " + err.Error())
+	if err := webhook.DeleteWebhookByOwnerID(ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil {
+		ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
 	} else {
 		ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
 	}
diff --git a/routers/web/repo/webhook.go b/routers/web/repo/webhook.go
index d27d0f1bf0..f30588967e 100644
--- a/routers/web/repo/webhook.go
+++ b/routers/web/repo/webhook.go
@@ -33,6 +33,7 @@ const (
 	tplHooks        base.TplName = "repo/settings/webhook/base"
 	tplHookNew      base.TplName = "repo/settings/webhook/new"
 	tplOrgHookNew   base.TplName = "org/settings/hook_new"
+	tplUserHookNew  base.TplName = "user/settings/hook_new"
 	tplAdminHookNew base.TplName = "admin/hook_new"
 )
 
@@ -54,8 +55,8 @@ func Webhooks(ctx *context.Context) {
 	ctx.HTML(http.StatusOK, tplHooks)
 }
 
-type orgRepoCtx struct {
-	OrgID           int64
+type ownerRepoCtx struct {
+	OwnerID         int64
 	RepoID          int64
 	IsAdmin         bool
 	IsSystemWebhook bool
@@ -64,10 +65,10 @@ type orgRepoCtx struct {
 	NewTemplate     base.TplName
 }
 
-// getOrgRepoCtx determines whether this is a repo, organization, or admin (both default and system) context.
-func getOrgRepoCtx(ctx *context.Context) (*orgRepoCtx, error) {
-	if len(ctx.Repo.RepoLink) > 0 {
-		return &orgRepoCtx{
+// getOwnerRepoCtx determines whether this is a repo, owner, or admin (both default and system) context.
+func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
+	if is, ok := ctx.Data["IsRepositoryWebhook"]; ok && is.(bool) {
+		return &ownerRepoCtx{
 			RepoID:      ctx.Repo.Repository.ID,
 			Link:        path.Join(ctx.Repo.RepoLink, "settings/hooks"),
 			LinkNew:     path.Join(ctx.Repo.RepoLink, "settings/hooks"),
@@ -75,37 +76,35 @@ func getOrgRepoCtx(ctx *context.Context) (*orgRepoCtx, error) {
 		}, nil
 	}
 
-	if len(ctx.Org.OrgLink) > 0 {
-		return &orgRepoCtx{
-			OrgID:       ctx.Org.Organization.ID,
+	if is, ok := ctx.Data["IsOrganizationWebhook"]; ok && is.(bool) {
+		return &ownerRepoCtx{
+			OwnerID:     ctx.ContextUser.ID,
 			Link:        path.Join(ctx.Org.OrgLink, "settings/hooks"),
 			LinkNew:     path.Join(ctx.Org.OrgLink, "settings/hooks"),
 			NewTemplate: tplOrgHookNew,
 		}, nil
 	}
 
-	if ctx.Doer.IsAdmin {
-		// Are we looking at default webhooks?
-		if ctx.Params(":configType") == "default-hooks" {
-			return &orgRepoCtx{
-				IsAdmin:     true,
-				Link:        path.Join(setting.AppSubURL, "/admin/hooks"),
-				LinkNew:     path.Join(setting.AppSubURL, "/admin/default-hooks"),
-				NewTemplate: tplAdminHookNew,
-			}, nil
-		}
+	if is, ok := ctx.Data["IsUserWebhook"]; ok && is.(bool) {
+		return &ownerRepoCtx{
+			OwnerID:     ctx.Doer.ID,
+			Link:        path.Join(setting.AppSubURL, "/user/settings/hooks"),
+			LinkNew:     path.Join(setting.AppSubURL, "/user/settings/hooks"),
+			NewTemplate: tplUserHookNew,
+		}, nil
+	}
 
-		// Must be system webhooks instead
-		return &orgRepoCtx{
+	if ctx.Doer.IsAdmin {
+		return &ownerRepoCtx{
 			IsAdmin:         true,
-			IsSystemWebhook: true,
+			IsSystemWebhook: ctx.Params(":configType") == "system-hooks",
 			Link:            path.Join(setting.AppSubURL, "/admin/hooks"),
 			LinkNew:         path.Join(setting.AppSubURL, "/admin/system-hooks"),
 			NewTemplate:     tplAdminHookNew,
 		}, nil
 	}
 
-	return nil, errors.New("unable to set OrgRepo context")
+	return nil, errors.New("unable to set OwnerRepo context")
 }
 
 func checkHookType(ctx *context.Context) string {
@@ -122,9 +121,9 @@ func WebhooksNew(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook")
 	ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}}
 
-	orCtx, err := getOrgRepoCtx(ctx)
+	orCtx, err := getOwnerRepoCtx(ctx)
 	if err != nil {
-		ctx.ServerError("getOrgRepoCtx", err)
+		ctx.ServerError("getOwnerRepoCtx", err)
 		return
 	}
 
@@ -205,9 +204,9 @@ func createWebhook(ctx *context.Context, params webhookParams) {
 	ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}}
 	ctx.Data["HookType"] = params.Type
 
-	orCtx, err := getOrgRepoCtx(ctx)
+	orCtx, err := getOwnerRepoCtx(ctx)
 	if err != nil {
-		ctx.ServerError("getOrgRepoCtx", err)
+		ctx.ServerError("getOwnerRepoCtx", err)
 		return
 	}
 	ctx.Data["BaseLink"] = orCtx.LinkNew
@@ -236,7 +235,7 @@ func createWebhook(ctx *context.Context, params webhookParams) {
 		IsActive:        params.WebhookForm.Active,
 		Type:            params.Type,
 		Meta:            string(meta),
-		OrgID:           orCtx.OrgID,
+		OwnerID:         orCtx.OwnerID,
 		IsSystemWebhook: orCtx.IsSystemWebhook,
 	}
 	err = w.SetHeaderAuthorization(params.WebhookForm.AuthorizationHeader)
@@ -577,19 +576,19 @@ func packagistHookParams(ctx *context.Context) webhookParams {
 	}
 }
 
-func checkWebhook(ctx *context.Context) (*orgRepoCtx, *webhook.Webhook) {
-	orCtx, err := getOrgRepoCtx(ctx)
+func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) {
+	orCtx, err := getOwnerRepoCtx(ctx)
 	if err != nil {
-		ctx.ServerError("getOrgRepoCtx", err)
+		ctx.ServerError("getOwnerRepoCtx", err)
 		return nil, nil
 	}
 	ctx.Data["BaseLink"] = orCtx.Link
 
 	var w *webhook.Webhook
 	if orCtx.RepoID > 0 {
-		w, err = webhook.GetWebhookByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
-	} else if orCtx.OrgID > 0 {
-		w, err = webhook.GetWebhookByOrgID(ctx.Org.Organization.ID, ctx.ParamsInt64(":id"))
+		w, err = webhook.GetWebhookByRepoID(orCtx.RepoID, ctx.ParamsInt64(":id"))
+	} else if orCtx.OwnerID > 0 {
+		w, err = webhook.GetWebhookByOwnerID(orCtx.OwnerID, ctx.ParamsInt64(":id"))
 	} else if orCtx.IsAdmin {
 		w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.ParamsInt64(":id"))
 	}
diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go
new file mode 100644
index 0000000000..9b0b0c9611
--- /dev/null
+++ b/routers/web/user/setting/webhooks.go
@@ -0,0 +1,48 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"net/http"
+
+	"code.gitea.io/gitea/models/webhook"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+const (
+	tplSettingsHooks base.TplName = "user/settings/hooks"
+)
+
+// Webhooks render webhook list page
+func Webhooks(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("settings")
+	ctx.Data["PageIsSettingsHooks"] = true
+	ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks"
+	ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks"
+	ctx.Data["Description"] = ctx.Tr("settings.hooks.desc")
+
+	ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID})
+	if err != nil {
+		ctx.ServerError("ListWebhooksByOpts", err)
+		return
+	}
+
+	ctx.Data["Webhooks"] = ws
+	ctx.HTML(http.StatusOK, tplSettingsHooks)
+}
+
+// DeleteWebhook response for delete webhook
+func DeleteWebhook(ctx *context.Context) {
+	if err := webhook.DeleteWebhookByOwnerID(ctx.Doer.ID, ctx.FormInt64("id")); err != nil {
+		ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
+	} else {
+		ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"redirect": setting.AppSubURL + "/user/settings/hooks",
+	})
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index ff312992dd..e4179d5802 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -315,6 +315,35 @@ func RegisterRoutes(m *web.Route) {
 		}
 	}
 
+	addWebhookAddRoutes := func() {
+		m.Get("/{type}/new", repo.WebhooksNew)
+		m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost)
+		m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost)
+		m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost)
+		m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost)
+		m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost)
+		m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost)
+		m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost)
+		m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost)
+		m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost)
+		m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost)
+		m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost)
+	}
+
+	addWebhookEditRoutes := func() {
+		m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost)
+		m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost)
+		m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost)
+		m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost)
+		m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost)
+		m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost)
+		m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost)
+		m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost)
+		m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost)
+		m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost)
+		m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost)
+	}
+
 	// FIXME: not all routes need go through same middleware.
 	// Especially some AJAX requests, we can reduce middleware number to improve performance.
 	// Routers.
@@ -482,6 +511,19 @@ func RegisterRoutes(m *web.Route) {
 		m.Get("/organization", user_setting.Organization)
 		m.Get("/repos", user_setting.Repos)
 		m.Post("/repos/unadopted", user_setting.AdoptOrDeleteRepository)
+
+		m.Group("/hooks", func() {
+			m.Get("", user_setting.Webhooks)
+			m.Post("/delete", user_setting.DeleteWebhook)
+			addWebhookAddRoutes()
+			m.Group("/{id}", func() {
+				m.Get("", repo.WebHooksEdit)
+				m.Post("/replay/{uuid}", repo.ReplayWebhook)
+			})
+			addWebhookEditRoutes()
+		}, webhooksEnabled, func(ctx *context.Context) {
+			ctx.Data["IsUserWebhook"] = true
+		})
 	}, reqSignIn, func(ctx *context.Context) {
 		ctx.Data["PageIsUserSettings"] = true
 		ctx.Data["AllThemes"] = setting.UI.Themes
@@ -575,32 +617,11 @@ func RegisterRoutes(m *web.Route) {
 				m.Get("", repo.WebHooksEdit)
 				m.Post("/replay/{uuid}", repo.ReplayWebhook)
 			})
-			m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost)
-			m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost)
-			m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost)
-			m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost)
-			m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost)
-			m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost)
-			m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost)
-			m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost)
-			m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost)
-			m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost)
-			m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost)
+			addWebhookEditRoutes()
 		}, webhooksEnabled)
 
 		m.Group("/{configType:default-hooks|system-hooks}", func() {
-			m.Get("/{type}/new", repo.WebhooksNew)
-			m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost)
-			m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost)
-			m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost)
-			m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost)
-			m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost)
-			m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost)
-			m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost)
-			m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost)
-			m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost)
-			m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost)
-			m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost)
+			addWebhookAddRoutes()
 		})
 
 		m.Group("/auths", func() {
@@ -759,32 +780,15 @@ func RegisterRoutes(m *web.Route) {
 				m.Group("/hooks", func() {
 					m.Get("", org.Webhooks)
 					m.Post("/delete", org.DeleteWebhook)
-					m.Get("/{type}/new", repo.WebhooksNew)
-					m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost)
-					m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost)
-					m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost)
-					m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost)
-					m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost)
-					m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost)
-					m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost)
-					m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost)
-					m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost)
-					m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost)
+					addWebhookAddRoutes()
 					m.Group("/{id}", func() {
 						m.Get("", repo.WebHooksEdit)
 						m.Post("/replay/{uuid}", repo.ReplayWebhook)
 					})
-					m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost)
-					m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost)
-					m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost)
-					m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost)
-					m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost)
-					m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost)
-					m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost)
-					m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost)
-					m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost)
-					m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost)
-				}, webhooksEnabled)
+					addWebhookEditRoutes()
+				}, webhooksEnabled, func(ctx *context.Context) {
+					ctx.Data["IsOrganizationWebhook"] = true
+				})
 
 				m.Group("/labels", func() {
 					m.Get("", org.RetrieveLabels, org.Labels)
@@ -962,35 +966,16 @@ func RegisterRoutes(m *web.Route) {
 			m.Group("/hooks", func() {
 				m.Get("", repo.Webhooks)
 				m.Post("/delete", repo.DeleteWebhook)
-				m.Get("/{type}/new", repo.WebhooksNew)
-				m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost)
-				m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost)
-				m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost)
-				m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost)
-				m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost)
-				m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost)
-				m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost)
-				m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost)
-				m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost)
-				m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost)
-				m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost)
+				addWebhookAddRoutes()
 				m.Group("/{id}", func() {
 					m.Get("", repo.WebHooksEdit)
 					m.Post("/test", repo.TestWebhook)
 					m.Post("/replay/{uuid}", repo.ReplayWebhook)
 				})
-				m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost)
-				m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost)
-				m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost)
-				m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost)
-				m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost)
-				m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost)
-				m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost)
-				m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost)
-				m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost)
-				m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost)
-				m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost)
-			}, webhooksEnabled)
+				addWebhookEditRoutes()
+			}, webhooksEnabled, func(ctx *context.Context) {
+				ctx.Data["IsRepositoryWebhook"] = true
+			})
 
 			m.Group("/keys", func() {
 				m.Combo("").Get(repo.DeployKeys).
diff --git a/services/repository/hooks.go b/services/repository/hooks.go
index a8b6f7a622..8506fa3413 100644
--- a/services/repository/hooks.go
+++ b/services/repository/hooks.go
@@ -101,7 +101,7 @@ func GenerateWebhooks(ctx context.Context, templateRepo, generateRepo *repo_mode
 			HookEvent:   templateWebhook.HookEvent,
 			IsActive:    templateWebhook.IsActive,
 			Type:        templateWebhook.Type,
-			OrgID:       templateWebhook.OrgID,
+			OwnerID:     templateWebhook.OwnerID,
 			Events:      templateWebhook.Events,
 			Meta:        templateWebhook.Meta,
 		})
diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go
index afd8e3c105..b862d5bff1 100644
--- a/services/webhook/webhook.go
+++ b/services/webhook/webhook.go
@@ -229,16 +229,16 @@ func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_modu
 		owner = source.Repository.MustOwner(ctx)
 	}
 
-	// check if owner is an org and append additional webhooks
-	if owner != nil && owner.IsOrganization() {
-		orgHooks, err := webhook_model.ListWebhooksByOpts(ctx, &webhook_model.ListWebhookOptions{
-			OrgID:    owner.ID,
+	// append additional webhooks of a user or organization
+	if owner != nil {
+		ownerHooks, err := webhook_model.ListWebhooksByOpts(ctx, &webhook_model.ListWebhookOptions{
+			OwnerID:  owner.ID,
 			IsActive: util.OptionalBoolTrue,
 		})
 		if err != nil {
 			return fmt.Errorf("ListWebhooksByOpts: %w", err)
 		}
-		ws = append(ws, orgHooks...)
+		ws = append(ws, ownerHooks...)
 	}
 
 	// Add any admin-defined system webhooks
diff --git a/templates/repo/settings/webhook/history.tmpl b/templates/repo/settings/webhook/history.tmpl
index bf7fe05de2..f76cdb147d 100644
--- a/templates/repo/settings/webhook/history.tmpl
+++ b/templates/repo/settings/webhook/history.tmpl
@@ -40,7 +40,7 @@
 									<span class="ui label">N/A</span>
 								{{end}}
 							</a>
-							{{if or $.Permission.IsAdmin $.IsOrganizationOwner $.PageIsAdmin}}
+							{{if or $.Permission.IsAdmin $.IsOrganizationOwner $.PageIsAdmin $.PageIsUserSettings}}
 							<div class="right menu">
 								<form class="item" action="{{$.Link}}/replay/{{.UUID}}" method="post">
 									{{$.CsrfTokenHtml}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 0605937599..cb88e175ea 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -13014,6 +13014,152 @@
         }
       }
     },
+    "/user/hooks": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "List the authenticated user's webhooks",
+        "operationId": "userListHooks",
+        "parameters": [
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/HookList"
+          }
+        }
+      },
+      "post": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Create a hook",
+        "operationId": "userCreateHook",
+        "parameters": [
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/CreateHookOption"
+            }
+          }
+        ],
+        "responses": {
+          "201": {
+            "$ref": "#/responses/Hook"
+          }
+        }
+      }
+    },
+    "/user/hooks/{id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get a hook",
+        "operationId": "userGetHook",
+        "parameters": [
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "id of the hook to get",
+            "name": "id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/Hook"
+          }
+        }
+      },
+      "delete": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Delete a hook",
+        "operationId": "userDeleteHook",
+        "parameters": [
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "id of the hook to delete",
+            "name": "id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      },
+      "patch": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Update a hook",
+        "operationId": "userEditHook",
+        "parameters": [
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "id of the hook to update",
+            "name": "id",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/EditHookOption"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/Hook"
+          }
+        }
+      }
+    },
     "/user/keys": {
       "get": {
         "produces": [
diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl
index ef9ac9a977..b0cd37d44c 100644
--- a/templates/user/settings/applications.tmpl
+++ b/templates/user/settings/applications.tmpl
@@ -138,6 +138,12 @@
 							<label>admin:org_hook</label>
 						</div>
 					</div>
+					<div class="field">
+						<div class="ui checkbox">
+							<input class="enable-system" type="checkbox" name="scope" value="admin:user_hook">
+							<label>admin:user_hook</label>
+						</div>
+					</div>
 					<div class="field">
 						<div class="ui checkbox">
 							<input class="enable-system" type="checkbox" name="scope" value="notification">
diff --git a/templates/user/settings/hook_new.tmpl b/templates/user/settings/hook_new.tmpl
new file mode 100644
index 0000000000..20aaf65f62
--- /dev/null
+++ b/templates/user/settings/hook_new.tmpl
@@ -0,0 +1,53 @@
+{{template "base/head" .}}
+<div class="page-content user  settings new webhook">
+	{{template "user/settings/navbar" .}}
+	<div class="ui container">
+		<div class="twelve wide column content">
+			{{template "base/alert" .}}
+			<h4 class="ui top attached header">
+				{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
+				<div class="ui right">
+					{{if eq .HookType "gitea"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea.svg">
+					{{else if eq .HookType "gogs"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
+					{{else if eq .HookType "slack"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/slack.png">
+					{{else if eq .HookType "discord"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/discord.png">
+					{{else if eq .HookType "dingtalk"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/dingtalk.ico">
+					{{else if eq .HookType "telegram"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/telegram.png">
+					{{else if eq .HookType "msteams"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/msteams.png">
+					{{else if eq .HookType "feishu"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/feishu.png">
+					{{else if eq .HookType "matrix"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/matrix.svg">
+					{{else if eq .HookType "wechatwork"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/wechatwork.png">
+					{{else if eq .HookType "packagist"}}
+						<img width="26" height="26" src="{{AssetUrlPrefix}}/img/packagist.png">
+					{{end}}
+				</div>
+			</h4>
+			<div class="ui attached segment">
+				{{template "repo/settings/webhook/gitea" .}}
+				{{template "repo/settings/webhook/gogs" .}}
+				{{template "repo/settings/webhook/slack" .}}
+				{{template "repo/settings/webhook/discord" .}}
+				{{template "repo/settings/webhook/dingtalk" .}}
+				{{template "repo/settings/webhook/telegram" .}}
+				{{template "repo/settings/webhook/msteams" .}}
+				{{template "repo/settings/webhook/feishu" .}}
+				{{template "repo/settings/webhook/matrix" .}}
+				{{template "repo/settings/webhook/wechatwork" .}}
+				{{template "repo/settings/webhook/packagist" .}}
+			</div>
+
+			{{template "repo/settings/webhook/history" .}}
+		</div>
+	</div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/user/settings/hooks.tmpl b/templates/user/settings/hooks.tmpl
new file mode 100644
index 0000000000..02bfa8a4e6
--- /dev/null
+++ b/templates/user/settings/hooks.tmpl
@@ -0,0 +1,8 @@
+{{template "base/head" .}}
+<div class="page-content user settings webhooks">
+	{{template "user/settings/navbar" .}}
+	<div class="ui container">
+		{{template "repo/settings/webhook/list" .}}
+	</div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl
index 8deffde0b2..4afe2173c2 100644
--- a/templates/user/settings/navbar.tmpl
+++ b/templates/user/settings/navbar.tmpl
@@ -26,6 +26,11 @@
 			{{.locale.Tr "packages.title"}}
 		</a>
 		{{end}}
+		{{if not DisableWebhooks}}
+		<a class="{{if .PageIsSettingsHooks}}active {{end}}item" href="{{AppSubUrl}}/user/settings/hooks">
+			{{.locale.Tr "repo.settings.hooks"}}
+		</a>
+		{{end}}
 		<a class="{{if .PageIsSettingsOrganization}}active {{end}}item" href="{{AppSubUrl}}/user/settings/organization">
 			{{.locale.Tr "settings.organization"}}
 		</a>