365 lines
29 KiB
Gherkin
365 lines
29 KiB
Gherkin
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
|