Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-03-PLAN.md
2026-10-04 19:41:08 +02:00

88 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
12.1-user-plugin-admin-screens 03 execute 3
12.1-02
../fonoteka.go/plugins/golem15/user/updates/202610040001_add_users_permissions.go
../fonoteka.go/plugins/golem15/user/updates/202610040002_add_users_last_seen.go
../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go
../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go
../fonoteka.go/plugins/golem15/user/models/user.go
../fonoteka.go/plugins/golem15/user/models/permission_set.go
../fonoteka.go/plugins/golem15/user/models/frontend_permission.go
../fonoteka.go/plugins/golem15/user/models/user/fields.yaml
../fonoteka.go/plugins/golem15/user/models/user/columns.yaml
../fonoteka.go/plugins/golem15/user/config/config.yaml
../fonoteka.go/plugins/golem15/user/classes/privileged.go
../fonoteka.go/plugins/golem15/user/classes/admin_actions.go
../fonoteka.go/plugins/golem15/user/classes/permissions.go
../fonoteka.go/plugins/golem15/user/classes/permissions_test.go
../fonoteka.go/plugins/golem15/user/classes/last_seen.go
../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go
../fonoteka.go/plugins/golem15/user/controllers/request_db.go
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml
../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml
../fonoteka.go/plugins/golem15/user/controllers/users/config_filter.yaml
../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm
../fonoteka.go/plugins/golem15/user/admin.go
../fonoteka.go/plugins/golem15/user/admin_permissions.go
../fonoteka.go/plugins/golem15/user/admin_navigation.go
../fonoteka.go/plugins/golem15/user/plugin.go
../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
../fonoteka.go/plugins/golem15/user/views/mail/invite.htm
../fonoteka.go/plugins/golem15/user/views/mail/invite-en.htm
../fonoteka.go/plugins/golem15/user/README.md
../fonoteka.go/plugins/golem15/user/admin_harness_test.go
../fonoteka.go/plugins/golem15/user/admin_users_test.go
../fonoteka.go/plugins/golem15/user/last_seen_test.go
true
SC-1
SC-3
tokens raw_tokens tasks confidence
270000 270000 4 low
truths artifacts key_links
Per D-26, this is plan 03 of five: the plugin foundation and the Users screen in sm-user-plugin (mounted at ../fonoteka.go/plugins/golem15/user), built on the framework tag v0.1.3; the groups field, the User Groups and Organisations screens and the submodule pointer bump are plan 04.
Per D-01 and D-03, no impersonate action or button and no guest concept (no convert-guest action, no is_guest column, field or hint) exists in the Go admin screens; the seeded Guest group row is left as data.
Per D-02 and D-04, the plugin registers the four PHP permission codes golem15.users.access_users, golem15.users.access_groups, golem15.users.access_settings and golem15.users.impersonate_user plus the new golem15.users.manage_privileged_groups, all under the tab golem15.user::lang.plugin.tab; the Users screen is reachable from the admin navigation item `user` (order 555) and gated by golem15.users.access_users.
Per D-13, the Users list and form include deactivated (soft-deleted) users, `deactivate` is the soft delete, `restore` brings a user back, and `delete` (form button and bulk) permanently removes the user row together with its user_throttle rows, users_groups rows and attachments in one transaction, with blobs removed only after the commit and no typed confirmation.
Per D-14, the Users list offers the bulk actions activate, deactivate, restore, ban and unban plus the built-in bulk delete, and the preview offers the record actions activate (when not activated), unban (when banned) and unsuspend (when suspended); ban and suspend state is read from and written to user_throttle, and none of these actions writes users_groups.
Per D-12, Users rows carry the states deleted when deactivated, negative when banned and disabled when not activated, from one user_throttle query per page.
Per D-11, a Users row opens the preview screen first; the preview shows one status callout by precedence banned, deactivated, not activated, suspended, the preview-only fields created_ip_address and last_ip_address, the applicable record actions and an edit button labelled from users.update_details; after create and after save-and-close the admin lands on the preview.
Per D-15, the golem15_user_frontend_permissions table (code, label, tab, comment), the users.permissions column and a tolerant PermissionSet type exist; the user form's Permissions tab is a permissioneditor in radio mode (allow, deny, inherit) whose options come from that table; and classes.MergedPermissions plus classes.HasPermission reproduce PHP getMergedPermissions and hasPermission (groups merged with most positive wins, user values override, a permission is held only at exactly 1, wildcards on both sides). No application code calls the resolver yet.
Per D-17 and D-29, users.last_seen is an additive column shown in the Users list and written by login and by token refresh at most once per five minutes in a single conditional UPDATE; a failed write is logged and never fails authentication; last_seen, permissions, created_at and updated_at never appear in any user API payload.
Per D-18, there is no username column, list column or form field.
Per D-19, the user form takes a password with confirmation on create (required) and on update (empty means unchanged), hashes it with bouncer.HashPassword at the configured cost, never returns it, stamps tokens_valid_after when an admin resets a password, and `send_invite` (on by default, create only) sends exactly one golem15.user::mail.invite message with an activation link after the create commits; created_ip_address and last_ip_address show on the preview only.
Per D-29, a user created in the admin starts not activated and only the activate actions set is_activated; bulk activate skips users that are already active and the response reports the affected count.
Per D-30 (threats T-12.1-38 and T-12.1-39), a user who is a member of a privileged group (D-05) is protected against takeover: for an admin without golem15.users.manage_privileged_groups, an update that changes that user's email or submits a password for that user, and a permanent delete of that user by the form button or by the bulk delete, is refused with 403 and changes nothing (no column, no token stamp, no pivot, throttle or attachment row; a bulk delete whose selection contains such a user deletes none of the selected users); with the permission each succeeds. This deliberately differs from PHP, where golem15.users.access_users alone is enough.
Per D-30, no commit of the plugin has an unguarded write path: the guard and its helpers land in the commit that creates the Users controller (Task 1), before the permanent delete (Task 2) and the password field (Task 3) exist, so each of those is guarded from the commit that introduces it.
Per D-05 and D-30, the privileged list is the config key golem15.user.privileged_groups (default [admin]) read per request by classes.PrivilegedGroupCodes; the key and classes/privileged.go are created by this plan and reused unchanged by plan 04.
Per D-30 and D-14, the guard covers exactly email, password and permanent delete: a change of name, surname, organisation, avatar or frontend permissions of a privileged member, an update that submits the stored email unchanged and no password, and the actions activate, deactivate, restore, ban, unban and unsuspend still need only golem15.users.access_users.
Per D-20, the user form's avatar is a fileupload field (mode image, 260 by 260) bound to the same system_files attachment (field avatar, the user's morph name) the user API writes, so an avatar set in the admin shows in the user API payload and one uploaded through the API shows in the admin.
Per D-21, block_mail and MailBlocker are not ported; the todo .planning/todos/pending/mailblocker-with-mailing.md records them for the mailing work.
Per D-22, the user form's `organisation` field is a belongsTo relation picker that writes organisation_id through the WritableForeignKey opt-in.
Per D-23, the Users filters are groups (model scope filterByGroup with choices from user_groups), created date (daterange on created_at) and activated (switch on is_activated); no `conditions` key is used.
Per D-08, User.Groups and every new model field stay out of the user API payloads: the application's user API parity flows and TestPhase12Threats pass unchanged.
The migrations are additive only (two ALTER TABLE ADD COLUMN and one CREATE TABLE, each with a rollback), no shipped migration file is edited, the user API routes and payloads are unchanged, and no Go module is added.
On the Users form a validation failure (for example a password confirmation mismatch or a duplicate email, including the email of a deactivated user) answers 422 and marks the field.
UI P1 empty: The Users list with no rows shows `users.list_empty`; the empty-search state is the existing one.
UI P1 populated: The Users list shows Name, Email, Registered and Last seen, with row-state badges Deactivated, Banned and Not activated, and rows open the preview.
UI P1 zero-one-many: Users bulk confirms and success toasts address the selected users; counts come from the S1 `:count` rule.
UI P2 empty: The Users create form opens with `send_invite` on and every other field empty; a user without an avatar shows the Phase 12.2 `fileupload` empty state.
UI P2 partial: On the Users form `send_invite` shows on create only and the Permissions tab on update only.
UI P2 error (D-30, UI-SPEC S6 forbidden save): A refused email or password change on a member of a privileged group saves nothing, keeps every entered value, shows the forbidden banner with `users.privileged_member_forbidden` and marks the refused fields (`email`, `password`) through the 403 details.
UI P1 error (D-30, UI-SPEC S6 forbidden delete): A refused permanent delete of a member of a privileged group shows a danger toast with `users.privileged_member_delete_forbidden`, from the form's delete button and from the list's bulk delete alike; nothing is deleted.
UI P3 loading: The Users preview loads as S3: skeleton hint on first fetch, previous hint kept on refetch.
UI P3 error: The Users preview fails as S3, and its record actions fail as S2.
UI P3 overflow: The Users preview shows at most one status callout, by precedence banned, deactivated, not activated, suspended; action buttons wrap in the footer.
UI P3 long-text: Status callout text wraps and is never truncated.
path provides contains
../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go Users admin controller: scopes, row states, rules, virtual fields, bulk and record actions, hooks, partial data, relation and permission providers golem15.user.users
path provides
../fonoteka.go/plugins/golem15/user/classes/admin_actions.go ActivateUsers, DeactivateUsers, RestoreUsers, BanUsers, UnbanUsers, UnsuspendUser, ThrottleStates, ForceDeleteCleanup
path provides contains
../fonoteka.go/plugins/golem15/user/classes/privileged.go PermissionManagePrivilegedGroups, PrivilegedGroupCodes, IsPrivilegedCode, PrivilegedGroupIDs, IsPrivilegedMember IsPrivilegedMember
path provides contains
../fonoteka.go/plugins/golem15/user/classes/permissions.go MergedPermissions, HasPermission, UserHasPermission, FrontendPermissionOptions MergedPermissions
path provides
../fonoteka.go/plugins/golem15/user/models/permission_set.go PermissionSet with tolerant Scan and an object-of-integers Value
path provides contains
../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go golem15_user_frontend_permissions table golem15_user_frontend_permissions
path provides
../fonoteka.go/plugins/golem15/user/admin.go AdminFS (explicit embed list), AdminControllers, capability assertions
path provides
../fonoteka.go/plugins/golem15/user/admin_harness_test.go plugin admin test harness: boot with cabana mounted, mint a backend principal with chosen permissions
from to via pattern
../fonoteka.go/plugins/golem15/user/plugin.go ../fonoteka.go/plugins/golem15/user/admin.go the plugin implements pact.AdminAssets, pact.HasAdminControllers, pact.HasPermissions and pact.HasNavigation HasAdminControllers
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go bulk and record action Run functions call the class functions with the transaction from cabana.TxFromContext classes.(ActivateUsers|BanUsers|UnsuspendUser)
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go ../fonoteka.go/plugins/golem15/user/classes/privileged.go FormBeforeUpdate and FormBeforeDelete call classes.IsPrivilegedMember and cabana.Allows for PermissionManagePrivilegedGroups before a member's email, password or row is changed (D-30) IsPrivilegedMember
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go ../fonoteka.go/plugins/golem15/user/classes/last_seen.go Login and Refresh call classes.TouchLastSeen and ignore its error TouchLastSeen
from to via pattern
../fonoteka.go/plugins/golem15/user/models/user.go system_files AttachRelations declares the relation named avatar the user API already writes AttachRelations

