Files
summercms/modules/lagoon/backend_admin_migrations.go
Jakub Zych 143eca5a1f feat(cabana): add the My account page with subtitle and password change
- lagoon: 202610090001_backend_users_metadata adds WinterCMS's nullable
  jsonable backend_users.metadata column (TEXT); rollback drops only it
- cabana.BackendUser.Metadata and Subtitle read metadata.subtitle leniently
  (NULL, blank, null, [], invalid JSON and non-strings read as empty)
- GET /auth/me returns subtitle; PUT /account edits the signed-in admin's
  own names, email and subtitle, and the password with current_password;
  any other key is a 422 and nothing is written
- email: required, valid, 6-255 chars, unique against other admins' email
  and login ignoring case (soft-deleted included); a unique-index race is 422
- a password change sets tokens_valid_after, revokes the presented token and
  gives a cookie session a fresh cookie
- swag annotation, regenerated admin.json and schema.d.ts; route inventories,
  CSRF walk (27) and OpenAPI conformance learn the route
- SPA: My account item in the user menu opens /account (AccountView), which
  saves through PUT /account and updates the top bar via setCurrentUser
- backend::lang.myaccount strings (en, pl), rebuilt boardwalk dist
- cabana and lagoon READMEs, users-and-permissions and admin-spa docs
2026-10-09 17:38:04 +02:00

134 lines
4.9 KiB
Go

package lagoon
import (
"fmt"
"strings"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// BackendJWTBlacklistTable is the framework-owned jti table for admin tokens.
// It is distinct from the frontend jwt_blacklist table.
const BackendJWTBlacklistTable = "backend_jwt_blacklist"
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
// the developer and publisher system roles, makes backend user emails
// unique case-insensitively and adds WinterCMS's nullable jsonable metadata
// column to backend_users (the admin subtitle lives there). History is isolated under the summercms.cabana
// plugin id. DDL is re-runnable so the system-role seed stays idempotent if
// the history row is removed.
var BackendAdminMigrations = []*gormigrate.Migration{
{
ID: "202609240001_backend_admin_identity",
Migrate: func(tx *gorm.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS backend_user_roles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
code TEXT,
description TEXT,
permissions TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS backend_users (
id SERIAL PRIMARY KEY,
first_name TEXT,
last_name TEXT,
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
activation_code TEXT,
persist_code TEXT,
reset_password_code TEXT,
permissions TEXT,
is_activated BOOLEAN NOT NULL DEFAULT FALSE,
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
role_id INTEGER REFERENCES backend_user_roles(id),
activated_at TIMESTAMPTZ,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
tokens_valid_after TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS backend_jwt_blacklist (
jti TEXT PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS backend_users_role_id_index ON backend_users (role_id)`,
`CREATE INDEX IF NOT EXISTS backend_users_deleted_at_index ON backend_users (deleted_at)`,
`CREATE INDEX IF NOT EXISTS backend_users_activation_code_index ON backend_users (activation_code)`,
`CREATE INDEX IF NOT EXISTS backend_users_reset_password_code_index ON backend_users (reset_password_code)`,
`CREATE INDEX IF NOT EXISTS backend_user_roles_code_index ON backend_user_roles (code)`,
`INSERT INTO backend_user_roles (name, code, description, permissions, is_system)
VALUES
('Developer', 'developer', 'Site administrator with access to developer tools.', '{}', TRUE),
('Publisher', 'publisher', 'Site editor with access to publishing tools.', '{}', TRUE)
ON CONFLICT (name) DO NOTHING`,
}
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
for _, stmt := range []string{
`DROP TABLE IF EXISTS backend_jwt_blacklist`,
`DROP TABLE IF EXISTS backend_users`,
`DROP TABLE IF EXISTS backend_user_roles`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
},
{
// Emails are matched case-insensitively at login and by admin:create,
// so the table enforces the same rule. Rows copied from Winter may
// hold two emails that differ only in case; the migration refuses to
// run and names them instead of picking an account to drop.
ID: "202610010001_backend_users_email_ci_unique",
Migrate: func(tx *gorm.DB) error {
var dupes []struct {
Email string
Logins string
}
if err := tx.Raw(`SELECT lower(email) AS email, string_agg(login, ', ' ORDER BY id) AS logins
FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&dupes).Error; err != nil {
return err
}
if len(dupes) > 0 {
parts := make([]string, len(dupes))
for i, d := range dupes {
parts[i] = fmt.Sprintf("%s (logins: %s)", d.Email, d.Logins)
}
return fmt.Errorf("lagoon: backend_users has emails that differ only in case: %s; change or remove the duplicates, then run migrate again", strings.Join(parts, "; "))
}
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS backend_users_email_lower_unique ON backend_users (lower(email))`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP INDEX IF EXISTS backend_users_email_lower_unique`).Error
},
},
{
// WinterCMS 2023_02_16_000012_Db_Backend_Add_User_Metadata: a
// nullable jsonable column. It is TEXT, not JSONB, so a copied value
// such as [] or a blank string loads unchanged.
ID: "202610090001_backend_users_metadata",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
},
},
}