[MODERATION] organization blocking a user (#802)

- Resolves #476
- Follow up for: #540
- Ensure that the doer and blocked person cannot follow each other.
- Ensure that the block person cannot watch doer's repositories.
- Add unblock button to the blocked user list.
- Add blocked since information to the blocked user list.
- Add extra testing to moderation code.
- Blocked user will unwatch doer's owned repository upon blocking.
- Add flash messages to let the user know the block/unblock action was successful.
- Add "You haven't blocked any users" message.
- Add organization blocking a user.

Co-authored-by: Gusted <postmaster@gusted.xyz>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/802
(cherry picked from commit 0505a10421)
(cherry picked from commit 37b4e6ef9b)
(cherry picked from commit 217475385a)
(cherry picked from commit f2c38ce5c2)
(cherry picked from commit 1edfb68137)
(cherry picked from commit 2cbc12dc74)
This commit is contained in:
Gusted 2023-06-09 08:07:03 +00:00 committed by Earl Warren
parent 97bc6e619d
commit 79ff020f18
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
26 changed files with 375 additions and 18 deletions

View file

@ -37,7 +37,7 @@
lower_name: repo2 lower_name: repo2
name: repo2 name: repo2
default_branch: master default_branch: master
num_watches: 0 num_watches: 1
num_stars: 1 num_stars: 1
num_forks: 0 num_forks: 0
num_issues: 2 num_issues: 2

View file

@ -27,3 +27,9 @@
user_id: 11 user_id: 11
repo_id: 1 repo_id: 1
mode: 3 # auto mode: 3 # auto
-
id: 6
user_id: 4
repo_id: 2
mode: 1 # normal

View file

@ -177,3 +177,16 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo
Limit(30). Limit(30).
Find(&users) Find(&users)
} }
// GetWatchedRepoIDsOwnedBy returns the repos owned by a particular user watched by a particular user
func GetWatchedRepoIDsOwnedBy(ctx context.Context, userID, ownedByUserID int64) ([]int64, error) {
repoIDs := make([]int64, 0, 10)
err := db.GetEngine(ctx).
Table("repository").
Select("`repository`.id").
Join("LEFT", "watch", "`repository`.id=`watch`.repo_id").
Where("`watch`.user_id=?", userID).
And("`watch`.mode<>?", WatchModeDont).
And("`repository`.owner_id=?", ownedByUserID).Find(&repoIDs)
return repoIDs, err
}

View file

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, reviewers, 1) assert.Len(t, reviewers, 1)
} }
func GetWatchedRepoIDsOwnedBy(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(db.DefaultContext, user1.ID, user2.ID)
assert.NoError(t, err)
assert.Len(t, repoIDs, 1)
assert.EqualValues(t, 1, repoIDs[0])
}

View file

@ -201,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
} }
return watchRepoMode(ctx, watch, WatchModeAuto) return watchRepoMode(ctx, watch, WatchModeAuto)
} }
// UnwatchRepos will unwatch the user from all given repositories.
func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error {
_, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{})
return err
}

View file

@ -155,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) {
assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone)) assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone))
unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0)
} }
func TestUnwatchRepos(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2})
assert.NoError(t, err)
unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
}

View file

