diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index b121c453..344577b4 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -164,6 +164,15 @@ The server enforces these rules on every save, whatever the client sends: - On their own record an administrator may change login, email and names. A change of their own role, superuser flag or permission overrides is refused with 403, and so is a password: an administrator changes their own password in My account, which checks the current one. - A superuser cannot be demoted when it is the last active superuser. +Administrators are deactivated, deleted and restored, never removed: + +- **Activate** and **Deactivate** are actions on the preview and on the list selection; they switch `is_activated`. Deactivate is not offered on your own record, and a selection that holds it is refused as a whole. +- **Delete** is a soft delete through `deleted_at`, so personal API tokens, `linked_by` references and attachments keep valid foreign keys. You cannot delete your own account. Deleted administrators stay in the list with a "Deleted" badge (the Deleted filter hides them or shows only them), and **Restore** brings one back with its role and permissions. +- The last active superuser can be neither demoted, deactivated nor deleted, on the form, by an action or in a bulk selection. Every such write takes one database lock and counts the other active superusers inside its transaction, so two superusers removing each other at the same time leave one of them a superuser. The `admin:*` commands never clear the superuser flag. +- Deactivating or deleting an administrator ends their sessions at once (tokens issued before are refused) and stops their personal API tokens, because the guard refuses an inactive or deleted administrator. Activate and Restore do not issue anything new: the administrator signs in again, and their personal API tokens that are not revoked or expired work again. + +When a refused row is part of a bulk selection, the whole selection is rolled back and the refusal is shown. + Setting the password of another administrator ends that administrator's sessions at once, as `admin:reset-password` does: tokens issued before the change are refused. Their personal API tokens keep working, exactly as after `admin:reset-password`; revoke them with `admin:token:revoke` when they should stop too. ### Operator commands diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 2eb228a1..319485d4 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -54,7 +54,7 @@ Use cases: - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - Refusals: a lifecycle hook, a relation hook, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server. - Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active. -- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin. +- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin. - Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. 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 entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. 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 for JWT sessions: 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). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full. diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go index 0a02fd60..93391b5d 100644 --- a/modules/cabana/backend_screens_smoke_test.go +++ b/modules/cabana/backend_screens_smoke_test.go @@ -379,3 +379,91 @@ func TestAdministratorsFormSmoke(t *testing.T) { t.Fatalf("own role changed to %v", got.RoleID) } } + +// rowStates returns a list response's meta.row_states for id. +func rowStates(body map[string]any, id uint) []string { + meta, _ := body["meta"].(map[string]any) + states, _ := meta["row_states"].(map[string]any) + raw, _ := states[fmt.Sprint(id)].([]any) + out := []string{} + for _, state := range raw { + if text, ok := state.(string); ok { + out = append(out, text) + } + } + return out +} + +func TestAdministratorsLifecycleSmoke(t *testing.T) { + env := newScreensEnv(t) + root, rootToken := env.admin(t, "life-root", true, "") + + // Deactivate by record action ends the target's sessions at once (D-12). + target, targetToken := env.admin(t, "life-target", false, "") + if rec := env.call(t, http.MethodGet, "/auth/me", targetToken, nil); rec.Code != http.StatusOK { + t.Fatalf("target /auth/me before status=%d", rec.Code) + } + show := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, target.ID), rootToken, nil) + if names := recordActionNames(show); len(names) == 0 || names[0] != "deactivate" { + t.Fatalf("offered actions = %v, want deactivate first", names) + } + env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/deactivate", usersPath, target.ID), rootToken, map[string]any{}) + if got := env.storedUser(t, target.ID); got.IsActivated || got.TokensValidAfter == nil { + t.Fatalf("deactivated target = %+v", got) + } + if rec := env.call(t, http.MethodGet, "/auth/me", targetToken, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("target /auth/me after deactivate status=%d body=%s", rec.Code, rec.Body.String()) + } + // The superuser is not offered Deactivate on their own record. + own := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil) + for _, name := range recordActionNames(own) { + if name == "deactivate" { + t.Fatal("deactivate offered on the editor's own record") + } + } + + // Delete is a soft delete; the row lists as deleted and restores (D-10). + gone, _ := env.admin(t, "life-gone", false, "") + env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", usersPath, gone.ID), rootToken, nil) + stored := env.storedUser(t, gone.ID) + if !stored.DeletedAt.Valid || stored.TokensValidAfter == nil { + t.Fatalf("deleted admin = %+v", stored) + } + hint := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, gone.ID), rootToken, nil) + if !strings.Contains(fmt.Sprint(hint["data"]), "This account has been deleted") { + t.Fatalf("deleted admin hint = %v", hint["data"]) + } + list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil) + if !listIDs(t, list)[gone.ID] || fmt.Sprint(rowStates(list, gone.ID)) != "[deleted]" { + t.Fatalf("deleted admin row states = %v", rowStates(list, gone.ID)) + } + env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/restore", usersPath, gone.ID), rootToken, map[string]any{}) + list = env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil) + if !listIDs(t, list)[gone.ID] || len(rowStates(list, gone.ID)) != 0 { + t.Fatalf("restored admin row states = %v", rowStates(list, gone.ID)) + } + hidden := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=only", rootToken, nil) + if listIDs(t, hidden)[gone.ID] { + t.Fatal("the trashed=only filter lists a restored admin") + } + // The editor cannot delete their own account (D-08). + env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil, "your own account") + + // A bulk deactivate that selects the editor's own row is refused as a + // whole: the other row stays activated too. + other, _ := env.admin(t, "life-other", false, "") + env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk/deactivate", rootToken, + map[string]any{"ids": []uint{other.ID, root.ID}}, "deactivate your own account") + if !env.storedUser(t, other.ID).IsActivated || !env.storedUser(t, root.ID).IsActivated { + t.Fatal("a refused bulk deactivate changed a row") + } + // Bulk activate brings the deactivated target back without a new cutoff. + before := env.storedUser(t, target.ID).TokensValidAfter + result := env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/activate", rootToken, map[string]any{"ids": []uint{target.ID, other.ID}}) + if data, _ := result["data"].(map[string]any); data["affected"] != float64(1) { + t.Fatalf("bulk activate result = %v", result) + } + if got := env.storedUser(t, target.ID); !got.IsActivated || got.TokensValidAfter == nil || !got.TokensValidAfter.Equal(*before) { + t.Fatalf("activated target = %+v", got) + } +} diff --git a/modules/cabana/backend_users_admin.go b/modules/cabana/backend_users_admin.go index e17b1d5f..a0e5febe 100644 --- a/modules/cabana/backend_users_admin.go +++ b/modules/cabana/backend_users_admin.go @@ -25,23 +25,29 @@ type usersAdmin struct { } var ( - _ pact.AdminController = usersAdmin{} - _ pact.AdminPermissioned = usersAdmin{} - _ pact.AdminRecordSource = usersAdmin{} - _ pact.FormRecordTitle = usersAdmin{} - _ FieldRelationProvider = usersAdmin{} - _ pact.ListExtendQuery = usersAdmin{} - _ pact.FormExtendQuery = usersAdmin{} - _ pact.FormVirtualFields = usersAdmin{} - _ pact.FormRules = usersAdmin{} - _ pact.FilterOptions = usersAdmin{} - _ pact.FormBeforeCreate = usersAdmin{} - _ pact.FormBeforeUpdate = usersAdmin{} - _ pact.FormAfterUpdate = usersAdmin{} - _ RelationLockProvider = usersAdmin{} - _ PermissionEditorProvider = usersAdmin{} - _ recordDataExtender = usersAdmin{} - _ schemaFieldHider = usersAdmin{} + _ pact.AdminController = usersAdmin{} + _ pact.AdminPermissioned = usersAdmin{} + _ pact.AdminRecordSource = usersAdmin{} + _ pact.FormRecordTitle = usersAdmin{} + _ FieldRelationProvider = usersAdmin{} + _ pact.ListExtendQuery = usersAdmin{} + _ pact.FormExtendQuery = usersAdmin{} + _ pact.FormVirtualFields = usersAdmin{} + _ pact.FormRules = usersAdmin{} + _ pact.FilterOptions = usersAdmin{} + _ pact.FormBeforeCreate = usersAdmin{} + _ pact.FormBeforeUpdate = usersAdmin{} + _ pact.FormAfterUpdate = usersAdmin{} + _ RelationLockProvider = usersAdmin{} + _ PermissionEditorProvider = usersAdmin{} + _ recordDataExtender = usersAdmin{} + _ schemaFieldHider = usersAdmin{} + _ pact.ListRowStates = usersAdmin{} + _ pact.FormBeforeDelete = usersAdmin{} + _ pact.FormAfterDelete = usersAdmin{} + _ pact.HasAdminRecordActions = usersAdmin{} + _ pact.HasAdminBulkActions = usersAdmin{} + _ pact.AdminPartialData = usersAdmin{} ) func (usersAdmin) ID() string { return AdministratorsControllerID } @@ -137,24 +143,27 @@ func asBackendUser(model any) (*BackendUser, error) { return user, nil } -// ListExtendQuery hides superusers from an administrator who does not act -// as a superuser (D-03): they are never listed, and a bulk selection never +// ListExtendQuery includes deleted administrators (WinterCMS's +// withTrashed), so they are listed with their row state and can be +// restored, and hides superusers from an administrator who does not act as +// a superuser (D-03): they are never listed, and a bulk selection never // resolves to one. func (usersAdmin) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB { - return scopeSuperusers(ctx, db) + return scopeAdministrators(ctx, db) } // FormExtendQuery applies the same scope to every record lookup, so a -// superuser's id is not found (404) for anyone else: show, the preview -// partial, update, delete and record actions. +// deleted administrator opens and a superuser's id is not found (404) for +// anyone else: show, the preview partial, update, delete and record actions. func (usersAdmin) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB { - return scopeSuperusers(ctx, db) + return scopeAdministrators(ctx, db) } -func scopeSuperusers(ctx context.Context, db *gorm.DB) *gorm.DB { +func scopeAdministrators(ctx context.Context, db *gorm.DB) *gorm.DB { if db == nil { return db } + db = db.Unscoped() principal, _ := bouncer.User(ctx) if actsAsSuperuser(principal) { return db @@ -162,6 +171,259 @@ func scopeSuperusers(ctx context.Context, db *gorm.DB) *gorm.DB { return db.Where("backend_users.is_superuser = ?", false) } +// ListRowStates marks a deleted administrator deleted and a deactivated one +// disabled; a row may carry both. +func (usersAdmin) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) { + out := make([][]pact.RowState, len(records)) + for i, record := range records { + user, err := asBackendUser(record) + if err != nil { + return nil, err + } + if user.DeletedAt.Valid { + out[i] = append(out[i], pact.RowStateDeleted) + } + if !user.IsActivated { + out[i] = append(out[i], pact.RowStateDisabled) + } + } + return out, nil +} + +// FormBeforeDelete refuses deleting yourself (D-08), an administrator who +// outranks the editor (D-19) and the last active superuser (D-09). The +// framework calls it for the form's delete and once per row inside the bulk +// delete's transaction, so one refused row rolls the whole selection back. +func (c usersAdmin) FormBeforeDelete(ctx context.Context, model any) error { + user, err := asBackendUser(model) + if err != nil { + return err + } + principal, _ := bouncer.User(ctx) + if principal != nil && principal.ID == user.ID { + return &ForbiddenError{Message: usersLang + "self_delete"} + } + if err := guardOutranked(ctx, c.registry(), principal, user); err != nil { + return err + } + return guardLastSuperuser(ctx, user) +} + +// FormAfterDelete ends the deleted administrator's sessions (D-12). The +// delete itself stays the framework's soft delete through deleted_at: a +// backend user is never hard-deleted, so tokens, linked_by references and +// attachments keep valid foreign keys (D-10). Personal API tokens stop +// because the guard refuses a deleted administrator. +func (usersAdmin) FormAfterDelete(ctx context.Context, model any) error { + user, err := asBackendUser(model) + if err != nil { + return err + } + tx, ok := TxFromContext(ctx) + if !ok { + return errors.New("cabana: administrators: a delete needs its transaction") + } + cutoff := revocationCutoff() + return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). + Model(&BackendUser{}).Where("id = ?", user.ID). + Update("tokens_valid_after", cutoff).Error +} + +// usersLifecycle is one lifecycle action of the Administrators screen, +// shared by its record and bulk forms. +type usersLifecycle struct { + name string + // applies is the action's Applies for a record; self is true on the + // editor's own record. + applies func(user *BackendUser, self bool) bool + // run checks and writes one administrator through the action's + // transaction. + run func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error +} + +func (c usersAdmin) lifecycles() []usersLifecycle { + return []usersLifecycle{ + { + name: "activate", + applies: func(user *BackendUser, _ bool) bool { + return !user.DeletedAt.Valid && !user.IsActivated + }, + run: func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error { + if err := guardOutranked(ctx, c.registry(), editor, user); err != nil { + return err + } + now := time.Now().UTC() + return db.Model(&BackendUser{}).Where("id = ?", user.ID). + Updates(map[string]any{"is_activated": true, "activated_at": now}).Error + }, + }, + { + name: "deactivate", + // Deactivate is not offered on your own record; the run still + // refuses it (a bulk selection may hold it). + applies: func(user *BackendUser, self bool) bool { + return !user.DeletedAt.Valid && user.IsActivated && !self + }, + run: func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error { + if editor != nil && editor.ID == user.ID { + return &ForbiddenError{Message: usersLang + "self_deactivate"} + } + if err := guardOutranked(ctx, c.registry(), editor, user); err != nil { + return err + } + if err := guardLastSuperuser(ctx, user); err != nil { + return err + } + return db.Model(&BackendUser{}).Where("id = ?", user.ID). + Updates(map[string]any{"is_activated": false, "tokens_valid_after": revocationCutoff()}).Error + }, + }, + { + name: "restore", + applies: func(user *BackendUser, _ bool) bool { + return user.DeletedAt.Valid + }, + run: func(ctx context.Context, c usersAdmin, db *gorm.DB, editor *bouncer.Principal, user *BackendUser) error { + if err := guardOutranked(ctx, c.registry(), editor, user); err != nil { + return err + } + return db.Model(&BackendUser{}).Where("id = ?", user.ID). + Update("deleted_at", nil).Error + }, + }, + } +} + +// actionDB is the action's write transaction as a fresh, unscoped session. +func actionDB(ctx context.Context) (*gorm.DB, error) { + tx, ok := TxFromContext(ctx) + if !ok { + return nil, errors.New("cabana: administrators: an action needs its transaction") + } + return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(), nil +} + +// AdminRecordActions are Activate, Deactivate and Restore on the preview +// (D-10), each offered only while it applies; at most one applies at a time. +// Every refusal is a ForbiddenError with a message (the admin's toast shows +// only the message). +func (c usersAdmin) AdminRecordActions() []pact.AdminRecordAction { + lifecycles := c.lifecycles() + out := make([]pact.AdminRecordAction, 0, len(lifecycles)) + for _, lifecycle := range lifecycles { + lifecycle := lifecycle + out = append(out, pact.AdminRecordAction{ + Name: lifecycle.name, + Label: usersLang + lifecycle.name, + Confirm: usersLang + lifecycle.name + "_confirm", + Permissions: []string{PermissionManageUsers}, + Applies: func(ctx context.Context, record any) (bool, error) { + user, err := asBackendUser(record) + if err != nil { + return false, err + } + principal, _ := bouncer.User(ctx) + return lifecycle.applies(user, principal != nil && principal.ID == user.ID), nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + user, err := asBackendUser(in.Record) + if err != nil { + return pact.AdminRecordActionResult{}, err + } + db, err := actionDB(ctx) + if err != nil { + return pact.AdminRecordActionResult{}, err + } + principal, _ := bouncer.User(ctx) + if err := lifecycle.run(ctx, c, db, principal, user); err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{Message: usersLang + lifecycle.name + "d"}, nil + }, + }) + } + return out +} + +// AdminBulkActions are Activate, Deactivate and Restore on the selected +// rows. A row the action does not apply to is skipped and not counted, the +// editor's own row refuses a deactivate, and one refused row rolls the +// whole selection back. +func (c usersAdmin) AdminBulkActions() []pact.AdminBulkAction { + lifecycles := c.lifecycles() + out := make([]pact.AdminBulkAction, 0, len(lifecycles)) + for _, lifecycle := range lifecycles { + lifecycle := lifecycle + out = append(out, pact.AdminBulkAction{ + Name: lifecycle.name, + Label: usersLang + lifecycle.name, + Confirm: usersLang + lifecycle.name + "_selected_confirm", + Permissions: []string{PermissionManageUsers}, + Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) { + db, err := actionDB(ctx) + if err != nil { + return pact.AdminBulkActionResult{}, err + } + principal, _ := bouncer.User(ctx) + affected := 0 + for _, record := range in.Records { + user, err := asBackendUser(record) + if err != nil { + return pact.AdminBulkActionResult{}, err + } + // The editor's own row is checked by run, not skipped, + // so a deactivate that selects it is refused. + if !lifecycle.applies(user, false) { + continue + } + if err := lifecycle.run(ctx, c, db, principal, user); err != nil { + return pact.AdminBulkActionResult{}, err + } + affected++ + } + result := pact.AdminBulkActionResult{Affected: affected} + if affected == len(in.Records) { + result.Message = usersLang + lifecycle.name + "d_selected" + } + return result, nil + }, + }) + } + return out +} + +// usersHintView is the view model of controllers/users/_hint.htm, the status +// callout above the preview: the callout kind (danger or warning) and the +// phrase keys of its title and text. It is empty when no state applies, and +// the template then renders nothing. +type usersHintView struct { + Kind string + Title string + Text string +} + +// PartialData serves the preview header partial hint: one callout by +// precedence deleted, deactivated, the editor's own account. +func (usersAdmin) PartialData(ctx context.Context, name string, record any) (any, error) { + if name != "hint" { + return nil, fmt.Errorf("cabana: administrators: unknown partial %q", name) + } + user, ok := record.(*BackendUser) + if !ok || user == nil { + return usersHintView{}, nil + } + principal, _ := bouncer.User(ctx) + switch { + case user.DeletedAt.Valid: + return usersHintView{Kind: "danger", Title: usersLang + "trashed_hint_title", Text: usersLang + "trashed_hint_desc"}, nil + case !user.IsActivated: + return usersHintView{Kind: "warning", Title: usersLang + "deactivated_hint_title", Text: usersLang + "deactivated_hint_desc"}, nil + case principal != nil && principal.ID == user.ID: + return usersHintView{Kind: "warning", Title: usersLang + "self_hint_title", Text: usersLang + "self_hint_desc"}, nil + } + return usersHintView{}, nil +} + // extendRecordData sends the stored superuser flag to a superuser. The // is_superuser switch is a virtual field (the column is protected), and the // admin sends every field on save: without its stored value an untouched @@ -207,8 +469,15 @@ func (usersAdmin) FormRules(_ context.Context, op string) map[string]string { } } -// FilterOptions serves the role filter's choices: every role by name. +// FilterOptions serves the role filter's choices, every role by name, and +// the deleted filter's two choices. func (c usersAdmin) FilterOptions(scope string) []pact.Option { + if scope == filterTrashed { + return []pact.Option{ + {Value: trashedHide, Label: usersLang + "filter_deleted_hide"}, + {Value: trashedOnly, Label: usersLang + "filter_deleted_only"}, + } + } if scope != filterByRole { return nil } diff --git a/modules/cabana/backendscreens/controllers/users/_hint.htm b/modules/cabana/backendscreens/controllers/users/_hint.htm new file mode 100644 index 00000000..c6993df0 --- /dev/null +++ b/modules/cabana/backendscreens/controllers/users/_hint.htm @@ -0,0 +1,7 @@ +{{- /* Status hint above the user preview: one callout, or nothing (usersHintView). */ -}} +{{- if .Data.Title -}} +
+

{{ trans .Data.Title }}

+

{{ trans .Data.Text }}

+
+{{- end -}} diff --git a/modules/cabana/backendscreens/controllers/users/config_filter.yaml b/modules/cabana/backendscreens/controllers/users/config_filter.yaml index 1adb69c8..bfe338f2 100644 --- a/modules/cabana/backendscreens/controllers/users/config_filter.yaml +++ b/modules/cabana/backendscreens/controllers/users/config_filter.yaml @@ -16,3 +16,8 @@ scopes: label: backend::lang.user.last_login type: daterange column: last_login + trashed: + label: backend::lang.user.filter_deleted + modelClass: Backend\Models\User + nameFrom: login + scope: filterTrashed diff --git a/modules/cabana/backendscreens/controllers/users/config_form.yaml b/modules/cabana/backendscreens/controllers/users/config_form.yaml index b0e00e3f..6a272bd9 100644 --- a/modules/cabana/backendscreens/controllers/users/config_form.yaml +++ b/modules/cabana/backendscreens/controllers/users/config_form.yaml @@ -11,11 +11,16 @@ update: redirect: summercms/cabana/users/update/:id redirectClose: summercms/cabana/users/preview/:id -preview: {} +preview: + headerPartial: hint + +recordActions: [activate, deactivate, restore] messages: create: backend::lang.user.new update: backend::lang.messages.form.update saved: backend::lang.messages.form.saved + deleteConfirm: backend::lang.user.delete_confirm + deleted: backend::lang.messages.form.deleted edit: backend::lang.user.edit preview: backend::lang.user.preview diff --git a/modules/cabana/backendscreens/controllers/users/config_list.yaml b/modules/cabana/backendscreens/controllers/users/config_list.yaml index d9df9be5..65682cae 100644 --- a/modules/cabana/backendscreens/controllers/users/config_list.yaml +++ b/modules/cabana/backendscreens/controllers/users/config_list.yaml @@ -4,13 +4,21 @@ title: backend::lang.user.list_title recordUrl: summercms/cabana/users/preview/:id noRecordsMessage: backend::lang.user.list_empty recordsPerPage: 20 +showCheckboxes: true showSetup: true filter: config_filter.yaml toolbar: - buttons: [create] + buttons: [create, delete] search: prompt: backend::lang.list.search_prompt +bulkActions: [activate, deactivate, restore] + messages: create: backend::lang.user.new + empty: backend::lang.user.list_empty + deleteConfirm: backend::lang.user.delete_selected_confirm + deleted: backend::lang.messages.list.deleted + rowStateDeleted: backend::lang.user.state_deleted + rowStateDisabled: backend::lang.user.state_deactivated diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 97e98d73..be980b25 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -91,20 +91,40 @@ func (BackendUser) Fillable() []string { return []string{"login", "email", "first_name", "last_name"} } -// filterByRole is the Administrators list filter scope that keeps the -// holders of one role. -const filterByRole = "filterByRole" +// The Administrators list filter scopes: filterByRole keeps the holders of +// one role, filterTrashed shows or hides deleted administrators. +const ( + filterByRole = "filterByRole" + filterTrashed = "filterTrashed" +) + +// The filterTrashed values. +const ( + trashedHide = "hide" + trashedOnly = "only" +) // FilterScopes lists the scope names the Administrators list filters call. -func (BackendUser) FilterScopes() []string { return []string{filterByRole} } +func (BackendUser) FilterScopes() []string { return []string{filterByRole, filterTrashed} } -// FilterScope narrows db for the scope name: filterByRole keeps the holders -// of the role whose id is value. Any other name, and a value that is not a -// role id, match nothing; a nil db is returned as it is. +// FilterScope narrows db for the scope name. filterByRole keeps the holders +// of the role whose id is value; a value that is not a role id matches +// nothing. filterTrashed with "hide" leaves deleted administrators out and +// with "only" keeps only them; any other value does not narrow. Any other +// name matches nothing; a nil db is returned as it is. func (BackendUser) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil { return db } + if name == filterTrashed { + switch strings.TrimSpace(fmt.Sprint(value)) { + case trashedHide: + return db.Where("backend_users.deleted_at IS NULL") + case trashedOnly: + return db.Where("backend_users.deleted_at IS NOT NULL") + } + return db + } if name != filterByRole { return db.Where("1 = 0") }