Files
sm-user-plugin/account_contributions.go
Jakub Zych 970719ce75 fix: access-revoked event on ban and password changes, banned users lose their link, token-specific MCP revoke
- AccessRevokedEvent and FireAccessRevoked: fired in the write's transaction
  by the ban action (newly banned users only), the password reset, an
  administrator setting a password and the self-service password change;
  a revoked subject event per user after commit
- LinkedBackendUser answers no administrator for a banned user
- FireSubjectEvent / FireSubjectEventAfterCommit carry TokenID or ClientID;
  DELETE /tokens/{id} ends only that token's MCP sessions
- the MCP personal-token path stamps last_used_ip from towel.ClientIP
- BackendLinksFor replaces the duplicate lookups; requestUsable uses
  IsUsableAt
2026-10-23 16:47:56 +02:00

141 lines
4.8 KiB
Go

package user
import (
"context"
"errors"
"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"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
var _ pact.HasAccountContributions = (*Plugin)(nil)
// backendLinkLang prefixes the phrase keys of the account link.
const backendLinkLang = "golem15.user::lang.backend_link."
// AccountContributions adds the "Frontend account" section to the admin's
// My account page (D-04): its status names the linked frontend account,
// "link" starts a link request and answers the site URL the administrator
// opens to sign in there as the frontend user and confirm (D-05; the link is
// written only by that confirmation), and "unlink" removes the link.
func (p *Plugin) AccountContributions() []pact.AccountContribution {
return []pact.AccountContribution{{
Name: "frontend_account",
Label: backendLinkLang + "section",
Description: backendLinkLang + "section_comment",
Status: p.frontendAccountStatus,
Actions: []pact.AccountAction{
{
Name: "link",
Label: backendLinkLang + "link",
Applies: func(ctx context.Context, adminID uint) (bool, error) {
link, err := p.backendLinks().LinkFor(ctx, adminID)
return link == nil, err
},
Run: p.startFrontendLink,
},
{
Name: "unlink",
Label: backendLinkLang + "unlink",
Confirm: backendLinkLang + "unlink_confirm",
Danger: true,
Applies: func(ctx context.Context, adminID uint) (bool, error) {
link, err := p.backendLinks().LinkFor(ctx, adminID)
return link != nil, err
},
Run: p.unlinkOwnFrontendAccount,
},
},
}}
}
// backendLinks returns the published link service, or one over the app.
func (p *Plugin) backendLinks() *classes.BackendLinks {
return classes.BackendLinksFor(p.app)
}
// frontendAccountStatus answers "Linked to <email>" or "Not linked" in the
// request locale for the signed-in administrator.
func (p *Plugin) frontendAccountStatus(ctx context.Context, adminID uint) (string, error) {
link, err := p.backendLinks().LinkFor(ctx, adminID)
if err != nil {
return "", err
}
if link == nil {
return backendLinkLang + "status_unlinked", nil
}
email, err := p.frontendEmail(ctx, link.UserID)
if err != nil {
return "", err
}
const key = backendLinkLang + "status_linked"
if p.app != nil {
if tr, ok := p.app.Lookup[*phrasebook.Translator](); ok && tr != nil {
if text := tr.Get(ctx, key, map[string]string{"email": email}); text != "" && text != key {
return text, nil
}
}
}
return "Linked to " + email, nil
}
// frontendEmail reads the email of a frontend user, deactivated or not.
func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error) {
var db *gorm.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx.Session(&gorm.Session{NewDB: true})
} else if p.app != nil {
db, _ = p.app.Lookup[*gorm.DB]()
}
if db == nil {
return "", errors.New("golem15.user: gorm handle is missing")
}
var emails []string
if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", userID).Pluck("email", &emails).Error; err != nil {
return "", err
}
if len(emails) == 0 {
return "", nil
}
return emails[0], nil
}
// startFrontendLink creates a link request for the signed-in administrator
// and answers the site page that confirms it: golem15.user.frontend_url
// (else app.url), then golem15.user.backend_links.page_path (default
// /link-admin), then the code in the URL fragment. Browsers never send a
// fragment to a server, so the code stays out of access logs; the page reads
// it in the browser and posts it to /_user/api/v1/backend-link/show.
func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID)
if err != nil {
return pact.AccountActionResult{}, err
}
return pact.AccountActionResult{
Message: backendLinkLang + "link_started",
URL: classes.FrontendURL(p.app) + classes.BackendLinkPagePath(p.app) + "#code=" + plain,
}, nil
}
// unlinkOwnFrontendAccount removes the signed-in administrator's link in
// the action's transaction.
func (p *Plugin) unlinkOwnFrontendAccount(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AccountActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
}
links := p.backendLinks()
link, err := links.LinkFor(ctx, in.AdminID)
if err != nil {
return pact.AccountActionResult{}, err
}
if err := links.RemoveLink(ctx, tx, link); err != nil {
return pact.AccountActionResult{}, err
}
return pact.AccountActionResult{Message: backendLinkLang + "unlinked"}, nil
}