@ -53,10 +53,12 @@ func UnblockUser(ctx context.Context, userID, blockID int64) error {
} }
// ListBlockedUsers returns the users that the user has blocked. // ListBlockedUsers returns the users that the user has blocked.
// The created_unix field of the user struct is overridden by the creation_unix
// field of blockeduser.
func ListBlockedUsers(ctx context.Context, userID int64) ([]*User, error) { func ListBlockedUsers(ctx context.Context, userID int64) ([]*User, error) {
users := make([]*User, 0, 8) users := make([]*User, 0, 8)
err := db.GetEngine(ctx). err := db.GetEngine(ctx).
Select("`user`.*"). Select("`forgejo_blocked_user`.created_unix, `user`.*").
Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id"). Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
Where("`forgejo_blocked_user`.user_id=?", userID). Where("`forgejo_blocked_user`.user_id=?", userID).
Find(&users) Find(&users)

View file

@ -24,16 +24,25 @@ func init() {
// IsFollowing returns true if user is following followID. // IsFollowing returns true if user is following followID.
func IsFollowing(userID, followID int64) bool { func IsFollowing(userID, followID int64) bool {
has, _ := db.GetEngine(db.DefaultContext).Get(&Follow{UserID: userID, FollowID: followID}) return IsFollowingCtx(db.DefaultContext, userID, followID)
}
// IsFollowingCtx returns true if user is following followID.
func IsFollowingCtx(ctx context.Context, userID, followID int64) bool {
has, _ := db.GetEngine(ctx).Get(&Follow{UserID: userID, FollowID: followID})
return has return has
} }
// FollowUser marks someone be another's follower. // FollowUser marks someone be another's follower.
func FollowUser(ctx context.Context, userID, followID int64) (err error) { func FollowUser(ctx context.Context, userID, followID int64) (err error) {
if userID == followID || IsFollowing(userID, followID) { if userID == followID || IsFollowingCtx(ctx, userID, followID) {
return nil return nil
} }
if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) {
return ErrBlockedByUser
}
ctx, committer, err := db.TxContext(ctx) ctx, committer, err := db.TxContext(ctx)
if err != nil { if err != nil {
return err return err
@ -56,7 +65,7 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) {
// UnfollowUser unmarks someone as another's follower. // UnfollowUser unmarks someone as another's follower.
func UnfollowUser(ctx context.Context, userID, followID int64) (err error) { func UnfollowUser(ctx context.Context, userID, followID int64) (err error) {
if userID == followID || !IsFollowing(userID, followID) { if userID == followID || !IsFollowingCtx(ctx, userID, followID) {
return nil return nil
} }

View file

@ -457,6 +457,12 @@ func TestFollowUser(t *testing.T) {
assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2)) assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
// Blocked user.
assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4))
assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1))
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4})
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1})
unittest.CheckConsistencyFor(t, &user_model.User{}) unittest.CheckConsistencyFor(t, &user_model.User{})
} }

View file

@ -601,6 +601,7 @@ block_user = Block User
block_user.detail = Please understand that if you block this user, other actions will be taken. Such as: block_user.detail = Please understand that if you block this user, other actions will be taken. Such as:
block_user.detail_1 = You are being unfollowed from this user. block_user.detail_1 = You are being unfollowed from this user.
block_user.detail_2 = This user cannot interact with your repositories, created issues and comments. block_user.detail_2 = This user cannot interact with your repositories, created issues and comments.
follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you.
form.name_reserved = The username "%s" is reserved. form.name_reserved = The username "%s" is reserved.
form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username.
@ -892,6 +893,7 @@ hooks.desc = Add webhooks which will be triggered for <strong>all repositories</
orgs_none = You are not a member of any organizations. orgs_none = You are not a member of any organizations.
repos_none = You do not own any repositories repos_none = You do not own any repositories
blocked_users_none = You haven't blocked any users.
delete_account = Delete Your Account delete_account = Delete Your Account
delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone. delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone.
@ -914,6 +916,10 @@ visibility.limited_tooltip = Visible to authenticated users only
visibility.private = Private visibility.private = Private
visibility.private_tooltip = Visible only to organization members visibility.private_tooltip = Visible only to organization members
blocked_since = Blocked since %s
user_unblock_success = The user has been unblocked successfully.
user_block_success = The user has been blocked successfully.
[repo] [repo]
new_repo_helper = A repository contains all project files, including revision history. Already have it elsewhere? <a href="%s">Migrate repository.</a> new_repo_helper = A repository contains all project files, including revision history. Already have it elsewhere? <a href="%s">Migrate repository.</a>
owner = Owner owner = Owner
@ -2523,6 +2529,7 @@ team_access_desc = Repository access
team_permission_desc = Permission team_permission_desc = Permission
team_unit_desc = Allow Access to Repository Sections team_unit_desc = Allow Access to Repository Sections
team_unit_disabled = (Disabled) team_unit_disabled = (Disabled)
follow_blocked_user = You cannot follow this organisation because this organisation has blocked you.
form.name_reserved = The organization name "%s" is reserved. form.name_reserved = The organization name "%s" is reserved.
form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name. form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.

