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
This commit is contained in:
@@ -33,6 +33,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie.
|
||||
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
|
||||
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
|
||||
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
|
||||
@@ -45,7 +46,8 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
|-----------------|---------|
|
||||
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
|
||||
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
|
||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
|
||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle`). |
|
||||
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
|
||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
|
||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
||||
@@ -212,6 +214,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. |
|
||||
| `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. |
|
||||
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
|
||||
| `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. |
|
||||
| `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag and role. |
|
||||
| `cabana.AdminAccountRequest` | Body of PUT `/account`: `email`, `first_name`, `last_name`, `subtitle` and the optional `current_password`, `password` and `password_confirmation`. These are the only accepted keys. |
|
||||
| `cabana.AdminAccountUpdate` | Swag annotation of the My account route. |
|
||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||
|
||||
432
modules/cabana/account.go
Normal file
432
modules/cabana/account.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// metadataSubtitle is the backend_users.metadata key of the admin subtitle,
|
||||
// the key WinterCMS's Journal author component reads.
|
||||
const metadataSubtitle = "subtitle"
|
||||
|
||||
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
|
||||
const passwordMaxBytes = 72
|
||||
|
||||
// accountKeys are the only keys PUT /account accepts. Any other key (login,
|
||||
// role_id, is_superuser, permissions, is_activated, id and so on) is refused
|
||||
// with a 422 and nothing is written.
|
||||
var accountKeys = []string{
|
||||
"first_name", "last_name", "email", "subtitle",
|
||||
"current_password", "password", "password_confirmation",
|
||||
}
|
||||
|
||||
// accountRules are the field rules of PUT /account. The confirmation, the
|
||||
// current password and the email uniqueness are checked in Go afterwards.
|
||||
var accountRules = []lagoon.RequestRule{
|
||||
{Field: "first_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
{Field: "last_name", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
{Field: "email", Rules: lagoon.ParseRules("required|string|email|between:6,255")},
|
||||
{Field: "subtitle", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
{Field: "current_password", Rules: lagoon.ParseRules("nullable|string")},
|
||||
{Field: "password", Rules: lagoon.ParseRules("nullable|string|min:4|max:72")},
|
||||
{Field: "password_confirmation", Rules: lagoon.ParseRules("nullable|string")},
|
||||
}
|
||||
|
||||
// errAccountGone is a signed-in admin whose row is missing or deactivated.
|
||||
var errAccountGone = errors.New("cabana: account is missing or deactivated")
|
||||
|
||||
// errMetadataNotObject is stored metadata that is neither empty, [] nor a
|
||||
// JSON object; it is never overwritten.
|
||||
var errMetadataNotObject = errors.New("cabana: backend user metadata is not a JSON object")
|
||||
|
||||
// decodeMetadata parses Winter's jsonable metadata. NULL, blank, null and []
|
||||
// (PHP's json_encode of an empty array) are an empty object. ok is false for
|
||||
// text that is not a JSON object.
|
||||
func decodeMetadata(raw *string) (map[string]json.RawMessage, bool) {
|
||||
if raw == nil {
|
||||
return map[string]json.RawMessage{}, true
|
||||
}
|
||||
text := strings.TrimSpace(*raw)
|
||||
if text == "" || text == "null" || text == "[]" {
|
||||
return map[string]json.RawMessage{}, true
|
||||
}
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil || out == nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// metadataString returns the string value of key in Winter's jsonable
|
||||
// metadata, or "" when the metadata is empty or malformed or the value is
|
||||
// not a string.
|
||||
func metadataString(raw *string, key string) string {
|
||||
values, ok := decodeMetadata(raw)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
encoded, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(encoded, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// setMetadataString returns metadata with key set to value, every other key
|
||||
// kept as stored. An empty value removes the key, and metadata without keys
|
||||
// is nil (SQL NULL). Stored text that is not empty, [] or a JSON object is
|
||||
// an error and is never overwritten.
|
||||
func setMetadataString(raw *string, key, value string) (*string, error) {
|
||||
values, ok := decodeMetadata(raw)
|
||||
if !ok {
|
||||
return nil, errMetadataNotObject
|
||||
}
|
||||
if value == "" {
|
||||
delete(values, key)
|
||||
} else {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[key] = encoded
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text := string(out)
|
||||
return &text, nil
|
||||
}
|
||||
|
||||
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
|
||||
// the My account routes.
|
||||
func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Subtitle: user.Subtitle(),
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// accountPrincipal is the signed-in backend principal, or nil after a 401
|
||||
// was written.
|
||||
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend || principal.ID == 0 {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return nil
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
// lockAccount loads the admin's own row FOR UPDATE with the role, by the
|
||||
// principal id only. A missing or deactivated row is errAccountGone.
|
||||
func lockAccount(ctx context.Context, tx *gorm.DB, id uint) (BackendUser, error) {
|
||||
var user BackendUser
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("Role").Where("id = ?", id).Take(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BackendUser{}, errAccountGone
|
||||
}
|
||||
if err != nil {
|
||||
return BackendUser{}, err
|
||||
}
|
||||
if !user.IsActivated {
|
||||
return BackendUser{}, errAccountGone
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// backendMessage is a backend::lang line in the request locale, or fallback
|
||||
// when no translator has the key.
|
||||
func backendMessage(ctx context.Context, tr *phrasebook.Translator, key, fallback string) string {
|
||||
full := "backend::lang." + key
|
||||
if tr != nil && tr.Has(full) {
|
||||
if s := tr.Get(ctx, full, nil); s != "" && s != full {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// accountMessage is a lagoon::validation line for a My account field, with
|
||||
// Laravel's English text for the rules fieldMessage has no fallback for.
|
||||
func accountMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
key := "lagoon::validation." + rule
|
||||
if tr == nil || !tr.Has(key) {
|
||||
attr := strings.ReplaceAll(field, "_", " ")
|
||||
switch rule {
|
||||
case "confirmed":
|
||||
return "The " + attr + " confirmation does not match."
|
||||
case "unique":
|
||||
return "The " + attr + " has already been taken."
|
||||
case "max.string":
|
||||
return "The " + attr + " may not be greater than " + params["max"] + " characters."
|
||||
}
|
||||
}
|
||||
return fieldMessage(ctx, tr, rule, field, params)
|
||||
}
|
||||
|
||||
// accountString is the trimmed string value of key, "" when it is absent,
|
||||
// null or not a string.
|
||||
func accountString(body map[string]any, key string) string {
|
||||
s, _ := body[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// accountUpdate serves PUT /account: the signed-in admin edits their own
|
||||
// first name, last name, email and subtitle and, with the current password,
|
||||
// changes the password. The row is chosen by the token subject, never by the
|
||||
// request. A password change ends every other session (tokens_valid_after)
|
||||
// and gives a cookie session a fresh cookie.
|
||||
func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
principal := accountPrincipal(w, r)
|
||||
if principal == nil {
|
||||
return
|
||||
}
|
||||
body, err := s.decodeCappedObject(w, r)
|
||||
if err != nil {
|
||||
writeRelationError(w, err)
|
||||
return
|
||||
}
|
||||
tr := s.translator()
|
||||
if details := refusedAccountKeys(r.Context(), tr, body); len(details) > 0 {
|
||||
writeCRUDError(w, &ValidationError{Details: details})
|
||||
return
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
// Laravel's TrimStrings: names, email and subtitle are trimmed before
|
||||
// they are validated; passwords are taken as typed.
|
||||
for _, key := range []string{"first_name", "last_name", "email", "subtitle"} {
|
||||
if v, ok := body[key].(string); ok {
|
||||
body[key] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
password, _ := body["password"].(string)
|
||||
changePassword := password != ""
|
||||
var saved BackendUser
|
||||
var failedCurrent bool
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
user, err := lockAccount(ctx, tx, principal.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errs, err := lagoon.ValidateRequest(ctx, tx, body, accountRules, tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if errs == nil {
|
||||
errs = map[string][]string{}
|
||||
}
|
||||
add := func(field, message string) { errs[field] = append(errs[field], message) }
|
||||
if changePassword {
|
||||
if _, failed := errs["password"]; !failed && len(password) > passwordMaxBytes {
|
||||
add("password", accountMessage(ctx, tr, "max.string", "password", map[string]string{"max": strconv.Itoa(passwordMaxBytes)}))
|
||||
}
|
||||
if confirmation, _ := body["password_confirmation"].(string); confirmation != password {
|
||||
add("password", accountMessage(ctx, tr, "confirmed", "password", nil))
|
||||
}
|
||||
current, _ := body["current_password"].(string)
|
||||
switch {
|
||||
case len(errs["current_password"]) > 0:
|
||||
case current == "":
|
||||
add("current_password", fieldMessage(ctx, tr, "required", "current_password", nil))
|
||||
case !bouncer.CheckPassword(user.Password, current):
|
||||
add("current_password", backendMessage(ctx, tr, "myaccount.current_password_invalid", "The current password is incorrect."))
|
||||
failedCurrent = true
|
||||
}
|
||||
}
|
||||
email := accountString(body, "email")
|
||||
if len(errs["email"]) == 0 {
|
||||
taken, err := emailTaken(ctx, tx, user.ID, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
add("email", accountMessage(ctx, tr, "unique", "email", nil))
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{
|
||||
"first_name": accountString(body, "first_name"),
|
||||
"last_name": accountString(body, "last_name"),
|
||||
"email": email,
|
||||
"updated_at": now,
|
||||
}
|
||||
if subtitle := accountString(body, "subtitle"); len(errs["subtitle"]) == 0 && subtitle != user.Subtitle() {
|
||||
metadata, err := setMetadataString(user.Metadata, metadataSubtitle, subtitle)
|
||||
if err != nil {
|
||||
add("subtitle", backendMessage(ctx, tr, "myaccount.subtitle_unavailable", "The subtitle cannot be saved because the stored profile metadata is not a JSON object."))
|
||||
} else {
|
||||
updates["metadata"] = metadata
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return &ValidationError{Details: accountDetails(errs)}
|
||||
}
|
||||
if changePassword {
|
||||
hash, err := bouncer.HashPassword(s.bcryptCost, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["password"] = hash
|
||||
// JWT iat has whole-second precision: the cutoff is the start of
|
||||
// this second, so the fresh cookie minted after it still passes
|
||||
// while every token issued in an earlier second is refused.
|
||||
updates["tokens_valid_after"] = now.Truncate(time.Second)
|
||||
}
|
||||
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
||||
if emailConflict(err) {
|
||||
return &ValidationError{Details: fieldDetail("email", accountMessage(ctx, tr, "unique", "email", nil))}
|
||||
}
|
||||
return err
|
||||
}
|
||||
saved, err = lockAccount(ctx, tx, user.ID)
|
||||
return err
|
||||
})
|
||||
if failedCurrent {
|
||||
s.logAuth(r, "failed", principal.ID)
|
||||
}
|
||||
if err != nil {
|
||||
s.writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
if changePassword {
|
||||
s.logAuth(r, "password_changed", principal.ID)
|
||||
s.revokePresentedToken(r)
|
||||
if _, fromCookie := sessionToken(r); fromCookie {
|
||||
token, _, err := bouncer.MintAudience(s.secret, uitoa(principal.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
|
||||
if err != nil {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err)
|
||||
} else {
|
||||
s.writeSessionCookie(w, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
|
||||
}
|
||||
|
||||
// revokePresentedToken blacklists the token the request was made with, so
|
||||
// the session that changed the password cannot be replayed even within the
|
||||
// second the tokens_valid_after cutoff cannot separate.
|
||||
func (s *service) revokePresentedToken(r *http.Request) {
|
||||
if s.bl == nil {
|
||||
return
|
||||
}
|
||||
raw, _ := sessionToken(r)
|
||||
_, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, s.secret, bouncer.AudienceBackend, s.refreshTTL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
expiresAt := iat.Add(s.refreshTTL).Add(time.Minute)
|
||||
if until := exp.Add(time.Minute); until.After(expiresAt) {
|
||||
expiresAt = until
|
||||
}
|
||||
if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil {
|
||||
slog.Default().WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// refusedAccountKeys answers one detail per body key PUT /account does not
|
||||
// accept.
|
||||
func refusedAccountKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any {
|
||||
var details map[string]any
|
||||
for key := range body {
|
||||
if slices.Contains(accountKeys, key) {
|
||||
continue
|
||||
}
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed on My account.")}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// accountDetails orders each field's messages as lagoon.ErrorKeys ranks the
|
||||
// fields and returns them as validation details.
|
||||
func accountDetails(errs map[string][]string) map[string]any {
|
||||
details := make(map[string]any, len(errs))
|
||||
for _, key := range lagoon.ErrorKeys(errs, accountRules) {
|
||||
details[key] = errs[key]
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// emailTaken reports whether email equals, ignoring case, the email or login
|
||||
// of any other admin, soft-deleted admins included.
|
||||
func emailTaken(ctx context.Context, tx *gorm.DB, id uint, email string) (bool, error) {
|
||||
if email == "" {
|
||||
return false, nil
|
||||
}
|
||||
var n int64
|
||||
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Unscoped().Model(&BackendUser{}).
|
||||
Where("id <> ? AND (lower(email) = lower(?) OR lower(login) = lower(?))", id, email, email).
|
||||
Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// emailConflict reports a unique violation on a backend_users email index,
|
||||
// the race the emailTaken check cannot close.
|
||||
func emailConflict(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
|
||||
return false
|
||||
}
|
||||
return pgErr.ConstraintName == "backend_users_email_lower_unique" || pgErr.ConstraintName == "backend_users_email_key"
|
||||
}
|
||||
|
||||
// writeAccountError maps a My account failure: a missing or deactivated
|
||||
// admin is 401, a body past the cap 413, validation 422 and anything else a
|
||||
// logged 500.
|
||||
func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, errAccountGone) {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
var ve *ValidationError
|
||||
var tooBig *http.MaxBytesError
|
||||
if !errors.As(err, &ve) && !errors.As(err, &tooBig) {
|
||||
slog.Default().ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err)
|
||||
}
|
||||
writeCRUDError(w, err)
|
||||
}
|
||||
93
modules/cabana/account_test.go
Normal file
93
modules/cabana/account_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// accountStamp is a per-run suffix for logins and emails, so the shared
|
||||
// harness database can hold every run's admins side by side.
|
||||
func accountStamp() string {
|
||||
return time.Now().Format("150405.000000000")
|
||||
}
|
||||
|
||||
// putAccount sends PUT /account with a Bearer token.
|
||||
func putAccount(t *testing.T, h http.Handler, token string, body any) *httpResult {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, raw)
|
||||
return &httpResult{Code: rec.Code, Body: rec.Body.Bytes(), Header: rec.Header()}
|
||||
}
|
||||
|
||||
// httpResult is a recorded response.
|
||||
type httpResult struct {
|
||||
Code int
|
||||
Body []byte
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// profileOf decodes an AdminProfile envelope.
|
||||
func profileOf(t *testing.T, raw []byte) cabana.AdminProfile {
|
||||
t.Helper()
|
||||
var body cabana.Envelope[cabana.AdminProfile]
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("profile json: %v body=%s", err, raw)
|
||||
}
|
||||
return body.Data
|
||||
}
|
||||
|
||||
// signIn logs an admin in over Bearer transport and returns the token.
|
||||
func signIn(t *testing.T, h http.Handler, login string) string {
|
||||
t.Helper()
|
||||
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return accessToken(t, rec.Body.Bytes())
|
||||
}
|
||||
|
||||
func TestAccountProfileSmoke(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
stamp := accountStamp()
|
||||
login := "acct-smoke-" + stamp
|
||||
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||
token := signIn(t, h, login)
|
||||
|
||||
res := putAccount(t, h, token, map[string]any{
|
||||
"first_name": "Grace",
|
||||
"last_name": "Hopper",
|
||||
"email": "grace-" + stamp + "@example.test",
|
||||
"subtitle": "Rear admiral",
|
||||
})
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("put status=%d body=%s", res.Code, res.Body)
|
||||
}
|
||||
if got := profileOf(t, res.Body); got.FirstName != "Grace" || got.Subtitle != "Rear admiral" {
|
||||
t.Fatalf("put profile = %+v", got)
|
||||
}
|
||||
me := getAuth(t, h, adminAPI("/auth/me"), token)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
||||
}
|
||||
got := profileOf(t, me.Body.Bytes())
|
||||
if got.FirstName != "Grace" || got.LastName != "Hopper" || got.Email != "grace-"+stamp+"@example.test" || got.Subtitle != "Rear admiral" || got.Login != login {
|
||||
t.Fatalf("me profile = %+v", got)
|
||||
}
|
||||
|
||||
refused := putAccount(t, h, token, map[string]any{"email": "grace-" + stamp + "@example.test", "is_superuser": true})
|
||||
if refused.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("is_superuser status=%d body=%s", refused.Code, refused.Body)
|
||||
}
|
||||
var superuser bool
|
||||
if err := gdb.Raw(`SELECT is_superuser FROM backend_users WHERE login = ?`, login).Scan(&superuser).Error; err != nil || superuser {
|
||||
t.Fatalf("is_superuser=%v err=%v", superuser, err)
|
||||
}
|
||||
}
|
||||
@@ -99,15 +99,32 @@ type AdminRoleSummary struct {
|
||||
|
||||
// AdminProfile is the GET /auth/me payload.
|
||||
type AdminProfile struct {
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
// Subtitle is backend_users.metadata.subtitle; "" when not set.
|
||||
Subtitle string `json:"subtitle"`
|
||||
IsSuperuser bool `json:"is_superuser"`
|
||||
Role *AdminRoleSummary `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// AdminAccountRequest is the body of PUT /account. It documents the only keys
|
||||
// the route accepts: any other key is a 422 on that key and nothing is
|
||||
// written. PUT replaces the profile, so an absent first_name, last_name or
|
||||
// subtitle is saved empty. The password keys change the password only when
|
||||
// password is not empty, and then current_password must match.
|
||||
type AdminAccountRequest struct {
|
||||
Email string `json:"email"`
|
||||
FirstName *string `json:"first_name,omitempty"`
|
||||
LastName *string `json:"last_name,omitempty"`
|
||||
Subtitle *string `json:"subtitle,omitempty"`
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
PasswordConfirmation string `json:"password_confirmation,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLogin documents POST /auth/login.
|
||||
//
|
||||
// @Summary Admin login
|
||||
@@ -164,6 +181,7 @@ func AdminLogout() {}
|
||||
// AdminMe documents GET /auth/me.
|
||||
//
|
||||
// @Summary Current admin
|
||||
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag and the role.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
@@ -174,6 +192,23 @@ func AdminLogout() {}
|
||||
// @Router /auth/me [get]
|
||||
func AdminMe() {}
|
||||
|
||||
// AdminAccountUpdate documents PUT /account.
|
||||
//
|
||||
// @Summary Update my account
|
||||
// @Description Saves the signed-in admin's own first name, last name, email and subtitle; the row is the token subject, never a request value. Only the AdminAccountRequest keys are accepted: any other key (login, role_id, is_superuser, permissions, is_activated, id) is a 422 on that key and nothing is written. The email must be valid, 6 to 255 characters, and not equal (ignoring case) the email or login of another admin. A non-empty password needs current_password, at least 4 characters, at most 72 bytes and an equal password_confirmation; it ends every other session, and a cookie session receives a fresh cookie.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param body body AdminAccountRequest true "Profile and optional password change"
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 413 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /account [put]
|
||||
func AdminAccountUpdate() {}
|
||||
|
||||
// AdminNavigation documents GET /navigation.
|
||||
//
|
||||
// @Summary Admin navigation
|
||||
|
||||
@@ -356,22 +356,7 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
|
||||
}
|
||||
|
||||
func profileOf(user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
return profile
|
||||
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, user), map[string]any{})
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
|
||||
@@ -30,6 +30,9 @@ func (BackendUserRole) TableName() string { return "backend_user_roles" }
|
||||
// BackendUser is the Winter backend_users row. It is not a frontend user.
|
||||
// Nullable Winter columns are mapped so a copied row round-trips without a
|
||||
// schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column.
|
||||
// Metadata is Winter's jsonable metadata column kept as raw text, so a copied
|
||||
// value that is not a JSON object (NULL, blank or []) still loads; read it
|
||||
// through Subtitle.
|
||||
type BackendUser struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
FirstName string `gorm:"column:first_name"`
|
||||
@@ -41,6 +44,7 @@ type BackendUser struct {
|
||||
PersistCode string `gorm:"column:persist_code"`
|
||||
ResetPasswordCode string `gorm:"column:reset_password_code"`
|
||||
Permissions string `gorm:"column:permissions"`
|
||||
Metadata *string `gorm:"column:metadata"`
|
||||
IsActivated bool `gorm:"column:is_activated"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser"`
|
||||
RoleID *uint `gorm:"column:role_id"`
|
||||
@@ -55,6 +59,11 @@ type BackendUser struct {
|
||||
|
||||
func (BackendUser) TableName() string { return "backend_users" }
|
||||
|
||||
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
|
||||
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
|
||||
// all read as an empty subtitle.
|
||||
func (u BackendUser) Subtitle() string { return metadataString(u.Metadata, metadataSubtitle) }
|
||||
|
||||
// Option is a dropdown choice shared with later schema plans.
|
||||
type Option = pact.Option
|
||||
|
||||
|
||||
@@ -236,6 +236,8 @@ func (s *service) mount(r pact.Router) {
|
||||
})
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Get("/auth/me", s.me)
|
||||
// My account: the signed-in admin edits their own profile.
|
||||
g.Put("/account", requireAjax(s.accountUpdate))
|
||||
g.Get("/navigation", s.navigation)
|
||||
// Form previews render through RenderMarkdown; nothing is stored.
|
||||
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
|
||||
|
||||
@@ -356,6 +356,16 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
||||
{"PUT /account", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
// No password: a password change would end the token the later
|
||||
// cases still use.
|
||||
rec := e.send(t, http.MethodPut, "/account", map[string]any{"first_name": "Conform", "last_name": "Admin", "email": e.login + "@example.test", "subtitle": "Conformance"}, true)
|
||||
var body cabana.Envelope[cabana.AdminProfile]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.FirstName != "Conform" || body.Data.Subtitle != "Conformance" {
|
||||
t.Fatalf("account body = %s (%v)", rec.Body.String(), err)
|
||||
}
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
||||
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
||||
|
||||
@@ -109,6 +109,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder",
|
||||
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
||||
"PUT /account",
|
||||
"PUT /settings/{code}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||
@@ -117,7 +118,7 @@ func TestPhase10Coverage(t *testing.T) {
|
||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||
}
|
||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 26 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 27 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||
}
|
||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||
// nested pattern serving field options, filter options and relation lists.
|
||||
|
||||
@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
// refresh, logout, markdown preview, settings put, create, bulk-delete, bulk action,
|
||||
// refresh, logout, account update, markdown preview, settings put, create, bulk-delete, bulk action,
|
||||
// widget action, toolbar action, update, delete, record action, link,
|
||||
// unlink, file
|
||||
// upload, file reorder, file caption, file remove, relation child
|
||||
// create, update and delete, pivot update, child file upload, reorder,
|
||||
// caption and remove
|
||||
if unsafe != 26 {
|
||||
t.Fatalf("walked %d state-changing routes, want 26: %v", unsafe, router.order)
|
||||
if unsafe != 27 {
|
||||
t.Fatalf("walked %d state-changing routes, want 27: %v", unsafe, router.order)
|
||||
}
|
||||
|
||||
loginHandler := router.handlers[login]
|
||||
|
||||
@@ -39,6 +39,7 @@ var phase09Routes = []adminRoute{
|
||||
// refreshable token can be revoked) and is therefore not behind the guard.
|
||||
{key: "POST /auth/logout", public: true},
|
||||
{key: "GET /auth/me"},
|
||||
{key: "PUT /account"},
|
||||
{key: "GET /navigation"},
|
||||
{key: "GET /settings"},
|
||||
{key: "GET /settings/{code}/schema"},
|
||||
|
||||
Reference in New Issue
Block a user