- 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)
143 lines
4.7 KiB
Go
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
|
|
}
|