Skip to content
/ gitea Public
  • Sponsor go-gitea/gitea

  • Notifications You must be signed in to change notification settings
  • Fork 5.7k
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display User signin metadata in admin dashboard #33955

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
10 changes: 10 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
@@ -3075,6 +3075,16 @@ users.list_status_filter.is_2fa_enabled = 2FA Enabled
users.list_status_filter.not_2fa_enabled = 2FA Disabled
users.details = User Details
ips.ip = IP Address
ips.user_agent = User Agent
ips.ip_manage_panel = Signup IP Management
ips.signup_metadata = Signup Metadata
ips.not_available = Signup metadata not available
ips.filter_sort.ip = Sort by IP (asc)
ips.filter_sort.ip_reverse = Sort by IP (desc)
ips.filter_sort.name = Sort by Username (asc)
ips.filter_sort.name_reverse = Sort by Username (desc)
emails.email_manage_panel = User Email Management
emails.primary = Primary
emails.activated = Activated
142 changes: 142 additions & 0 deletions routers/web/admin/ips.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package admin

import (
"net/http"
"strings"

"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"

"xorm.io/xorm"
)

const (
tplIPs templates.TplName = "admin/ips/list"
)

// trimPortFromIP removes the client port from an IP address
// Handles both IPv4 and IPv6 addresses with ports
func trimPortFromIP(ip string) string {
// Handle IPv6 with brackets: [IPv6]:port
if strings.HasPrefix(ip, "[") {
// If there's no port, return as is
if !strings.Contains(ip, "]:") {
return ip
}
// Remove the port part after ]:
return strings.Split(ip, "]:")[0] + "]"
}

// Count colons to differentiate between IPv4 and IPv6
colonCount := strings.Count(ip, ":")

// Handle IPv4 with port (single colon)
if colonCount == 1 {
return strings.Split(ip, ":")[0]
}

return ip
}

func buildIPQuery(ctx *context.Context, keyword string) *xorm.Session {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to move them into models package.

query := db.GetEngine(ctx).
Table("user_setting").
Join("INNER", "user", "user.id = user_setting.user_id").
Where("user_setting.setting_key = ?", user_model.SignupIP)

if len(keyword) > 0 {
query = query.And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)",
"%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%")
}
return query
}

// IPs show all user signup IPs
func IPs(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.ips.ip")
ctx.Data["PageIsAdminIPs"] = true
ctx.Data["RecordUserSignupMetadata"] = setting.RecordUserSignupMetadata

// If record user signup metadata is disabled, don't show the page
if !setting.RecordUserSignupMetadata {
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}

page := ctx.FormInt("page")
if page <= 1 {
page = 1
}

// Define the user IP result struct
type UserIPResult struct {
UID int64
Name string
FullName string
IP string
}

var (
userIPs []UserIPResult
count int64
err error
orderBy string
keyword = ctx.FormTrim("q")
sortType = ctx.FormString("sort")
)

ctx.Data["SortType"] = sortType
switch sortType {
case "ip":
orderBy = "user_setting.setting_value ASC, user.id ASC"
case "reverseip":
orderBy = "user_setting.setting_value DESC, user.id DESC"
case "username":
orderBy = "user.lower_name ASC, user.id ASC"
case "reverseusername":
orderBy = "user.lower_name DESC, user.id DESC"
default:
ctx.Data["SortType"] = "ip"
orderBy = "user_setting.setting_value ASC, user.id ASC"
}

// Get the count and user IPs for pagination
query := buildIPQuery(ctx, keyword)

count, err = query.Count(new(user_model.Setting))
if err != nil {
ctx.ServerError("Count", err)
return
}

err = buildIPQuery(ctx, keyword).
Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip").
OrderBy(orderBy).
Limit(setting.UI.Admin.UserPagingNum, (page-1)*setting.UI.Admin.UserPagingNum).
Find(&userIPs)
if err != nil {
ctx.ServerError("Find", err)
return
}

for i := range userIPs {
// Trim the port from the IP
// FIXME: Maybe have a different helper for this?
userIPs[i].IP = trimPortFromIP(userIPs[i].IP)
}

ctx.Data["UserIPs"] = userIPs
ctx.Data["Total"] = count
ctx.Data["Keyword"] = keyword

// Setup pagination
ctx.Data["Page"] = context.NewPagination(int(count), setting.UI.Admin.UserPagingNum, page, 5)

ctx.HTML(http.StatusOK, tplIPs)
}
20 changes: 20 additions & 0 deletions routers/web/admin/users.go
Original file line number Diff line number Diff line change
@@ -263,6 +263,7 @@ func ViewUser(ctx *context.Context) {
ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["ShowUserSignupMetadata"] = setting.RecordUserSignupMetadata

u := prepareUserInfo(ctx)
if ctx.Written() {
@@ -292,6 +293,25 @@ func ViewUser(ctx *context.Context) {
ctx.Data["Emails"] = emails
ctx.Data["EmailsTotal"] = len(emails)

// If record user signup metadata is enabled, get the user's signup IP and user agent
if setting.RecordUserSignupMetadata {
signupIP, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupIP)
if err == nil && len(signupIP) > 0 {
ctx.Data["HasSignupIP"] = true
ctx.Data["SignupIP"] = trimPortFromIP(signupIP)
} else {
ctx.Data["HasSignupIP"] = false
}

signupUserAgent, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupUserAgent)
if err == nil && len(signupUserAgent) > 0 {
ctx.Data["HasSignupUserAgent"] = true
ctx.Data["SignupUserAgent"] = signupUserAgent
} else {
ctx.Data["HasSignupUserAgent"] = false
}
}

orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
ListOptions: db.ListOptionsAll,
UserID: u.ID,
6 changes: 5 additions & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
@@ -757,6 +757,10 @@ func registerRoutes(m *web.Router) {
m.Post("/delete", admin.DeleteEmail)
})

m.Group("/ips", func() {
m.Get("", admin.IPs)
})

m.Group("/orgs", func() {
m.Get("", admin.Organizations)
})
@@ -816,7 +820,7 @@ func registerRoutes(m *web.Router) {
addSettingsRunnersRoutes()
addSettingsVariablesRoutes()
})
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
}, adminReq, ctxDataSet("RecordUserSignupMetadata", setting.RecordUserSignupMetadata, "EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
// ***** END: Admin *****

m.Group("", func() {
58 changes: 58 additions & 0 deletions templates/admin/ips/list.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.ips.ip_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
</h4>
<div class="ui attached segment">
<div class="ui secondary filter menu tw-items-center tw-mx-0">
<form class="ui form ignore-dirty tw-flex-1">
{{template "shared/search/combo" dict "Value" .Keyword}}
</form>
<!-- Sort -->
<div class="ui dropdown type jump item tw-mr-0">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="{{if or (eq .SortType "ip") (not .SortType)}}active {{end}}item" href="?sort=ip&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.ip"}}</a>
<a class="{{if eq .SortType "reverseip"}}active {{end}}item" href="?sort=reverseip&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.ip_reverse"}}</a>
<a class="{{if eq .SortType "username"}}active {{end}}item" href="?sort=username&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.name"}}</a>
<a class="{{if eq .SortType "reverseusername"}}active {{end}}item" href="?sort=reverseusername&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.name_reverse"}}</a>
</div>
</div>
</div>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table unstackable">
<thead>
<tr>
<th data-sortt-asc="username" data-sortt-desc="reverseusername">
{{ctx.Locale.Tr "admin.users.name"}}
{{SortArrow "username" "reverseusername" $.SortType false}}
</th>
<th>{{ctx.Locale.Tr "admin.users.full_name"}}</th>
<th data-sortt-asc="ip" data-sortt-desc="reverseip" data-sortt-default="true">
{{ctx.Locale.Tr "admin.ips.ip"}}
{{SortArrow "ip" "reverseip" $.SortType true}}
</th>
</tr>
</thead>
<tbody>
{{range .UserIPs}}
<tr>
<td><a href="{{AppSubUrl}}/-/admin/users/{{.UID}}">{{.Name}}</a></td>
<td>{{.FullName}}</td>
<td><a href="?q={{.IP}}&sort={{$.SortType}}">{{.IP}}</a></td>
</tr>
{{else}}
<tr><td class="tw-text-center" colspan="3">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}}
</tbody>
</table>
</div>

{{template "base/paginate" .}}
</div>

{{template "admin/layout_footer" .}}
7 changes: 6 additions & 1 deletion templates/admin/navbar.tmpl
Original file line number Diff line number Diff line change
@@ -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 .PageIsAdminEmails .PageIsAdminIPs .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">
@@ -28,6 +28,11 @@
<a class="{{if .PageIsAdminEmails}}active {{end}}item" href="{{AppSubUrl}}/-/admin/emails">
{{ctx.Locale.Tr "admin.emails"}}
</a>
{{if .RecordUserSignupMetadata}}
<a class="{{if .PageIsAdminIPs}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ips">
{{ctx.Locale.Tr "admin.ips.ip"}}
</a>
{{end}}
</div>
</details>
<details class="item toggleable-item" {{if or .PageIsAdminRepositories (and .EnablePackages .PageIsAdminPackages)}}open{{end}}>
8 changes: 8 additions & 0 deletions templates/admin/user/view.tmpl
Original file line number Diff line number Diff line change
@@ -25,6 +25,14 @@
</div>
</div>
</div>
{{if .ShowUserSignupMetadata}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.ips.signup_metadata"}}
</h4>
<div class="ui attached segment">
{{template "admin/user/view_ip" .}}
</div>
{{end}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.repositories"}}
<div class="ui right">
18 changes: 18 additions & 0 deletions templates/admin/user/view_ip.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{{if .HasSignupIP}}
<div class="flex-list">
<div class="flex-item">
<div class="flex-item-main">
<div class="flex-text-block">
<strong>{{ctx.Locale.Tr "admin.ips.ip"}}:</strong> <a href="{{AppSubUrl}}/-/admin/ips?q={{.SignupIP}}">{{.SignupIP}}</a>
</div>
{{if .HasSignupUserAgent}}
<div class="flex-text-block">
<strong>{{ctx.Locale.Tr "admin.ips.user_agent"}}:</strong> {{.SignupUserAgent}}
</div>
{{end}}
</div>
</div>
</div>
{{else}}
<div>{{ctx.Locale.Tr "admin.ips.not_available"}}</div>
{{end}}