mirror of
https://github.com/go-gitea/gitea
synced 2025-01-03 21:45:58 +01:00
Implemented Badge Management in administration panel (#29798)
Co-authored-by: Diogo Vicente <diogo.m.s.vicente@tecnico.ulisboa.pt>
This commit is contained in:
parent
7adf8d7727
commit
8b86c3140a
@ -6,8 +6,14 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// Badge represents a user badge
|
||||
@ -30,6 +36,10 @@ func init() {
|
||||
db.RegisterModel(new(UserBadge))
|
||||
}
|
||||
|
||||
func AdminCreateBadge(ctx context.Context, badge *Badge) error {
|
||||
return CreateBadge(ctx, badge)
|
||||
}
|
||||
|
||||
// GetUserBadges returns the user's badges.
|
||||
func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) {
|
||||
sess := db.GetEngine(ctx).
|
||||
@ -42,9 +52,30 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) {
|
||||
return badges, count, err
|
||||
}
|
||||
|
||||
// GetBadgeUsers returns the badges users.
|
||||
func GetBadgeUsers(ctx context.Context, b *Badge) ([]*User, int64, error) {
|
||||
sess := db.GetEngine(ctx).
|
||||
Select("`user`.*").
|
||||
Join("INNER", "user_badge", "`user_badge`.user_id=user.id").
|
||||
Where("user_badge.badge_id=?", b.ID)
|
||||
|
||||
users := make([]*User, 0, 8)
|
||||
count, err := sess.FindAndCount(&users)
|
||||
return users, count, err
|
||||
}
|
||||
|
||||
// CreateBadge creates a new badge.
|
||||
func CreateBadge(ctx context.Context, badge *Badge) error {
|
||||
_, err := db.GetEngine(ctx).Insert(badge)
|
||||
isExist, err := IsBadgeExist(ctx, 0, badge.Slug)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if isExist {
|
||||
return ErrBadgeAlreadyExist{badge.Slug}
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Insert(badge)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@ -58,9 +89,22 @@ func GetBadge(ctx context.Context, slug string) (*Badge, error) {
|
||||
return badge, err
|
||||
}
|
||||
|
||||
// GetBadgeByID returns a badge
|
||||
func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) {
|
||||
badge := new(Badge)
|
||||
has, err := db.GetEngine(ctx).Where("id=?", id).Get(badge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrBadgeNotExist{ID: id}
|
||||
}
|
||||
|
||||
return badge, err
|
||||
}
|
||||
|
||||
// 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("id=?", badge.ID).Cols("slug", "description", "image_url").Update(badge)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -70,6 +114,15 @@ func DeleteBadge(ctx context.Context, badge *Badge) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserBadgeRecord deletes a user badge record.
|
||||
func DeleteUserBadgeRecord(ctx context.Context, badge *Badge) error {
|
||||
userBadge := &UserBadge{
|
||||
BadgeID: badge.ID,
|
||||
}
|
||||
_, err := db.GetEngine(ctx).Where("badge_id=?", userBadge.BadgeID).Delete(userBadge)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddUserBadge adds a badge to a user.
|
||||
func AddUserBadge(ctx context.Context, u *User, badge *Badge) error {
|
||||
return AddUserBadges(ctx, u, []*Badge{badge})
|
||||
@ -122,3 +175,107 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error {
|
||||
_, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{})
|
||||
return err
|
||||
}
|
||||
|
||||
// HTMLURL returns the badges full link.
|
||||
func (u *Badge) HTMLURL() string {
|
||||
return setting.AppURL + url.PathEscape(u.Slug)
|
||||
}
|
||||
|
||||
// IsBadgeExist checks if given badge slug exist,
|
||||
// it is used when creating/updating a badge slug
|
||||
func IsBadgeExist(ctx context.Context, uid int64, slug string) (bool, error) {
|
||||
if len(slug) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return db.GetEngine(ctx).
|
||||
Where("slug!=?", uid).
|
||||
Get(&Badge{Slug: strings.ToLower(slug)})
|
||||
}
|
||||
|
||||
// SearchBadgeOptions represents the options when fdin badges
|
||||
type SearchBadgeOptions struct {
|
||||
db.ListOptions
|
||||
|
||||
Keyword string
|
||||
Slug string
|
||||
ID int64
|
||||
OrderBy db.SearchOrderBy
|
||||
Actor *User // The user doing the search
|
||||
|
||||
ExtraParamStrings map[string]string
|
||||
}
|
||||
|
||||
func (opts *SearchBadgeOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if opts.Keyword != "" {
|
||||
cond = cond.And(builder.Like{"badge.slug", opts.Keyword})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *SearchBadgeOptions) ToOrders() string {
|
||||
orderBy := "badge.slug"
|
||||
return orderBy
|
||||
}
|
||||
|
||||
func (opts *SearchBadgeOptions) ToJoins() []db.JoinFunc {
|
||||
return []db.JoinFunc{
|
||||
func(e db.Engine) error {
|
||||
e.Join("INNER", "badge", "badge.badge_id = badge.id")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) (badges []*Badge, _ int64, _ error) {
|
||||
sessCount := opts.toSearchQueryBase(ctx)
|
||||
defer sessCount.Close()
|
||||
count, err := sessCount.Count(new(Badge))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count: %w", err)
|
||||
}
|
||||
|
||||
if len(opts.OrderBy) == 0 {
|
||||
opts.OrderBy = db.SearchOrderByID
|
||||
}
|
||||
|
||||
sessQuery := opts.toSearchQueryBase(ctx).OrderBy(opts.OrderBy.String())
|
||||
defer sessQuery.Close()
|
||||
if opts.Page != 0 {
|
||||
sessQuery = db.SetSessionPagination(sessQuery, opts)
|
||||
}
|
||||
|
||||
// the sql may contain JOIN, so we must only select Badge related columns
|
||||
sessQuery = sessQuery.Select("`badge`.*")
|
||||
badges = make([]*Badge, 0, opts.PageSize)
|
||||
return badges, count, sessQuery.Find(&badges)
|
||||
}
|
||||
|
||||
func (opts *SearchBadgeOptions) toSearchQueryBase(ctx context.Context) *xorm.Session {
|
||||
var cond builder.Cond
|
||||
cond = builder.Neq{"id": -1}
|
||||
|
||||
if len(opts.Keyword) > 0 {
|
||||
lowerKeyword := strings.ToLower(opts.Keyword)
|
||||
keywordCond := builder.Or(
|
||||
builder.Like{"slug", lowerKeyword},
|
||||
builder.Like{"description", lowerKeyword},
|
||||
builder.Like{"id", lowerKeyword},
|
||||
)
|
||||
cond = cond.And(keywordCond)
|
||||
}
|
||||
|
||||
if opts.ID > 0 {
|
||||
cond = cond.And(builder.Eq{"id": opts.ID})
|
||||
}
|
||||
|
||||
if len(opts.Slug) > 0 {
|
||||
cond = cond.And(builder.Eq{"slug": opts.Slug})
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
return e.Where(cond)
|
||||
}
|
||||
|
@ -107,3 +107,44 @@ func IsErrUserIsNotLocal(err error) bool {
|
||||
_, ok := err.(ErrUserIsNotLocal)
|
||||
return ok
|
||||
}
|
||||
|
||||
// ErrBadgeAlreadyExist represents a "badge already exists" error.
|
||||
type ErrBadgeAlreadyExist struct {
|
||||
Slug string
|
||||
}
|
||||
|
||||
// IsErrBadgeAlreadyExist checks if an error is a ErrBadgeAlreadyExist.
|
||||
func IsErrBadgeAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrBadgeAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBadgeAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("badge already exists [slug: %s]", err.Slug)
|
||||
}
|
||||
|
||||
// Unwrap unwraps this error as a ErrExist error
|
||||
func (err ErrBadgeAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrBadgeNotExist represents a "BadgeNotExist" kind of error.
|
||||
type ErrBadgeNotExist struct {
|
||||
Slug string
|
||||
ID int64
|
||||
}
|
||||
|
||||
// IsErrBadgeNotExist checks if an error is a ErrBadgeNotExist.
|
||||
func IsErrBadgeNotExist(err error) bool {
|
||||
_, ok := err.(ErrBadgeNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBadgeNotExist) Error() string {
|
||||
return fmt.Sprintf("badge does not exist [slug: %s | id: %i]", err.Slug, err.ID)
|
||||
}
|
||||
|
||||
// Unwrap unwraps this error as a ErrNotExist error
|
||||
func (err ErrBadgeNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
@ -26,6 +26,8 @@ const (
|
||||
ErrUsername = "UsernameError"
|
||||
// ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
|
||||
ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
|
||||
ErrInvalidImageURL = "InvalidImageURL"
|
||||
ErrInvalidSlug = "InvalidSlug"
|
||||
)
|
||||
|
||||
// AddBindingRules adds additional binding rules
|
||||
@ -38,6 +40,8 @@ func AddBindingRules() {
|
||||
addGlobOrRegexPatternRule()
|
||||
addUsernamePatternRule()
|
||||
addValidGroupTeamMapRule()
|
||||
addValidImageURLBindingRule()
|
||||
addSlugPatternRule()
|
||||
}
|
||||
|
||||
func addGitRefNameBindingRule() {
|
||||
@ -94,6 +98,40 @@ func addValidSiteURLBindingRule() {
|
||||
})
|
||||
}
|
||||
|
||||
func addValidImageURLBindingRule() {
|
||||
// URL validation rule
|
||||
binding.AddRule(&binding.Rule{
|
||||
IsMatch: func(rule string) bool {
|
||||
return strings.HasPrefix(rule, "ValidImageUrl")
|
||||
},
|
||||
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
|
||||
str := fmt.Sprintf("%v", val)
|
||||
if len(str) != 0 && !IsValidImageURL(str) {
|
||||
errs.Add([]string{name}, ErrInvalidImageURL, "ImageURL")
|
||||
return false, errs
|
||||
}
|
||||
|
||||
return true, errs
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func addSlugPatternRule() {
|
||||
binding.AddRule(&binding.Rule{
|
||||
IsMatch: func(rule string) bool {
|
||||
return rule == "Slug"
|
||||
},
|
||||
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
|
||||
str := fmt.Sprintf("%v", val)
|
||||
if !IsValidSlug(str) {
|
||||
errs.Add([]string{name}, ErrInvalidSlug, "invalid slug")
|
||||
return false, errs
|
||||
}
|
||||
return true, errs
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func addGlobPatternRule() {
|
||||
binding.AddRule(&binding.Rule{
|
||||
IsMatch: func(rule string) bool {
|
||||
|
@ -6,6 +6,7 @@ package validation
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@ -50,6 +51,29 @@ func IsValidSiteURL(uri string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidImageURL checks if URL is valid and points to an image
|
||||
func IsValidImageURL(uri string) bool {
|
||||
u, err := url.ParseRequestURI(uri)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !validPort(portOnly(u.Host)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, scheme := range setting.Service.ValidSiteURLSchemes {
|
||||
if scheme == u.Scheme {
|
||||
// Check if the path has an image file extension
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".bmp" || ext == ".svg" || ext == ".webp" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsEmailDomainListed checks whether the domain of an email address
|
||||
// matches a list of domains
|
||||
func IsEmailDomainListed(globs []glob.Glob, email string) bool {
|
||||
@ -127,3 +151,7 @@ func IsValidUsername(name string) bool {
|
||||
// but it's easier to use positive and negative checks.
|
||||
return validUsernamePattern.MatchString(name) && !invalidUsernamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
func IsValidSlug(slug string) bool {
|
||||
return IsValidUsername(slug)
|
||||
}
|
||||
|
@ -138,6 +138,10 @@ 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.ErrInvalidImageURL:
|
||||
data["ErrorMsg"] = l.TrString("form.invalid_image_url_error")
|
||||
case validation.ErrInvalidSlug:
|
||||
data["ErrorMsg"] = l.TrString("form.invalid_slug_error")
|
||||
default:
|
||||
msg := errs[0].Classification
|
||||
if msg != "" && errs[0].Message != "" {
|
||||
|
@ -169,6 +169,7 @@ exact = Exact
|
||||
exact_tooltip = Include only results that match the exact search term
|
||||
repo_kind = Search repos...
|
||||
user_kind = Search users...
|
||||
badge_kind = Search badges...
|
||||
org_kind = Search orgs...
|
||||
team_kind = Search teams...
|
||||
code_kind = Search code...
|
||||
@ -544,6 +545,7 @@ PayloadUrl = Payload URL
|
||||
TeamName = Team name
|
||||
AuthName = Authorization name
|
||||
AdminEmail = Admin email
|
||||
ImageURL = Image URL
|
||||
|
||||
NewBranchName = New branch name
|
||||
CommitSummary = Commit summary
|
||||
@ -573,10 +575,13 @@ unknown_error = Unknown error:
|
||||
captcha_incorrect = The CAPTCHA code is incorrect.
|
||||
password_not_match = The passwords do not match.
|
||||
lang_select_error = Select a language from the list.
|
||||
invalid_image_url_error = `Please provide a valid image URL.`
|
||||
invalid_slug_error = `Please provide a valid slug.`
|
||||
|
||||
username_been_taken = The username is already taken.
|
||||
username_change_not_local_user = Non-local users are not allowed to change their username.
|
||||
username_has_not_been_changed = Username has not been changed
|
||||
slug_been_taken = The slug is already taken.
|
||||
repo_name_been_taken = The repository name is already used.
|
||||
repository_force_private = Force Private is enabled: private repositories cannot be made public.
|
||||
repository_files_already_exist = Files already exist for this repository. Contact the system administrator.
|
||||
@ -2794,6 +2799,7 @@ dashboard = Dashboard
|
||||
self_check = Self Check
|
||||
identity_access = Identity & Access
|
||||
users = User Accounts
|
||||
badges = Badges
|
||||
organizations = Organizations
|
||||
assets = Code Assets
|
||||
repositories = Repositories
|
||||
@ -2968,6 +2974,22 @@ emails.duplicate_active = This email address is already active for a different u
|
||||
emails.change_email_header = Update Email Properties
|
||||
emails.change_email_text = Are you sure you want to update this email address?
|
||||
|
||||
badges.badges_manage_panel = Badge Management
|
||||
badges.details = Badge Details
|
||||
badges.new_badge = Create New Badge
|
||||
badges.slug = Slug
|
||||
badges.description = Description
|
||||
badges.image_url = Image URL
|
||||
badges.slug.must_fill = Slug must be filled.
|
||||
badges.new_success = The badge "%s" has been created.
|
||||
badges.update_success = The badge has been updated.
|
||||
badges.deletion_success = The badge has been deleted.
|
||||
badges.edit_badge = Edit Badge
|
||||
badges.update_badge = Update Badge
|
||||
badges.delete_badge = Delete Badge
|
||||
badges.delete_badge_desc = Are you sure you want to permanently delete this badge?
|
||||
|
||||
|
||||
orgs.org_manage_panel = Organization Management
|
||||
orgs.name = Name
|
||||
orgs.teams = Teams
|
||||
|
215
routers/web/admin/badges.go
Normal file
215
routers/web/admin/badges.go
Normal file
@ -0,0 +1,215 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2024 The Gitea Authors.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/web/explore"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
)
|
||||
|
||||
const (
|
||||
tplBadges base.TplName = "admin/badge/list"
|
||||
tplBadgeNew base.TplName = "admin/badge/new"
|
||||
tplBadgeView base.TplName = "admin/badge/view"
|
||||
tplBadgeEdit base.TplName = "admin/badge/edit"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
sortType := ctx.FormString("sort")
|
||||
if sortType == "" {
|
||||
sortType = BadgeSearchDefaultAdminSort
|
||||
ctx.SetFormString("sort", sortType)
|
||||
}
|
||||
ctx.PageData["adminBadgeListSearchForm"] = map[string]any{
|
||||
"SortType": sortType,
|
||||
}
|
||||
|
||||
explore.RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{
|
||||
Actor: ctx.Doer,
|
||||
ListOptions: db.ListOptions{
|
||||
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)
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge")
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplBadgeNew)
|
||||
return
|
||||
}
|
||||
|
||||
b := &user_model.Badge{
|
||||
Slug: form.Slug,
|
||||
Description: form.Description,
|
||||
ImageURL: form.ImageURL,
|
||||
}
|
||||
|
||||
if len(form.Slug) < 1 {
|
||||
ctx.Data["Err_Slug"] = true
|
||||
ctx.RenderWithErr(ctx.Tr("admin.badges.must_fill"), tplBadgeNew, &form)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user_model.AdminCreateBadge(ctx, b); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrBadgeAlreadyExist(err):
|
||||
ctx.Data["Err_Slug"] = true
|
||||
ctx.RenderWithErr(ctx.Tr("form.slug_been_taken"), tplBadgeNew, &form)
|
||||
default:
|
||||
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.Redirect(setting.AppSubURL + "/admin/badges/" + strconv.FormatInt(b.ID, 10))
|
||||
}
|
||||
|
||||
func prepareBadgeInfo(ctx *context.Context) *user_model.Badge {
|
||||
b, err := user_model.GetBadgeByID(ctx, ctx.ParamsInt64(":badgeid"))
|
||||
if err != nil {
|
||||
if user_model.IsErrBadgeNotExist(err) {
|
||||
ctx.Redirect(setting.AppSubURL + "/admin/badges")
|
||||
} else {
|
||||
ctx.ServerError("GetBadgeByID", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx.Data["Badge"] = b
|
||||
ctx.Data["Image"] = b.ImageURL != ""
|
||||
|
||||
users, count, err := user_model.GetBadgeUsers(ctx, b)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.Redirect(setting.AppSubURL + "/admin/badges")
|
||||
} else {
|
||||
ctx.ServerError("GetBadgeUsers", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx.Data["Users"] = users
|
||||
ctx.Data["UsersTotal"] = int(count)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBadgeView)
|
||||
}
|
||||
|
||||
// EditBadge show editing badge page
|
||||
func EditBadge(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badges")
|
||||
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) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badges")
|
||||
ctx.Data["PageIsAdminBadges"] = true
|
||||
b := prepareBadgeInfo(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm)
|
||||
if ctx.HasError() {
|
||||
ctx.HTML(http.StatusOK, tplBadgeEdit)
|
||||
return
|
||||
}
|
||||
|
||||
if form.Slug != "" {
|
||||
if err := user_service.RenameBadge(ctx, ctx.Data["Badge"].(*user_model.Badge), form.Slug); err != nil {
|
||||
switch {
|
||||
case user_model.IsErrBadgeAlreadyExist(err):
|
||||
ctx.Data["Err_Slug"] = true
|
||||
ctx.RenderWithErr(ctx.Tr("form.slug_been_taken"), tplBadgeEdit, &form)
|
||||
default:
|
||||
ctx.ServerError("RenameBadge", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
b.ImageURL = form.ImageURL
|
||||
b.Description = form.Description
|
||||
|
||||
if err := user_model.UpdateBadge(ctx, ctx.Data["Badge"].(*user_model.Badge)); 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.Redirect(setting.AppSubURL + "/admin/badges/" + url.PathEscape(ctx.Params(":badgeid")))
|
||||
}
|
||||
|
||||
// DeleteBadge response for deleting a badge
|
||||
func DeleteBadge(ctx *context.Context) {
|
||||
b, err := user_model.GetBadgeByID(ctx, ctx.ParamsInt64(":badgeid"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBadgeByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = user_service.DeleteBadge(ctx, b, true); 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")
|
||||
}
|
97
routers/web/explore/badge.go
Normal file
97
routers/web/explore/badge.go
Normal file
@ -0,0 +1,97 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package explore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/sitemap"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName base.TplName) {
|
||||
// Sitemap index for sitemap paths
|
||||
opts.Page = int(ctx.ParamsInt64("idx"))
|
||||
isSitemap := ctx.Params("idx") != ""
|
||||
if opts.Page <= 1 {
|
||||
opts.Page = ctx.FormInt("page")
|
||||
}
|
||||
if opts.Page <= 1 {
|
||||
opts.Page = 1
|
||||
}
|
||||
|
||||
if isSitemap {
|
||||
opts.PageSize = setting.UI.SitemapPagingNum
|
||||
}
|
||||
|
||||
var (
|
||||
badges []*user_model.Badge
|
||||
count int64
|
||||
err error
|
||||
orderBy db.SearchOrderBy
|
||||
)
|
||||
|
||||
// we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns
|
||||
|
||||
sortOrder := ctx.FormString("sort")
|
||||
if sortOrder == "" {
|
||||
sortOrder = setting.UI.ExploreDefaultSort
|
||||
}
|
||||
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 sortType is not valid, we set it to recent update
|
||||
sortOrder = "alphabetically"
|
||||
ctx.Data["SortType"] = "alphabetically"
|
||||
orderBy = "`badge`.slug 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
|
||||
}
|
||||
}
|
||||
if isSitemap {
|
||||
m := sitemap.NewSitemap()
|
||||
for _, item := range badges {
|
||||
m.Add(sitemap.URL{URL: item.HTMLURL()})
|
||||
}
|
||||
ctx.Resp.Header().Set("Content-Type", "text/xml")
|
||||
if _, err := m.WriteTo(ctx.Resp); err != nil {
|
||||
log.Error("Failed writing sitemap: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Keyword"] = opts.Keyword
|
||||
ctx.Data["Total"] = count
|
||||
ctx.Data["Badges"] = badges
|
||||
|
||||
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
|
||||
pager.SetDefaultParams(ctx)
|
||||
for paramKey, paramVal := range opts.ExtraParamStrings {
|
||||
pager.AddParamString(paramKey, paramVal)
|
||||
}
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, tplName)
|
||||
}
|
@ -720,6 +720,14 @@ func registerRoutes(m *web.Router) {
|
||||
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("/{badgeid}", admin.ViewBadge)
|
||||
m.Combo("/{badgeid}/edit").Get(admin.EditBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.EditBadgePost)
|
||||
m.Post("/{badgeid}/delete", admin.DeleteBadge)
|
||||
})
|
||||
|
||||
m.Group("/emails", func() {
|
||||
m.Get("", admin.Emails)
|
||||
m.Post("/activate", admin.ActivateEmail)
|
||||
|
@ -25,6 +25,19 @@ type AdminCreateUserForm struct {
|
||||
Visibility structs.VisibleType
|
||||
}
|
||||
|
||||
// AdminCreateBadgeForm form for admin to create badge
|
||||
type AdminCreateBadgeForm struct {
|
||||
Slug string `binding:"Required;Slug"`
|
||||
Description string
|
||||
ImageURL string `binding:"ValidImageUrl"`
|
||||
}
|
||||
|
||||
// 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 *AdminCreateUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||
ctx := context.GetValidateContext(req)
|
||||
|
75
services/user/badge.go
Normal file
75
services/user/badge.go
Normal file
@ -0,0 +1,75 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
)
|
||||
|
||||
// RenameBadge changes the slug of a badge.
|
||||
func RenameBadge(ctx context.Context, b *user_model.Badge, newSlug string) error {
|
||||
if newSlug == b.Slug {
|
||||
return nil
|
||||
}
|
||||
|
||||
olderSlug := b.Slug
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
isExist, err := user_model.IsBadgeExist(ctx, b.ID, newSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isExist {
|
||||
return user_model.ErrBadgeAlreadyExist{
|
||||
Slug: newSlug,
|
||||
}
|
||||
}
|
||||
|
||||
b.Slug = newSlug
|
||||
if err := user_model.UpdateBadge(ctx, b); err != nil {
|
||||
b.Slug = olderSlug
|
||||
return err
|
||||
}
|
||||
if err = committer.Commit(); err != nil {
|
||||
b.Slug = olderSlug
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBadge completely and permanently deletes everything of a badge
|
||||
func DeleteBadge(ctx context.Context, b *user_model.Badge, purge bool) error {
|
||||
if purge {
|
||||
err := user_model.DeleteUserBadgeRecord(ctx, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err := user_model.DeleteBadge(ctx, b); err != nil {
|
||||
return fmt.Errorf("DeleteBadge: %w", err)
|
||||
}
|
||||
|
||||
if err := committer.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = committer.Close()
|
||||
|
||||
return nil
|
||||
}
|
48
templates/admin/badge/edit.tmpl
Normal file
48
templates/admin/badge/edit.tmpl
Normal file
@ -0,0 +1,48 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit user")}}
|
||||
<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" action="./edit" method="post">
|
||||
{{template "base/disable_form_autofill"}}
|
||||
{{.CsrfTokenHtml}}
|
||||
|
||||
<div class="required non-local field {{if .Err_Slug}}error{{end}}">
|
||||
<label for="slug">{{ctx.Locale.Tr "admin.badges.slug"}}</label>
|
||||
<input autofocus required id="slug" name="slug" value="{{.Badge.Slug}}">
|
||||
</div>
|
||||
<div class="field {{if .Err_Description}}error{{end}}">
|
||||
<label for="description">{{ctx.Locale.Tr "admin.badges.description"}}</label>
|
||||
<textarea id="description" type="text" name="description" rows="2">{{.Badge.Description}}</textarea>
|
||||
</div>
|
||||
<div class="field {{if .Err_ImageURL}}error{{end}}">
|
||||
<label for="image_url">{{ctx.Locale.Tr "admin.badges.image_url"}}</label>
|
||||
<input id="image_url" 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 delete 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>
|
||||
{{$.CsrfTokenHtml}}
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
77
templates/admin/badge/list.tmpl
Normal file
77
templates/admin/badge/list.tmpl
Normal file
@ -0,0 +1,77 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}
|
||||
<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" id="user-list-search-form">
|
||||
|
||||
<!-- Right Menu -->
|
||||
<div class="ui right floated secondary filter menu">
|
||||
<!-- 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="item" name="sort" value="oldest">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</button>
|
||||
<button class="item" name="sort" value="newest">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</button>
|
||||
<button class="item" name="sort" value="alphabetically">{{ctx.Locale.Tr "repo.issues.label.filter_sort.alphabetically"}}</button>
|
||||
<button class="item" name="sort" value="reversealphabetically">{{ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.badge_kind")}}
|
||||
</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" "reversealphabeically" $.SortType true}}
|
||||
</th>
|
||||
<th>{{ctx.Locale.Tr "admin.badges.description"}}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Badges}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>
|
||||
<a href="">{{.Slug}}</a>
|
||||
</td>
|
||||
<td class="gt-ellipsis tw-max-w-48">{{.Description}}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<a href="{{$.Link}}/{{.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}">{{svg "octicon-star"}}</a>
|
||||
<a href="{{$.Link}}/{{.ID}}/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" .}}
|
30
templates/admin/badge/new.tmpl
Normal file
30
templates/admin/badge/new.tmpl
Normal file
@ -0,0 +1,30 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new user")}}
|
||||
<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" action="{{.Link}}" method="post">
|
||||
{{template "base/disable_form_autofill"}}
|
||||
{{.CsrfTokenHtml}}
|
||||
|
||||
<div class="required non-local field {{if .Err_Slug}}error{{end}}">
|
||||
<label for="slug">{{ctx.Locale.Tr "admin.badges.slug"}}</label>
|
||||
<input autofocus required id="slug" name="slug" value="{{.slug}}">
|
||||
</div>
|
||||
<div class="field {{if .Err_Description}}error{{end}}">
|
||||
<label for="description">{{ctx.Locale.Tr "admin.badges.description"}}</label>
|
||||
<textarea id="description" type="text" name="description" rows="2">{{.description}}</textarea>
|
||||
</div>
|
||||
<div class="field {{if .Err_ImageURL}}error{{end}}">
|
||||
<label for="image_url">{{ctx.Locale.Tr "admin.badges.image_url"}}</label>
|
||||
<input id="image_url" type="url" name="image_url" value="{{.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" .}}
|
44
templates/admin/badge/view.tmpl
Normal file
44
templates/admin/badge/view.tmpl
Normal file
@ -0,0 +1,44 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin view user")}}
|
||||
|
||||
<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.users.edit"}}</a>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-list">
|
||||
<div class="flex-item">
|
||||
{{if .Image}}
|
||||
<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"}}
|
||||
<div class="ui right">
|
||||
{{.UsersTotal}}
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{template "explore/user_list" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user