feat: roles SPA Users, Profile, API keys, Activity

This commit is contained in:
Florian Sylvain committed 2026-10-28 00:10:15 +01:00
1 parent 3db490a615
commit 34dffe222f
98 files changed
+4957 -434

No files matched your search

+364
View File
@@ -0,0 +1,364 @@
Feature: SPA Users, Profile, API keys and Activity for roles and permissions (slice 7)
As an owner, an admin, or a person who manages users
I want to invite with a role, change a role, hand over ownership, and see my role, my keys' role and the role events
So that who can do what is visible and changeable without leaving Users
Background:
Given the backend contract of features/roles_permissions_users_api.feature, roles_permissions_roles_api.feature and roles_permissions_enforcement.feature
And the spec of record is docs/design/screens/roles.md sections 5, 7, 8, 9 and 10, the tables "No-escalation feedback", "HTTP status to copy" and "Existing screens that change", docs/design/screens/users.md, docs/design/routes.md and the Users, Profile, API keys and Activity rows of docs/design/change-list.md
And slices 5 and 6 are reused, not respecified: stores/permissions.ts (can, scopeOf), test/access.ts, NoAccessState, OwnerOnlyTag, ReadOnlyBanner, ViewOnlyMark, the meta.access route guard, useApi 403/409 handling and refusalCopy, composables/useRoles.ts, RolePicker, DeleteRoleDialog pieces, RoleBadge, lib/permissionCopy.ts, lib/rolePreview.ts and the grant locks of lib/grantLocks.ts
And already built and not respecified: the Users list with the role column, the cancel and renew invite flows, the Only-you hint, loading, error and mobile cards
And a refusal is plain text: role_locked, own_role, escalation (X-Escalation-Key), missing_permission (X-Missing-Permission), invalid_role, user_role_changed, invalid_transfer, confirmation_mismatch, user_not_found, session_required
And GET /v1/user items are {id, kind "user", username, display_name, email, role RoleRef or null, role_assigned_by {id, name} or null, status "accepted", created_at} and {id, kind "invite", email, role, status "pending" or "expired", created_at}
And GET /v1/auth/me answers access.role RoleRef and access.role_assigned_by {id, name} or null
And the routes used are GET /v1/user, POST /v1/user/invite {email, role_id?}, POST /v1/user/invite/{id}/renew, DELETE /v1/user/invite/{id}, PUT /v1/user/{id}/role {role_id}, POST /v1/site/transfer-ownership {user_id, confirm_email}, GET /v1/role, GET /v1/auth/me, GET /v1/api-key, GET /v1/activity
And PUT /v1/user/{id}/role answers the user item; transfer answers {owner {id, name}, former_owner {id, name, role}}
And the permission keys are users.view, users.invite, users.change_role, system.transfer_ownership, roles.view, settings.api_keys, activity.view
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR copy in i18n/messages.ts, no comments in code
And design copy marked TBD or listed as an open point is not asserted by a test; it is listed in the qa as a design gap
And "Remove user" and "Send password reset" for another user are out of this slice (open points 2 and 3)
And this slice also changes the backend for "Assigned by": the Rule "Backend: who assigned a role" below, and features/roles_permissions_users_api.feature keeps every other contract
Rule: Backend: who assigned a role
Background for this Rule (house style of roles_permissions_users_api.feature):
Given "role_assigned_by" is {"id": <user id>, "name": <string>} or null
And it is stored as one nullable column on the user, role_assigned_by_id (uint32), added to the GORM User model and created by AutoMigrate; no foreign key constraint, no backfill, every existing row is NULL
And the name is never stored: it is resolved at read time from the current record of that user (display_name when set, else username), so a rename shows at once
And an assigner that no longer resolves (record gone) reads as null for the whole object, never {"id": n, "name": ""}
And null means "no assigner known" and the SPA shows no "Assigned by" line
And there is no new permission and no new route: the field rides GET /v1/user (gate users.view, unchanged) and GET /v1/auth/me (own data, any signed-in caller)
And invite items (kind "invite") never carry the key before accept; the inviter is only copied onto the user at accept
And the layers touched are: internal/domain/user (entity field RoleAssignedBy *uint32 or equivalent, repository UpdateRole and TransferOwnership carry the assigner), internal/domain/invite (invited_by already exists), the use cases ChangeUserRole, TransferOwnership, AcceptInvite, ListUsersAndInvites, GetMyAccess, persistence/models and mappers, persistence/repositories (UpdateRole(id, from, to, assignedBy) stays a compare-and-swap), the handlers' JSON structs (user item and /auth/me access), and docs/api*.md
And no handler reaches a repository directly, and no domain package imports gorm
Scenario Outline: When the assigner is set
When <event> happens
Then the stored role_assigned_by_id of <who> is <value>
Examples:
| event | who | value |
| user 5 sends PUT /v1/user/41/role {"role_id": <Writer id>} | user 41 | 5 |
| user 3 sends the same PUT for user 41 afterwards | user 41 | 3 |
| user 5 sends PUT /v1/user/41/role with 41's current role | user 41 | unchanged (no write) |
| an invite created by user 5 is accepted by "kim" | kim | 5 |
| an invite whose inviter record no longer exists is accepted | kim | NULL |
| user 3 transfers ownership to user 5 | user 5 (new Owner) | 3 |
| user 3 transfers ownership to user 5 | user 3 (former Owner) | NULL |
| the first user registers and receives the Owner role | that user | NULL |
| POST /v1/role/<Contributor id>/reassign moves user 41 | user 41 | unchanged |
And a refused call (403, 404, 400, 409) changes no role_assigned_by_id
And a failed write changes nothing and the answer is the existing 500
And the transfer writes both rows and both role_ids in the same transaction, TransferOwnership(from, to, ownerRoleID, formerRoleID) stays one transaction
And a user whose role is set by the legacy migration keeps NULL
Scenario Outline: Where it is exposed
When <caller> sends <call>
Then <result>
Examples:
| caller | call | result |
| user 41 | GET /v1/auth/me | access.role_assigned_by is {"id": 5, "name": "jonas"} after user 5 changed 41's role |
| user 3 (Owner) | GET /v1/auth/me | access.role_assigned_by is null (key present) |
| user 5 Admin | GET /v1/user | the accepted-user item of 41 carries role_assigned_by {"id": 5, "name": "jonas"} |
| user 5 Admin | GET /v1/user | the item of user 3 carries role_assigned_by null; pending and expired invite items carry no such key |
| user 5 Admin | PUT /v1/user/41/role | the answering user item carries the new role_assigned_by (the caller) |
And the user item keys become exactly id, kind, username, display_name, email, role, role_assigned_by, status, created_at
And the invite item keys stay exactly id, kind, email, role, status, created_at
And the assigners of a list are resolved from the users already loaded for the list, never one query per row, and /auth/me adds at most one user lookup
Scenario: A renamed or removed assigner
Given user 41's role was assigned by user 5 "jonas"
When user 5 sets a display name "Jonas W." through PATCH /v1/user/me
Then GET /v1/auth/me for user 41 shows the name "Jonas W."
And when user 5's record cannot be found, role_assigned_by is null and nothing else about the response changes
Scenario: The public shapes elsewhere do not change
Then PublicUser, POST /v1/auth/login, /verify, /register and /invite/accept carry no role_assigned_by key
And the audit events user.change_role and user.transfer_ownership are unchanged (the actor is already recorded)
Rule: Gating on Users
Scenario Outline: What the Users page offers per grant
Given the session grants <grants>
When the person opens /users
Then "Invite user" is <invite>
And the row menu of an accepted user, other than the owner, offers <menu>
Examples:
| grants | invite | menu |
| users.view | absent | no menu |
| users.view, users.invite | enabled | no menu |
| users.view, users.change_role | absent | Change role |
| users.view, users.invite, users.change_role | enabled | Change role |
| the Owner (every key) | enabled | Change role |
Scenario: A caller without users.view
Given the session grants content.read=all
When the person opens /users
Then the page-level 403 state of slice 5 renders and no /v1/user call is made
Rule: Invite with a role (section 5, Invite dialog)
Scenario: The role picker in the invite dialog
Given the session grants users.invite and roles.view
And GET /v1/role returns Owner (locked), Admin, Writer, Contributor, Content lead
When the person presses "Invite user"
Then the dialog reads "Invite user" / "They get an email with a link to set a password." with Email, Role and "Cancel" / "Send invite"
And Role defaults to Writer and shows its summary
And Owner is not offered
And Writer reads "Writer is the default. You can change a role later." while it stays selected
Scenario Outline: Sending the invite
When the person types "lea.martin@renew.example", picks <role> and presses "Send invite"
Then POST /v1/user/invite is sent with <body>
And on 201 the dialog closes, the new row is at the top as Pending with the <role> RoleBadge, and the toast reads "Invite sent" / "lea.martin@renew.example has 3 days to accept."
Examples:
| role | body |
| Writer | {"email": "lea.martin@renew.example", "role_id": <Writer id>} |
| Contributor | {"email": "lea.martin@renew.example", "role_id": <Contributor id>} |
Scenario Outline: The picker follows the no-escalation rule
Given the session grants <grants> and GET /v1/role returns the world of the users API feature
Then <role> in the invite picker is <state>
Examples:
| grants | role | state |
| the Owner | Content lead | enabled |
| the Owner | Admin | enabled |
| an Admin | Admin | enabled |
| users.invite, roles.view, content.read=all, content.create, content.update=own, content.publish=own | Writer | disabled with "Your role can't assign a role with more power than yours." |
| the same | Intern | enabled |
Scenario: The default Writer is above the caller
Given the session cannot cover Writer
Then Role does not default to Writer, it shows "Choose a role" and "Send invite" stays disabled until a covered role is chosen
Scenario: Inviting without roles.view
Given the session grants users.invite and not roles.view
Then GET /v1/role is not called, no Role field shows, and POST /v1/user/invite is sent with {email} only (the server default is Writer)
Scenario Outline: Invite answers that concern roles
When POST /v1/user/invite answers <status> <code>
Then <effect>
And the dialog stays open and the address stays in the field
Examples:
| status | code | effect |
| 403 | escalation | "You can't grant that" / "You can only grant what your role has." |
| 403 | role_locked | "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." |
| 400 | invalid_role | the role list is re-read and the picker returns to "Choose a role" (copy TBD) |
| 409 | (existing) | the existing "already a user" and "pending invite" states of users.md, unchanged |
Scenario: Renew keeps the role and the escalation refusal is shown
Given invite 2 is expired for Contributor
When "Renew invite" is pressed and the server answers 200
Then the row is Pending, keeps its Contributor RoleBadge, and the toast reads "Invite renewed"
And when the server answers 403 escalation the toast reads "You can't grant that" / "You can only grant what your role has." and the row stays Expired
Rule: Change a user's role (section 5)
Scenario: The row menu
Given the session grants users.view and users.change_role
When the person opens the menu on Ines Duarte (Writer)
Then the items are "Change role" and nothing else
And an invite row has "Cancel invite" / "Renew invite" only, as before
Scenario: The dialog
Given GET /v1/role returns Owner, Admin, Writer, Contributor, Content lead and Ines Duarte holds Writer
When "Change role" is chosen
Then the dialog "Change role for Ines Duarte" reads "Their API keys follow the new role."
And the options are Admin, Writer (marked "Current"), Contributor, Content lead, each with its summary, with "Cancel" and "Change role"
And "Change role" is disabled until an option other than the current one is chosen
Scenario Outline: Options blocked
Given the session grants <grants>
Then <role> in the Change role dialog is <state>
Examples:
| grants | role | state |
| an Admin | Owner | disabled, "There is one owner. Transfer ownership to change who it is." |
| the Owner | Owner | disabled, the same reason |
| a People manager | Content lead | disabled, "Your role can't assign a role with more power than yours." |
| a People manager | Intern | enabled |
Scenario: Changing the role
When the person picks Contributor and presses "Change role"
Then PUT /v1/user/<id>/role {"role_id": <Contributor id>} is sent
And while in flight the dialog cannot be dismissed and both buttons are disabled
And on 200 the dialog closes, the row shows the Contributor RoleBadge from the answer and a toast reads "Role changed" / "Ines Duarte is now a Contributor." (copy TBD)
And no other row changes and the list is not reloaded
Scenario Outline: Change role answers
When the server answers <status> <code>
Then <effect>
And the dialog stays open unless stated
Examples:
| status | code | effect |
| 403 | escalation | "You can't grant that" / "You can only grant what your role has." |
| 403 | role_locked | "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." |
| 403 | own_role | "You can't change your own role" / "Ask another owner or admin." |
| 403 | missing_permission | the shared refusal toast "You don't have permission to do that" / "Your role doesn't include Change a user's role. Ask an owner or admin." and the dialog closes |
| 409 | user_role_changed | the list is re-read, the dialog closes and a toast says the role changed meanwhile (copy TBD) |
| 400 | invalid_role | the role list is re-read, the dialog stays open (copy TBD) |
| 404 | user_not_found | the list is re-read and the row is gone, the dialog closes (copy TBD) |
| 500 | (network) | the dialog stays open with an inline error (copy TBD) |
Scenario: Your own row
Given the person is Jonas Weber, an Admin with users.change_role
Then the menu of the row marked "you" offers "Change role" disabled with "You can't change your own role. Ask another owner or admin."
Scenario: Changing without roles.view
Given the session grants users.view and users.change_role and not roles.view
Then "Change role" is disabled with a tooltip (copy TBD) and GET /v1/role is not called
Rule: The owner row and the transfer of ownership (section 5)
Scenario Outline: The owner row menu
Given the session is <who>
When the menu on the Owner row is opened
Then the items are <items>
Examples:
| who | items |
| an Admin with users.change_role | "Change role" disabled, tooltip "The owner can't be changed or removed. Transfer ownership first."; no Transfer ownership |
| the Owner (their own row, marked "you") | "Change role" disabled, "Transfer ownership" enabled |
| users.view only | no menu |
Scenario: Pick a member
Given the Owner opens "Transfer ownership"
And the list holds the Owner, Jonas Weber and Lea Martin (accepted), and a pending invite
Then the dialog "Transfer ownership" reads "Choose who becomes the owner. Only members can be chosen." with "Cancel" and "Continue"
And only accepted users other than the owner are offered, invites are never offered
And "Continue" is disabled until a member is chosen
Scenario: Confirm
When the Owner picks Jonas Weber and presses "Continue"
Then the dialog "Transfer ownership to Jonas Weber" reads "This takes effect straight away." with the consequences "Jonas Weber becomes the owner.", "You become an Admin. You can't take ownership back unless they transfer it.", "Only the owner can apply updates, delete a site and change activity retention." and "Your API keys follow your new role."
And the field reads "Type jonas@renewbuild.example to confirm" with the hint "The email must match exactly."
And "Transfer ownership" is disabled until the typed text equals the email exactly, case included, and "Back" returns to the pick step with the member still chosen
Scenario: Transferring
When the Owner types the exact email and presses "Transfer ownership"
Then POST /v1/site/transfer-ownership {"user_id": <Jonas id>, "confirm_email": "jonas@renewbuild.example"} is sent
And while in flight the dialog cannot be dismissed and the buttons are disabled
And on 200 the dialog closes, GET /v1/auth/me is re-read so the caller's role, grants, sidebar and drawer follow the Admin grants at once, the list is re-read and shows Jonas Weber as Owner and the caller as Admin
And a toast reads "Ownership transferred" / "Jonas Weber is now the owner. You are an Admin."
Scenario Outline: Transfer answers
When the server answers <status> <code>
Then <effect>
Examples:
| status | code | effect |
| 400 | invalid_transfer | the dialog stays open on the pick step, the list is re-read, inline error (copy TBD) |
| 400 | confirmation_mismatch | inline error under the field (copy TBD); unreachable while the button gate holds |
| 409 | user_role_changed | the session and the list are re-read, the dialog closes and a toast says ownership changed meanwhile (copy TBD) |
| 403 | missing_permission | the shared refusal toast and the dialog closes |
| 403 | session_required | handled by the API client as an auth refusal, nothing else shows |
| 500 | (network) | the dialog stays open with an inline error (copy TBD) |
Scenario: Transfer is owner-only in the UI
Given the session grants everything except system.transfer_ownership
Then "Transfer ownership" is absent from every row menu, including the owner row
Rule: Profile, your role (section 9)
Scenario Outline: The role card and Assigned by
Given GET /v1/auth/me answers access.role Contributor and role_assigned_by <assigner>
When the person opens /profile
Then a card "Your role" shows the RoleBadge "Contributor" and "Your API keys follow this role. If your role changes, they change too."
And <line>
And the card is placed with the other cards of Profile (after the Account card, confirmed against the board by the coder)
Examples:
| assigner | line |
| {"id": 3, "name": "Marta Alves"} | "Assigned by Marta Alves" shows under the badge (design copy: "Your role · Contributor · Assigned by Marta Alves") |
| null | no "Assigned by" text and no empty placeholder is rendered |
| absent (older server) | the same as null |
Scenario: Assigned by follows a role change
Given the session is refreshed and access.role_assigned_by changes from Marta Alves to Jonas Weber
Then the card reads "Assigned by Jonas Weber"
Scenario: Assigned by is not on the Users list
Then the Users table and cards do not show role_assigned_by (the design has no column for it)
Scenario: See what I can do
When "See what I can do" is pressed
Then a dialog on desktop and a bottom sheet on mobile opens: "What you can do" / "A summary of your role, Contributor. Read-only." with "Close"
And the body is the RolePreview of slice 6 built from the session grants, with no "Updates as you edit." and no member line
And no GET /v1/role call is made and Close returns focus to the button
Scenario: The card follows the role live
Given the Profile is open
When the session is refreshed and access.role becomes Writer
Then the card shows "Writer" and the summary dialog follows
Scenario: The card while the session loads
Given the session has not loaded
Then the existing profile skeleton shows and the role card is part of it, with no role text
Rule: API keys follow the role (section 10)
Scenario: The info alert
Given the person holds the API keys grants
When they open /settings/api-keys
Then an info alert reads "Keys follow their creator's role" / "A key can do what its creator's role can do, today. If the person's role changes, their keys change with it."
And it replaces the existing short line "Keys act as you, with your role. You only see your own keys." only when that line says the same thing
Scenario Outline: The Follows role column
Given GET /v1/api-key returns keys of <creators>
Then the column "Follows role" shows <cells>
Examples:
| creators | cells |
| the caller only (settings.api_keys=own) | the RoleBadge of access.role on every row |
| the caller and Lea (scope all) | the caller's RoleBadge on the caller's rows and "—" on Lea's rows |
Scenario: Mobile
Given a 390 px viewport
Then each key card shows the RoleBadge after the status, with the label "Follows role"
Scenario: A role change is reflected
Given the caller's role changed and the session was refreshed
Then the badge shows the new role without re-requesting GET /v1/api-key
Rule: Activity events for roles (section 8)
Scenario Outline: Sentences
Given GET /v1/activity returns an event <action> by Marta Alves
Then the sentence reads <sentence>
Examples:
| action | data | sentence |
| role.create | target role Contributor | Role Contributor created |
| role.edit | target role Contributor | Role Contributor edited |
| role.delete | target role Intern | Role Intern deleted |
| user.change_role | target Ines Duarte, changes role before Writer after Content lead | Ines Duarte's role changed from Writer to Content lead |
| user.transfer_ownership | actor Marta Alves, target Jonas Weber | Ownership transferred from Marta Alves to Jonas Weber |
| role.reassign | params from, to, moved | a sentence (copy TBD) |
Scenario Outline: Failed events
Given the outcome is failed
Then <action> shows the failed template of the house catalogue (copy TBD) and the failed badge as for any other action
Scenario: Category and filters
Then the "users" and "roles" categories appear in the category select with the human names of the house style (copy TBD)
And the security chip shows on user.transfer_ownership
Scenario: Detail panel, role diff
Given the person opens the event "Role Contributor edited" with params added [{key content.delete, scope own}] and removed [{key content.publish, scope all}]
Then the panel "Activity details" shows "What changed" with "Added" / "Delete entries (Own)" and "Removed" / "Publish"
And permission names come from lib/permissionNames.ts, never raw keys
And a changed entry shows "Changed" with the human name and "from -> to" scopes (copy TBD)
And the line "4 members have this role." shows only on role.delete, from its params members
Scenario: Detail panel, name and description diff
Given a role.edit event carries changes for name and description
Then they render through the existing ActivityDiff text rows
Scenario: Detail panel, role change and transfer
Given the person opens a user.change_role event
Then the existing diff shows the field "Role" with before and after
And a user.transfer_ownership event shows the new owner as its target and no diff
Scenario: Unknown action keeps working
Given an event with an action the catalogue does not know
Then it renders as before, with no crash
Rule: Language
Scenario: EN and FR
Then every new key exists in EN and FR in i18n/messages.ts with matching placeholders, including "Assigned by {0}"
And the French copy keeps the role names as written by the owner of the site
+79
View File
@@ -0,0 +1,79 @@
# QA procedure: SPA Users, Profile, API keys and Activity for roles (slice 7)
Spec: features/roles_spa_users.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked through the existing kits (the useApi mock used in views/__tests__), access bodies built with web/src/test/access.ts (ownerAccess, adminAccess, writerAccess, customAccess, meBody). No live server, no real SMTP. Visual checks are out of this slice by user decision.
This slice now touches Go as well as the SPA (backend rule "Who assigned a role"). `task test` (go test -tags nofrontend ./test/... -count=1), `task lint:go` / `gofmt -l` and `go vet -tags nofrontend` apply, as does the mutation scope of `task mutate` for the touched use cases (ChangeUserRole, TransferOwnership, AcceptInvite, ListUsersAndInvites, GetMyAccess), scoped through MUTATE_FILES and MUTATE_RUN. Baseline: run both `task test` and `task test:web` on the unmodified tree first (known Go failure: TestRouterRoutesAreInTheCatalogue).
## Honesty rules
0. First run the baselines above and record exit codes and failing specs.
1. If the runner cannot execute, every SPA item is NOT RUN, never passing.
2. Report each new or edited spec and which already failed in the baseline.
3. Spec only the copy the design gives. Items marked "copy TBD" in the feature get behaviour specs (the toast or inline error exists, the dialog closes or stays open) that do not assert text, and are listed as design gaps.
4. Date and clock: fixed `created_at` / `updated_at` strings; no spec freezes the system date; no scenario asserts a relative date.
5. "Remove user" and "Send password reset" for another user are not built. A spec asserts their absence only where listed below.
## Automated (Vitest)
| Spec | Cases |
|---|---|
| composables/__tests__/useUsers.spec.ts (extend) | inviteUser sends role_id when given and {email} only when not; changeUserRole PUT /v1/user/{id}/role and its answer; transferOwnership POST body and answer; refusal mapping of escalation, role_locked, own_role, missing_permission, user_role_changed, invalid_role, invalid_transfer, confirmation_mismatch, user_not_found, session_required |
| views/__tests__/UsersView.spec.ts (extend) | gating table (users.view, invite, change_role, Owner), 403 page without users.view, invite row with role badge, ownership refresh of the list, a user item carrying role_assigned_by renders no "Assigned by" text in the table or the cards |
| views/__tests__/UsersRowActions.spec.ts (extend) | row menu per grant, own row, owner row for an Admin and for the Owner, invite rows unchanged, Transfer ownership absent without system.transfer_ownership |
| components/__tests__/InviteUserModal.spec.ts (extend, or create if the modal has no spec) | role picker default Writer and its hint, Owner not offered, escalation disabling through grantLocks, Writer above the caller then no default and Send disabled, no roles.view then no picker and {email} only, answers 403 escalation / 403 role_locked / 400 invalid_role keep the dialog and the address |
| components/__tests__/ChangeRoleDialog.spec.ts (new) | title and body, options with summaries, Current mark, button off until another option, Owner option disabled with its reason, escalation reason, in-flight lock, each answer of the table, roles.view absent |
| components/__tests__/TransferOwnershipDialog.spec.ts (new) | pick step (accepted users only, no self, no invites), Continue gate, confirm copy with the member name and email, exact-match gate (case and whitespace), Back keeps the choice, in-flight lock, success refreshes /auth/me then the list, each refusal |
| components/__tests__/RolePicker.spec.ts (new or via the dialogs) | the extension that lists a locked role as disabled with a reason; slice 6 behaviour unchanged (locked and source still hidden in DeleteRoleDialog) |
| views/__tests__/ProfileView.spec.ts (extend) | Your role card with RoleBadge and the keys sentence; with {id: 3, name: "Marta Alves"} the card reads "Assigned by Marta Alves"; with null or an absent key no "Assigned by" text exists; the line follows a refreshed session; live change of access.role; skeleton; See what I can do dialog on desktop and sheet at mobile with Close and focus return; no GET /v1/role call |
| lib/__tests__/permissions.spec.ts (extend) | parsing access.role_assigned_by: object, null, absent |
| views/__tests__/ApiKeysView.spec.ts (extend) | info alert copy, Follows role column for own keys, "—" for other creators, mobile card, badge follows a refreshed role |
| lib/__tests__/activitySentence.spec.ts (extend, new if absent) | the five design sentences, role.reassign (no text assertion), failed templates (no text assertion), unknown action |
| views/__tests__/ActivityDetails.spec.ts (extend) | role.edit Added/Removed with human names and "(Own)", name/description diff, role.delete members line, user.change_role Role diff, user.transfer_ownership, empty diff |
| views/__tests__/ActivityFilters.spec.ts (extend) | users and roles categories present, security chip on transfer |
| i18n/roleMessages.spec.ts (extend) | new keys exist in EN and FR, placeholders match, "Assigned by {0}" |
Mocks to reuse: GET /v1/role as the world of features/roles_permissions_users_api.feature (Owner locked, Admin, Writer, Contributor, Intern, Content lead, People manager, Viewer, Inviter); GET /v1/user with Marta Alves (Owner), Jonas Weber (jonas@renewbuild.example, Admin), Ines Duarte (Writer), Lea Martin, an expired invite and a pending invite; access built with ownerAccess() / adminAccess() / customAccess().
## Automated (Go, httptest, in-memory fakes) in test/
Build each handler with `NewHandler(...)` over the use cases and in-memory fakes of user.Repository, invite.Repository and role.Repository. The existing users-API fakes are extended with the assigner field. Mount them on a fresh `chi.NewRouter()` that mirrors api/router.go, with `jwtauth.Verifier` + `Authenticator` and a test `jwtauth.New("HS256", …)`. Call `validator.InitValidator()`. No SQLite file, no SMTP, no live server. Name the new fakes uniquely across the package.
| Test | Steps and assertions |
|---|---|
| TestRoleAssignedByIsSetOnChangeRole | user 5 PUTs a role for 41; the fake stores 5; GET /v1/auth/me as 41 shows access.role_assigned_by {id 5, name}; a second PUT by user 3 stores 3 |
| TestRoleAssignedByUnchangedOnNoOp | PUT with the current role: 200, fake UpdateRole not called, stored value unchanged |
| TestRoleAssignedByUnchangedOnRefusal | table of 403 role_locked, 403 own_role, 403 escalation, 404, 400 invalid_role, 409 user_role_changed: stored value unchanged |
| TestRoleAssignedByAtAcceptInvite | accept an invite created by user 5: the new user's assigner is 5; an invite whose inviter is missing gives null and still succeeds |
| TestRoleAssignedByAtTransfer | transfer 3 to 5: user 5's assigner is 3, user 3's is null, both in the one fake transaction; a failed transaction (fake error, stale from) changes neither |
| TestRoleAssignedByAtRegister | the Owner registered first has null |
| TestRoleAssignedByUnchangedOnReassign | POST /v1/role/{id}/reassign leaves the assigner of the moved users unchanged |
| TestUserListCarriesRoleAssignedBy | GET /v1/user as an Admin: exact key set of user items and invite items (no key on invites), object and null cases, assigner resolved from the listed users with no per-row repository call (the fake counts GetByID calls) |
| TestRoleAssignedByFollowsRename | rename user 5 in the fake, then GET /v1/auth/me for 41 shows the new name; delete user 5 from the fake, the field is null, never a nameless object |
| TestAuthMeKeepsOtherShapes | login, verify, register and invite/accept bodies carry no role_assigned_by; PublicUser keys unchanged |
| TestRoleAssignedByNoNewGate | a user with only users.view reads it on GET /v1/user; any signed-in user reads their own on /auth/me; an unauthenticated caller gets 401 as before |
| TestAutoMigrateAddsColumn (persistence level, only if the suite already has an in-memory persistence test; otherwise MANUAL) | the model has a nullable role_assigned_by_id column, existing rows read NULL. Real SQLite file and an upgrade of an existing database are MANUAL item 9 |
Layering checks go in the existing layering test or an added one: internal/domain/user imports no gorm, chi or infrastructure; the handlers import no persistence.
Sub-decisions applied: S1 transfer sets the new owner's assigner to the former owner and the former owner's to null; S2 bulk reassign leaves the assigner unchanged; S3 the name is display_name when set, else username; S4 a PUT with the same role changes nothing.
## Manual checks
1. Keyboard: tab and focus order in Change role (radio group, Current mark announced), Transfer ownership (pick, confirm field) and the invite dialog; focus returns to the row menu button after close; Escape is blocked while sending.
2. Screen reader: disabled options announce their reason; the Current option is announced; the toast on role change is announced politely.
3. Two browsers: an owner changes a user's role while an admin has the Change role dialog open; the admin's save gets the user_role_changed path. Same for two simultaneous transfers.
4. Real flow in a real browser: create Contributor in Roles, invite someone with it, accept the invite from a real SMTP mailbox (real delivery is manual), change the role, check the new user's sidebar follows it with no re-login.
5. Real transfer: the Owner transfers to an Admin, the former owner's sidebar loses owner-only entries without reload, an API key created by the former owner now acts as Admin.
6. Mobile 390 px: card list menus, bottom sheet for See what I can do, 44 px targets, French copy, dark mode.
7. Activity: perform each of the six events in the real app and read them in /activity.
8. `task lint:web` (eslint and vue-tsc) green; `task test:web` exit 0 on the final tree.
9. Upgrade a real database from the previous release with `task run`: the column appears, old users show no "Assigned by" line, then change a role and see it on that user's Profile with the actor's name.
10. Rename the assigner and reload the assigned user's Profile; confirm the new name.
## Design gaps and backend notes
- G1 "Assigned by" is now stored and exposed. The design shows it only on the Profile role card. The Users list has no such column and does not show it.
- G2 "Remove user" and "Send password reset" in the user row menu have no route (open points 2 and 3). Omitted.
- G3 copy TBD in the feature: role changed toast, user_role_changed, invalid_role, user_not_found, invalid_transfer, confirmation_mismatch, 500 inline errors of the two dialogs, no-roles.view tooltip, role.reassign sentence, failed templates, category names, Changed diff line.
- G4 the design's blocked-option reason per role ("It can publish. Your role can't grant that.") is replaced by the generic sentence already in RolePicker.
- G5 the design says "Her API keys" and "unless he transfers it": gendered; neutral copy is used ("Their API keys follow the new role.", "unless they transfer it").
- G6 design lists POST /v1/invite and role.created / role.updated / role.deleted; the backend uses POST /v1/user/invite and role.create / role.edit / role.delete / role.reassign. Backend wins.
- G7 API keys list has no creator role; "Follows role" shows the caller's badge on the caller's keys and "—" on other creators' keys.
- G8 the Profile role card sits after the Account card; the coder confirms against the AppFrame board.
- G9 RolePicker gains an opt-in prop (Owner shown disabled with a reason, "Current" mark); DeleteRoleDialog is unchanged.
- Backend change: role_assigned_by only (sub-decisions S1 to S4). docs/api*.md document `access.role_assigned_by` on GET /v1/auth/me and `role_assigned_by` on the GET /v1/user accepted-user item. features/roles_permissions_users_api.feature is not edited; this feature carries the delta.