User Account Management page with search for users

This commit is contained in:
2026-02-16 22:44:59 -07:00
parent 4a7e137147
commit 5070eb0a79
6 changed files with 233 additions and 42 deletions
+1 -16
View File
@@ -218,21 +218,6 @@ type CMData struct {
Level uint16 Level uint16
} }
// fieldMap maps field names to search field indexes.
var fieldMap = map[string]int{
"name": database.SearchUserFieldName,
"descr": database.SearchUserFieldDescription,
"first": database.SearchUserFieldFirstName,
"last": database.SearchUserFieldLastName,
}
// operMap maps operator names to search operator indices.
var operMap = map[string]int{
"st": database.SearchUserOperPrefix,
"in": database.SearchUserOperSubstring,
"re": database.SearchUserOperRegex,
}
/* ConferenceMembers shows the conference members and allows their access levels to be adjusted. /* ConferenceMembers shows the conference members and allows their access levels to be adjusted.
* Parameters: * Parameters:
* ctxt - The AmContext for the request. * ctxt - The AmContext for the request.
@@ -350,7 +335,7 @@ func ConferenceMembers(ctxt ui.AmContext) (string, any) {
mr[i].Level = members[i].Level mr[i].Level = members[i].Level
} }
case "comm": case "comm":
ulist, t, err := database.AmSearchCommunityMembers(ctxt.Ctx(), comm, fieldMap[field], operMap[oper], term, offset, int(maxPage)) ulist, t, err := database.AmSearchCommunityMembers(ctxt.Ctx(), comm, SearchUserFieldMap[field], SearchUserOperMap[oper], term, offset, int(maxPage))
if err != nil { if err != nil {
return "error", err return "error", err
} }
+18 -18
View File
@@ -17,6 +17,21 @@ import (
"git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/ui"
) )
// SearchUserFieldMap maps field names to search field indexes.
var SearchUserFieldMap = map[string]int{
"name": database.SearchUserFieldName,
"descr": database.SearchUserFieldDescription,
"first": database.SearchUserFieldFirstName,
"last": database.SearchUserFieldLastName,
}
// SearchUserOperMap maps operator names to search operator indices.
var SearchUserOperMap = map[string]int{
"st": database.SearchUserOperPrefix,
"in": database.SearchUserOperSubstring,
"re": database.SearchUserOperRegex,
}
// loadCategoryInformation loads the current category information to the context. // loadCategoryInformation loads the current category information to the context.
func loadCategoryInformation(ctxt ui.AmContext, offset int) error { func loadCategoryInformation(ctxt ui.AmContext, offset int) error {
if ctxt.GlobalFlags().Get(database.GlobalFlagNoCategories) { if ctxt.GlobalFlags().Get(database.GlobalFlagNoCategories) {
@@ -217,27 +232,12 @@ func Find(ctxt ui.AmContext) (string, any) {
} }
case "USR": case "USR":
var iField, iOper int var iField, iOper int
switch field { var ok bool
case "name": if iField, ok = SearchUserFieldMap[field]; !ok {
iField = database.SearchUserFieldName
case "descr":
iField = database.SearchUserFieldDescription
case "first":
iField = database.SearchUserFieldFirstName
case "last":
iField = database.SearchUserFieldLastName
default:
ctxt.VarMap().Set("errorMessage", "invalid parameter to find") ctxt.VarMap().Set("errorMessage", "invalid parameter to find")
return "framed", "find.jet" return "framed", "find.jet"
} }
switch oper { if iOper, ok = SearchUserOperMap[oper]; !ok {
case "st":
iOper = database.SearchUserOperPrefix
case "in":
iOper = database.SearchUserOperSubstring
case "re":
iOper = database.SearchUserOperRegex
default:
ctxt.VarMap().Set("errorMessage", "invalid parameter to find") ctxt.VarMap().Set("errorMessage", "invalid parameter to find")
return "framed", "find.jet" return "framed", "find.jet"
} }
+6 -7
View File
@@ -30,6 +30,8 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var GetAndPost = []string{http.MethodGet, http.MethodPost}
// setupEcho creates, configures, and returns a new Echo instance. // setupEcho creates, configures, and returns a new Echo instance.
func setupEcho() *echo.Echo { func setupEcho() *echo.Echo {
e := echo.New() e := echo.New()
@@ -46,9 +48,7 @@ func setupEcho() *echo.Echo {
e.Use(LogrusMiddleware, ui.SessionStoreInjector, ui.ContextCreator) e.Use(LogrusMiddleware, ui.SessionStoreInjector, ui.ContextCreator)
e.Use(ui.IPBanTest, ui.CookieLoginTest) e.Use(ui.IPBanTest, ui.CookieLoginTest)
fn := ui.AmWrap(NotImplPage) e.Match(GetAndPost, "/TODO/*", ui.AmWrap(NotImplPage))
e.GET("/TODO/*", fn)
e.POST("/TODO/*", fn)
e.GET("/img/*", ui.AmServeImage) e.GET("/img/*", ui.AmServeImage)
e.GET("/static/*", ui.AmStaticFileHandler()) e.GET("/static/*", ui.AmStaticFileHandler())
e.GET("/go/:postlink", ui.AmWrap(JumpToShortcut)) e.GET("/go/:postlink", ui.AmWrap(JumpToShortcut))
@@ -78,6 +78,7 @@ func setupEcho() *echo.Echo {
e.GET("/sysadmin", ui.AmWrap(SysAdminMenu)) e.GET("/sysadmin", ui.AmWrap(SysAdminMenu))
e.GET("/sysadmin/globals", ui.AmWrap(GlobalPropertiesForm)) e.GET("/sysadmin/globals", ui.AmWrap(GlobalPropertiesForm))
e.POST("/sysadmin/globals", ui.AmWrap(GlobalPropertiesSet)) e.POST("/sysadmin/globals", ui.AmWrap(GlobalPropertiesSet))
e.Match(GetAndPost, "/sysadmin/users", ui.AmWrap(UserManagementSearch))
e.GET("/create_comm", ui.AmWrap(CreateCommunityForm)) e.GET("/create_comm", ui.AmWrap(CreateCommunityForm))
e.POST("/create_comm", ui.AmWrap(CreateCommunity)) e.POST("/create_comm", ui.AmWrap(CreateCommunity))
e.GET("/manage_comm", ui.AmWrap(ManageCommunities)) e.GET("/manage_comm", ui.AmWrap(ManageCommunities))
@@ -87,7 +88,7 @@ func setupEcho() *echo.Echo {
// community group // community group
commGroup := e.Group("/comm/:cid", ui.SetCommunity) commGroup := e.Group("/comm/:cid", ui.SetCommunity)
fn = ui.AmWrap(ShowCommunity) fn := ui.AmWrap(ShowCommunity)
commGroup.GET("", fn) commGroup.GET("", fn)
commGroup.GET("/profile", fn) commGroup.GET("/profile", fn)
commGroup.GET("/join", ui.AmWrap(JoinCommunity)) commGroup.GET("/join", ui.AmWrap(JoinCommunity))
@@ -123,9 +124,7 @@ func setupEcho() *echo.Echo {
confGroup.POST("/edit", ui.AmWrap(EditConference)) confGroup.POST("/edit", ui.AmWrap(EditConference))
confGroup.GET("/aliases", ui.AmWrap(ConferenceAliasForm)) confGroup.GET("/aliases", ui.AmWrap(ConferenceAliasForm))
confGroup.POST("/aliases", ui.AmWrap(ConferenceAliasAdd)) confGroup.POST("/aliases", ui.AmWrap(ConferenceAliasAdd))
fn = ui.AmWrap(ConferenceMembers) confGroup.Match(GetAndPost, "/members", ui.AmWrap(ConferenceMembers))
confGroup.GET("/members", fn)
confGroup.POST("/members", fn)
confGroup.GET("/custom", ui.AmWrap(ConfCustomForm)) confGroup.GET("/custom", ui.AmWrap(ConfCustomForm))
confGroup.POST("/custom", ui.AmWrap(ConfCustom)) confGroup.POST("/custom", ui.AmWrap(ConfCustom))
confGroup.GET("/activity", ui.AmWrap(ConfReports)) confGroup.GET("/activity", ui.AmWrap(ConfReports))
+67
View File
@@ -11,6 +11,9 @@
package main package main
import ( import (
"fmt"
"strconv"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/ui"
) )
@@ -138,3 +141,67 @@ func GlobalPropertiesSet(ctxt ui.AmContext) (string, any) {
} }
return "redirect", "/sysadmin" return "redirect", "/sysadmin"
} }
/* UserManagementSearch displays the user management page and performs searches.
* Parameters:
* ctxt - The AmContext for the request.
* Returns:
* Command string dictating what to be rendered.
* Data as a parameter for the command string.
*/
func UserManagementSearch(ctxt ui.AmContext) (string, any) {
if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) {
return "error", ENOACCESS
}
field := "name"
oper := "st"
term := ""
ofs := 0
doSearch := false
listMax := int(ctxt.Globals().MaxSearchPage)
if ctxt.Verb() == "POST" {
field = ctxt.FormField("field")
oper = ctxt.FormField("oper")
term = ctxt.FormField("term")
ofsStr := ctxt.FormField("ofs")
if n, err := strconv.Atoi(ofsStr); err == nil {
ofs = n
}
if ctxt.FormFieldIsSet("prev") {
ofs = max(0, ofs-listMax)
} else if ctxt.FormFieldIsSet("next") {
ofs += listMax
}
doSearch = true
}
ctxt.VarMap().Set("field", field)
ctxt.VarMap().Set("oper", oper)
ctxt.VarMap().Set("term", term)
ctxt.VarMap().Set("ofs", ofs)
if doSearch {
ulist, total, err := database.AmSearchUsers(ctxt.Ctx(), SearchUserFieldMap[field], SearchUserOperMap[oper], term, ofs, listMax)
if err == nil {
resultLine := ""
if len(ulist) == 0 {
resultLine = "None found"
} else {
resultLine = fmt.Sprintf("Displaying %d-%d of %d", ofs+1, ofs+len(ulist), total)
}
ctxt.VarMap().Set("resultHeader", resultLine)
if len(ulist) > 0 {
ctxt.VarMap().Set("resultList", ulist)
if ofs > 0 {
ctxt.VarMap().Set("resultShowPrev", true)
}
if (ofs + listMax) < total {
ctxt.VarMap().Set("resultShowNext", true)
}
}
} else {
ctxt.VarMap().Set("errorMessage", err.Error())
}
}
ctxt.SetFrameTitle("User Account Management")
return "framed", "admin_users.jet"
}
+1 -1
View File
@@ -44,7 +44,7 @@ menudefs:
disabled: true disabled: true
permission: "Global.SysAdminAccess" permission: "Global.SysAdminAccess"
- text: "User Account Management" - text: "User Account Management"
link: "/TODO/sysadmin/find_user" link: "/sysadmin/users"
permission: "Global.SysAdminAccess" permission: "Global.SysAdminAccess"
- text: "System Audit Logs" - text: "System Audit Logs"
link: "/TODO/sysadmin/audit" link: "/TODO/sysadmin/audit"
+140
View File
@@ -0,0 +1,140 @@
{*
* Amsterdam Web Communities System
* Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*}
<div class="p-4">
<!-- Page Title -->
<div class="mb-6">
<h1 class="text-blue-800 text-4xl font-bold mb-2">User Account Management</h1>
<hr class="border-2 border-gray-400 w-4/5 mb-6">
</div>
<!-- Backlink -->
<div class="mb-4">
<a class="text-blue-700 hover:text-blue-900 text-sm flex items-center gap-2 w-fit" href="/sysadmin">
<span>←</span>
Return to System Administration Menu
</a>
</div>
{{ if isset(errorMessage) }}
<!-- Error Message Banner -->
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6" id="error-banner">
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-red-500 text-xl">⚠️</span>
</div>
<div class="ml-3">
<p class="text-sm font-medium" id="error-message">{{ CapitalizeString(errorMessage) }}.</p>
</div>
</div>
</div>
{{ end }}
<!-- Search Form -->
<div class="max-w-3xl mb-8">
<form method="POST" action="/sysadmin/users">
<input type="hidden" name="ofs" value="0">
<div class="bg-gray-50 p-6 rounded-lg">
<h2 class="text-xl font-bold text-black mb-4">Find Users:</h2>
<div class="space-y-4">
<!-- Field Selection -->
<div class="flex items-center gap-2 text-sm">
<span class="text-black">Display all users whose</span>
<select name="field"
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="name" {{ if field == "name" }}selected{{ end }}>user name</option>
<option value="descr"{{ if field == "descr" }}selected{{ end }}>description</option>
<option value="first"{{ if field == "first" }}selected{{ end }}>first name</option>
<option value="last"{{ if field == "last" }}selected{{ end }}>last name</option>
</select>
</div>
<!-- Search Criteria -->
<div class="flex items-center gap-2 text-sm flex-wrap">
<select name="oper"
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="st" {{ if oper == "st" }}selected{{ end }}>starts with the string</option>
<option value="in" {{ if oper == "in" }}selected{{ end }}>contains the string</option>
<option value="re" {{ if oper == "in" }}selected{{ end }}>matches the regular expression</option>
</select>
<input type="text" name="term" size="32" maxlength="255" value="{{ term }}"
placeholder="Enter search term..."
class="flex-1 min-w-64 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Search Button -->
<div>
<button type="submit" name="search"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
Search
</button>
</div>
</div>
</div>
</form>
</div>
{{ if isset(resultHeader) }}
<!-- Search results -->
<hr class="border-gray-400 mb-4">
<div class="flex justify-between items-center mb-4">
<div class="text-sm text-black"><b>Search Results</b> ({{ resultHeader }})</div>
</div>
{{ if isset(resultList) }}
<!-- Results List -->
<div class="bg-gray-50 p-6 rounded-lg">
<div class="space-y-4">
{{ range _, rx := resultList }}
<!-- User Result -->
<div class="flex items-start gap-3">
<span class="text-sm pt-0.5 flex-shrink-0">🟣</span>
<div class="flex-1">
<div class="mb-2">
<a href="/user/{{ rx.Username }}"
class="text-blue-700 hover:text-blue-900 font-bold text-base">{{ rx.Username }}</a>
</div>
<div class="text-sm text-gray-700 space-y-1">
<div>
{{ ci := UserContactInfo(rx, .) }}
{{ DisplayFullName(ci) }}, from {{ ci.Locality }}, {{ ci.Region }} {{ ci.Country }}
</div>
</div>
<div class="text-sm space-y-1">
<a href="/sysadmin/users/{{ rx.Username }}" class="text-blue-700 hover:text-blue-900 font-bold text-base">[Modify User]</a>
</div>
</div>
</div>
{{ end }}
</div>
{{ if isset(resultShowPrev) || isset(resultShowNext) }}
<div class="flex justify-center gap-4 mt-6">
<form method="POST" action="/sysadmin/users">
<input type="hidden" name="ofs" value="{{ ofs }}"/>
<input type="hidden" name="field" value="{{ field }}"/>
<input type="hidden" name="oper" value="{{ oper }}"/>
<input type="hidden" name="term" value="{{ term }}"/>
{{ if isset(resultShowPrev) }}
<button type="submit" name="prev"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
⏪ Prev
</button>
{{ end }}
{{ if isset(resultShowNext) }}
<button type="submit" name="next"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
Next ⏩
</button>
{{ end }}
</form>
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
</div>