mirror of https://github.com/go-gitea/gitea
Add user blocking (#29028)
Fixes #17453 This PR adds the abbility to block a user from a personal account or organization to restrict how the blocked user can interact with the blocker. The docs explain what's the consequence of blocking a user. Screenshots: ![grafik](https://github.com/go-gitea/gitea/assets/1666336/4ed884f3-e06a-4862-afd3-3b8aa2488dc6) ![grafik](https://github.com/go-gitea/gitea/assets/1666336/ae6d4981-f252-4f50-a429-04f0f9f1cdf1) ![grafik](https://github.com/go-gitea/gitea/assets/1666336/ca153599-5b0f-4b4a-90fe-18bdfd6f0b6b) --------- Co-authored-by: Lauris BH <lauris@nix.lv>pull/28894/head^2
parent
8e12ba34ba
commit
c337ff0ec7
@ -0,0 +1,56 @@ |
||||
--- |
||||
date: "2024-01-31T00:00:00+00:00" |
||||
title: "Blocking a user" |
||||
slug: "blocking-user" |
||||
sidebar_position: 25 |
||||
toc: false |
||||
draft: false |
||||
aliases: |
||||
- /en-us/webhooks |
||||
menu: |
||||
sidebar: |
||||
parent: "usage" |
||||
name: "Blocking a user" |
||||
sidebar_position: 30 |
||||
identifier: "blocking-user" |
||||
--- |
||||
|
||||
# Blocking a user |
||||
|
||||
Gitea supports blocking of users to restrict how they can interact with you and your content. |
||||
|
||||
You can block a user in your account settings, from the user's profile or from comments created by the user. |
||||
The user is not directly notified about the block, but they can notice they are blocked when they attempt to interact with you. |
||||
Organization owners can block anyone who is not a member of the organization too. |
||||
If a blocked user has admin permissions, they can still perform all actions even if blocked. |
||||
|
||||
### When you block a user |
||||
|
||||
- the user stops following you |
||||
- you stop following the user |
||||
- the user's stars are removed from your repositories |
||||
- your stars are removed from their repositories |
||||
- the user stops watching your repositories |
||||
- you stop watching their repositories |
||||
- the user's issue assignments are removed from your repositories |
||||
- your issue assignments are removed from their repositories |
||||
- the user is removed as a collaborator on your repositories |
||||
- you are removed as a collaborator on their repositories |
||||
- any pending repository transfers to or from the blocked user are canceled |
||||
|
||||
### When you block a user, the user cannot |
||||
|
||||
- follow you |
||||
- watch your repositories |
||||
- star your repositories |
||||
- fork your repositories |
||||
- transfer repositories to you |
||||
- open issues or pull requests on your repositories |
||||
- comment on issues or pull requests you've created |
||||
- comment on issues or pull requests on your repositories |
||||
- react to your comments on issues or pull requests |
||||
- react to comments on issues or pull requests on your repositories |
||||
- assign you to issues or pull requests |
||||
- add you as a collaborator on their repositories |
||||
- send you notifications by @mentioning your username |
||||
- be added as team member (if blocked by an organization) |
@ -0,0 +1,19 @@ |
||||
- |
||||
id: 1 |
||||
blocker_id: 2 |
||||
blockee_id: 29 |
||||
|
||||
- |
||||
id: 2 |
||||
blocker_id: 17 |
||||
blockee_id: 28 |
||||
|
||||
- |
||||
id: 3 |
||||
blocker_id: 2 |
||||
blockee_id: 34 |
||||
|
||||
- |
||||
id: 4 |
||||
blocker_id: 50 |
||||
blockee_id: 34 |
@ -0,0 +1,26 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_22 //nolint
|
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"xorm.io/xorm" |
||||
) |
||||
|
||||
type Blocking struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
BlockerID int64 `xorm:"UNIQUE(block)"` |
||||
BlockeeID int64 `xorm:"UNIQUE(block)"` |
||||
Note string |
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` |
||||
} |
||||
|
||||
func (*Blocking) TableName() string { |
||||
return "user_blocking" |
||||
} |
||||
|
||||
func AddUserBlockingTable(x *xorm.Engine) error { |
||||
return x.Sync(&Blocking{}) |
||||
} |
@ -0,0 +1,123 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/container" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
var ( |
||||
ErrBlockOrganization = util.NewInvalidArgumentErrorf("cannot block an organization") |
||||
ErrCanNotBlock = util.NewInvalidArgumentErrorf("cannot block the user") |
||||
ErrCanNotUnblock = util.NewInvalidArgumentErrorf("cannot unblock the user") |
||||
ErrBlockedUser = util.NewPermissionDeniedErrorf("user is blocked") |
||||
) |
||||
|
||||
type Blocking struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
BlockerID int64 `xorm:"UNIQUE(block)"` |
||||
Blocker *User `xorm:"-"` |
||||
BlockeeID int64 `xorm:"UNIQUE(block)"` |
||||
Blockee *User `xorm:"-"` |
||||
Note string |
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` |
||||
} |
||||
|
||||
func (*Blocking) TableName() string { |
||||
return "user_blocking" |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(Blocking)) |
||||
} |
||||
|
||||
func UpdateBlockingNote(ctx context.Context, id int64, note string) error { |
||||
_, err := db.GetEngine(ctx).ID(id).Cols("note").Update(&Blocking{Note: note}) |
||||
return err |
||||
} |
||||
|
||||
func IsUserBlockedBy(ctx context.Context, blockee *User, blockerIDs ...int64) bool { |
||||
if len(blockerIDs) == 0 { |
||||
return false |
||||
} |
||||
|
||||
if blockee.IsAdmin { |
||||
return false |
||||
} |
||||
|
||||
cond := builder.Eq{"user_blocking.blockee_id": blockee.ID}. |
||||
And(builder.In("user_blocking.blocker_id", blockerIDs)) |
||||
|
||||
has, _ := db.GetEngine(ctx).Where(cond).Exist(&Blocking{}) |
||||
return has |
||||
} |
||||
|
||||
type FindBlockingOptions struct { |
||||
db.ListOptions |
||||
BlockerID int64 |
||||
BlockeeID int64 |
||||
} |
||||
|
||||
func (opts *FindBlockingOptions) ToConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.BlockerID != 0 { |
||||
cond = cond.And(builder.Eq{"user_blocking.blocker_id": opts.BlockerID}) |
||||
} |
||||
if opts.BlockeeID != 0 { |
||||
cond = cond.And(builder.Eq{"user_blocking.blockee_id": opts.BlockeeID}) |
||||
} |
||||
return cond |
||||
} |
||||
|
||||
func FindBlockings(ctx context.Context, opts *FindBlockingOptions) ([]*Blocking, int64, error) { |
||||
return db.FindAndCount[Blocking](ctx, opts) |
||||
} |
||||
|
||||
func GetBlocking(ctx context.Context, blockerID, blockeeID int64) (*Blocking, error) { |
||||
blocks, _, err := FindBlockings(ctx, &FindBlockingOptions{ |
||||
BlockerID: blockerID, |
||||
BlockeeID: blockeeID, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if len(blocks) == 0 { |
||||
return nil, nil |
||||
} |
||||
return blocks[0], nil |
||||
} |
||||
|
||||
type BlockingList []*Blocking |
||||
|
||||
func (blocks BlockingList) LoadAttributes(ctx context.Context) error { |
||||
ids := make(container.Set[int64], len(blocks)*2) |
||||
for _, b := range blocks { |
||||
ids.Add(b.BlockerID) |
||||
ids.Add(b.BlockeeID) |
||||
} |
||||
|
||||
userList, err := GetUsersByIDs(ctx, ids.Values()) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
userMap := make(map[int64]*User, len(userList)) |
||||
for _, u := range userList { |
||||
userMap[u.ID] = u |
||||
} |
||||
|
||||
for _, b := range blocks { |
||||
b.Blocker = userMap[b.BlockerID] |
||||
b.Blockee = userMap[b.BlockeeID] |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,116 @@ |
||||
// Copyright 2024 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org |
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/routers/api/v1/shared" |
||||
"code.gitea.io/gitea/services/context" |
||||
) |
||||
|
||||
func ListBlocks(ctx *context.APIContext) { |
||||
// swagger:operation GET /orgs/{org}/blocks organization organizationListBlocks
|
||||
// ---
|
||||
// summary: List users blocked by the organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - 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
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
|
||||
shared.ListBlocks(ctx, ctx.Org.Organization.AsUser()) |
||||
} |
||||
|
||||
func CheckUserBlock(ctx *context.APIContext) { |
||||
// swagger:operation GET /orgs/{org}/blocks/{username} organization organizationCheckUserBlock
|
||||
// ---
|
||||
// summary: Check if a user is blocked by the organization
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: user to check
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.CheckUserBlock(ctx, ctx.Org.Organization.AsUser()) |
||||
} |
||||
|
||||
func BlockUser(ctx *context.APIContext) { |
||||
// swagger:operation PUT /orgs/{org}/blocks/{username} organization organizationBlockUser
|
||||
// ---
|
||||
// summary: Block a user
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: user to block
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: note
|
||||
// in: query
|
||||
// description: optional note for the block
|
||||
// type: string
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
shared.BlockUser(ctx, ctx.Org.Organization.AsUser()) |
||||
} |
||||
|
||||
func UnblockUser(ctx *context.APIContext) { |
||||
// swagger:operation DELETE /orgs/{org}/blocks/{username} organization organizationUnblockUser
|
||||
// ---
|
||||
// summary: Unblock a user
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: user to unblock
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
shared.UnblockUser(ctx, ctx.Doer, ctx.Org.Organization.AsUser()) |
||||
} |
@ -0,0 +1,98 @@ |
||||
// Copyright 2024 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package shared |
||||
|
||||
import ( |
||||
"errors" |
||||
"net/http" |
||||
|
||||
user_model "code.gitea.io/gitea/models/user" |
||||
api "code.gitea.io/gitea/modules/structs" |
||||
"code.gitea.io/gitea/routers/api/v1/utils" |
||||
"code.gitea.io/gitea/services/context" |
||||
"code.gitea.io/gitea/services/convert" |
||||
user_service "code.gitea.io/gitea/services/user" |
||||
) |
||||
|
||||
func ListBlocks(ctx *context.APIContext, blocker *user_model.User) { |
||||
blocks, total, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{ |
||||
ListOptions: utils.GetListOptions(ctx), |
||||
BlockerID: blocker.ID, |
||||
}) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, "FindBlockings", err) |
||||
return |
||||
} |
||||
|
||||
if err := user_model.BlockingList(blocks).LoadAttributes(ctx); err != nil { |
||||
ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) |
||||
return |
||||
} |
||||
|
||||
users := make([]*api.User, 0, len(blocks)) |
||||
for _, b := range blocks { |
||||
users = append(users, convert.ToUser(ctx, b.Blockee, blocker)) |
||||
} |
||||
|
||||
ctx.SetTotalCountHeader(total) |
||||
ctx.JSON(http.StatusOK, &users) |
||||
} |
||||
|
||||
func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) { |
||||
blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) |
||||
if err != nil { |
||||
ctx.NotFound("GetUserByName", err) |
||||
return |
||||
} |
||||
|
||||
status := http.StatusNotFound |
||||
blocking, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, "GetBlocking", err) |
||||
return |
||||
} |
||||
if blocking != nil { |
||||
status = http.StatusNoContent |
||||
} |
||||
|
||||
ctx.Status(status) |
||||
} |
||||
|
||||
func BlockUser(ctx *context.APIContext, blocker *user_model.User) { |
||||
blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) |
||||
if err != nil { |
||||
ctx.NotFound("GetUserByName", err) |
||||
return |
||||
} |
||||
|
||||
if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, ctx.FormString("note")); err != nil { |
||||
if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { |
||||
ctx.Error(http.StatusBadRequest, "BlockUser", err) |
||||
} else { |
||||
ctx.Error(http.StatusInternalServerError, "BlockUser", err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
ctx.Status(http.StatusNoContent) |
||||
} |
||||
|
||||
func UnblockUser(ctx *context.APIContext, doer, blocker *user_model.User) { |
||||
blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) |
||||
if err != nil { |
||||
ctx.NotFound("GetUserByName", err) |
||||
return |
||||
} |
||||
|
||||
if err := user_service.UnblockUser(ctx, doer, blocker, blockee); err != nil { |
||||
if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { |
||||
ctx.Error(http.StatusBadRequest, "UnblockUser", err) |
||||
} else { |
||||
ctx.Error(http.StatusInternalServerError, "UnblockUser", err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
ctx.Status(http.StatusNoContent) |
||||
} |
@ -0,0 +1,96 @@ |
||||
// Copyright 2024 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user |
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/routers/api/v1/shared" |
||||
"code.gitea.io/gitea/services/context" |
||||
) |
||||
|
||||
func ListBlocks(ctx *context.APIContext) { |
||||
// swagger:operation GET /user/blocks user userListBlocks
|
||||
// ---
|
||||
// summary: List users blocked by the authenticated user
|
||||
// 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
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/UserList"
|
||||
|
||||
shared.ListBlocks(ctx, ctx.Doer) |
||||
} |
||||
|
||||
func CheckUserBlock(ctx *context.APIContext) { |
||||
// swagger:operation GET /user/blocks/{username} user userCheckUserBlock
|
||||
// ---
|
||||
// summary: Check if a user is blocked by the authenticated user
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: user to check
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.CheckUserBlock(ctx, ctx.Doer) |
||||
} |
||||
|
||||
func BlockUser(ctx *context.APIContext) { |
||||
// swagger:operation PUT /user/blocks/{username} user userBlockUser
|
||||
// ---
|
||||
// summary: Block a user
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: user to block
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: note
|
||||
// in: query
|
||||
// description: optional note for the block
|
||||
// type: string
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
shared.BlockUser(ctx, ctx.Doer) |
||||
} |
||||
|
||||
func UnblockUser(ctx *context.APIContext) { |
||||
// swagger:operation DELETE /user/blocks/{username} user userUnblockUser
|
||||
// ---
|
||||
// summary: Unblock a user
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: user to unblock
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
shared.UnblockUser(ctx, ctx.Doer, ctx.Doer) |
||||
} |
@ -0,0 +1,38 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org |
||||
|
||||
import ( |
||||
"net/http" |
||||
|
||||
"code.gitea.io/gitea/modules/base" |
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user" |
||||
"code.gitea.io/gitea/services/context" |
||||
) |
||||
|
||||
const ( |
||||
tplSettingsBlockedUsers base.TplName = "org/settings/blocked_users" |
||||
) |
||||
|
||||
func BlockedUsers(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("user.block.list") |
||||
ctx.Data["PageIsOrgSettings"] = true |
||||
ctx.Data["PageIsSettingsBlockedUsers"] = true |
||||
|
||||
shared_user.BlockedUsers(ctx, ctx.ContextUser) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) |
||||
} |
||||
|
||||
func BlockedUsersPost(ctx *context.Context) { |
||||
shared_user.BlockedUsersPost(ctx, ctx.ContextUser) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
ctx.Redirect(ctx.ContextUser.OrganisationLink() + "/settings/blocked_users") |
||||
} |
@ -0,0 +1,76 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user |
||||
|
||||
import ( |
||||
"errors" |
||||
|
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/web" |
||||
"code.gitea.io/gitea/services/context" |
||||
"code.gitea.io/gitea/services/forms" |
||||
user_service "code.gitea.io/gitea/services/user" |
||||
) |
||||
|
||||
func BlockedUsers(ctx *context.Context, blocker *user_model.User) { |
||||
blocks, _, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{ |
||||
BlockerID: blocker.ID, |
||||
}) |
||||
if err != nil { |
||||
ctx.ServerError("FindBlockings", err) |
||||
return |
||||
} |
||||
if err := user_model.BlockingList(blocks).LoadAttributes(ctx); err != nil { |
||||
ctx.ServerError("LoadAttributes", err) |
||||
return |
||||
} |
||||
ctx.Data["UserBlocks"] = blocks |
||||
} |
||||
|
||||
func BlockedUsersPost(ctx *context.Context, blocker *user_model.User) { |
||||
form := web.GetForm(ctx).(*forms.BlockUserForm) |
||||
if ctx.HasError() { |
||||
ctx.ServerError("FormValidation", nil) |
||||
return |
||||
} |
||||
|
||||
blockee, err := user_model.GetUserByName(ctx, form.Blockee) |
||||
if err != nil { |
||||
ctx.ServerError("GetUserByName", nil) |
||||
return |
||||
} |
||||
|
||||
switch form.Action { |
||||
case "block": |
||||
if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note); err != nil { |
||||
if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { |
||||
ctx.Flash.Error(ctx.Tr("user.block.block.failure", err.Error())) |
||||
} else { |
||||
ctx.ServerError("BlockUser", err) |
||||
return |
||||
} |
||||
} |
||||
case "unblock": |
||||
if err := user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee); err != nil { |
||||
if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { |
||||
ctx.Flash.Error(ctx.Tr("user.block.unblock.failure", err.Error())) |
||||
} else { |
||||
ctx.ServerError("UnblockUser", err) |
||||
return |
||||
} |
||||
} |
||||
case "note": |
||||
block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) |
||||
if err != nil { |
||||
ctx.ServerError("GetBlocking", err) |
||||
return |
||||
} |
||||
if block != nil { |
||||
if err := user_model.UpdateBlockingNote(ctx, block.ID, form.Note); err != nil { |
||||
ctx.ServerError("UpdateBlockingNote", err) |
||||
return |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,38 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting |
||||
|
||||
import ( |
||||
"net/http" |
||||
|
||||
"code.gitea.io/gitea/modules/base" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user" |
||||
"code.gitea.io/gitea/services/context" |
||||
) |
||||
|
||||
const ( |
||||
tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" |
||||
) |
||||
|
||||
func BlockedUsers(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("user.block.list") |
||||
ctx.Data["PageIsSettingsBlockedUsers"] = true |
||||
|
||||
shared_user.BlockedUsers(ctx, ctx.Doer) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) |
||||
} |
||||
|
||||
func BlockedUsersPost(ctx *context.Context) { |
||||
shared_user.BlockedUsersPost(ctx, ctx.Doer) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") |
||||
} |
@ -0,0 +1,50 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
) |
||||
|
||||
// CreateIssueReaction creates a reaction on an issue.
|
||||
func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) { |
||||
if err := issue.LoadRepo(ctx); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { |
||||
return nil, user_model.ErrBlockedUser |
||||
} |
||||
|
||||
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ |
||||
Type: content, |
||||
DoerID: doer.ID, |
||||
IssueID: issue.ID, |
||||
}) |
||||
} |
||||
|
||||
// CreateCommentReaction creates a reaction on a comment.
|
||||
func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) { |
||||
if err := comment.LoadIssue(ctx); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := comment.Issue.LoadRepo(ctx); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, comment.Issue.PosterID, comment.Issue.Repo.OwnerID, comment.PosterID) { |
||||
return nil, user_model.ErrBlockedUser |
||||
} |
||||
|
||||
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ |
||||
Type: content, |
||||
DoerID: doer.ID, |
||||
IssueID: comment.Issue.ID, |
||||
CommentID: comment.ID, |
||||
}) |
||||
} |
@ -0,0 +1,308 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models" |
||||
"code.gitea.io/gitea/models/db" |
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
org_model "code.gitea.io/gitea/models/organization" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
repo_service "code.gitea.io/gitea/services/repository" |
||||
) |
||||
|
||||
func CanBlockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool { |
||||
if blocker.ID == blockee.ID { |
||||
return false |
||||
} |
||||
if doer.ID == blockee.ID { |
||||
return false |
||||
} |
||||
|
||||
if blockee.IsOrganization() { |
||||
return false |
||||
} |
||||
|
||||
if user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) { |
||||
return false |
||||
} |
||||
|
||||
if blocker.IsOrganization() { |
||||
org := org_model.OrgFromUser(blocker) |
||||
if isMember, _ := org.IsOrgMember(ctx, blockee.ID); isMember { |
||||
return false |
||||
} |
||||
if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin { |
||||
return false |
||||
} |
||||
} else if !doer.IsAdmin && doer.ID != blocker.ID { |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
func CanUnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool { |
||||
if doer.ID == blockee.ID { |
||||
return false |
||||
} |
||||
|
||||
if !user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) { |
||||
return false |
||||
} |
||||
|
||||
if blocker.IsOrganization() { |
||||
org := org_model.OrgFromUser(blocker) |
||||
if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin { |
||||
return false |
||||
} |
||||
} else if !doer.IsAdmin && doer.ID != blocker.ID { |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
func BlockUser(ctx context.Context, doer, blocker, blockee *user_model.User, note string) error { |
||||
if blockee.IsOrganization() { |
||||
return user_model.ErrBlockOrganization |
||||
} |
||||
|
||||
if !CanBlockUser(ctx, doer, blocker, blockee) { |
||||
return user_model.ErrCanNotBlock |
||||
} |
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error { |
||||
// unfollow each other
|
||||
if err := user_model.UnfollowUser(ctx, blocker.ID, blockee.ID); err != nil { |
||||
return err |
||||
} |
||||
if err := user_model.UnfollowUser(ctx, blockee.ID, blocker.ID); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// unstar each other
|
||||
if err := unstarRepos(ctx, blocker, blockee); err != nil { |
||||
return err |
||||
} |
||||
if err := unstarRepos(ctx, blockee, blocker); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// unwatch each others repositories
|
||||
if err := unwatchRepos(ctx, blocker, blockee); err != nil { |
||||
return err |
||||
} |
||||
if err := unwatchRepos(ctx, blockee, blocker); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// unassign each other from issues
|
||||
if err := unassignIssues(ctx, blocker, blockee); err != nil { |
||||
return err |
||||
} |
||||
if err := unassignIssues(ctx, blockee, blocker); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// remove each other from repository collaborations
|
||||
if err := removeCollaborations(ctx, blocker, blockee); err != nil { |
||||
return err |
||||
} |
||||
if err := removeCollaborations(ctx, blockee, blocker); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// cancel each other repository transfers
|
||||
if err := cancelRepositoryTransfers(ctx, blocker, blockee); err != nil { |
||||
return err |
||||
} |
||||
if err := cancelRepositoryTransfers(ctx, blockee, blocker); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return db.Insert(ctx, &user_model.Blocking{ |
||||
BlockerID: blocker.ID, |
||||
BlockeeID: blockee.ID, |
||||
Note: note, |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
func unstarRepos(ctx context.Context, starrer, repoOwner *user_model.User) error { |
||||
opts := &repo_model.StarredReposOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: 1, |
||||
PageSize: 25, |
||||
}, |
||||
StarrerID: starrer.ID, |
||||
RepoOwnerID: repoOwner.ID, |
||||
} |
||||
|
||||
for { |
||||
repos, err := repo_model.GetStarredRepos(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if len(repos) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
for _, repo := range repos { |
||||
if err := repo_model.StarRepo(ctx, starrer, repo, false); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
opts.Page++ |
||||
} |
||||
} |
||||
|
||||
func unwatchRepos(ctx context.Context, watcher, repoOwner *user_model.User) error { |
||||
opts := &repo_model.WatchedReposOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: 1, |
||||
PageSize: 25, |
||||
}, |
||||
WatcherID: watcher.ID, |
||||
RepoOwnerID: repoOwner.ID, |
||||
} |
||||
|
||||
for { |
||||
repos, _, err := repo_model.GetWatchedRepos(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if len(repos) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
for _, repo := range repos { |
||||
if err := repo_model.WatchRepo(ctx, watcher, repo, false); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
opts.Page++ |
||||
} |
||||
} |
||||
|
||||
func cancelRepositoryTransfers(ctx context.Context, sender, recipient *user_model.User) error { |
||||
transfers, err := models.GetPendingRepositoryTransfers(ctx, &models.PendingRepositoryTransferOptions{ |
||||
SenderID: sender.ID, |
||||
RecipientID: recipient.ID, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, transfer := range transfers { |
||||
repo, err := repo_model.GetRepositoryByID(ctx, transfer.RepoID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err := repo_service.CancelRepositoryTransfer(ctx, repo); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func unassignIssues(ctx context.Context, assignee, repoOwner *user_model.User) error { |
||||
opts := &issues_model.AssignedIssuesOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: 1, |
||||
PageSize: 25, |
||||
}, |
||||
AssigneeID: assignee.ID, |
||||
RepoOwnerID: repoOwner.ID, |
||||
} |
||||
|
||||
for { |
||||
issues, _, err := issues_model.GetAssignedIssues(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if len(issues) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
for _, issue := range issues { |
||||
if err := issue.LoadAssignees(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if _, _, err := issues_model.ToggleIssueAssignee(ctx, issue, assignee, assignee.ID); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
opts.Page++ |
||||
} |
||||
} |
||||
|
||||
func removeCollaborations(ctx context.Context, repoOwner, collaborator *user_model.User) error { |
||||
opts := &repo_model.FindCollaborationOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: 1, |
||||
PageSize: 25, |
||||
}, |
||||
CollaboratorID: collaborator.ID, |
||||
RepoOwnerID: repoOwner.ID, |
||||
} |
||||
|
||||
for { |
||||
collaborations, _, err := repo_model.GetCollaborators(ctx, opts) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if len(collaborations) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
for _, collaboration := range collaborations { |
||||
repo, err := repo_model.GetRepositoryByID(ctx, collaboration.Collaboration.RepoID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err := repo_service.DeleteCollaboration(ctx, repo, collaborator); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
opts.Page++ |
||||
} |
||||
} |
||||
|
||||
func UnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) error { |
||||
if blockee.IsOrganization() { |
||||
return user_model.ErrBlockOrganization |
||||
} |
||||
|
||||
if !CanUnblockUser(ctx, doer, blocker, blockee) { |
||||
return user_model.ErrCanNotUnblock |
||||
} |
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error { |
||||
block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if block != nil { |
||||
_, err = db.DeleteByID[user_model.Blocking](ctx, block.ID) |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
} |
@ -0,0 +1,66 @@ |
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/models/unittest" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestCanBlockUser(t *testing.T) { |
||||
assert.NoError(t, unittest.PrepareTestDatabase()) |
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) |
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) |
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) |
||||
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) |
||||
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) |
||||
|
||||
// Doer can't self block
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user1)) |
||||
// Blocker can't be blockee
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user2)) |
||||
// Can't block already blocked user
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user29)) |
||||
// Blockee can't be an organization
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, org3)) |
||||
// Doer must be blocker or admin
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user2, user4, user29)) |
||||
// Organization can't block a member
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, org3, user4)) |
||||
// Doer must be organization owner or admin if blocker is an organization
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user4, org3, user2)) |
||||
|
||||
assert.True(t, CanBlockUser(db.DefaultContext, user1, user2, user4)) |
||||
assert.True(t, CanBlockUser(db.DefaultContext, user2, user2, user4)) |
||||
assert.True(t, CanBlockUser(db.DefaultContext, user2, org3, user29)) |
||||
} |
||||
|
||||
func TestCanUnblockUser(t *testing.T) { |
||||
assert.NoError(t, unittest.PrepareTestDatabase()) |
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) |
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) |
||||
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) |
||||
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) |
||||
org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) |
||||
|
||||
// Doer can't self unblock
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user1)) |
||||
// Can't unblock not blocked user
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user28)) |
||||
// Doer must be blocker or admin
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user28, user2, user29)) |
||||
// Doer must be organization owner or admin if blocker is an organization
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user2, org17, user28)) |
||||
|
||||
assert.True(t, CanUnblockUser(db.DefaultContext, user1, user2, user29)) |
||||
assert.True(t, CanUnblockUser(db.DefaultContext, user2, user2, user29)) |
||||
assert.True(t, CanUnblockUser(db.DefaultContext, user1, org17, user28)) |
||||
} |
@ -0,0 +1,5 @@ |
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked_users")}} |
||||
<div class="org-setting-content"> |
||||
{{template "shared/user/blocked_users" .}} |
||||
</div> |
||||
{{template "org/settings/layout_footer" .}} |
@ -0,0 +1,23 @@ |
||||
<div class="ui small modal" id="block-user-modal"> |
||||
<div class="header">{{ctx.Locale.Tr "user.block.title"}}</div> |
||||
<div class="content"> |
||||
<div class="ui warning message">{{ctx.Locale.Tr "user.block.info"}}</div> |
||||
<form class="ui form modal-form" method="post"> |
||||
{{.CsrfTokenHtml}} |
||||
<input type="hidden" name="action" value="block" /> |
||||
<input type="hidden" name="blockee" class="modal-blockee" /> |
||||
<div class="field"> |
||||
<label>{{ctx.Locale.Tr "user.block.user_to_block"}}: <span class="text red modal-blockee-name"></span></label> |
||||
</div> |
||||
<div class="field"> |
||||
<label for="block-note">{{ctx.Locale.Tr "user.block.note.title"}}</label> |
||||
<input id="block-note" name="note"> |
||||
<p class="help">{{ctx.Locale.Tr "user.block.note.info"}}</p> |
||||
</div> |
||||
<div class="text right actions"> |
||||
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button> |
||||
<button class="ui red button">{{ctx.Locale.Tr "user.block.block"}}</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
@ -0,0 +1,83 @@ |
||||
<h4 class="ui top attached header"> |
||||
{{ctx.Locale.Tr "user.block.title"}} |
||||
</h4> |
||||
<div class="ui attached segment"> |
||||
<p>{{ctx.Locale.Tr "user.block.info_1"}}</p> |
||||
<ul> |
||||
<li>{{ctx.Locale.Tr "user.block.info_2"}}</li> |
||||
<li>{{ctx.Locale.Tr "user.block.info_3"}}</li> |
||||
<li>{{ctx.Locale.Tr "user.block.info_4"}}</li> |
||||
<li>{{ctx.Locale.Tr "user.block.info_5"}}</li> |
||||
<li>{{ctx.Locale.Tr "user.block.info_6"}}</li> |
||||
<li>{{ctx.Locale.Tr "user.block.info_7"}}</li> |
||||
</ul> |
||||
</div> |
||||
<div class="ui segment"> |
||||
<form class="ui form ignore-dirty" action="{{$.Link}}" method="post"> |
||||
{{.CsrfTokenHtml}} |
||||
<input type="hidden" name="action" value="block" /> |
||||
<div id="search-user-box" class="field ui fluid search input"> |
||||
<input class="prompt gt-mr-3" name="blockee" placeholder="{{ctx.Locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required> |
||||
<button class="ui red button">{{ctx.Locale.Tr "user.block.block"}}</button> |
||||
</div> |
||||
<div class="field"> |
||||
<label>{{ctx.Locale.Tr "user.block.note.title"}}</label> |
||||
<input name="note"> |
||||
<p class="help">{{ctx.Locale.Tr "user.block.note.info"}}</p> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
<h4 class="ui top attached header"> |
||||
{{ctx.Locale.Tr "user.block.list"}} |
||||
</h4> |
||||
<div class="ui attached segment"> |
||||
<div class="flex-list"> |
||||
{{range .UserBlocks}} |
||||
<div class="flex-item"> |
||||
<div class="flex-item-leading"> |
||||
{{ctx.AvatarUtils.Avatar .Blockee}} |
||||
</div> |
||||
<div class="flex-item-main"> |
||||
<div class="flex-item-title"> |
||||
<a class="item" href="{{.Blockee.HTMLURL}}">{{.Blockee.GetDisplayName}}</a> |
||||
</div> |
||||
{{if .Note}} |
||||
<div class="flex-item-body"> |
||||
<i>{{ctx.Locale.Tr "user.block.note"}}:</i> {{.Note}} |
||||
</div> |
||||
{{end}} |
||||
</div> |
||||
<div class="flex-item-trailing"> |
||||
<button class="ui compact mini button show-modal" data-modal="#block-user-note-modal" data-modal-modal-blockee="{{.Blockee.Name}}" data-modal-modal-note="{{.Note}}">{{ctx.Locale.Tr "user.block.note.edit"}}</button> |
||||
<form action="{{$.Link}}" method="post"> |
||||
{{$.CsrfTokenHtml}} |
||||
<input type="hidden" name="action" value="unblock" /> |
||||
<input type="hidden" name="blockee" value="{{.Blockee.Name}}" /> |
||||
<button class="ui compact mini button">{{ctx.Locale.Tr "user.block.unblock"}}</button> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
{{else}} |
||||
<div class="item">{{ctx.Locale.Tr "user.block.list.none"}}</div> |
||||
{{end}} |
||||
</div> |
||||
</div> |
||||
<div class="ui small modal" id="block-user-note-modal"> |
||||
<div class="header">{{ctx.Locale.Tr "user.block.note.edit"}}</div> |
||||
<div class="content"> |
||||
<form class="ui form" action="{{$.Link}}" method="post"> |
||||
{{.CsrfTokenHtml}} |
||||
<input type="hidden" name="action" value="note" /> |
||||
<input type="hidden" name="blockee" class="modal-blockee" /> |
||||
<div class="field"> |
||||
<label>{{ctx.Locale.Tr "user.block.note.title"}}</label> |
||||
<input name="note" class="modal-note" /> |
||||
<p class="help">{{ctx.Locale.Tr "user.block.note.info"}}</p> |
||||
</div> |
||||
<div class="text right actions"> |
||||
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button> |
||||
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
@ -0,0 +1,5 @@ |
||||
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked_users")}} |
||||
<div class="user-setting-content"> |
||||
{{template "shared/user/blocked_users" .}} |
||||
</div> |
||||
{{template "user/settings/layout_footer" .}} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue