Skip to content
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
11 changes: 11 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3075,6 +3075,17 @@ users.list_status_filter.is_2fa_enabled = 2FA Enabled
users.list_status_filter.not_2fa_enabled = 2FA Disabled
users.details = User Details
ips = Signup IPs
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
Expand Down
163 changes: 163 additions & 0 deletions routers/web/admin/ips.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// 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"
)

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
}

// IPs show all user signup IPs
func IPs(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.ips")
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

Check failure on line 64 in routers/web/admin/ips.go

View workflow job for this annotation

GitHub Actions / lint-backend

File is not properly formatted (gofmt)

Check failure on line 64 in routers/web/admin/ips.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

File is not properly formatted (gofmt)

Check failure on line 64 in routers/web/admin/ips.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

File is not properly formatted (gofmt)
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"
sortType = "ip"

Check failure on line 91 in routers/web/admin/ips.go

View workflow job for this annotation

GitHub Actions / lint-backend

ineffectual assignment to sortType (ineffassign)

Check failure on line 91 in routers/web/admin/ips.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

ineffectual assignment to sortType (ineffassign)

Check failure on line 91 in routers/web/admin/ips.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

ineffectual assignment to sortType (ineffassign)
orderBy = "user_setting.setting_value ASC, user.id ASC"
}

// Get the count and user IPs for pagination
if len(keyword) == 0 {
// Simple count without keyword
count, err = db.GetEngine(ctx).
Join("INNER", "user", "user.id = user_setting.user_id").
Where("user_setting.setting_key = ?", user_model.SignupIP).
Count(new(user_model.Setting))
if err != nil {
ctx.ServerError("Count", err)
return
}

// Get the user IPs
err = db.GetEngine(ctx).
Table("user_setting").
Join("INNER", "user", "user.id = user_setting.user_id").
Where("user_setting.setting_key = ?", user_model.SignupIP).
Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip, '' as user_agent").
OrderBy(orderBy).
Limit(setting.UI.Admin.UserPagingNum, (page-1)*setting.UI.Admin.UserPagingNum).
Find(&userIPs)
if err != nil {
ctx.ServerError("Find", err)
return
}
} else {
// Count with keyword filter
count, err = db.GetEngine(ctx).
Join("INNER", "user", "user.id = user_setting.user_id").
Where("user_setting.setting_key = ?", user_model.SignupIP).
And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)",
"%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%").
Count(new(user_model.Setting))
if err != nil {
ctx.ServerError("Count", err)
return
}

// Get the user IPs with keyword filter
err = db.GetEngine(ctx).
Table("user_setting").
Join("INNER", "user", "user.id = user_setting.user_id").
Where("user_setting.setting_key = ?", user_model.SignupIP).
And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)",
"%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%").
Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip, '' as user_agent").
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
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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() {
Expand Down
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
Expand Up @@ -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">
Expand All @@ -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"}}
</a>
{{end}}
</div>
</details>
<details class="item toggleable-item" {{if or .PageIsAdminRepositories (and .EnablePackages .PageIsAdminPackages)}}open{{end}}>
Expand Down
8 changes: 8 additions & 0 deletions templates/admin/user/view.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
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}}
Loading