View file

@ -5,6 +5,7 @@
package user package user
import ( import (
"errors"
"net/http" "net/http"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -217,8 +218,14 @@ func Follow(ctx *context.APIContext) {
// responses: // responses:
// "204": // "204":
// "$ref": "#/responses/empty" // "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "BlockedByUser", err)
return
}
ctx.Error(http.StatusInternalServerError, "FollowUser", err) ctx.Error(http.StatusInternalServerError, "FollowUser", err)
return return
} }

View file

@ -0,0 +1,61 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"net/http"
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/routers/utils"
user_service "code.gitea.io/gitea/services/user"
)
const tplBlockedUsers = "org/settings/blocked_users"
// BlockedUsers renders the blocked users page.
func BlockedUsers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
ctx.Data["PageIsSettingsBlockedUsers"] = true
blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID)
if err != nil {
ctx.ServerError("ListBlockedUsers", err)
return
}
ctx.Data["BlockedUsers"] = blockedUsers
ctx.HTML(http.StatusOK, tplBlockedUsers)
}
// BlockedUsersBlock blocks a particular user from the organization.
func BlockedUsersBlock(ctx *context.Context) {
uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname")))
u, err := user_model.GetUserByName(ctx, uname)
if err != nil {
ctx.ServerError("GetUserByName", err)
return
}
if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
ctx.ServerError("BlockUser", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.user_block_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
}
// BlockedUsersUnblock unblocks a particular user from the organization.
func BlockedUsersUnblock(ctx *context.Context) {
if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, ctx.FormInt64("user_id")); err != nil {
ctx.ServerError("BlockUser", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
}

View file

@ -5,6 +5,7 @@
package user package user
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -369,10 +370,18 @@ func Action(ctx *context.Context) {
} }
if err != nil { if err != nil {
if !errors.Is(err, user_model.ErrBlockedByUser) {
ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err) ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err)
return return
} }
if ctx.ContextUser.IsOrganization() {
ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"))
} else {
ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"))
}
}
if redirectViaJSON { if redirectViaJSON {
ctx.JSON(http.StatusOK, map[string]interface{}{ ctx.JSON(http.StatusOK, map[string]interface{}{
"redirect": ctx.ContextUser.HomeLink(), "redirect": ctx.ContextUser.HomeLink(),

View file

@ -32,3 +32,14 @@ func BlockedUsers(ctx *context.Context) {
ctx.Data["BlockedUsers"] = blockedUsers ctx.Data["BlockedUsers"] = blockedUsers
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
} }
// UnblockUser unblocks a particular user for the doer.
func UnblockUser(ctx *context.Context) {
if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
ctx.ServerError("UnblockUser", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
}

View file

@ -517,7 +517,10 @@ func registerRoutes(m *web.Route) {
addWebhookEditRoutes() addWebhookEditRoutes()
}, webhooksEnabled) }, webhooksEnabled)
m.Get("/blocked_users", user_setting.BlockedUsers) m.Group("/blocked_users", func() {
m.Get("", user_setting.BlockedUsers)
m.Post("/unblock", user_setting.UnblockUser)
})
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
m.Group("/user", func() { m.Group("/user", func() {
@ -770,6 +773,12 @@ func registerRoutes(m *web.Route) {
addSettingsSecretsRoutes() addSettingsSecretsRoutes()
}, actions.MustEnableActions) }, actions.MustEnableActions)
m.Group("/blocked_users", func() {
m.Get("", org_setting.BlockedUsers)
m.Post("/block", org_setting.BlockedUsersBlock)
m.Post("/unblock", org_setting.BlockedUsersUnblock)
})
m.RouteMethods("/delete", "GET,POST", org.SettingsDelete) m.RouteMethods("/delete", "GET,POST", org.SettingsDelete)
m.Group("/packages", func() { m.Group("/packages", func() {

View file

@ -6,6 +6,7 @@ import (
"context" "context"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
) )
@ -30,11 +31,28 @@ func BlockUser(ctx context.Context, userID, blockID int64) error {
return err return err
} }
// Unfollow the user from block's perspective. // Unfollow the user from the block's perspective.
err = user_model.UnfollowUser(ctx, blockID, userID) err = user_model.UnfollowUser(ctx, blockID, userID)
if err != nil { if err != nil {
return err return err
} }
// Unfollow the user from the doer's perspective.
err = user_model.UnfollowUser(ctx, userID, blockID)
if err != nil {
return err
}
// Blocked user unwatch all repository owned by the doer.
repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID)
if err != nil {
return err
}
err = repo_model.UnwatchRepos(ctx, blockID, repoIDs)
if err != nil {
return err
}
return committer.Commit() return committer.Commit()
} }

View file

@ -0,0 +1,41 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
// TestBlockUser will ensure that when you block a user, certain actions have
// been taken, like unfollowing each other etc.
func TestBlockUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
// Follow each other.
assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID))
assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID))
// Blocked user watch repository of doer.
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID})
assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true))
assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
// Ensure they aren't following each other anymore.
assert.False(t, user_model.IsFollowing(doer.ID, blockedUser.ID))
assert.False(t, user_model.IsFollowing(blockedUser.ID, doer.ID))
// Ensure blocked user isn't following doer's repository.
assert.False(t, repo_model.IsWatching(blockedUser.ID, repo.ID))
}

