feat(12.1-02): password and form-only fields, rules per operation and preset
- pact.FormVirtualFields lists form fields that are not model columns: never bound, filled or projected; their values reach the Form hooks through cabana.VirtualFieldsFromContext when the field's context allows the operation - type: password is a masked field that must be listed as virtual - pact.FormRules supplies the rule set per operation and replaces the model's Rules() for admin saves; a rule on a virtual field sees the submitted value - preset on a text field follows another text field on the create form - SPA: PasswordField, preset handling in FormView, empty password left out of an update - README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
@@ -2,6 +2,8 @@ package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -38,13 +40,35 @@ type rosterPerson struct {
|
||||
Active bool `gorm:"column:active"`
|
||||
Banned bool `gorm:"column:banned"`
|
||||
// JoinedIP is shown on the preview screen only (context: preview).
|
||||
JoinedIP *string `gorm:"column:joined_ip"`
|
||||
JoinedIP *string `gorm:"column:joined_ip"`
|
||||
// Password is a stored hash. The form's password field is virtual: the
|
||||
// controller's hooks derive this column from the submitted value.
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (rosterPerson) TableName() string { return "roster_people" }
|
||||
func (rosterPerson) Fillable() []string { return []string{"name", "email"} }
|
||||
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func (rosterPerson) TableName() string { return "roster_people" }
|
||||
func (rosterPerson) Fillable() []string { return []string{"name", "email", "slug"} }
|
||||
|
||||
// Rules are the model's own (sign-up) rules: every save needs a confirmed
|
||||
// password. The admin form replaces them through the controller's FormRules.
|
||||
func (rosterPerson) Rules() map[string]string {
|
||||
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
||||
}
|
||||
|
||||
// rosterHash is the fixture's stand-in for a password hash.
|
||||
func rosterHash(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// rosterVirtual is what one Form hook read from VirtualFieldsFromContext.
|
||||
type rosterVirtual struct {
|
||||
Hook string
|
||||
Values map[string]any
|
||||
Found bool
|
||||
}
|
||||
|
||||
// rosterSpy records what each registered action's Run receives.
|
||||
type rosterSpy struct {
|
||||
@@ -53,6 +77,31 @@ type rosterSpy struct {
|
||||
record []pact.AdminRecordActionInput
|
||||
// states counts ListRowStates calls and keeps the size of each page.
|
||||
states []int
|
||||
// virtual keeps what each Form hook read from the context.
|
||||
virtual []rosterVirtual
|
||||
}
|
||||
|
||||
func (s *rosterSpy) recordVirtual(hook string, ctx context.Context) map[string]any {
|
||||
values, found := cabana.VirtualFieldsFromContext(ctx)
|
||||
if s == nil {
|
||||
return values
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
kept := make(map[string]any, len(values))
|
||||
for name, value := range values {
|
||||
kept[name] = value
|
||||
}
|
||||
s.virtual = append(s.virtual, rosterVirtual{Hook: hook, Values: kept, Found: found})
|
||||
return values
|
||||
}
|
||||
|
||||
func (s *rosterSpy) takeVirtual() []rosterVirtual {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.virtual
|
||||
s.virtual = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *rosterSpy) recordStates(n int) {
|
||||
@@ -216,9 +265,49 @@ const rosterLocked = "Locked"
|
||||
// copy and never write into it.
|
||||
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
|
||||
|
||||
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
|
||||
// the field, and fails with a plain error for the name Boom.
|
||||
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
|
||||
// FormVirtualFields lists the form fields that are not columns of the form:
|
||||
// the password pair and the create-only notify checkbox.
|
||||
func (rosterController) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", "notify"}
|
||||
}
|
||||
|
||||
// FormRules are the admin form's rules: a create needs a confirmed password,
|
||||
// an update takes one only when it is submitted.
|
||||
func (rosterController) FormRules(_ context.Context, op string) map[string]string {
|
||||
if op == "create" {
|
||||
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
||||
}
|
||||
return map[string]string{"name": "required", "password": "nullable|between:8,255|confirmed"}
|
||||
}
|
||||
|
||||
// storePassword derives the stored hash from a submitted password.
|
||||
func storePassword(person *rosterPerson, values map[string]any) {
|
||||
if plain, ok := values["password"].(string); ok && plain != "" {
|
||||
person.Password = rosterHash(plain)
|
||||
}
|
||||
}
|
||||
|
||||
// FormBeforeCreate stamps the tenant and stores the hash of the submitted
|
||||
// password. It also drops notify from its own copy of the virtual values: the
|
||||
// after hook must still see it.
|
||||
func (c rosterController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
values := c.spy.recordVirtual("before-create", ctx)
|
||||
model.(*rosterPerson).Tenant = "acme"
|
||||
storePassword(model.(*rosterPerson), values)
|
||||
delete(values, "notify")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c rosterController) FormAfterCreate(ctx context.Context, _ any) error {
|
||||
c.spy.recordVirtual("after-create", ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormBeforeUpdate stores a submitted password, refuses the reserved name
|
||||
// with a ForbiddenError naming the field, and fails with a plain error for
|
||||
// the name Boom.
|
||||
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
|
||||
switch model.(*rosterPerson).Name {
|
||||
case "Reserved":
|
||||
return &cabana.ForbiddenError{
|
||||
|
||||
Reference in New Issue
Block a user