feat(14.1-01): add OAuth identities table and empty-list Index

- Model golem15_user_oauth_identities with Encrypted tokens and empty Fillable
- gormigrate 202610050001 with both unique indexes, cascade FK, and jsonb profile_data
- OAuthIdentitiesIndex returns {"data":[]} for a signed-in caller with no rows
This commit is contained in:
Jakub Zych
2026-10-05 21:09:28 +02:00
parent 2b04cda3ff
commit 63ac504c42
4 changed files with 172 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package controllers
import (
"net/http"
"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)})
}
}
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
}

32
models/oauth_identity.go Normal file
View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// OAuthIdentity is one linked social-login provider for a user.
type OAuthIdentity struct {
ID uint `gorm:"column:id;primaryKey"`
UserID uint `gorm:"column:user_id"`
Provider string `gorm:"column:provider"`
ProviderID string `gorm:"column:provider_id"`
AccessToken lagoon.Encrypted `gorm:"column:access_token" json:"-"`
RefreshToken lagoon.Encrypted `gorm:"column:refresh_token" json:"-"`
TokenExpiresAt *time.Time `gorm:"column:token_expires_at"`
ProfileData lagoon.Jsonable[map[string]any] `gorm:"column:profile_data"`
LinkedAt *time.Time `gorm:"column:linked_at"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (OAuthIdentity) TableName() string { return "golem15_user_oauth_identities" }
func (OAuthIdentity) Fillable() []string { return []string{} }
func (OAuthIdentity) Hidden() []string {
return []string{"access_token", "refresh_token", "profile_data"}
}
func init() { Register(OAuthIdentity{}) }

37
oauth_identities_test.go Normal file
View File

@@ -0,0 +1,37 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
func TestOAuthIdentitiesIndexEmptyList(t *testing.T) {
app, db := sessionApp(t, "0s")
user := insertUser(t, db, "oauth-empty@example.test", "correct-horse")
req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil)
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}))
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"data":[]}` {
t.Fatalf("body = %s", rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("top-level keys = %#v", body)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") {
t.Fatalf("Cache-Control = %q", cc)
}
}

View File

@@ -0,0 +1,36 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var oauthIdentityMigrations = []*gormigrate.Migration{
{
ID: "202610050001_create_oauth_identities",
Migrate: func(tx *gorm.DB) error {
return execStmts(tx, []string{
`CREATE TABLE golem15_user_oauth_identities (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
access_token TEXT NULL,
refresh_token TEXT NULL,
token_expires_at TIMESTAMPTZ NULL,
profile_data jsonb NULL,
linked_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE UNIQUE INDEX oauth_identities_user_provider_unique ON golem15_user_oauth_identities (user_id, provider)`,
`CREATE UNIQUE INDEX oauth_identities_provider_identity_unique ON golem15_user_oauth_identities (provider, provider_id)`,
})
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS golem15_user_oauth_identities").Error
},
},
}
func init() { Register(oauthIdentityMigrations...) }