Phase Goal

ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): Backend admins manage frontend users, user groups and organisations in the admin SPA without SQL, so the PHP backend is not needed for user administration after cutover. The Users, User Groups and Organisations screens of the PHP user plugin are ported to golem15.user, driven by its fields.yaml/columns.yaml.

This plan's slice: after it, a backend admin with the users permission opens Users from the navigation, filters and searches the list, sees who is deactivated, banned or not activated, opens a user's preview, activates, bans, unbans, unsuspends, deactivates, restores and permanently deletes users, creates a user with a password and an invitation, and edits name, email, organisation, avatar and frontend permissions.

Give `golem15.user` (repo `sm-user-plugin`) its admin foundation (three additive migrations, five permissions, navigation, the embedded admin YAML, a test harness) and port the PHP Users screen onto the framework contracts of v0.1.3: list with filters and row state, preview with status hint and record actions, form with password, invitation, organisation picker, avatar and the frontend permission editor, the delete semantics of PHP `Users.php`, the merged-permission resolver and the `last_seen` write.

Purpose: success criteria 1 and 3 for the Users screen; the groups field and its guard (success criterion 4) follow in plan 04 so users_groups never has an unguarded writer. The takeover guard of D-30 (a privileged-group member's email, password and permanent delete need golem15.users.manage_privileged_groups) lands here, in the commit that creates the Users controller, so no write path of this plan ever exists without it. Output: the files listed in the frontmatter, committed inside the plugin checkout.

Repos: every code change of this plan is committed inside the plugin checkout with git -C ../fonoteka.go/plugins/golem15/user (repo sm-user-plugin). Never stage plugin files from the application repository, and do not bump the submodule pointer or push (plan 04 bumps the pointer in a local commit; the plugin is pushed once, at the end of plan 05, after the security review and only when the framework tag v0.1.3 is on origin). Planning docs are committed separately in summercms.go. Never add co-author tags. The PHP reference at /media/nvme/dev/golem15/fonoteka is read-only. The plugin is shared across projects: migrations are additive, existing routes, payloads and exported functions keep their behaviour.

Known transitional state: the application's schema parity test (TestSchemaMatchesPHPSnapshot) reports the new table golem15_user_frontend_permissions until plan 04 adds its allow-list entry in the application repository. This plan therefore verifies the plugin's own suite, the application's user API parity flows and its admin boot tests, not the schema parity test.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md - Framework contracts at v0.1.3 (final names in the 12.1-01 and 12.1-02 summaries): `pact.AdminBulkAction`, `pact.HasAdminBulkActions`, `pact.AdminRecordAction`, `pact.HasAdminRecordActions`, `pact.RowState` with `RowStateDeleted`, `RowStateNegative`, `RowStateDisabled`, `pact.ListRowStates`, `pact.FormVirtualFields`, `pact.FormRules`, `pact.FilterScope`, `pact.FilterOptions` (controller first), `pact.AdminPartialData`, the Form hooks; `cabana.TxFromContext`, `cabana.VirtualFieldsFromContext`, `cabana.ValidationError`, `cabana.ForbiddenError`, `cabana.Allows`, `cabana.FieldRelationContract` (with `WritableForeignKey`), `cabana.FieldRelationProvider`, `cabana.PermissionOption`, `cabana.PermissionEditorProvider`; YAML keys `bulkActions`, `recordActions`, `preview`, `invisible`, `preset`, types `password`, `permissioneditor`, `fileupload`, list messages `rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`, form messages `preview`, `edit`. - Plugin today (`../fonoteka.go/plugins/golem15/user`, module `git.golem15.com/golem15/sm-user-plugin`, package `user`): `plugin.go` (`Plugin{app}`, interface assertion block, embeds `config`, `views/mail`, `lang`, `MailTemplates()`), `models.User` (soft delete through `DeletedAt`, `Groups` many2many with json "-", `MorphName()`, `Fillable()`, `Rules()` as the register rule set that must not change), `models.UserGroup`, `models.Organisation`, `models.Throttle`, `models.Register`; `classes.UserGroupCodes`, `classes.HasGroupCode`, `classes.IssueActivationCode`, `classes.VerifyActivationCode`, `classes.ContextWithThrottle`, `classes.RejectIfThrottled`, `classes.CheckAndRecordLogin`, `classes.LookupByEmail`; `controllers.Login`, `controllers.Refresh` (never loads the user row; the subject id comes from the token), `apiArray` (explicit payload map with `"permissions": nil` and `"groups": []`), `deleteAvatar` (attach.DeleteForOwner then attach.DeleteKeys), `activationLink`, `mailName`, `mailVars`, `sendTemplateVars`; `updates.Register`, `execStmts`; config namespace `golem15.user.*` (`password.bcrypt_cost`, `password.min_length`, `throttle.suspension_minutes`, `activation.activation_ttl_hours`). - Throttle rows are keyed by user and optional IP; login reads the row for the request IP, else the row with a NULL IP (`classes.RejectIfThrottled`). `user_throttle.user_id` references users(id) without ON DELETE. - Application analogs to copy: `../fonoteka.go/plugins/golem15/fonoteka/admin.go` (explicit `//go:embed` file list, `AdminFS`, `AdminControllers` with a lazy app lookup), `admin_permissions.go`, `admin_navigation.go`, `controllers/admin_registry.go`, `controllers/request_db.go`, `controllers/albums_admin_controller.go`, `controllers/collections_admin_controller.go`, `controllers/collections/*.yaml`, `models/collection/*.yaml`, `controllers/albums/_stats.htm`, `admin_tracer_test.go` (`assembleTracer`, `loginToken`, `getAuth`, `adminAPI`), `admin_collections_test.go` (minting a backend role and user with a chosen permission JSON). - PHP reference (read-only): `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user` (`controllers/Users.php`, `controllers/users/*`, `models/user/*.yaml`, `models/User.php`, `models/FrontendPermission.php`, `lang/en/lang.php`, `lang/pl/lang.php`, `views/mail/invite.htm`, `Plugin.php`) and `/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php` (`hasPermission`, `setPermissionsAttribute`) and `Throttle.php`. - Navigation icons available in the admin SPA at v0.1.3 (admin/src/app/icons.ts): `user`, `users`, `users-round`.

Artifacts this phase produces

(This plan's share.)

  • Migrations (gormigrate ids, one file each): 202610040001_add_users_permissions (users.permissions TEXT NULL), 202610040002_add_users_last_seen (users.last_seen TIMESTAMPTZ NULL), 202610040003_create_frontend_permissions (table golem15_user_frontend_permissions: id, code unique, label, tab, comment, created_at, updated_at).
  • Models: models.User fields Permissions PermissionSet, LastSeen *time.Time, read-only CreatedAt, UpdatedAt (all json "-"), methods AttachRelations(), FilterScopes(), FilterScope(); models.PermissionSet; models.FrontendPermission.
  • Classes: ActivateUsers, DeactivateUsers, RestoreUsers, BanUsers, UnbanUsers, UnsuspendUser, ThrottleStates, ForceDeleteCleanup, MergedPermissions, HasPermission, UserHasPermission, FrontendPermissionOptions, TouchLastSeen; in classes/privileged.go the constant PermissionManagePrivilegedGroups and PrivilegedGroupCodes, IsPrivilegedCode, PrivilegedGroupIDs, IsPrivilegedMember (plan 04 reuses them).
  • Config key golem15.user.privileged_groups (list of group codes, default [admin]).
  • Admin: controller id golem15.user.users (model name Golem15\User\Models\User, config dir controllers/users); controllers.AdminControllers; plugin methods AdminFS, AdminControllers, Permissions, Navigation; permission codes golem15.users.access_users, golem15.users.access_groups, golem15.users.access_settings, golem15.users.impersonate_user, golem15.users.manage_privileged_groups; navigation code user with side item users.
  • YAML: controllers/users/config_list.yaml, config_form.yaml, config_filter.yaml, _hint.htm; models/user/fields.yaml, columns.yaml.
  • Bulk actions activate, deactivate, restore, ban, unban; record actions activate, unban, unsuspend.
  • Mail templates golem15.user::mail.invite, golem15.user::mail.invite-en.
  • Lang keys under golem15.user::lang.plugin.*, users.*, user.*, plus organisation.label and organisation.empty_organisation (en and pl); among them the two D-30 messages users.privileged_member_forbidden and users.privileged_member_delete_forbidden.
  • Tests: admin_harness_test.go, TestAdminUsersTracer, TestAdminPrivilegedMember, TestAdminUserActions, TestAdminUserForceDelete, TestAdminUserPassword, TestAdminUserInvite, TestAdminAvatarSharedWithAPI, TestMergedPermissions, TestPermissionSetScan, TestLastSeen, migration tests in updates/admin_columns_test.go.

Planner decisions recorded for this plan

  • Privileged-groups permission (D-04 discretion). Code golem15.users.manage_privileged_groups, tab golem15.user::lang.plugin.tab, label golem15.user::lang.plugin.manage_privileged_groups. It is registered here and has no default role, so only a superuser or a role that was granted it explicitly holds it; the four PHP codes default to the developer role like the application's own permissions. This plan enforces it for the takeover paths of D-30; plan 04 enforces it for group membership and group codes.
  • Takeover guard (D-30) and its ordering. D-30 was decided after the plan check. The config key golem15.user.privileged_groups (D-05 discretion) and classes/privileged.go move from plan 04 into Task 1 of this plan, because Task 1's commit is the first that can write a user's email. The whole guard (email, password, delete) is written in that commit: the password half reads the form-only value, which does not exist until Task 3 declares the field, and the delete half sits in FormBeforeDelete, which guards the permanent delete from the moment Task 2 adds it. Plan 04 reuses the helpers unchanged.
  • What D-30 covers. Exactly the three operations D-30 names: email, password, permanent delete (form button and bulk delete). Everything else on a privileged member (name, surname, organisation, avatar, frontend permissions; activate, deactivate, restore, ban, unban, unsuspend) still needs only golem15.users.access_users, as in PHP (D-14): none of it hands the member's credentials to the acting admin, and each can be undone by another admin. The security review of plan 05 records this boundary next to the accepted risk T-12.1-26.
  • Presentation of a refusal (D-30). The email and password inputs are not shown locked: the framework at v0.1.3 has no per-record read-only state for text and password fields, and D-27 fixes the seam list. The refusal uses the forbidden presentation of UI-SPEC S6: the banner with marked fields on save, a danger toast on delete. The two message keys were added after the UI-SPEC sign-off; their texts are in Task 1 and follow the wording of users.privileged_group_forbidden.
  • Navigation in two steps. A navigation entry must point at a registered controller, so this plan registers the main item user with the side item users only; plan 04 adds usergroups and organisations together with their controllers.
  • Ban semantics on Go throttle rows. Ban sets is_banned on every user_throttle row of the user and makes sure a row with a NULL IP exists and is banned, so a login from any address is refused; unban clears the flag on every row; a user is banned when any row is banned; unsuspend clears is_suspended, suspended_at and attempts on every row; suspended is a pure read (a row with is_suspended and suspended_at inside golem15.user.throttle.suspension_minutes).
  • Activate. Sets is_activated, stamps activated_at, clears the activation code columns and restores the user when deactivated (as the Go VerifyActivationCode does); users already active are skipped.
  • Bulk success copy. A bulk action returns its fixed success phrase only when every selected user was affected; otherwise it returns no message and the affected count, so the framework's count toast is shown and never overstates (UI-SPEC checker note; D-29).
  • Preview content. No scoreboard (UI-SPEC: optional, left out).
  • Password stamp. An admin password reset stamps tokens_valid_after, as the user's own change-password path does, so existing tokens stop working.
  • Spec-less probe fallback: skipped (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" 1 to 6, 11 and 12 and the UI-SPEC rows lifted into must_haves.
Task 1: An admin with the users permission opens Users from the navigation, filters and searches the list with row states, and edits a user's name and email D-04 and D-15 (user-confirmed): the permission codes are stored in backend roles of every host application, and the additive migrations ship in the shared user plugin. The framework tag exists: `git tag --list v0.1.3` in summercms.go prints v0.1.3 (plan 02, D-25). ../fonoteka.go/plugins/golem15/user/updates/202610040001_add_users_permissions.go, ../fonoteka.go/plugins/golem15/user/updates/202610040002_add_users_last_seen.go, ../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go, ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/models/user/columns.yaml, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/classes/privileged.go, ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/request_db.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_filter.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go .planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md (D-04, D-05, D-30), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory and Gap Map" Users list and Plugin registration; "Current State of sm-user-plugin"; Anti-Patterns; Pitfalls 1, 2, 11), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (the plugin sections: users_admin_controller.go, request_db.go and admin_registry.go, admin.go and permissions and navigation, plugin YAML, models, updates, plugin admin tests, "No Analog Found"; the classes section on privileged.go; "Hook reading the write transaction and the backend principal"), .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Navigation and permissions, Users list; S6 "Forbidden save"), ../fonoteka.go/plugins/golem15/user/config/config.yaml, modules/compass/config.go, modules/cabana/contracts.go (Allows), .planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md, .planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/throttle.go, ../fonoteka.go/plugins/golem15/user/models/registry.go, ../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go, ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go, ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/plugins/golem15/user/session_test.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_permissions.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/request_db.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/columns.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go, modules/cabana/testdata/list/all_filters.yaml, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md (including "Refusing a write"), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/Users.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/config_list.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/config_filter.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/user/columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/lang/en/lang.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/lang/pl/lang.php Per D-02, D-03, D-04, D-05, D-12, D-13, D-15, D-17, D-18, D-23 and D-30. Repo: sm-user-plugin (commit with `git -C ../fonoteka.go/plugins/golem15/user`; one logical change per commit, for example migrations and model fields, then the admin registration with the controller, its YAML and the takeover guard of step (4a), then tests). The file classes/privileged.go and the guard go into the same commit that creates controllers/users_admin_controller.go, so the controller never exists without them.

(1) Migrations, one new file each, in the style of 202609220005_extend_users.go and 202610020001_create_user_groups.go (package-level slice, self-registration in init, rollback with IF EXISTS, a header comment naming the PHP source): id 202610040001_add_users_permissions adds users.permissions TEXT NULL; id 202610040002_add_users_last_seen adds users.last_seen TIMESTAMPTZ NULL; id 202610040003_create_frontend_permissions creates golem15_user_frontend_permissions with id SERIAL PRIMARY KEY, code VARCHAR(255) NOT NULL with a unique index, label VARCHAR(255) NOT NULL, tab VARCHAR(255) NOT NULL, comment VARCHAR(255) NULL, created_at and updated_at TIMESTAMPTZ NULL (PHP updates/v2.8.0/create_frontend_permissions_table.php). No shipped migration file is edited. updates/admin_columns_test.go: each migration applies and rolls back on a real Postgres through the existing harness.

(2) models/user.go: add LastSeen *time.Time (column last_seen), CreatedAt time.Time and UpdatedAt time.Time as read-only GORM fields (the arrow-only permission, so GORM never writes or stamps them) on columns created_at and updated_at; all three carry json "-". Add FilterScopes() returning the single name filterByGroup and FilterScope(name, db, value) narrowing users to members of the group id in value through a subquery on users_groups (an unknown name returns a query that matches nothing). Do not change Fillable(), Rules(), Hidden() or any existing field.

(3) Registration. admin_permissions.go: Permissions() with the five codes of the Artifacts section, tab constant golem15.user::lang.plugin.tab, labels golem15.user::lang.plugin.access_users, access_groups, access_settings, impersonate_user and manage_privileged_groups; the four PHP codes carry the default role developer; the privileged-groups code carries no role. admin_navigation.go: Navigation() with one item: code user, label golem15.user::lang.users.menu_label, icon user, permissions golem15.users.* (wildcard), order 555, controller golem15.user.users, side menu item users (label golem15.user::lang.users.menu_label, icon user, permission golem15.users.access_users, controller golem15.user.users). admin.go: an embed.FS with an explicit //go:embed list of every admin YAML and partial file (the controllers directory also holds a non-admin file, so no directory embed), AdminFS(), AdminControllers() delegating to controllers.AdminControllers with a closure that returns the plugin's app lazily, and the four compile-time assertions (pact.AdminAssets, pact.HasAdminControllers, pact.HasPermissions, pact.HasNavigation). controllers/admin_registry.go: AdminControllers(app func() *backpack.App) []pact.AdminController returning the users controller, plus the compile-time assertions of every optional interface the controller implements. controllers/request_db.go: the requestDB helper copied from the application analog (transaction from cabana.TxFromContext, else the pool).

(4) controllers/users_admin_controller.go: type usersAdminController holding the lazy app accessor; ID golem15.user.users; ModelName the PHP class string of the User model; ConfigDir controllers/users; RequiredPermissions golem15.users.access_users; NewRecord a new models.User. ListExtendQuery and FormExtendQuery both return the unscoped query (D-13 withTrashed). ListRowStates (pact.ListRowStates): one call of classes.ThrottleStates for the page's user ids, then per row RowStateDeleted when DeletedAt is set, RowStateNegative when banned, RowStateDisabled when not activated. FormRules (pact.FormRules): for create and update the email rule "required|between:6,255|email|unique:users" (password rules join in Task 3); the model's own Rules() stays the register contract. FilterOptions (pact.FilterOptions, on the controller): for scope filterByGroup the rows of user_groups ordered by name as value (the id as text) and label (the name); any other scope returns nothing. classes/admin_actions.go starts with ThrottleStates(ctx, db, userIDs) returning the banned and the suspended id sets per the ban semantics recorded above (a pure read).

(4a) Takeover guard (D-30, with the list of D-05). config/config.yaml: add the top-level key privileged_groups with the single default value admin (config path golem15.user.privileged_groups). classes/privileged.go, free functions in the style of classes/user_groups.go: constant PermissionManagePrivilegedGroups with the value golem15.users.manage_privileged_groups; PrivilegedGroupCodes(cfg) reading the list from the config on every call (a missing key or a nil config gives the single code admin; blank entries are dropped); IsPrivilegedCode(codes, code) taking the group's nullable code, comparing case-sensitively and returning false for a missing code; PrivilegedGroupIDs(ctx, db, codes) returning the ids of user_groups rows whose code is in the list; IsPrivilegedMember(ctx, db, codes, userID) reporting whether the user belongs to at least one group whose code is in the list, built on the existing classes.UserGroupCodes (a user in no group and a group without a code give false). Nothing is cached at boot. In the controller, one private helper serves both hooks: it answers nil when the principal on the context passes cabana.Allows for PermissionManagePrivilegedGroups, or when classes.IsPrivilegedMember (asked through requestDB, so inside the write transaction) is false; otherwise it answers a cabana.ForbiddenError with the message key and details it was given. A failed query is returned as it is and becomes the opaque 500. FormBeforeUpdate (pact.FormBeforeUpdate) begins with the guard, ahead of everything later tasks add to the hook: it reads the stored email of the user by id through requestDB with deactivated users included and compares it with the email on the model the hook receives, which is the value the save would write; it reads a submitted password from cabana.VirtualFieldsFromContext under the name password (no such form field exists until Task 3 declares it, so this half does nothing until then and guards the password from the commit that adds the field); when the email differs or a non-empty password was submitted it asks the helper with the message golem15.user::lang.users.privileged_member_forbidden and details that name email, password or both, each with a one-element list holding that message. FormBeforeDelete (pact.FormBeforeDelete) asks the helper with the message golem15.user::lang.users.privileged_member_delete_forbidden and no details; the framework calls this hook for the form's delete and once per record inside the single transaction of the bulk delete, so one refused record rolls the whole selection back. An update that changes neither value, a user who is not a privileged member and an admin holding the permission pass untouched; create is not guarded (a new user has no membership). Add the assertion lines for pact.FormBeforeUpdate and pact.FormBeforeDelete in admin_registry.go.

(5) YAML in the Go dialect (never copy PHP YAML verbatim: tabs, secondaryTabs, cssClass, hidden, disabled, valueFrom, a scalar toolbar.buttons and conditions are boot errors). controllers/users/config_list.yaml: list pointing at models/user/columns.yaml, the modelClass, title golem15.user::lang.users.list_title, recordUrl golem15/user/users/update/:id (Task 2 switches it to preview), noRecordsMessage, recordsPerPage 20, showCheckboxes true, showSetup true, filter config_filter.yaml, toolbar with an empty buttons list and the search prompt, and a messages block with empty golem15.user::lang.users.list_empty, rowStateDeleted golem15.user::lang.users.state_deactivated, rowStateNegative golem15.user::lang.users.state_banned, rowStateDisabled golem15.user::lang.users.state_not_activated. models/user/columns.yaml: id (invisible), name (searchable), surname (searchable, invisible), email (searchable), created_at (type datetime, label golem15.user::lang.user.created_at), last_seen (type datetime), created_ip_address and last_ip_address (searchable, invisible); no username and no is_guest column. controllers/users/config_filter.yaml: groups (label golem15.user::lang.group.label, modelClass of the UserGroup PHP class string, nameFrom name, scope filterByGroup), created_date (type daterange, column created_at), activated (type switch, column is_activated). controllers/users/config_form.yaml: name golem15.user::lang.user.label, form pointing at models/user/fields.yaml, the modelClass, defaultRedirect golem15/user/users, update redirect golem15/user/users/update/:id and redirectClose golem15/user/users, a messages block (update, saved, deleteConfirm golem15.user::lang.users.delete_confirm, deleted). models/user/fields.yaml: name, surname and email as type text under the tab golem15.user::lang.user.account (name and surname move into the Account tab; email span full).

(6) lang/en/lang.yaml and lang/pl/lang.yaml: add the keys this task names under plugin (tab, access_users, access_groups, access_settings, impersonate_user, manage_privileged_groups), users (menu_label, list_title, list_empty, state_deactivated, state_banned, state_not_activated, delete_confirm), user (label, id, name, surname, email, account, created_at, last_seen, created_ip_address, last_ip_address, status_activated) and group (label), with the PHP values from lang/en/lang.php and lang/pl/lang.php and the UI-SPEC texts for keys marked new or changed there. Keep the existing account keys. Two more keys under users for D-30 (new, decided after the UI-SPEC): privileged_member_forbidden with the text "This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved." and in Polish "Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane."; privileged_member_delete_forbidden with the text "Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted." and in Polish "Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte."

(7) plugin.go: no behaviour change; the new capabilities live in admin.go, admin_permissions.go and admin_navigation.go.

(8) Test harness admin_harness_test.go (package user): boot the plugin with cabana mounted on a real Postgres (the application's assembleTracer shows the recipe: application object, lagoon.Publish, party.Activate of golem15.user alone, lagoon.Migrate, surf.Assemble), helpers to insert a backend role with a chosen permission JSON and a backend user in it, log in and return the token, plus adminAPI path building and JSON request helpers. admin_users_test.go TestAdminUsersTracer: the navigation lists the item user for an admin holding golem15.users.access_users and not for an admin with no permission; that admin gets 403 on the Users list; the list returns active and deactivated users with meta.row_states (deleted for a deactivated user, negative for a banned one, disabled for a not activated one); a search by an invisible column (surname) finds the user and the row has no surname key; the groups filter narrows to a group's members and its options route lists the groups; the daterange and the activated filter work; an update of name and email answers 200 and persists; an update of a deactivated user answers 200 and keeps deleted_at; a duplicate email answers 422; no response body contains a password key. TestAdminPrivilegedMember (D-30) in the same file, with a group whose code is admin and an ordinary group created by the test and memberships inserted directly into users_groups (the groups field does not exist until plan 04): as an admin holding only golem15.users.access_users, an update that changes a privileged member's email answers 403 with the text of users.privileged_member_forbidden and details on email, and the stored email and name are unchanged although the same body also changed the name; an update of the same user that sends the stored email unchanged together with a new name answers 200 and the name persists; the form delete of that user answers 403 with the text of users.privileged_member_delete_forbidden and the row is still there with deleted_at unchanged; a member of the ordinary group only gets 200 for the same email change; a deactivated privileged member is protected the same way; with the config key overridden to the ordinary group's code the protection moves to that group's member; as an admin who also holds golem15.users.manage_privileged_groups the email change answers 200 and the delete succeeds. go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/updates -run '^(TestAdminColumns|TestUserGroups)' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUsersTracer|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1 <fails_when>Any command exits non-zero; the tracer run lacks "--- PASS: TestAdminUsersTracer" or "--- PASS: TestAdminPrivilegedMember"; the updates run lacks a "--- PASS: TestAdminColumns" line; any run prints "no tests to run" or a line starting with "FAIL".</fails_when> <acceptance_criteria> - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUsersTracer$' -count=1 -v prints "--- PASS: TestAdminUsersTracer"; the test asserts navigation gating, the three row states, the groups filter and its options, an update of a deactivated user, and a 422 on a duplicate email. - git -C ../fonoteka.go/plugins/golem15/user ls-files updates | grep -c '2026100400' prints 3. - git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- updates/00_base.go updates/10_organisations.go updates/202609220005_extend_users.go updates/202609220006_create_user_throttle.go updates/202609220007_create_jwt_blacklist.go updates/202610020001_create_user_groups.go prints nothing (shipped migrations are untouched). - grep -c 'golem15.users.manage_privileged_groups' ../fonoteka.go/plugins/golem15/user/admin_permissions.go prints 1 and grep -c 'impersonate_user' ../fonoteka.go/plugins/golem15/user/admin_permissions.go prints at least 1. - TestAdminUsersTracer asserts, on the list schema served by the admin API, that the column keys are exactly id, name, surname, email, created_at, last_seen, created_ip_address and last_ip_address (so neither the dropped login-name column of D-18 nor the dropped guest column of D-03 exists) and that the filters are exactly groups, created_date and activated. - go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1 passes (the user API payloads are unchanged). - git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- go.mod shows no added require line for a new module. - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v prints "--- PASS: TestAdminPrivilegedMember"; the test asserts, for an admin without golem15.users.manage_privileged_groups, 403 with an unchanged row for an email change and for the form delete of a privileged member, 200 for a name-only update of that member, and success for both refused requests once the admin holds the permission (D-30). - git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- controllers/users_admin_controller.go and git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go print the same commit sha, and the controller file as of that commit contains the guard: git -C ../fonoteka.go/plugins/golem15/user grep -q 'IsPrivilegedMember' "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go)" -- controllers/users_admin_controller.go exits 0. - grep -c 'privileged_groups' ../fonoteka.go/plugins/golem15/user/config/config.yaml prints 1 and grep -c 'privileged_member_delete_forbidden' ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml prints 1. </acceptance_criteria> The user plugin is an admin plugin: it registers permissions and navigation, its Users list with filters, search and row states works for an admin with the users permission, and a user's name and email can be edited; the email and the delete of a privileged-group member are refused without the privileged-groups permission.

Task 2: An admin opens a user's preview, sees the status hint, and activates, bans, unbans, unsuspends, deactivates, restores and permanently deletes users Plugin controller code and YAML on top of the tagged framework contract; the delete is permanent by D-13 (user-confirmed) and guarded by the standard confirm. ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_users_test.go .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Users list bulk actions table, Users preview, the status hint and record action tables; "Partial style kit addition"), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" Users preview; "T-12-18" paths 2, 5 and 8; Pitfall 3; "Don't Hand-Roll"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (classes section: attachment cleanup on force delete; partial view model), ../fonoteka.go/plugins/golem15/user/classes/throttle.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (deleteAvatar, Login's restore of a deactivated user), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go (PartialData), modules/cabana/testdata/roster/controllers/people/_status.htm, modules/cabana/testdata/roster/controllers/people/config_form.yaml, modules/lagoon/transaction.go (AfterCommit), modules/lagoon/attach/file.go (DeleteForOwner, DeleteKeys), docs/backend/admin-controllers.md (Bulk actions, Record actions), docs/backend/forms.md (Preview screen), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/Users.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/_hint_banned.htm, _hint_trashed.htm, _hint_activate.htm and _preview_toolbar.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (attemptActivation, ban, unban, unsuspend, afterDelete), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/Throttle.php Per D-11, D-13, D-14 and D-30; UI-SPEC "Users list" and "Users preview". Repo: sm-user-plugin.

(1) classes/admin_actions.go, free functions taking the context and the transaction handle (never opening a second transaction): ActivateUsers(ctx, db, users) returning the number activated (skips users already active; sets is_activated, stamps activated_at, clears activation_code and activation_code_issued_at, clears deleted_at when set); DeactivateUsers (sets deleted_at on users that are not deactivated; returns the number changed); RestoreUsers (clears deleted_at on deactivated users); BanUsers and UnbanUsers per the ban semantics recorded above; UnsuspendUser(ctx, db, userID) (clears is_suspended, suspended_at and attempts on every row of the user); ForceDeleteCleanup(ctx, db, bucket, user) which deletes the user's user_throttle rows and users_groups rows, removes the user's attachments with attach.DeleteForOwner collecting the blob keys, and registers the blob deletion with lagoon.AfterCommit so blobs go only after the commit. None of these functions writes users_groups except the cleanup of a user being permanently deleted.

(2) Controller. AdminBulkActions (pact.HasAdminBulkActions): activate, deactivate, restore, ban and unban, each with label golem15.user::lang.users.NAME_selected, confirm golem15.user::lang.users.NAME_selected_confirm, permission golem15.users.access_users, and a Run that asserts every record is a user, calls the class function with the transaction from cabana.TxFromContext, returns the affected count, and returns the message golem15.user::lang.users.NAME_selected_success only when the affected count equals the number of records (otherwise an empty message). AdminRecordActions (pact.HasAdminRecordActions): activate (label golem15.user::lang.users.activate_user, confirm users.activate_confirm, success users.activated_success, applies when the user is not activated), unban (label users.unban_user, confirm users.unban_confirm, success users.unbanned_success, applies when banned) and unsuspend (label users.unsuspend_user, confirm users.unsuspend_confirm, success users.unsuspend_success, applies when suspended), each with permission golem15.users.access_users and an Applies that is a pure read. FormAfterDelete (pact.FormAfterDelete): call ForceDeleteCleanup and then delete the user row unscoped inside the same transaction, so the form delete button and the bulk delete are permanent (D-13). The FormBeforeDelete guard of Task 1 stays as it is and runs before this hook, so the permanent delete is guarded from the commit that introduces it (D-30): for an admin without golem15.users.manage_privileged_groups the delete of a privileged-group member is refused with 403, no cleanup runs and no blob deletion is registered, and a bulk delete whose selection contains such a user deletes none of the selected users. The bulk action deactivate is not a permanent delete and is not guarded. PartialData (pact.AdminPartialData) for the partial named hint: a curated view model with the callout kind (danger or warning) and the title and text phrase keys, chosen by precedence banned, then deactivated, then not activated, then suspended; an empty view model when none applies; an unknown partial name is an error. No impersonate action and no guest action exists.

(3) YAML. config_list.yaml: recordUrl becomes golem15/user/users/preview/:id; toolbar.buttons becomes [delete]; bulkActions [activate, deactivate, restore, ban, unban]; messages gain deleteConfirm golem15.user::lang.users.delete_selected_confirm and deleted. config_form.yaml: a preview block with headerPartial hint; recordActions [activate, unban, unsuspend]; messages edit golem15.user::lang.users.update_details; update.redirectClose golem15/user/users/preview/:id. models/user/fields.yaml gains created_ip_address and last_ip_address (type text, context preview, tab account, without the PHP disabled key). New controllers/users/_hint.htm: one callout using the summer-callout classes and role status, rendering nothing for an empty view model; add it and every changed YAML file to the embed list in admin.go.

(4) lang (en and pl), with the PHP values and the UI-SPEC texts for new and changed keys: users.activate_selected, deactivate_selected, restore_selected, ban_selected, unban_selected and their _confirm and _success keys; users.delete_selected_confirm; users.update_details; users.banned_hint_title and banned_hint_desc; users.trashed_hint_title and trashed_hint_desc (the PHP values copied verbatim); users.activate_warning_title and activate_warning_desc; users.suspended_hint_title and suspended_hint_desc; users.activate_user, activate_confirm, activated_success; users.unban_user, unban_confirm, unbanned_success; users.unsuspend_user, unsuspend_confirm, unsuspend_success.

(5) Tests in admin_users_test.go. TestAdminUserActions: bulk activate on two inactive and one active user answers affected 2 with no message and all three are active; bulk deactivate sets deleted_at and the users are still listed with the deleted state; bulk restore clears it; bulk ban makes a login attempt of that user fail and the row state negative; bulk unban allows login again; the record action activate is offered only for a not activated user and answers 409 on a second run; unban is offered only for a banned user; unsuspend is offered only for a user suspended inside the window and clears the suspension; the preview partial renders the banned callout before the deactivated one; the users_groups rows of every user are identical before and after each action. TestAdminUserForceDelete: the form delete and the bulk delete remove the user row, its user_throttle rows, its users_groups rows and its system_files rows for a user who has a throttle row, an ordinary group and an avatar, and the blob is deleted after the commit; an admin without golem15.users.access_users gets 403. Extend TestAdminPrivilegedMember (D-30) with the permanent delete: as an admin holding only golem15.users.access_users, the form delete of a privileged member who has a throttle row and an avatar answers 403 and the user row, its users_groups, user_throttle and system_files rows and its blob are all still there; a bulk delete of that member together with two ordinary users answers 403 with the text of users.privileged_member_delete_forbidden and all three users and their rows still exist; the bulk action deactivate on the member answers 200; as an admin who also holds golem15.users.manage_privileged_groups the form delete and the bulk delete remove the member with the full cleanup. go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUserActions|TestAdminUserForceDelete|TestAdminUsersTracer|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 <fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestAdminUserActions", "--- PASS: TestAdminUserForceDelete" or "--- PASS: TestAdminPrivilegedMember"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when> <acceptance_criteria> - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserActions$' -count=1 -v prints "--- PASS: TestAdminUserActions"; the test asserts the affected count of bulk activate with an already active user in the selection and that users_groups is unchanged after every action. - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserForceDelete$' -count=1 -v prints "--- PASS: TestAdminUserForceDelete"; the test deletes a user that has a user_throttle row (the foreign key without ON DELETE) and asserts no orphan row remains. - grep -c 'preview/:id' ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml prints 1. - TestAdminUserActions asserts that the list schema's bulk actions are exactly delete, activate, deactivate, restore, ban and unban and that the record actions the controller registers are exactly activate, unban and unsuspend (D-01 and D-03: nothing else is offered). - grep -c 'summer-callout' ../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm prints at least 1. - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v prints "--- PASS: TestAdminPrivilegedMember"; the test asserts that a bulk delete of one privileged member and two ordinary users by an admin without golem15.users.manage_privileged_groups answers 403 and leaves all three users, their throttle, pivot and attachment rows in place, and that the same bulk delete succeeds with the permission (D-30). </acceptance_criteria> The Users preview, the status hint, the three record actions, the five bulk actions and the permanent delete behave as PHP Users.php does, on the framework's scoped action routes.

Task 3: An admin creates a user with a password and an invitation, resets passwords, picks an organisation, sets an avatar shared with the app, and edits frontend permissions that a resolver can answer D-15 (user-confirmed): the stored permission JSON must stay readable for the cutover import, and the resolver's semantics become what other plugins rely on. ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/permission_set.go, ../fonoteka.go/plugins/golem15/user/models/frontend_permission.go, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/classes/permissions.go, ../fonoteka.go/plugins/golem15/user/classes/permissions_test.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/views/mail/invite.htm, ../fonoteka.go/plugins/golem15/user/views/mail/invite-en.htm, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_users_test.go .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Users form), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" Users form; Pitfalls 1, 4, 5, 6, 12; "File upload (D-20)"; "Don't Hand-Roll"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (models section; "No Analog Found" resolver note), ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (UploadAvatar, applyAvatar, ChangePassword, activationLink, mailName, mailVars, sendTemplateVars), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/classes/mail.go, ../fonoteka.go/plugins/golem15/user/views/mail/activate.htm, ../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm, ../fonoteka.go/plugins/golem15/user/avatar_test.go, ../fonoteka.go/plugins/golem15/user/mail_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (captureMail), ../fonoteka.go/plugins/golem15/feedback/models/submission.go (AttachRelations), modules/cabana/contracts.go (granted, the wildcard reference), modules/cabana/example_form_seams_test.go, docs/backend/forms.md (password, form-only fields, permission editor, file uploads), docs/backend/relation-manager.md (writable foreign keys), modules/lagoon/validate.go (unique and soft-deleted rows), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/user/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (getMergedPermissions, afterCreate, sendInvitation, rules), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/FrontendPermission.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/views/mail/invite.htm, /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php (hasAccess, hasPermission, setPermissionsAttribute) Per D-15, D-16, D-19, D-20, D-22, D-29 and D-30; UI-SPEC "Users form"; RESEARCH Pitfalls 1, 4, 5, 6 and 12. Repo: sm-user-plugin.

(1) Permission data. models/permission_set.go: PermissionSet (a map of code to integer) with a tolerant Scan (NULL, an empty string, the texts [] and null give an empty set; a JSON object gives its entries, accepting integers and numeric strings and skipping entries whose value is not numeric; any other content is an error) and a Value that writes NULL for an empty set and otherwise a JSON object of integers without zero values. models/user.go: add Permissions PermissionSet on column permissions with json "-". models/frontend_permission.go: FrontendPermission (ID, Code, Label, Tab, Comment as a nullable string, CreatedAt and UpdatedAt as nullable times; table golem15_user_frontend_permissions; registered in init). Rows come from migrations or seeds, as in PHP; no plugin-declared registry.

(2) Resolver in classes/permissions.go, a line-by-line port of PHP: MergedPermissions(ctx, db, userID) reads the permissions of the user's groups through users_groups (scanned tolerantly) and keeps per code the highest value across groups (most positive wins), then overlays the user's own values (the user's value replaces the group's); HasPermission(merged, code) is true only when the matching value is exactly 1 and supports a trailing or leading asterisk wildcard on the asked code and on the stored code, as Winter's hasPermission does; UserHasPermission(ctx, db, userID, code) combines the two; FrontendPermissionOptions(ctx, db) returns the table's rows ordered by tab then code. Nothing in the application calls the resolver yet.

(3) Form. models/user/fields.yaml (tab account unless said otherwise): send_invite (type checkbox, default true, context create, label and comment from user.send_invite and user.send_invite_comment); password (type password, span left, context create and update, label user.password, comment user.password_comment); password_confirmation (type password, span right, context create and update, label user.confirm_password, comment user.confirm_password_comment); organisation (type relation, nameFrom name, span full, emptyOption golem15.user::lang.organisation.empty_organisation, label golem15.user::lang.organisation.label); avatar (type fileupload, mode image, imageWidth 260, imageHeight 260, label user.avatar); permissions (type permissioneditor, mode radio, span full, context update, tab golem15.user::lang.user.permissions_tab). No username, block_mail, groups (plan 04) or guest field. config_list.yaml toolbar.buttons becomes [create, delete] and messages gain create golem15.user::lang.users.new_user. config_form.yaml: create.redirect golem15/user/users/preview/:id, create.redirectClose golem15/user/users, messages create.

(4) Controller. FormVirtualFields: password, password_confirmation, send_invite. FormRules: on create the password rule is required, between the configured minimum length (golem15.user.password.min_length, default 8) and 255, confirmed; on update it is nullable with the same bounds and confirmed; the email rule stays. The takeover guard of Task 1 stays the first statement of FormBeforeUpdate and is not changed: now that password is a declared form-only field, a non-empty password submitted for a privileged-group member by an admin without golem15.users.manage_privileged_groups is refused with 403 and details on password before the uniqueness check, the hashing and the token stamp below run (D-30); everything this step adds to the hook goes after the guard. FormBeforeCreate and FormBeforeUpdate: refuse an email that another user, deactivated ones included, already has with a ValidationError on email (the unique rule ignores deactivated rows); when a password was submitted (read from cabana.VirtualFieldsFromContext) store its hash from bouncer.HashPassword at the configured cost (golem15.user.password.bcrypt_cost, default 10); on update a submitted password also stamps tokens_valid_after with the current time; an absent or empty password on update changes nothing. A user created in the admin keeps is_activated false (D-29). FormAfterCreate: when send_invite is true, issue an activation code with classes.IssueActivationCode inside the transaction and register with lagoon.AfterCommit the sending of exactly one mail from the template golem15.user::mail.invite (the locale variant chosen as the other user mails do) carrying the user's name and the activation link built by the existing activationLink; a create that rolls back sends nothing. AdminFieldRelations (cabana.FieldRelationProvider): organisation as belongsTo to models.Organisation with foreign key organisation_id, label column name and WritableForeignKey true. PermissionEditorProvider for the field permissions: options from FrontendPermissionOptions (code, label, tab, comment; none locked), values from the user's Permissions, and the setter assigning the validated map to it. models/user.go: AttachRelations() declaring the relation named avatar with the same public flag the user API uses when it stores an avatar.

(5) Mail. views/mail/invite.htm and invite-en.htm ported from the PHP invite template in the structure of the existing activate templates; plugin.go MailTemplates() gains golem15.user::mail.invite and golem15.user::mail.invite-en.

(6) lang (en and pl): users.new_user; user.password and user.password_comment (new, UI-SPEC texts); user.confirm_password and its comment; user.send_invite and its comment; user.avatar; user.permissions_tab (new); organisation.label and organisation.empty_organisation; the invite mail's subject key if the template uses one.

(7) Tests. classes/permissions_test.go: TestMergedPermissions as a table of cases (two groups with different values for one code keep the higher; a user value of -1 overrides a group's 1; a user value of 1 overrides a group's -1; a code held only at 0 or -1 is not held; trailing and leading wildcards on either side; a user with no groups) and TestPermissionSetScan (NULL, empty string, the text [], an object with numeric strings, an object with a non-numeric entry, malformed JSON; Value for an empty set and for a set with a zero). admin_users_test.go: TestAdminUserPassword (create with matching passwords answers 201, the stored hash verifies with bouncer.CheckPassword, the user is not activated, and no create, show, update or list response contains the password or a password key; a confirmation mismatch answers 422 on password; an update without a password keeps the hash and tokens_valid_after; an update with a new password changes the hash and stamps tokens_valid_after; a create with the email of a deactivated user answers 422 on email), TestAdminUserInvite (create with send_invite true sends exactly one invite mail whose link activates the user through the existing activation route; with send_invite false no mail; a create that fails validation sends none), TestAdminAvatarSharedWithAPI (an avatar uploaded through the admin file route and saved appears as avatar_url and has_avatar in the user API payload; an avatar uploaded through the user API appears in the admin file list of the avatar field), plus cases in an existing or new test for the organisation picker (a save sets and clears organisation_id) and the permission editor (the form schema offers the seeded permissions grouped by tab; saving allow and deny stores the JSON object; an unknown code answers 422; the user API payload still carries permissions as null). Extend TestAdminPrivilegedMember (D-30) with the password: as an admin holding only golem15.users.access_users, an update that submits a password and its confirmation for a privileged member answers 403 with the text of users.privileged_member_forbidden and details on password, the stored hash and tokens_valid_after are unchanged, a login with the old password still succeeds and a login with the attempted password fails; a body that changes the email and submits a password names both fields in the details; a body with an empty password and a new name answers 200; changing the avatar, the organisation and the frontend permissions of that member answers 200; as an admin who also holds golem15.users.manage_privileged_groups the password reset answers 200 and stamps tokens_valid_after. go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^(TestMergedPermissions|TestPermissionSetScan)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUserPassword|TestAdminUserInvite|TestAdminAvatarSharedWithAPI|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1 <fails_when>Any command exits non-zero; a named run lacks any of "--- PASS: TestMergedPermissions", "--- PASS: TestPermissionSetScan", "--- PASS: TestAdminUserPassword", "--- PASS: TestAdminUserInvite", "--- PASS: TestAdminAvatarSharedWithAPI", "--- PASS: TestAdminPrivilegedMember"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when> <acceptance_criteria> - go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^TestMergedPermissions$' -count=1 -v prints "--- PASS: TestMergedPermissions"; its table includes a case where a user-level 1 overrides a group-level -1 (the port is not "deny wins"). - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserPassword$' -count=1 -v prints "--- PASS: TestAdminUserPassword"; the test asserts that an update changing only the name answers 200 (RESEARCH Pitfall 1) and that no response contains the password. - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserInvite$' -count=1 -v prints "--- PASS: TestAdminUserInvite"; the test asserts exactly one mail. - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminAvatarSharedWithAPI$' -count=1 -v prints "--- PASS: TestAdminAvatarSharedWithAPI". - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v prints "--- PASS: TestAdminPrivilegedMember"; the test asserts that a password submitted for a privileged member by an admin without golem15.users.manage_privileged_groups answers 403 with an unchanged hash and an unchanged tokens_valid_after, that the old password still logs in, and that the same reset answers 200 with the permission (D-30). - grep -c 'imageWidth: 260' ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml prints 1. - grep -c 'golem15.user::mail.invite' ../fonoteka.go/plugins/golem15/user/plugin.go prints 2. - git -C ../fonoteka.go/plugins/golem15/user diff 8a65890 -- models/user.go shows no change inside the Rules(), Fillable() and Hidden() methods. - go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1 passes. </acceptance_criteria> The Users form carries the PHP create and update behaviour (password, invitation, organisation, avatar, frontend permissions), and any plugin can ask the resolver whether a user holds a frontend permission.

Task 4: The Users list shows when each user was last seen, written by login and refresh without ever failing them, and the plugin README describes the admin screens One guarded UPDATE in two handlers and documentation; the column itself was added in Task 1. ../fonoteka.go/plugins/golem15/user/classes/last_seen.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/last_seen_test.go, ../fonoteka.go/plugins/golem15/user/README.md .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Open Question 4, resolved by D-29; Pitfall 11; "Current State of sm-user-plugin" auth path), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login, Refresh, apiArray), ../fonoteka.go/plugins/golem15/user/session_test.go, ../fonoteka.go/plugins/golem15/user/README.md, modules/bouncer (Refresh and VerifyClaims signatures, through `go doc ./modules/bouncer Refresh` and `go doc ./modules/bouncer VerifyClaims`) Per D-17 and D-29. Repo: sm-user-plugin.

(1) classes/last_seen.go: TouchLastSeen(ctx, db, userID) runs one UPDATE on users that sets last_seen to the current time for that id only when last_seen is NULL or older than five minutes, and returns the error to the caller without logging secrets. It does not touch updated_at and applies to deactivated users too.

(2) controllers/api_controller.go: Login calls TouchLastSeen after the successful credential and throttle checks and before the response; Refresh calls it after a successful refresh with the subject id read from the new token's claims (Refresh never loads the user row). In both, an error is logged at warning level with the user id and the handler continues: the response, its status and its body are exactly what they are today. No payload gains a key.

(3) last_seen_test.go TestLastSeen: a login sets last_seen; a second login within five minutes leaves the value unchanged; with last_seen moved six minutes into the past a refresh updates it; with the users table made unwritable for that statement (for example a database handle whose update fails) login and refresh still answer 200 with the same body shape; the login, fetch and refresh payloads contain none of the keys last_seen, created_at, updated_at, and their permissions key is still null and groups still an empty list; marshalling a models.User with encoding/json yields no last_seen, permissions, created_at or updated_at key.

(4) README.md of the plugin (the standard structure, no consuming-application name): the admin screens (Users now; User Groups and Organisations announced as part of the same release and filled in by plan 04), the five permission codes, the navigation item, the three migrations, the frontend permission resolver (MergedPermissions, HasPermission, UserHasPermission), the last_seen rule, and the protection of privileged-group members (D-30): the config key golem15.user.privileged_groups with its default, and the rule that changing such a user's email or password or permanently deleting them needs golem15.users.manage_privileged_groups on top of golem15.users.access_users.

(5) Run the plugin's full suite and the application checks named in the verify block; record the measured run times in the summary for VALIDATION.md. go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestLastSeen|TestLogin|TestRefresh)' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1 <fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestLastSeen"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when> <acceptance_criteria> - go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestLastSeen$' -count=1 -v prints "--- PASS: TestLastSeen"; the test asserts the five-minute rule, that a failing write leaves login and refresh at 200, and that no user payload carries last_seen. - grep -c 'TouchLastSeen' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go prints 2. - go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1 passes (payloads byte-identical). - grep -c 'manage_privileged_groups' ../fonoteka.go/plugins/golem15/user/README.md prints at least 1 and grep -ci 'fonoteka\|płytarium\|plytarium' ../fonoteka.go/plugins/golem15/user/README.md prints 0. - git -C ../fonoteka.go/plugins/golem15/user status --short prints nothing after the task's commits. </acceptance_criteria> last_seen is recorded on login and refresh under the five-minute rule without any effect on authentication or payloads, the Users list shows it, and the plugin README documents the admin half.

<threat_model>

Trust Boundaries

Boundary Description
Backend admin → admin API (Users controller) Field values, virtual values, ids and action names from an authenticated admin
Admin screens → frontend user accounts Admin actions change who can log in (activate, ban, password, delete)
Admin with the users permission → accounts of privileged-group members Whoever controls such an account's email or password holds its site-admin powers (D-30)
User plugin → user API payloads New model fields must not reach the Nuxt contract
Plugin → mail transport An admin-triggered invitation leaves the system

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-12.1-18 Elevation of Privilege Users screens and actions without the users permission high mitigate RequiredPermissions golem15.users.access_users on the controller and on every bulk and record action; navigation filtered per principal; 403 asserted for an admin without it (Tasks 1, 2).
T-12.1-19 Information Disclosure password hash, plain password or reset codes in admin responses high mitigate No form field maps to the hash; password is a virtual password field that the framework never projects; model secrets keep json "-"; tests scan every response body (Tasks 1, 3).
T-12.1-20 Tampering mass assignment of is_activated, permissions or organisation_id through the user form high mitigate The framework's protected fill keys stay; activation only through the activate actions (D-29); permissions only through the permission editor's validated path; organisation_id only through the relation field with the explicit opt-in and scoped ids (Task 3).
T-12.1-21 Spoofing tokens issued before an admin password reset stay valid medium mitigate A password submitted on update stamps tokens_valid_after, as ChangePassword does (Task 3, TestAdminUserPassword).
T-12.1-22 Denial of Service invitation mail abuse low mitigate send_invite is create-only and sends one mail per created user after the commit; there is no bulk invite action (Task 3, TestAdminUserInvite).
T-12.1-23 Tampering permanent delete leaves orphans or half-deleted users medium mitigate ForceDeleteCleanup and the unscoped delete run in the save transaction; blobs are deleted after the commit; the standard confirm states that the delete is permanent (Task 2, TestAdminUserForceDelete).
T-12.1-24 Information Disclosure last_seen, permissions, timestamps or groups leak into user API payloads high mitigate json "-" on every new field; payloads come from the explicit apiArray map; TestLastSeen, TestUserAPINuxtFlows, TestParityCorpus and TestPhase12Threats (Tasks 1, 3, 4).
T-12.1-25 Denial of Service last_seen write fails or amplifies writes on the auth path low mitigate One conditional UPDATE at most every five minutes per user; errors are logged and ignored (Task 4).
T-12.1-26 Elevation of Privilege a deactivated site admin is re-enabled by restore or activate medium accept PHP behaves the same (the membership survives a soft delete) and the action needs golem15.users.access_users; recorded for the security review of plan 05 (RESEARCH T-12-18 path 8).
T-12.1-27 Elevation of Privilege a wrong port of the permission merge grants a frontend permission medium mitigate Line-by-line port of getMergedPermissions and hasPermission with a table test covering overrides, the exactly-1 rule and wildcards; no caller exists yet (Task 3, TestMergedPermissions).
T-12.1-38 Elevation of Privilege takeover of a privileged-group member's account: an admin holding only golem15.users.access_users resets that user's password, or changes their email and then uses the password-reset mail, and logs in as a site admin critical mitigate D-30 (user decision at the plan check; differs from PHP on purpose). The guard at the top of the Users controller's FormBeforeUpdate refuses a changed email or a submitted password on a privileged member with cabana.ForbiddenError (403, rollback, nothing saved) unless the principal passes cabana.Allows for golem15.users.manage_privileged_groups; membership is read inside the write transaction against the per-request list of D-05; the guard exists from the commit that creates the controller (Tasks 1 and 3, TestAdminPrivilegedMember; removal test in plan 05).
T-12.1-39 Tampering permanent delete of a privileged-group member, by the form button or inside a bulk delete, by an admin holding only golem15.users.access_users high mitigate D-30. FormBeforeDelete refuses with cabana.ForbiddenError before the row, its cleanup or any blob deletion is touched; the bulk delete runs in one transaction, so a selection containing one such user is refused as a whole (Tasks 1 and 2, TestAdminPrivilegedMember; removal test in plan 05). Outside D-30 by its wording and left at golem15.users.access_users as in PHP (D-14): deactivate, restore, ban, unban, unsuspend and activate on a privileged member; none of them gives the acting admin the member's credentials, and T-12.1-26 records the related accepted risk.
T-12.1-SC Tampering npm/pip/cargo installs high mitigate No package is installed: the plugin's go.mod gains no require line (acceptance check in Task 1); any need for a module stops at a blocking human checkpoint.
</threat_model>
- `go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1` green after every commit in the plugin checkout. - `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v` green after Tasks 1, 2 and 3 (D-30: email, permanent delete, password). - `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1` green (the application still boots its admin with the plugin's controllers; T-12-18's payload rule holds). - `go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1` green (user API contract unchanged). - Not expected green until plan 04: `go -C ../fonoteka.go test ./parity -run TestSchemaMatchesPHPSnapshot` (the new table needs its allow-list entry).

<success_criteria>

  • Users has its list (columns, search, the three PHP filters, row states), preview (hint, preview-only fields, record actions) and create and update form (password, invitation, organisation, avatar, permissions), reachable from the navigation and gated by backend permissions.
  • activate, unban, unsuspend, delete and the bulk actions behave as PHP Users.php, with the deviations recorded in D-29.
  • The frontend permission data and resolver exist and are proven by unit tests; last_seen is written under the five-minute rule.
  • A privileged-group member's email, password and permanent delete need golem15.users.manage_privileged_groups; without it the request is refused with 403 and nothing changes, a bulk delete as a whole (D-30).
  • The user API payloads are byte-identical; migrations are additive; no dependency was added. </success_criteria>
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md` when done. Record the plugin commit shas, the harness helper names (plan 04 and 05 reuse them) and the measured run times.