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:
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
151
backend_link_admin_smoke_test.go
Normal file
151
backend_link_admin_smoke_test.go
Normal 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
128
backend_links_admin.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user