Files
sm-user-plugin/account_contributions.go
Jakub Zych 7ac80c9aea feat: verified frontend account link for administrators
- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
  golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
  JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
  published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
  deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
2026-10-23 12:03:26 +02:00

143 lines
4.7 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 {
if p.app != nil {
if links, ok := p.app.Lookup[*classes.BackendLinks](); ok && links != nil {
return links
}
}
return &classes.BackendLinks{App: 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) + /link-admin?code=<code>.
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) + "/link-admin?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
}