View file

@ -1,5 +1,10 @@
{{template "base/head" .}} {{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content organization profile"> <div role="main" aria-label="{{.Title}}" class="page-content organization profile">
{{if .Flash}}
<div class="ui container gt-mb-5">
{{template "base/alert" .}}
</div>
{{end}}
<div class="ui container gt-df"> <div class="ui container gt-df">
{{avatar $.Context .Org 140 "org-avatar"}} {{avatar $.Context .Org 140 "org-avatar"}}
<div id="org-info"> <div id="org-info">

View file

@ -0,0 +1,40 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked-users")}}
<div class="org-setting-content">
<div class="ui attached segment">
<form class="ui form ignore-dirty" id="block-user-form" action="{{$.Link}}/block" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="uid" value="">
<div class="inline field ui left">
<div id="search-user-box" class="ui search">
<div class="ui input">
<input class="prompt" name="uname" placeholder="{{.locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required>
</div>
</div>
</div>
<button type="submit" class="ui red button">{{.locale.Tr "user.block"}}</button>
</form>
</div>
<div class="ui bottom attached table segment blocked-users">
{{range .BlockedUsers}}
<div class="item gt-df gt-ac gt-fw">
{{avatar $.Context . 48 "gt-mr-3 gt-mb-0"}}
<div class="gt-df gt-fc">
<a href="{{.HomeLink}}">{{.Name}}</a>
<i class="gt-mt-2">{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</i>
</div>
<div class="gt-ml-auto content">
<form action="{{$.Link}}/unblock" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="user_id" value="{{.ID}}">
<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
</form>
</div>
</div>
{{else}}
<div class="item">
<span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
</div>
{{end}}
</div>
</div>
{{template "org/settings/layout_footer" .}}

View file

@ -35,6 +35,9 @@
</div> </div>
</details> </details>
{{end}} {{end}}
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
{{.locale.Tr "settings.blocked_users"}}
</a>
<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete"> <a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
{{.locale.Tr "org.settings.delete"}} {{.locale.Tr "org.settings.delete"}}
</a> </a>

View file

@ -13966,6 +13966,9 @@
"responses": { "responses": {
"204": { "204": {
"$ref": "#/responses/empty" "$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
} }
} }
}, },

View file

@ -1,6 +1,7 @@
{{template "base/head" .}} {{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content user profile"> <div role="main" aria-label="{{.Title}}" class="page-content user profile">
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}}
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="ui four wide column"> <div class="ui four wide column">
<div class="ui card"> <div class="ui card">

View file

@ -6,8 +6,23 @@
<div class="ui attached segment"> <div class="ui attached segment">
<div class="ui blocked-user list gt-mt-0"> <div class="ui blocked-user list gt-mt-0">
{{range .BlockedUsers}} {{range .BlockedUsers}}
<div class="item gt-df gt-ac">
{{avatar $.Context . 28 "gt-mr-3"}}
<div class="gt-df gt-fc">
<a href="{{.HomeLink}}">{{.Name}}</a>
<i class="gt-mt-2">{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</i>
</div>
<div class="gt-ml-auto content">
<form action="{{$.Link}}/unblock" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="user_id" value="{{.ID}}">
<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
</form>
</div>
</div>
{{else}}
<div class="item"> <div class="item">
{{avatar $.Context . 28 "gt-mr-3"}}<a href="{{.HomeLink}}">{{.Name}}</a> <span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
</div> </div>
{{end}} {{end}}
</div> </div>

View file

@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
user1 := "user4" user1 := "user4"
user2 := "user1" user2 := "user10"
session1 := loginUser(t, user1) session1 := loginUser(t, user1)
token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser) token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)

