Add user badges (#36752)

Implemented #29798

This feature implements list badges, create new badges, view badge, edit
badge and assign badge to users.

- List all badges
![(screenshot)](https://github.com/user-attachments/assets/9dbf243e-c704-49f8-915a-73704e226da9)
- Create new badges
![(screenshot)](https://github.com/user-attachments/assets/8a3fff7e-fe6f-49b0-a7c5-bbba34478019)
- View badge
![(screenshot)](https://github.com/user-attachments/assets/dd7a882b-6e2c-47d2-93e0-05a2698a41e5)
![(screenshot)](https://private-user-images.githubusercontent.com/75789103/558982759-53536300-e189-406b-8b0e-824e1a768b92.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzQxOTMyMjUsIm5iZiI6MTc3NDE5MjkyNSwicGF0aCI6Ii83NTc4OTEwMy81NTg5ODI3NTktNTM1MzYzMDAtZTE4OS00MDZiLThiMGUtODI0ZTFhNzY4YjkyLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjAzMjIlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwMzIyVDE1MjIwNVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUxNjQ5ZDUyMGVlNWRmODg1OGUyN2NiOWI3YTAxODhiMjRhM2U1OGQ1NWMwNjQ0MTBmNTRjNTBjYjIzN2ExMWEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.4aAfpFaziiXDG7W2HaNJop0B62-NR4f0Ni9YNjTZq0M)
- Edit badge
![(screenshot)](https://github.com/user-attachments/assets/7124671a-ed97-4c98-ac7d-34863377fa62)
- Add user to badge
![(screenshot)](https://github.com/user-attachments/assets/3438b492-0197-4acb-b9f2-2f9f7c80582e)
This commit is contained in:
Nicolas
2026-03-22 16:49:45 +01:00
committed by GitHub
parent aa9aea2c6e
commit 4ba90207cf
22 changed files with 1195 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
-
id: 1
slug: badge1
description: just a test badge
image_url: badge1.png

View File

@@ -403,6 +403,7 @@ func prepareMigrationTasks() []*migration {
newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID),
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
}
return preparedMigrations
}

View File

@@ -0,0 +1,62 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"fmt"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
type UserBadge struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
BadgeID int64
UserID int64
}
// TableIndices implements xorm's TableIndices interface
func (n *UserBadge) TableIndices() []*schemas.Index {
indices := make([]*schemas.Index, 0, 1)
ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType)
ubUnique.AddColumn("user_id", "badge_id")
indices = append(indices, ubUnique)
return indices
}
// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table
// and it replaces an old index on user_id
func AddUniqueIndexForUserBadge(x *xorm.Engine) error {
// remove possible duplicated records in table user_badge
type result struct {
UserID int64
BadgeID int64
Cnt int
}
var results []result
if err := x.Select("user_id, badge_id, count(*) as cnt").
Table("user_badge").
GroupBy("user_id, badge_id").
Having("count(*) > 1").
Find(&results); err != nil {
return err
}
for _, r := range results {
if x.Dialect().URI().DBType == schemas.MSSQL {
if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil {
return err
}
} else {
var ids []int64
if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil {
return err
}
if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil {
return err
}
}
}
return x.Sync(new(UserBadge))
}

View File

@@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"testing"
"code.gitea.io/gitea/models/migrations/base"
"github.com/stretchr/testify/assert"
)
type UserBadgeBefore struct {
ID int64 `xorm:"pk autoincr"`
BadgeID int64
UserID int64 `xorm:"INDEX"`
}
func (UserBadgeBefore) TableName() string {
return "user_badge"
}
func Test_AddUniqueIndexForUserBadge(t *testing.T) {
x, deferable := base.PrepareTestEnv(t, 0, new(UserBadgeBefore))
defer deferable()
if x == nil || t.Failed() {
return
}
testData := []*UserBadgeBefore{
{UserID: 1, BadgeID: 1},
{UserID: 1, BadgeID: 1}, // duplicate
{UserID: 2, BadgeID: 1},
{UserID: 1, BadgeID: 2},
{UserID: 3, BadgeID: 3},
{UserID: 3, BadgeID: 3}, // duplicate
}
for _, data := range testData {
_, err := x.Insert(data)
assert.NoError(t, err)
}
// check that we have duplicates
count, err := x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(2), count)
count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(2), count)
totalCount, err := x.Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(6), totalCount)
// run the migration
if err := AddUniqueIndexForUserBadge(x); err != nil {
assert.NoError(t, err)
return
}
// verify the duplicates were removed
count, err = x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(1), count)
count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(1), count)
// check total count
totalCount, err = x.Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(4), totalCount)
// fail to insert a duplicate
_, err = x.Insert(&UserBadge{UserID: 1, BadgeID: 1})
assert.Error(t, err)
// succeed adding a non-duplicate
_, err = x.Insert(&UserBadge{UserID: 4, BadgeID: 1})
assert.NoError(t, err)
}

View File

