landed community membership controls

This commit is contained in:
2026-02-23 15:19:30 -07:00
parent 4db82f63d5
commit 192c0515ab
7 changed files with 353 additions and 14 deletions
+141
View File
@@ -481,6 +481,139 @@ func CommunityCategory(ctxt ui.AmContext) (string, any) {
return "framed", "comm_category.jet"
}
func CommunityMembers(ctxt ui.AmContext) (string, any) {
comm := ctxt.CurrentCommunity()
if !comm.TestPermission("Community.Write", ctxt.EffectiveLevel()) {
return "error", ENOACCESS
}
// Set the first batch of page variables.
hostRole := database.AmRole("Community.Host")
ctxt.VarMap().Set("commName", comm.Name)
ctxt.VarMap().Set("backLink", fmt.Sprintf("/comm/%s/admin", comm.Alias))
ctxt.VarMap().Set("selfLink", fmt.Sprintf("/comm/%s/admin/members", comm.Alias))
ctxt.VarMap().Set("roleList", database.AmRoleList("Community.Userlevels"))
ctxt.VarMap().Set("hostRole", hostRole)
ctxt.SetFrameTitle(fmt.Sprintf("Membership in Community: %s", comm.Name))
// Get the search parameter values and adjust them.
mode := "comm"
field := "name"
oper := "st"
term := ""
offset := 0
if ctxt.Verb() == "POST" {
mode = ctxt.FormField("mode")
field = ctxt.FormField("field")
oper = ctxt.FormField("oper")
term = ctxt.FormField("term")
var e1 error
offset, e1 = ctxt.FormFieldInt("ofs")
if e1 != nil {
offset = 0
}
}
maxPage := ctxt.Globals().MaxSearchPage
// Adjust the offset based on the page buttons.
if ctxt.FormFieldIsSet("prev") {
offset = max(0, offset-int(maxPage))
} else if ctxt.FormFieldIsSet("next") {
offset += int(maxPage)
}
// Write the search parameters back to the page variables.
ctxt.VarMap().Set("mode", mode)
ctxt.VarMap().Set("field", field)
ctxt.VarMap().Set("oper", oper)
ctxt.VarMap().Set("term", term)
ctxt.VarMap().Set("offset", offset)
ctxt.VarMap().Set("max", maxPage)
if ctxt.FormFieldIsSet("update") {
// Parse out the list of valid UIDs.
uids := util.Map(strings.Split(ctxt.FormField("validUids"), "|"), func(in string) int32 {
rc, err := strconv.Atoi(in)
if err != nil {
return -1
}
return int32(rc)
})
for _, uid := range uids {
if uid > 0 {
// Get old and new access levels from the form.
tmp, err := ctxt.FormFieldInt(fmt.Sprintf("old_%d", uid))
if err == nil {
oldLevel := uint16(tmp)
if oldLevel == hostRole.Level() {
tmp = int(oldLevel)
} else {
tmp, err = ctxt.FormFieldInt(fmt.Sprintf("new_%d", uid))
}
if err == nil {
newLevel := uint16(tmp)
oldLock := ctxt.FormField(fmt.Sprintf("oldlock_%d", uid)) == "1"
newLock := ctxt.FormField(fmt.Sprintf("lock_%d", uid)) == "Y"
if (oldLevel != newLevel) || (oldLock != newLock) {
// Update the level for this user.
var u *database.User
u, err = database.AmGetUser(ctxt.Ctx(), uid)
if err == nil {
err = comm.SetMembership(ctxt.Ctx(), u, newLevel, newLock, ctxt.CurrentUserId(), ctxt.RemoteIP())
}
}
}
}
if err != nil {
return "error", err
}
}
}
ctxt.VarMap().Set("updated", true)
}
// Generate the result list.
total := 0
var err error
var userlist []*database.User
switch mode {
case "comm":
userlist, total, err = comm.ListMembers(ctxt.Ctx(), database.ListMembersFieldNone, database.ListMembersOperNone, "", offset, int(maxPage), false)
case "user":
userlist, total, err = database.AmSearchUsers(ctxt.Ctx(), SearchUserFieldMap[field], SearchUserOperMap[oper], term, offset, int(maxPage))
}
if err != nil {
return "error", err
}
mr := make([]CMData, len(userlist))
for i := range userlist {
mr[i].User = userlist[i]
var mbr bool
mbr, mr[i].Lock, mr[i].Level, err = comm.Membership(ctxt.Ctx(), userlist[i])
if err != nil {
return "error", err
}
if !mbr {
mr[i].Level = 0
mr[i].Lock = false
}
}
// Set the last few variables and return.
ctxt.VarMap().Set("resultList", mr)
ctxt.VarMap().Set("total", total)
ctxt.VarMap().Set("validUids", strings.Join(util.Map(mr, func(cd CMData) string {
return fmt.Sprintf("%d", cd.User.Uid)
}), "|"))
if offset > 0 {
ctxt.VarMap().Set("showPrev", true)
}
if (offset + len(mr)) < total {
ctxt.VarMap().Set("showNext", true)
}
return "framed", "comm_members.jet"
}
/* CommunityEmailForm displays the form for sending mass mail to the community.
* Parameters:
* ctxt - The AmContext for the request.
@@ -503,6 +636,14 @@ func CommunityEmailForm(ctxt ui.AmContext) (string, any) {
return "framed", "comm_email.jet"
}
/* CommunityEmail sends mass mail to the community.
* Parameters:
* ctxt - The AmContext for the request.
* Returns:
* Command string dictating what to be rendered.
* Data as a parameter for the command string.
* Standard Go error status.
*/
func CommunityEmail(ctxt ui.AmContext) (string, any) {
comm := ctxt.CurrentCommunity()
if !comm.TestPermission("Community.MassMail", ctxt.EffectiveLevel()) {
+2 -1
View File
@@ -214,10 +214,11 @@ func ConferenceAliasAdd(ctxt ui.AmContext) (string, any) {
return "framed", "conf_aliases.jet"
}
// CMData is the result data passed to the conference members page.
// CMData is the result data passed to the community or conference members page.
type CMData struct {
User *database.User
Level uint16
Lock bool
}
/* ConferenceMembers shows the conference members and allows their access levels to be adjusted.
+43 -11
View File
@@ -1,6 +1,6 @@
/*
* Amsterdam Web Communities System
* Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
* 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
@@ -15,6 +15,7 @@ import (
"strconv"
"strings"
"github.com/labstack/gommon/log"
"gopkg.in/yaml.v3"
)
@@ -239,7 +240,11 @@ func (r *CfgRoleList) FindForLevel(level uint16) Role {
* The specified role.
*/
func AmRole(id string) Role {
return securityRoot.roleMap[id]
rc, ok := securityRoot.roleMap[id]
if !ok {
log.Errorf("AmRole('%s') - role not found!", id)
}
return rc
}
/* AmDefaultRole returns a Role given a default ID.
@@ -249,7 +254,12 @@ func AmRole(id string) Role {
* The specified role.
*/
func AmDefaultRole(id string) Role {
return securityRoot.defaultsMap[id].roleptr
dr, ok := securityRoot.defaultsMap[id]
if !ok {
log.Errorf("AmDefaultRole('%s') - default role not found!", id)
return nil
}
return dr.roleptr
}
/* AmRoleList returns a RoleList given a list ID.
@@ -259,7 +269,11 @@ func AmDefaultRole(id string) Role {
* The specified role list.
*/
func AmRoleList(id string) RoleList {
return securityRoot.listsMap[id]
rc, ok := securityRoot.listsMap[id]
if !ok {
log.Errorf("AmRoleList('%s') - role list not found!", id)
}
return rc
}
/* AmTestPermission tests a specified access level to see if it satisfies the given permission.
@@ -270,12 +284,22 @@ func AmRoleList(id string) RoleList {
* true if the permission test is satisfied, false if not.
*/
func AmTestPermission(id string, level uint16) bool {
return securityRoot.permsMap[id].level <= level
perm, ok := securityRoot.permsMap[id]
if !ok {
log.Errorf("AmTestPermission('%s') - permission not found!", id)
return false
}
return perm.level <= level
}
// AmPermissionLevel returns a level value for a permission.
func AmPermissionLevel(id string) uint16 {
return securityRoot.permsMap[id].level
perm, ok := securityRoot.permsMap[id]
if !ok {
log.Errorf("AmPermissionLevel('%s') - permission not found!", id)
return 0
}
return perm.level
}
/* AmCombinePermissionRole combines a permission and a role into a single permission level.
@@ -286,10 +310,18 @@ func AmPermissionLevel(id string) uint16 {
* The combined permission level.
*/
func AmCombinePermissionRole(perm string, role string) uint16 {
p1 := securityRoot.permsMap[perm].level
p2 := securityRoot.roleMap[role].level
if p1 > p2 {
return p1
pperm, ok := securityRoot.permsMap[perm]
if !ok {
log.Errorf("AmCombinePermissionRole('%s', '%s') - permission not found!", perm, role)
return 0
}
return p2
prole, ok := securityRoot.roleMap[role]
if !ok {
log.Errorf("AmCombinePermissionRole('%s', '%s') - role not found!", perm, role)
return 0
}
if pperm.level > prole.level {
return pperm.level
}
return prole.level
}
+1 -1
View File
@@ -25,7 +25,7 @@ _(italicized items can be deferred)_
- ~~Conferences List honor "hide in list" flag~~
- Community Admin Menu:
- ~~Set Community Category~~
- Membership Control
- ~~Membership Control~~
- ~~E-Mail to All Members~~
- ~~Display Audit Records~~
- Delete Community
+5
View File
@@ -124,6 +124,7 @@ func setupEcho() *echo.Echo {
adminGroup.POST("/logo", ui.AmWrap(EditCommunityLogo))
adminGroup.Match(GetAndPost, "/audit", ui.AmWrap(CommunityAudit))
adminGroup.GET("/category", ui.AmWrap(CommunityCategory))
adminGroup.Match(GetAndPost, "/members", ui.AmWrap(CommunityMembers))
adminGroup.GET("/massmail", ui.AmWrap(CommunityEmailForm))
adminGroup.POST("/massmail", ui.AmWrap(CommunityEmail))
@@ -189,6 +190,7 @@ var ampool *util.WorkerPool
// main is Ye Olde Main Function.
func main() {
start := time.Now()
// Configure the system.
config.SetupConfig()
closer, err := database.SetupDb()
@@ -230,6 +232,9 @@ func main() {
database.AmStoreAudit(database.AmNewAudit(database.AuditShutdown, 0, myIP.String()))
}()
stime := time.Since(start)
log.Infof("Amsterdam startup sequence completed in %v", stime)
// Start server
go func() {
if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed {
+1 -1
View File
@@ -72,7 +72,7 @@ menudefs:
permission: "Community.Write"
disabled: true
- text: "Membership Control"
link: "/TODO/comm/[CID]/admin/members"
link: "/comm/[CID]/admin/members"
permission: "Community.ShowAdmin"
- text: "E-Mail to All Members"
link: "/comm/[CID]/admin/massmail"
+160
View File
@@ -0,0 +1,160 @@
{*
* 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">
<div class="flex items-baseline gap-2">
<h1 class="text-blue-800 text-4xl font-bold">Set Community Membership:</h1>
<span class="text-blue-800 text-2xl">{{ commName }}</span>
</div>
<hr class="border-2 border-gray-400 w-4/5 mt-2 mb-6">
</div>
<!-- Upper Links -->
<div class="mb-4 flex items-baseline gap-2">
<a class="text-blue-700 hover:text-blue-900 text-sm flex items-center gap-2 w-fit" href="{{ selfLink }}">
<span>🔃</span>
Redisplay Member List
</a>
|
<a class="text-blue-700 hover:text-blue-900 text-sm flex items-center gap-2 w-fit" href="{{ backLink }}">
<span>←</span>
Return to Community Administration Menu
</a>
</div>
<!-- Member Find Form -->
<div class="max-w-3xl mb-8">
<form method="POST" action="{{ selfLink }}">
<input type="hidden" name="mode" value="user">
<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">
<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 == "re" }}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>
<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>
<hr class="border-gray-400 mb-4">
<div class="max-w-3xl flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-black mb-4">
{{ if mode == "comm" }}
Community Members:
{{ else if mode == "user" }}
Search Results:
{{ end }}
</h2>
{{ if total > 0 }}
<div class="text-sm text-black font-bold">(Displaying {{ offset + 1 }}-{{ offset + len(resultList) }} of {{ total }})</div>
{{ else }}
<div class="text-sm text-black font-bold">(None found)</div>
{{ end }}
{{ if isset(showPrev) || isset(showNext) }}
<div class="flex justify-center gap-4 mt-6">
<form method="POST" action="{{ selfLink }}">
<input type="hidden" name="mode" value="{{ mode }}">
<input type="hidden" name="field" value="{{ field }}">
<input type="hidden" name="oper" value="{{ oper }}">
<input type="hidden" name="term" value="{{ term }}">
<input type="hidden" name="ofs" value="{{ offset }}">
{{ if isset(showPrev) }}
<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(showNext) }}
<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>
<form method="POST" action="{{ selfLink }}">
<input type="hidden" name="mode" value="{{ mode }}">
<input type="hidden" name="field" value="{{ field }}">
<input type="hidden" name="oper" value="{{ oper }}">
<input type="hidden" name="term" value="{{ term }}">
<input type="hidden" name="ofs" value="{{ offset }}">
<input type="hidden" name="validUids" value="{{ validUids }}">
<div class="max-w-2xl bg-gray-50 p-6 rounded-lg">
<div class="space-y-4">
{{ range _, rx := resultList }}
{{ isHost := (rx.Level == hostRole.Level()) }}
<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.User.Username }}"
class="text-blue-700 hover:text-blue-900 font-bold text-base">{{ rx.User.Username }}</a>
</div>
</div>
<input type="hidden" name="old_{{ rx.User.Uid }}" value="{{ rx.Level }}">
<select id="new_{{ rx.User.Uid }}" name="new_{{ rx.User.Uid }}" {{ if isHost }}disabled{{ end }}
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{{ if isHost }}
<option value="{{ hostRole.Level() }}" selected>{{ hostRole.Name() }}</option>
{{ else }}
{{ range _, role := roleList.Roles() }}
<option value="{{ role.Level() }}" {{ if role.Level() == rx.Level }}selected{{ end }}>{{ role.Name() }}</option>
{{ end }}
{{ end }}
</select>
<input type="hidden" name="oldlock_{{ rx.User.Uid }}" value="{{ iif(rx.Lock,1,0) }}">
<span class="pt-0.5 flex-shrink-0">🔒</span>
<input type="checkbox" name="lock_{{ rx.User.Uid }}" value="Y" {{ if rx.Lock }}checked{{ end }} {{ if isHost }}disabled{{ end }}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" />
</div>
{{ end }}
<div class="flex items-start gap-3">
<button type="submit" name="update"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">Update</button>
{{ if isset(updated) }}
<span class="text-lg text-green font-bold">✅ Updated!</span>
{{ end }}
</div>
</div>
</div>
</form>
</div>