feat: link and unlink an administrator's frontend account on the Administrators screen

- golem15.users.manage_backend_links (no default role), en and pl labels
- record actions on summercms.cabana.users: link_frontend_account asks for
  the frontend user's email (exact, case-insensitive, active users only),
  records linked_by and fires the link events; unlink_frontend_account
  (danger) removes the link
- a taken or unknown account answers 422 on the prompt
This commit is contained in:
Jakub Zych
2026-10-23 12:14:21 +02:00
parent 0f545f5c81
commit 6f1cb1a774
8 changed files with 307 additions and 9 deletions

View File

@@ -1,16 +1,22 @@
package user
import "git.golem15.com/golem15/summercms/modules/pact"
import (
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/pact"
)
const userPermissionTab = "golem15.user::lang.plugin.tab"
// Permissions is the plugin's Winter registerPermissions() catalog plus one
// code of its own. The four PHP codes default to the developer role;
// Permissions is the plugin's Winter registerPermissions() catalog plus two
// codes of its own. The four PHP codes default to the developer role;
// impersonate_user is registered so imported backend roles that name it stay
// valid, although no screen uses it. The fifth code
// (classes.PermissionManagePrivilegedGroups) has no default role: only a
// superuser, or a role that was granted it explicitly, may change the email
// or password of a privileged-group member or delete one.
// or password of a privileged-group member or delete one. The sixth
// (classes.PermissionManageBackendLinks) has no default role either: it
// links and unlinks another administrator's frontend account on the
// Administrators screen.
func (*Plugin) Permissions() []pact.Permission {
return []pact.Permission{
{
@@ -42,5 +48,10 @@ func (*Plugin) Permissions() []pact.Permission {
Tab: userPermissionTab,
Label: "golem15.user::lang.plugin.manage_privileged_groups",
},
{
Code: classes.PermissionManageBackendLinks,
Tab: userPermissionTab,
Label: "golem15.user::lang.plugin.manage_backend_links",
},
}
}

View File

@@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+
func TestAdminRegistration(t *testing.T) {
plugin := &Plugin{}
// The five permission codes, each on the plugin's tab.
// The six permission codes, each on the plugin's tab.
var codes []string
for _, permission := range plugin.Permissions() {
codes = append(codes, permission.Code)
@@ -40,14 +40,15 @@ func TestAdminRegistration(t *testing.T) {
wantCodes := []string{
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
"golem15.users.manage_backend_links",
}
if !reflect.DeepEqual(codes, wantCodes) {
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
}
// The privileged-groups permission has no default role: nobody holds it
// until it is granted on purpose.
// The privileged-groups and backend-links permissions have no default
// role: nobody holds them until they are granted on purpose.
for _, permission := range plugin.Permissions() {
if privileged := permission.Code == permManagePrivileged; privileged != (len(permission.Roles) == 0) {
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) {
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
}
}

View File

@@ -49,7 +49,7 @@ func TestAdminUsersTracer(t *testing.T) {
if want := []string{
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
classes.PermissionManagePrivilegedGroups + "@-",
classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-",
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
t.Fatalf("permissions = %v", codes)
}

View File

@@ -0,0 +1,151 @@
package user
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
const permManageBackendLinks = "golem15.users.manage_backend_links"
// administratorPath is the admin API path of one administrator on the
// Administrators screen, with an optional suffix.
func administratorPath(id uint, suffix string) string {
return adminAPI("/summercms/cabana/users/" + strconv.FormatUint(uint64(id), 10) + suffix)
}
// superAdmin is admin with the superuser flag set.
func (e *adminEnv) superAdmin(t *testing.T) string {
t.Helper()
token := e.admin(t)
if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, adminID(t, token)).Error; err != nil {
t.Fatal(err)
}
return token
}
// administratorActions returns the record actions the Administrators screen
// offers on an administrator.
func (e *adminEnv) administratorActions(t *testing.T, token string, id uint) []string {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, administratorPath(id, ""), token, nil)
var meta struct {
Actions []struct {
Name string `json:"name"`
} `json:"actions"`
}
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
t.Fatal(err)
}
names := []string{}
for _, action := range meta.Actions {
if strings.HasSuffix(action.Name, "frontend_account") {
names = append(names, action.Name)
}
}
return names
}
// TestBackendLinkAdminSmoke: on the Administrators screen an administrator
// with golem15.users.manage_backend_links links a frontend account to
// another administrator by its exact email (any letter case) and unlinks
// it; linked_by records the caller, the event fires, a taken or unknown
// account is a 422 on the prompt, and an administrator without the
// permission is neither offered the actions nor allowed to run them.
func TestBackendLinkAdminSmoke(t *testing.T) {
env := newAdminEnv(t)
var mu sync.Mutex
var linked []models.BackendLink
env.app.Events.Listen("test.backend_linked", func(_ context.Context, e *classes.BackendLinkedEvent) error {
mu.Lock()
linked = append(linked, *e.Link)
mu.Unlock()
return nil
})
stamp := adminStamp()
editor := env.seedUser(t, "editor", func(u *models.User) { u.Email = "editor-" + stamp + "@example.test" })
second := env.seedUser(t, "second", nil)
targetToken := env.admin(t)
target := adminID(t, targetToken)
super := env.superAdmin(t)
superID := adminID(t, super)
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
t.Fatalf("offered before the link = %v", got)
}
link := func(token, value string) *http.Response {
t.Helper()
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), token, map[string]any{"payload": value})
return rec.Result()
}
// Unknown email: 422 on payload with the unknown_user text.
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), super, map[string]any{"payload": "nobody-" + stamp + "@example.test"})
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "No active frontend user has this email.") {
t.Fatalf("unknown email = %d %s", rec.Code, rec.Body.String())
}
// The email matches whatever its letter case.
if res := link(super, " Editor-"+stamp+"@Example.TEST "); res.StatusCode != http.StatusOK {
t.Fatalf("set = %d", res.StatusCode)
}
var row models.BackendLink
if err := env.gdb.Where("backend_user_id = ?", target).Take(&row).Error; err != nil || row.UserID != editor.ID || row.LinkedBy == nil || *row.LinkedBy != superID {
t.Fatalf("link row = %+v %v", row, err)
}
if got, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || !found || got.ID != target {
t.Fatalf("LinkedBackendUser = %+v %v %v", got, found, err)
}
mu.Lock()
if len(linked) != 1 || linked[0].UserID != editor.ID || linked[0].BackendUserID != target {
t.Fatalf("BackendLinkedEvent saw %+v", linked)
}
mu.Unlock()
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
t.Fatalf("offered after the link = %v", got)
}
// The administrator is linked: a second set no longer applies (409),
// and the same frontend user cannot be linked to another administrator.
if res := link(super, second.Email); res.StatusCode != http.StatusConflict {
t.Fatalf("second set on a linked administrator = %d", res.StatusCode)
}
otherToken := env.admin(t)
other := adminID(t, otherToken)
rec = env.call(t, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), super, map[string]any{"payload": editor.Email})
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "This administrator or this frontend user is already linked.") {
t.Fatalf("frontend user linked twice = %d %s", rec.Code, rec.Body.String())
}
// Without the permission: not offered, and refused.
limited := env.admin(t, cabana.PermissionManageUsers)
if got := env.administratorActions(t, limited, target); len(got) != 0 {
t.Fatalf("limited administrator is offered %v", got)
}
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), limited, map[string]any{})
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), limited, map[string]any{"payload": second.Email})
manager := env.admin(t, cabana.PermissionManageUsers, permManageBackendLinks)
if got := env.administratorActions(t, manager, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
t.Fatalf("manager is offered %v", got)
}
// Unlink.
env.expect(t, http.StatusOK, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), manager, map[string]any{})
if _, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || found {
t.Fatalf("after unlink found=%v err=%v", found, err)
}
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
t.Fatalf("offered after the unlink = %v", got)
}
}