@@ -5,9 +5,12 @@ package user
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
"xorm.io/xorm/schemas"
)
// Badge represents a user badge
@@ -22,7 +25,16 @@ type Badge struct {
type UserBadge struct { //nolint:revive // export stutter
ID int64 `xorm:"pk autoincr"`
BadgeID int64
UserID int64 `xorm:"INDEX"`
UserID int64
}
// TableIndices implements xorm's TableIndices interface
func (n *UserBadge) TableIndices() []*schemas.Index {
indices := make([]*schemas.Index, 0, 1)
ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType)
ubUnique.AddColumn("user_id", "badge_id")
indices = append(indices, ubUnique)
return indices
}
func init() {
@@ -42,32 +54,85 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) {
return badges, count, err
}
// CreateBadge creates a new badge.
func CreateBadge(ctx context.Context, badge *Badge) error {
_, err := db.GetEngine(ctx).Insert(badge)
return err
// GetBadgeUsersOptions contains options for getting users with a specific badge
type GetBadgeUsersOptions struct {
db.ListOptions
BadgeSlug string
}
// GetBadge returns a badge
// GetBadgeUsers returns the users that have a specific badge with pagination support.
func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) {
sess := db.GetEngine(ctx).
Select("`user`.*").
Join("INNER", "user_badge", "`user_badge`.user_id=user.id").
Join("INNER", "badge", "`user_badge`.badge_id=badge.id").
Where("badge.slug=?", opts.BadgeSlug)
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, opts)
}
users := make([]*User, 0, opts.PageSize)
count, err := sess.FindAndCount(&users)
return users, count, err
}
// CreateBadge creates a new badge.
func CreateBadge(ctx context.Context, badge *Badge) error {
exists, err := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge))
if err != nil {
return err
}
if exists {
return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug)
}
if _, err := db.GetEngine(ctx).Insert(badge); err != nil {
// Handle race between existence check and insert.
exists, existErr := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge))
if existErr == nil && exists {
return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug)
}
return err
}
return nil
}
// GetBadge returns a specific badge
func GetBadge(ctx context.Context, slug string) (*Badge, error) {
badge := new(Badge)
has, err := db.GetEngine(ctx).Where("slug=?", slug).Get(badge)
if !has {
if err != nil {
return nil, err
}
return badge, err
if !has {
return nil, util.NewNotExistErrorf("badge does not exist [slug: %s]", slug)
}
return badge, nil
}
// UpdateBadge updates a badge based on its slug.
func UpdateBadge(ctx context.Context, badge *Badge) error {
_, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Update(badge)
_, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Cols("description", "image_url").Update(badge)
return err
}
// DeleteBadge deletes a badge.
// DeleteBadge deletes a badge and all associated user_badge entries.
func DeleteBadge(ctx context.Context, badge *Badge) error {
_, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge)
return err
return db.WithTx(ctx, func(ctx context.Context) error {
// First delete all user_badge entries for this badge
if _, err := db.GetEngine(ctx).
Where("badge_id = ?", badge.ID).
Delete(&UserBadge{}); err != nil {
return err
}
// Then delete the badge itself
if _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge); err != nil {
return err
}
return nil
})
}
// AddUserBadge adds a badge to a user.
@@ -84,12 +149,25 @@ func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error {
if err != nil {
return err
} else if !has {
return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug)
return util.NewNotExistErrorf("badge does not exist [slug: %s]", badge.Slug)
}
exists, err := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge))
if err != nil {
return err
}
if exists {
return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID)
}
if err := db.Insert(ctx, &UserBadge{
BadgeID: badge.ID,
UserID: u.ID,
}); err != nil {
exists, existErr := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge))
if existErr == nil && exists {
return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID)
}
return err
}
}
@@ -102,16 +180,33 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error {
return RemoveUserBadges(ctx, u, []*Badge{badge})
}
// RemoveUserBadges removes badges from a user.
// RemoveUserBadges removes specific badges from a user.
func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if len(badges) == 0 {
return nil
}
badgeSlugs := make([]string, 0, len(badges))
for _, badge := range badges {
if _, err := db.GetEngine(ctx).
Join("INNER", "badge", "badge.id = `user_badge`.badge_id").
Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug).
Delete(&UserBadge{}); err != nil {
return err
}
badgeSlugs = append(badgeSlugs, badge.Slug)
}
var userBadges []UserBadge
if err := db.GetEngine(ctx).Table("user_badge").
Join("INNER", "badge", "badge.id = `user_badge`.badge_id").
Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs).
Find(&userBadges); err != nil {
return err
}
userBadgeIDs := make([]int64, 0, len(userBadges))
for _, ub := range userBadges {
userBadgeIDs = append(userBadgeIDs, ub.ID)
}
if len(userBadgeIDs) == 0 {
return nil
}
if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil {
return err
}
return nil
})
@@ -122,3 +217,57 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error {
_, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{})
return err
}
// SearchBadgeOptions represents the options when finding badges
type SearchBadgeOptions struct {
db.ListOptions
Keyword string
Slug string
ID int64
OrderBy db.SearchOrderBy
}
func (opts *SearchBadgeOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.Keyword != "" {
keywordCond := builder.Or(
db.BuildCaseInsensitiveLike("badge.slug", opts.Keyword),
db.BuildCaseInsensitiveLike("badge.description", opts.Keyword),
)
cond = cond.And(keywordCond)
}
if opts.ID > 0 {
cond = cond.And(builder.Eq{"badge.id": opts.ID})
}
if len(opts.Slug) > 0 {
cond = cond.And(builder.Eq{"badge.slug": opts.Slug})
}
return cond
}
func (opts *SearchBadgeOptions) ToOrders() string {
return opts.OrderBy.String()
}
// SearchBadges returns badges based on the provided SearchBadgeOptions options
func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) ([]*Badge, int64, error) {
return db.FindAndCount[Badge](ctx, opts)
}
// GetBadgeByID returns a specific badge by ID
func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) {
badge := new(Badge)
has, err := db.GetEngine(ctx).ID(id).Get(badge)
if err != nil {
return nil, err
}
if !has {
return nil, util.NewNotExistErrorf("badge does not exist [id: %d]", id)
}
return badge, nil
}

