Files
sm-user-plugin/backend_links_admin.go
Jakub Zych 6f1cb1a774 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
2026-10-23 12:14:21 +02:00

129 lines
4.8 KiB
Go

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
}