package controllers import ( "errors" "net/http" "slices" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) // OAuthIdentitiesOptions configures host-mounted list/unlink handlers. // Providers defaults to google, facebook, github when nil or empty. // WriteNotFound is injected by the host so 404 bodies match that app. type OAuthIdentitiesOptions struct { Providers []string WriteNotFound func(http.ResponseWriter, *http.Request) } // OAuthIdentitiesIndex lists the caller's linked OAuth providers. func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { db, user, ok := oauthIdentityDeps(w, r, app) if !ok { return } var rows []models.OAuthIdentity if err := db.WithContext(r.Context()). Where("user_id = ?", user.ID). Order("provider"). Find(&rows).Error; err != nil { writeOpaque500(w) return } data := make([]map[string]any, 0, len(rows)) for i := range rows { item := map[string]any{ "provider": rows[i].Provider, "linked_at": nil, } if rows[i].LinkedAt != nil { item["linked_at"] = &wire.Time{Time: rows[i].LinkedAt.UTC()} } data = append(data, item) } writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)}) } } // OAuthIdentitiesDestroy unlinks one provider for the caller. func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc { providers := opts.Providers if len(providers) == 0 { providers = []string{"google", "facebook", "github"} } return func(w http.ResponseWriter, r *http.Request) { db, user, ok := oauthIdentityDeps(w, r, app) if !ok { return } provider := r.PathValue("provider") if !slices.Contains(providers, provider) { writeOAuthIdentityNotFound(w, r, opts) return } var row models.OAuthIdentity err := db.WithContext(r.Context()). Where("user_id = ? AND provider = ?", user.ID, provider). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { writeOAuthIdentityNotFound(w, r, opts) return } if err != nil { writeOpaque500(w) return } var n int64 if err := db.WithContext(r.Context()). Model(&models.OAuthIdentity{}). Where("user_id = ?", user.ID). Count(&n).Error; err != nil { writeOpaque500(w) return } if n == 1 { writeJSON(w, http.StatusConflict, map[string]any{ "error": oauthLastMethodBlocked(r, app), }) return } if err := db.WithContext(r.Context()).Delete(&row).Error; err != nil { writeOpaque500(w) return } w.WriteHeader(http.StatusNoContent) } } func writeOAuthIdentityNotFound(w http.ResponseWriter, r *http.Request, opts OAuthIdentitiesOptions) { if opts.WriteNotFound != nil { opts.WriteNotFound(w, r) return } writeOpaque500(w) } func oauthLastMethodBlocked(r *http.Request, app *backpack.App) string { const fallback = "This is the only remaining way to sign in. Link another method before disconnecting this one." tr := appTranslator(app) if tr == nil { return fallback } s := tr.Get(r.Context(), "golem15.user::lang.oauth.last_method_blocked", nil) if s == "" || s == "golem15.user::lang.oauth.last_method_blocked" { return fallback } return s } func oauthIdentityDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) { if app == nil { writeOpaque500(w) return nil, nil, false } db, ok := app.Lookup[*gorm.DB]() if !ok || db == nil { writeOpaque500(w) return nil, nil, false } user, ok := bouncer.User(r.Context()) if !ok || user == nil || user.ID == 0 { writeUnauthorized(w) return nil, nil, false } return db, user, true }