landed community membership controls
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user