185
models/user/badge_test.go Normal file
View File

@@ -0,0 +1,185 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user_test
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
)
func TestBadge(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("GetBadgeNotExist", testGetBadgeNotExist)
t.Run("CreateBadgeAlreadyExists", testCreateBadgeAlreadyExists)
t.Run("GetBadgeUsers", testGetBadgeUsers)
t.Run("AddAndRemoveUserBadges", testAddAndRemoveUserBadges)
t.Run("SearchBadgesOrderingAndKeyword", testSearchBadgesOrderingAndKeyword)
}
func testGetBadgeNotExist(t *testing.T) {
badge, err := user_model.GetBadge(t.Context(), "does-not-exist")
assert.Nil(t, badge)
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrNotExist)
}
func testCreateBadgeAlreadyExists(t *testing.T) {
badge := &user_model.Badge{
Slug: "duplicate-badge-slug",
Description: "First",
}
assert.NoError(t, user_model.CreateBadge(t.Context(), badge))
err := user_model.CreateBadge(t.Context(), &user_model.Badge{
Slug: "duplicate-badge-slug",
Description: "Second",
})
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrAlreadyExist)
}
func testGetBadgeUsers(t *testing.T) {
// Create a test badge
badge := &user_model.Badge{
Slug: "test-badge-users",
Description: "Test Badge",
ImageURL: "test.png",
}
assert.NoError(t, user_model.CreateBadge(t.Context(), badge))
// Create test users and assign badges
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge))
assert.NoError(t, user_model.AddUserBadge(t.Context(), user2, badge))
defer func() {
assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge))
assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user2, badge))
}()
// Test getting users with pagination
opts := &user_model.GetBadgeUsersOptions{
BadgeSlug: badge.Slug,
ListOptions: db.ListOptions{
Page: 1,
PageSize: 1,
},
}
users, count, err := user_model.GetBadgeUsers(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 2, count)
assert.Len(t, users, 1)
// Test second page
opts.Page = 2
users, count, err = user_model.GetBadgeUsers(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 2, count)
assert.Len(t, users, 1)
// Test with non-existent badge
opts.BadgeSlug = "non-existent"
users, count, err = user_model.GetBadgeUsers(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
assert.Empty(t, users)
}
func testAddAndRemoveUserBadges(t *testing.T) {
badge1 := unittest.AssertExistsAndLoadBean(t, &user_model.Badge{ID: 1})
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
// Add a badge to user and verify that it is returned in the list
assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge1))
badges, count, err := user_model.GetUserBadges(t.Context(), user1)
assert.Equal(t, int64(1), count)
assert.Equal(t, badge1.Slug, badges[0].Slug)
assert.NoError(t, err)
// Confirm that it is impossible to duplicate the same badge
err = user_model.AddUserBadge(t.Context(), user1, badge1)
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrAlreadyExist)
// Nothing happened to the existing badge
badges, count, err = user_model.GetUserBadges(t.Context(), user1)
assert.Equal(t, int64(1), count)
assert.Equal(t, badge1.Slug, badges[0].Slug)
assert.NoError(t, err)
// Remove a badge from user and verify that it is no longer in the list
assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge1))
_, count, err = user_model.GetUserBadges(t.Context(), user1)
assert.Equal(t, int64(0), count)
assert.NoError(t, err)
// Removing empty or missing badge selections should be a no-op.
assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, nil))
assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, []*user_model.Badge{{Slug: "does-not-exist"}}))
}
func testSearchBadgesOrderingAndKeyword(t *testing.T) {
createdBadges := []*user_model.Badge{
{Slug: "badge-sort-b", Description: "Badge Sort B"},
{Slug: "badge-sort-c", Description: "Badge Sort C"},
{Slug: "badge-sort-a", Description: "Badge Sort A"},
{Slug: "badge-sort-case", Description: "MiXeDCaSeKeyword"},
}
for _, badge := range createdBadges {
assert.NoError(t, user_model.CreateBadge(t.Context(), badge))
}
opts := &user_model.SearchBadgeOptions{
ListOptions: db.ListOptions{ListAll: true},
Keyword: "badge-sort-",
OrderBy: db.SearchOrderBy("`badge`.id ASC"),
}
oldestFirst, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-b", "badge-sort-c", "badge-sort-a", "badge-sort-case"}, collectBadgeSlugs(oldestFirst))
opts.OrderBy = db.SearchOrderBy("`badge`.id DESC")
newestFirst, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-case", "badge-sort-a", "badge-sort-c", "badge-sort-b"}, collectBadgeSlugs(newestFirst))
opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC")
alpha, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-a", "badge-sort-b", "badge-sort-c", "badge-sort-case"}, collectBadgeSlugs(alpha))
opts.OrderBy = db.SearchOrderBy("`badge`.slug DESC")
reverseAlpha, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-case", "badge-sort-c", "badge-sort-b", "badge-sort-a"}, collectBadgeSlugs(reverseAlpha))
opts.Keyword = "mixedcasekeyword"
opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC")
caseInsensitive, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 1, count)
assert.Equal(t, []string{"badge-sort-case"}, collectBadgeSlugs(caseInsensitive))
}
func collectBadgeSlugs(badges []*user_model.Badge) []string {
slugs := make([]string, 0, len(badges))
for _, badge := range badges {
slugs = append(slugs, badge.Slug)
}
return slugs
}