View file

@ -41,7 +41,7 @@ func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
var respBody redirect var respBody redirect
DecodeJSON(t, resp, &respBody) DecodeJSON(t, resp, &respBody)
assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect) assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
assert.EqualValues(t, true, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
} }
func TestBlockUser(t *testing.T) { func TestBlockUser(t *testing.T) {
@ -156,3 +156,57 @@ func TestBlockCommentReaction(t *testing.T) {
assert.EqualValues(t, true, respBody.Empty) assert.EqualValues(t, true, respBody.Empty)
} }
// TestBlockFollow ensures that the doer and blocked user cannot follow each other.
func TestBlockFollow(t *testing.T) {
defer tests.PrepareTestEnv(t)()
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
BlockUser(t, doer, blockedUser)
// Doer cannot follow blocked user.
session := loginUser(t, doer.Name)
req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "follow",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
// Blocked user cannot follow doer.
session = loginUser(t, blockedUser.Name)
req = NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+doer.Name),
"action": "follow",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
}
// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user.
func TestBlockUserFromOrganization(t *testing.T) {
defer tests.PrepareTestEnv(t)()
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization})
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
session := loginUser(t, doer.Name)
req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
"uname": blockedUser.Name,
})
session.MakeRequest(t, req, http.StatusSeeOther)
assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}))
req = NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
"user_id": strconv.FormatInt(blockedUser.ID, 10),
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
}

View file

@ -191,30 +191,35 @@
} }
.organization.teams .repositories .item, .organization.teams .repositories .item,
.organization.teams .members .item { .organization.teams .members .item,
.organization.settings .blocked-users .item {
padding: 10px 19px; padding: 10px 19px;
} }
.organization.teams .repositories .item:not(:last-child), .organization.teams .repositories .item:not(:last-child),
.organization.teams .members .item:not(:last-child) { .organization.teams .members .item:not(:last-child),
.organization.settings .blocked-users .item:not(:last-child) {
border-bottom: 1px solid var(--color-secondary); border-bottom: 1px solid var(--color-secondary);
} }
.organization.teams .repositories .item .button, .organization.teams .repositories .item .button,
.organization.teams .members .item .button { .organization.teams .members .item .button,
.organization.settings .blocked-users .item button {
padding: 9px 10px; padding: 9px 10px;
margin: 0; margin: 0;
} }
.organization.teams #add-repo-form input, .organization.teams #add-repo-form input,
.organization.teams #repo-multiple-form input, .organization.teams #repo-multiple-form input,
.organization.teams #add-member-form input { .organization.teams #add-member-form input,
.organization.settings #block-user-form input {
margin-left: 0; margin-left: 0;
} }
.organization.teams #add-repo-form .ui.button, .organization.teams #add-repo-form .ui.button,
.organization.teams #repo-multiple-form .ui.button, .organization.teams #repo-multiple-form .ui.button,
.organization.teams #add-member-form .ui.button { .organization.teams #add-member-form .ui.button,
.organization.settings #block-user-form .ui.button {
margin-left: 5px; margin-left: 5px;
margin-top: -3px; margin-top: -3px;
} }