128
backend_links_admin.go Normal file
View File

@@ -0,0 +1,128 @@
package user
import (
"context"
"errors"
"strings"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
var _ pact.HasAdminActionExtensions = (*Plugin)(nil)
// AdminActionExtensions adds two record actions to the admin's
// Administrators screen (D-07, D-34), both behind
// golem15.users.manage_backend_links: link_frontend_account asks for the
// email of an active frontend user and links that user to the
// administrator shown (exact, case-insensitive match; no search and no
// list), recording who set it; unlink_frontend_account removes the
// administrator's link. cabana's outranking check runs before either.
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
return []pact.AdminActionExtension{{
Controller: cabana.AdministratorsControllerID,
RecordActions: []pact.AdminRecordAction{
{
Name: "link_frontend_account",
Label: backendLinkLang + "set",
Confirm: backendLinkLang + "set_confirm",
Permissions: []string{classes.PermissionManageBackendLinks},
Prompt: &pact.AdminActionPrompt{
Label: backendLinkLang + "prompt_email",
Comment: backendLinkLang + "prompt_comment",
MaxLength: 254,
},
Applies: func(ctx context.Context, record any) (bool, error) {
link, err := p.adminRecordLink(ctx, record)
return link == nil, err
},
Run: p.setFrontendLink,
},
{
Name: "unlink_frontend_account",
Label: backendLinkLang + "remove",
Confirm: backendLinkLang + "remove_confirm",
Danger: true,
Permissions: []string{classes.PermissionManageBackendLinks},
Applies: func(ctx context.Context, record any) (bool, error) {
link, err := p.adminRecordLink(ctx, record)
return link != nil, err
},
Run: p.removeFrontendLink,
},
},
}}
}
// adminRecordLink returns the link of the administrator record, or nil.
func (p *Plugin) adminRecordLink(ctx context.Context, record any) (*models.BackendLink, error) {
admin, ok := record.(*cabana.BackendUser)
if !ok || admin == nil {
return nil, errors.New("golem15.user: the record is not an administrator")
}
return p.backendLinks().LinkFor(ctx, admin.ID)
}
// payloadError is the 422 on the prompt's input.
func payloadError(key string) error {
return &cabana.ValidationError{Details: map[string]any{"payload": []string{backendLinkLang + key}}}
}
// setFrontendLink links the active frontend user whose email is the prompt
// value to the administrator record.
func (p *Plugin) setFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
admin, ok := in.Record.(*cabana.BackendUser)
if !ok || admin == nil {
return pact.AdminRecordActionResult{}, errors.New("golem15.user: the record is not an administrator")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminRecordActionResult{}, errors.New("golem15.user: linking needs the action's transaction")
}
email := strings.TrimSpace(in.Value)
var ids []uint
if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.User{}).
Where("lower(email) = lower(?) AND is_activated = TRUE", email).Limit(2).Pluck("id", &ids).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
if email == "" || len(ids) != 1 {
return pact.AdminRecordActionResult{}, payloadError("unknown_user")
}
var by uint
if caller, ok := bouncer.User(ctx); ok && caller != nil {
by = caller.ID
}
_, err := p.backendLinks().SetLink(ctx, tx, ids[0], admin.ID, by)
switch {
case err == nil:
case errors.Is(err, classes.ErrAlreadyLinked):
return pact.AdminRecordActionResult{}, payloadError("already_linked")
case errors.Is(err, classes.ErrFrontendUserUnavailable):
return pact.AdminRecordActionResult{}, payloadError("unknown_user")
case errors.Is(err, classes.ErrBackendUserUnavailable):
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{}
default:
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: backendLinkLang + "set_success"}, nil
}
// removeFrontendLink removes the administrator record's link.
func (p *Plugin) removeFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminRecordActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
}
link, err := p.adminRecordLink(ctx, in.Record)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
if err := p.backendLinks().RemoveLink(ctx, tx, link); err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: backendLinkLang + "remove_success"}, nil
}

View File

@@ -15,6 +15,11 @@ import (
// good.
const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups"
// PermissionManageBackendLinks is the backend permission an administrator
// needs to link a frontend account to another administrator, or to unlink
// one, on the Administrators screen. No role has it by default.
const PermissionManageBackendLinks = "golem15.users.manage_backend_links"
// privilegedGroupsKey is the config list of privileged group codes.
const privilegedGroupsKey = "golem15.user.privileged_groups"

View File

@@ -8,6 +8,7 @@ plugin:
access_settings: Manage User Settings
impersonate_user: Impersonate Users
manage_privileged_groups: Manage privileged group membership
manage_backend_links: Link frontend accounts to administrators
users:
menu_label: Users
new_user: New User

View File

@@ -8,6 +8,7 @@ plugin:
access_settings: Zarządzaj ustawieniami użytkownika
impersonate_user: Wcielaj się w użytkowników
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
manage_backend_links: Łącz konta na stronie z administratorami
users:
menu_label: Użytkownicy
new_user: Nowy użytkownik