View File

@@ -27,6 +27,8 @@ const (
ErrUsername = "UsernameError"
// ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
// ErrInvalidBadgeSlug is returned when a badge slug is invalid
ErrInvalidBadgeSlug = "InvalidBadgeSlug"
)
// AddBindingRules adds additional binding rules
@@ -40,6 +42,7 @@ func AddBindingRules() {
addGlobOrRegexPatternRule()
addUsernamePatternRule()
addValidGroupTeamMapRule()
addSlugPatternRule()
}
func addGitRefNameBindingRule() {
@@ -123,6 +126,22 @@ func addValidSiteURLBindingRule() {
})
}
func addSlugPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "BadgeSlug"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if !IsValidBadgeSlug(str) {
errs.Add([]string{name}, ErrInvalidBadgeSlug, "invalid badge slug")
return false, errs
}
return true, errs
},
})
}
func addGlobPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {

View File

@@ -16,16 +16,20 @@ import (
)
type globalVarsStruct struct {
externalTrackerRegex *regexp.Regexp
validUsernamePattern *regexp.Regexp
invalidUsernamePattern *regexp.Regexp
externalTrackerRegex *regexp.Regexp
validUsernamePattern *regexp.Regexp
invalidUsernamePattern *regexp.Regexp
validBadgeSlugPattern *regexp.Regexp
invalidBadgeSlugPattern *regexp.Regexp
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
return &globalVarsStruct{
externalTrackerRegex: regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`),
validUsernamePattern: regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`),
invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars
externalTrackerRegex: regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`),
validUsernamePattern: regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`),
invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars
validBadgeSlugPattern: regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`),
invalidBadgeSlugPattern: regexp.MustCompile(`[-._]{2,}|[-._]$`),
}
})
@@ -131,3 +135,8 @@ func IsValidUsername(name string) bool {
vars := globalVars()
return vars.validUsernamePattern.MatchString(name) && !vars.invalidUsernamePattern.MatchString(name)
}
func IsValidBadgeSlug(slug string) bool {
vars := globalVars()
return vars.validBadgeSlugPattern.MatchString(slug) && !vars.invalidBadgeSlugPattern.MatchString(slug)
}

View File

@@ -186,3 +186,24 @@ func TestIsValidUsername(t *testing.T) {
})
}
}
func TestIsValidBadgeSlug(t *testing.T) {
tests := []struct {
arg string
want bool
}{
{arg: "badge-1", want: true},
{arg: "badge.slug", want: true},
{arg: "new", want: true},
{arg: "Badge_1", want: true},
{arg: "a..b", want: false},
{arg: "a/b", want: false},
{arg: "Awesome!", want: false},
{arg: "Emoji 💯", want: false},
}
for _, tt := range tests {
t.Run(tt.arg, func(t *testing.T) {
assert.Equalf(t, tt.want, IsValidBadgeSlug(tt.arg), "IsValidBadgeSlug(%v)", tt.arg)
})
}
}

View File

@@ -138,6 +138,8 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo
data["ErrorMsg"] = trName + l.TrString("form.username_error")
case validation.ErrInvalidGroupTeamMap:
data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message)
case validation.ErrInvalidBadgeSlug:
data["ErrorMsg"] = trName + l.TrString("form.invalid_slug_error")
default:
msg := errs[0].Classification
if msg != "" && errs[0].Message != "" {

View File

@@ -169,6 +169,7 @@
"search.exact_tooltip": "Include only results that match the exact search term",
"search.repo_kind": "Search repos…",
"search.user_kind": "Search users…",
"search.badge_kind": "Search badges…",
"search.org_kind": "Search orgs…",
"search.team_kind": "Search teams…",
"search.code_kind": "Search code…",
@@ -543,6 +544,7 @@
"form.glob_pattern_error": " glob pattern is invalid: %s.",
"form.regex_pattern_error": " regex pattern is invalid: %s.",
"form.username_error": " can only contain alphanumeric characters ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric characters, and consecutive non-alphanumeric characters are also forbidden.",
"form.invalid_slug_error": " is invalid.",
"form.invalid_group_team_map_error": " mapping is invalid: %s",
"form.unknown_error": "Unknown error:",
"form.captcha_incorrect": "The CAPTCHA code is incorrect.",
@@ -2858,6 +2860,30 @@
"admin.hooks": "Webhooks",
"admin.integrations": "Integrations",
"admin.authentication": "Authentication Sources",
"admin.badges": "Badges",
"admin.badges.badges_manage_panel": "Badge Management",
"admin.badges.details": "Badge Details",
"admin.badges.new_badge": "Create New Badge",
"admin.badges.slug": "Slug",
"admin.badges.slug_been_taken": "The slug is already taken.",
"admin.badges.description": "Description",
"admin.badges.image_url": "Image URL",
"admin.badges.new_success": "The badge \"%s\" has been created.",
"admin.badges.update_success": "The badge has been updated.",
"admin.badges.deletion_success": "The badge has been deleted.",
"admin.badges.edit_badge": "Edit Badge",
"admin.badges.update_badge": "Update Badge",
"admin.badges.delete_badge": "Delete Badge",
"admin.badges.delete_badge_desc": "Are you sure you want to permanently delete this badge?",
"admin.badges.users_with_badge": "Users with badge: %s",
"admin.badges.not_found": "Badge not found.",
"admin.badges.user_already_has": "User already has this badge.",
"admin.badges.user_add_success": "Badge assigned to user successfully.",
"admin.badges.user_remove_success": "Badge removed from user successfully.",
"admin.badges.manage_users": "Manage Users",
"admin.badges.add_user": "Add User",
"admin.badges.remove_user": "Remove User",
"admin.badges.delete_user_desc": "Are you sure you want to remove this user from the badge?",
"admin.emails": "User Email Addresses",
"admin.config": "Configuration",
"admin.config_summary": "Summary",

317
routers/web/admin/badges.go Normal file
View File

@@ -0,0 +1,317 @@
// Copyright 2026 The Gitea Authors.
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"net/http"
"net/url"
"strings"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
)
const (
tplBadges templates.TplName = "admin/badge/list"
tplBadgeNew templates.TplName = "admin/badge/new"
tplBadgeView templates.TplName = "admin/badge/view"
tplBadgeEdit templates.TplName = "admin/badge/edit"
tplBadgeUsers templates.TplName = "admin/badge/users"
)
// BadgeSearchDefaultAdminSort is the default sort type for admin view
const BadgeSearchDefaultAdminSort = "oldest"
// Badges show all the badges
func Badges(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges")
ctx.Data["PageIsAdminBadges"] = true
RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{
ListOptions: db.ListOptions{
Page: max(ctx.FormInt("page"), 1),
PageSize: setting.UI.Admin.UserPagingNum,
},
}, tplBadges)
}
// NewBadge render adding a new badge
func NewBadge(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge")
ctx.Data["PageIsAdminBadges"] = true
ctx.HTML(http.StatusOK, tplBadgeNew)
}
// NewBadgePost response for adding a new badge
func NewBadgePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
b := &user_model.Badge{
Slug: form.Slug,
Description: form.Description,
ImageURL: form.ImageURL,
}
if err := user_model.CreateBadge(ctx, b); err != nil {
if errors.Is(err, util.ErrAlreadyExist) {
ctx.JSONError(ctx.Tr("admin.badges.slug_been_taken"))
} else {
ctx.ServerError("CreateBadge", err)
}
return
}
log.Trace("Badge created by admin (%s): %s", ctx.Doer.Name, b.Slug)
ctx.Flash.Success(ctx.Tr("admin.badges.new_success", b.Slug))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(b.Slug))
}
func prepareBadgeInfo(ctx *context.Context) *user_model.Badge {
b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Redirect(setting.AppSubURL + "/-/admin/badges")
} else {
ctx.ServerError("GetBadge", err)
}
return nil
}
ctx.Data["Badge"] = b
return b
}
func ViewBadge(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.details")
ctx.Data["PageIsAdminBadges"] = true
prepareBadgeInfo(ctx)
if ctx.Written() {
return
}
badge := ctx.Data["Badge"].(*user_model.Badge)
opts := &user_model.GetBadgeUsersOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: setting.UI.Admin.UserPagingNum,
},
BadgeSlug: badge.Slug,
}
users, count, err := user_model.GetBadgeUsers(ctx, opts)
if err != nil {
ctx.ServerError("GetBadgeUsers", err)
return
}
ctx.Data["Users"] = users
ctx.Data["UsersTotal"] = int(count)
ctx.HTML(http.StatusOK, tplBadgeView)
}
// EditBadge show editing badge page
func EditBadge(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badge")
ctx.Data["PageIsAdminBadges"] = true
prepareBadgeInfo(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplBadgeEdit)
}
// EditBadgePost response for editing badge
func EditBadgePost(ctx *context.Context) {
b := prepareBadgeInfo(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*forms.AdminEditBadgeForm)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
b.ImageURL = form.ImageURL
b.Description = form.Description
if err := user_model.UpdateBadge(ctx, b); err != nil {
ctx.ServerError("UpdateBadge", err)
return
}
log.Trace("Badge updated by admin (%s): %s", ctx.Doer.Name, b.Slug)
ctx.Flash.Success(ctx.Tr("admin.badges.update_success"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")))
}
// DeleteBadge response for deleting a badge
func DeleteBadge(ctx *context.Context) {
b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug"))
if err != nil {
ctx.ServerError("GetBadge", err)
return
}
if err = user_model.DeleteBadge(ctx, b); err != nil {
ctx.ServerError("DeleteBadge", err)
return
}
log.Trace("Badge deleted by admin (%s): %s", ctx.Doer.Name, b.Slug)
ctx.Flash.Success(ctx.Tr("admin.badges.deletion_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/badges")
}
func BadgeUsers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.users_with_badge", ctx.PathParam("badge_slug"))
ctx.Data["PageIsAdminBadges"] = true
page := max(ctx.FormInt("page"), 1)
badge := &user_model.Badge{Slug: ctx.PathParam("badge_slug")}
opts := &user_model.GetBadgeUsersOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.Admin.UserPagingNum,
},
BadgeSlug: badge.Slug,
}
users, count, err := user_model.GetBadgeUsers(ctx, opts)
if err != nil {
ctx.ServerError("GetBadgeUsers", err)
return
}
ctx.Data["Users"] = users
ctx.Data["Total"] = count
ctx.Data["Page"] = context.NewPagination(count, setting.UI.Admin.UserPagingNum, page, 5)
ctx.HTML(http.StatusOK, tplBadgeUsers)
}
// BadgeUsersPost response for actions for user badges
func BadgeUsersPost(ctx *context.Context) {
name := strings.ToLower(ctx.FormString("user"))
u, err := user_model.GetUserByName(ctx, name)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
} else {
ctx.ServerError("GetUserByName", err)
}
return
}
if err = user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Flash.Error(ctx.Tr("admin.badges.not_found"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
} else if errors.Is(err, util.ErrAlreadyExist) {
ctx.Flash.Error(ctx.Tr("admin.badges.user_already_has"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
} else {
ctx.ServerError("AddUserBadge", err)
}
return
}
ctx.Flash.Success(ctx.Tr("admin.badges.user_add_success"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
}
// DeleteBadgeUser delete a badge from a user
func DeleteBadgeUser(ctx *context.Context) {
badgeUsersURL := setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")) + "/users"
user, err := user_model.GetUserByID(ctx, ctx.FormInt64("id"))
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
ctx.JSONRedirect(badgeUsersURL)
return
} else {
ctx.ServerError("GetUserByID", err)
return
}
}
if err := user_model.RemoveUserBadge(ctx, user, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err == nil {
ctx.Flash.Success(ctx.Tr("admin.badges.user_remove_success"))
} else {
ctx.ServerError("RemoveUserBadge", err)
return
}
ctx.JSONRedirect(badgeUsersURL)
}
func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName templates.TplName) {
var (
badges []*user_model.Badge
count int64
err error
orderBy db.SearchOrderBy
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = BadgeSearchDefaultAdminSort
}
ctx.Data["SortType"] = sortOrder
switch sortOrder {
case "newest":
orderBy = "`badge`.id DESC"
case "oldest":
orderBy = "`badge`.id ASC"
case "reversealphabetically":
orderBy = "`badge`.slug DESC"
case "alphabetically":
orderBy = "`badge`.slug ASC"
default:
// In case the sort type is invalid, keep admin default sorting.
ctx.Data["SortType"] = "oldest"
orderBy = "`badge`.id ASC"
}
opts.Keyword = ctx.FormTrim("q")
opts.OrderBy = orderBy
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
badges, count, err = user_model.SearchBadges(ctx, opts)
if err != nil {
ctx.ServerError("SearchBadges", err)
return
}
}
ctx.Data["Keyword"] = opts.Keyword
ctx.Data["Total"] = count
ctx.Data["Badges"] = badges
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplName)
}

View File

@@ -788,6 +788,16 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
})
m.Group("/badges", func() {
m.Get("", admin.Badges)
m.Combo("/new").Get(admin.NewBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.NewBadgePost)
m.Get("/slug/{badge_slug}", admin.ViewBadge)
m.Combo("/slug/{badge_slug}/edit").Get(admin.EditBadge).Post(web.Bind(forms.AdminEditBadgeForm{}), admin.EditBadgePost)
m.Post("/slug/{badge_slug}/delete", admin.DeleteBadge)
m.Combo("/slug/{badge_slug}/users").Get(admin.BadgeUsers).Post(admin.BadgeUsersPost)
m.Post("/slug/{badge_slug}/users/delete", admin.DeleteBadgeUser)
})
m.Group("/emails", func() {
m.Get("", admin.Emails)
m.Post("/activate", admin.ActivateEmail)

View File

@@ -25,6 +25,31 @@ type AdminCreateUserForm struct {
Visibility structs.VisibleType
}
// AdminCreateBadgeForm form for admin to create badge
type AdminCreateBadgeForm struct {
Slug string `binding:"Required;BadgeSlug" locale:"admin.badges.slug"`
Description string `binding:"Required" locale:"admin.badges.description"`
ImageURL string `binding:"ValidUrl" locale:"admin.badges.image_url"`
}
// AdminEditBadgeForm form for admin to edit badge
type AdminEditBadgeForm struct {
Description string `binding:"Required" locale:"admin.badges.description"`
ImageURL string `binding:"ValidUrl" locale:"admin.badges.image_url"`
}
// Validate validates form fields
func (f *AdminCreateBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// Validate validates form fields
func (f *AdminEditBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// Validate validates form fields
func (f *AdminCreateUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)

View File

@@ -0,0 +1,44 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit badge")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.badges.edit_badge"}}
</h4>
<div class="ui attached segment">
<form class="ui form form-fetch-action" action="./edit" method="post">
<div class="field">
<label>{{ctx.Locale.Tr "admin.badges.slug"}}</label>
<input value="{{.Badge.Slug}}" readonly>
</div>
<div class="field {{if .Err_Description}}error{{end}}">
<label>{{ctx.Locale.Tr "admin.badges.description"}}</label>
<textarea name="description" rows="2">{{.Badge.Description}}</textarea>
</div>
<div class="field {{if .Err_ImageURL}}error{{end}}">
<label>{{ctx.Locale.Tr "admin.badges.image_url"}}</label>
<input type="url" name="image_url" value="{{.Badge.ImageURL}}">
</div>
<div class="divider"></div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "admin.badges.update_badge"}}</button>
<button class="ui red button show-modal" data-modal="#delete-badge-modal">{{ctx.Locale.Tr "admin.badges.delete_badge"}}</button>
</div>
</form>
</div>
</div>
<div class="ui g-modal-confirm modal" id="delete-badge-modal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "admin.badges.delete_badge"}}
</div>
<form class="ui form" method="post" action="./delete">
<div class="content">
<p>{{ctx.Locale.Tr "admin.badges.delete_badge_desc"}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</form>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,67 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.badges.badges_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
<div class="ui right">
<a class="ui primary tiny button" href="{{AppSubUrl}}/-/admin/badges/new">{{ctx.Locale.Tr "admin.badges.new_badge"}}</a>
</div>
</h4>
<div class="ui attached segment">
<form class="ui form ignore-dirty flex-text-block" id="user-list-search-form">
<div class="tw-flex-1">
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.badge_kind")}}
</div>
<!-- Right Menu -->
<div class="ui secondary menu tw-m-0">
<!-- Sort Menu Item -->
<div class="ui dropdown type jump item">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<button class="{{if eq $.SortType "oldest"}}active {{end}}item" name="sort" value="oldest">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</button>
<button class="{{if eq $.SortType "newest"}}active {{end}}item" name="sort" value="newest">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</button>
<button class="{{if eq $.SortType "alphabetically"}}active {{end}}item" name="sort" value="alphabetically">{{ctx.Locale.Tr "repo.issues.label.filter_sort.alphabetically"}}</button>
<button class="{{if eq $.SortType "reversealphabetically"}}active {{end}}item" name="sort" value="reversealphabetically">{{ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</button>
</div>
</div>
</div>
</form>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table unstackable">
<thead>
<tr>
<th data-sortt-asc="oldest" data-sortt-desc="newest" data-sortt-default="true">ID{{SortArrow "oldest" "newest" .SortType false}}</th>
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
{{ctx.Locale.Tr "admin.badges.slug"}}
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
</th>
<th>{{ctx.Locale.Tr "admin.badges.description"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Badges}}
<tr>
<td>{{.ID}}</td>
<td>
<a href="{{$.Link}}/slug/{{.Slug | PathEscape}}">{{.Slug}}</a>
</td>
<td class="gt-ellipsis tw-max-w-48">{{.Description}}</td>
<td>
<div class="tw-flex tw-gap-2">
<a href="{{$.Link}}/slug/{{.Slug | PathEscape}}" data-tooltip-content="{{ctx.Locale.Tr "admin.badges.details"}}">{{svg "octicon-star"}}</a>
<a href="{{$.Link}}/slug/{{.Slug | PathEscape}}/edit" data-tooltip-content="{{ctx.Locale.Tr "edit"}}">{{svg "octicon-pencil"}}</a>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "base/paginate" .}}
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,26 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new badge")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.badges.new_badge"}}
</h4>
<div class="ui attached segment">
<form class="ui form form-fetch-action" action="{{.Link}}" method="post">
<div class="required field">
<label>{{ctx.Locale.Tr "admin.badges.slug"}}</label>
<input autofocus required name="slug">
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "admin.badges.description"}}</label>
<textarea name="description" rows="2" required></textarea>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.badges.image_url"}}</label>
<input type="url" name="image_url">
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "admin.badges.new_badge"}}</button>
</div>
</form>
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,40 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{.Title}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}" method="post">
<div id="search-user-box" class="ui search input tw-align-middle">
<input class="prompt" name="user" placeholder="{{ctx.Locale.Tr "search.user_kind"}}" autocomplete="off" autofocus required>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "admin.badges.add_user"}}</button>
</form>
</div>
{{if .Users}}
<div class="ui attached segment">
<div class="flex-list">
{{range .Users}}
<div class="flex-item tw-items-center">
<div class="flex-item-leading">
<a href="{{.HomeLink}}">{{ctx.AvatarUtils.Avatar . 32}}</a>
</div>
<div class="flex-item-main">
<div class="flex-item-title">
{{template "shared/user/name" .}}
</div>
</div>
<div class="flex-item-trailing">
<a class="ui red tiny button inline link-action" data-url="{{$.Link}}/delete?id={{.ID}}" data-modal-confirm="{{ctx.Locale.Tr "admin.badges.delete_user_desc"}}">
{{ctx.Locale.Tr "admin.badges.remove_user"}}
</a>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{template "base/paginate" .}}
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,44 @@
{{template "admin/layout_head" (dict "ctxData" .)}}
<div class="admin-setting-content">
<div class="admin-responsive-columns">
<div class="tw-flex-1">
<h4 class="ui top attached header">
{{.Title}}
<div class="ui right">
<a class="ui primary tiny button" href="{{.Link}}/edit">{{ctx.Locale.Tr "admin.badges.edit_badge"}}</a>
</div>
</h4>
<div class="ui attached segment">
<div class="flex-list">
<div class="flex-item">
{{if .Badge.ImageURL}}
<div class="flex-item-leading">
<img width="64" height="64" src="{{.Badge.ImageURL}}" alt="{{.Badge.Description}}" data-tooltip-content="{{.Badge.Description}}">
</div>
{{end}}
<div class="flex-item-main">
<div class="flex-item-title">
{{.Badge.Slug}}
</div>
<div class="flex-item-body">
{{.Badge.Description}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "explore.users"}} ({{.UsersTotal}})
<div class="ui right">
<a class="ui primary tiny button" href="{{.Link}}/users">{{ctx.Locale.Tr "admin.badges.manage_users"}}</a>
</div>
</h4>
<div class="ui attached segment">
{{template "explore/user_list" .}}
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -13,7 +13,7 @@
</a>
</div>
</details>
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminBadges .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
<summary>{{ctx.Locale.Tr "admin.identity_access"}}</summary>
<div class="menu">
<a class="{{if .PageIsAdminAuthentications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/auths">
@@ -25,6 +25,9 @@
<a class="{{if .PageIsAdminUsers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/users">
{{ctx.Locale.Tr "admin.users"}}
</a>
<a class="{{if .PageIsAdminBadges}}active {{end}}item" href="{{AppSubUrl}}/-/admin/badges">
{{ctx.Locale.Tr "admin.badges"}}
</a>
<a class="{{if .PageIsAdminEmails}}active {{end}}item" href="{{AppSubUrl}}/-/admin/emails">
{{ctx.Locale.Tr "admin.emails"}}
</a>

View File

@@ -100,13 +100,17 @@
{{end}}
{{if .Badges}}
<li>
<ul class="user-badges">
<div class="user-badges">
{{range .Badges}}
<li>
<span class="user-badge-item">
{{if .ImageURL}}
<img loading="lazy" width="64" height="64" src="{{.ImageURL}}" alt="{{.Description}}" data-tooltip-content="{{.Description}}">
</li>
{{else}}
<span class="ui label user-badge-chip" data-tooltip-content="{{if .Description}}{{.Description}}{{else}}{{.Slug}}{{end}}">{{.Slug}}</span>
{{end}}
</span>
{{end}}
</ul>
</div>
</li>
{{end}}
{{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}}

View File

@@ -92,15 +92,31 @@
}
.user-badges {
display: grid;
grid-template-columns: repeat(auto-fill, 64px);
gap: 2px;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
min-width: 0;
}
.user-badge-item {
display: inline-flex;
flex: 0 0 auto;
min-width: max-content;
}
.user-badges img {
object-fit: contain;
}
.user-badge-chip {
max-width: none !important;
overflow: visible !important;
text-overflow: clip !important;
white-space: nowrap;
min-width: max-content;
}
#readme_profile {
padding: 1em 2em;
border-radius: var(--border-radius);