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

+1 -1
View File
@@ -380,7 +380,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo, repos.UserRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
+6 -4
View File
@@ -33,8 +33,9 @@ type meAuthor struct {
}
type meAccess struct {
Role useCases.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"`
Role useCases.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"`
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
}
type meResponse struct {
@@ -45,8 +46,9 @@ type meResponse struct {
func toMeAccess(access useCases.Access) meAccess {
return meAccess{
Role: useCases.NewRoleRef(access.Role),
Permissions: access.Permissions,
Role: useCases.NewRoleRef(access.Role),
Permissions: access.Permissions,
RoleAssignedBy: access.RoleAssignedBy,
}
}
+19 -26
View File
@@ -6,7 +6,6 @@ import (
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -50,14 +49,15 @@ type inviteListItem struct {
}
type userListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role *useCases.RoleRef `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role *useCases.RoleRef `json:"role"`
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, cancelInvite *useCases.CancelInviteUseCase, renewInvite *useCases.RenewInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler {
@@ -160,22 +160,6 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusOK, renewed)
}
func writeRoleRefusal(w http.ResponseWriter, err error) bool {
var escalation role.EscalationError
switch {
case errors.As(err, &escalation):
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrRoleLocked):
http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrUnknownRole):
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
default:
return false
}
return true
}
func writeManageInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
@@ -200,8 +184,10 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
}
switch {
case errors.Is(err, invite.ErrEmailHasAccount):
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite):
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
@@ -222,7 +208,14 @@ func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
items = append(items, inviteListItem{
ID: entry.ID,
Kind: entry.Kind,
Email: entry.Email,
Role: entry.Role,
Status: entry.Status,
CreatedAt: entry.CreatedAt,
})
continue
}
items = append(items, userListItemOf(entry))
-15
View File
@@ -27,21 +27,6 @@ func NewRoleHandler(changeRole *useCases.ChangeUserRoleUseCase) *RoleHandler {
return &RoleHandler{ChangeUserRoleUseCase: changeRole}
}
func userListItemOf(entry useCases.DirectoryEntry) userListItem {
return userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt}
}
func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func (h *RoleHandler) Change(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
+51
View File
@@ -0,0 +1,51 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
func userListItemOf(entry useCases.DirectoryEntry) userListItem {
return userListItem{
ID: entry.ID,
Kind: entry.Kind,
Username: entry.Username,
DisplayName: entry.DisplayName,
Email: entry.Email,
Role: entry.Role,
RoleAssignedBy: entry.RoleAssignedBy,
Status: entry.Status,
CreatedAt: entry.CreatedAt,
}
}
func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeRoleRefusal(w http.ResponseWriter, err error) bool {
var escalation role.EscalationError
switch {
case errors.As(err, &escalation):
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrRoleLocked):
http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrUnknownRole):
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
default:
return false
}
return true
}
+3
View File
@@ -19,6 +19,9 @@ const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const ErrorCodeHeader = "X-Error-Code"
const EmailHasAccountErrorCode = "email_has_account"
const InvitePendingErrorCode = "invite_pending"
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
+14 -2
View File
@@ -414,10 +414,21 @@ A user has exactly one stored role, `role_id`, and nothing else: there is no `ro
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`.
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
is read from the current record of that user at read time (`display_name` when set, else `username`), so a rename shows
at once. When that record is gone the whole object is `null`. It is set by `PUT /v1/user/{id}/role` to the caller (a
request with the user's current role writes nothing), by invite accept to the inviter (`NULL` when the inviter record is
missing), and by the ownership transfer: the new Owner is assigned by the former Owner, whose own value becomes `NULL`.
First-user registration and `POST /v1/role/{id}/reassign` leave it alone. The key is always present on accepted-user
items of `GET /v1/user` (gate `users.view`, unchanged; assigners are resolved from the users already loaded) and on
`access` of `GET /v1/auth/me` (own data, one extra lookup at most). Invite items never carry it, and `PublicUser` and the
login, verify, register and invite-accept bodies do not change.
| Route | Permission | Session only | Purpose |
| --- | --- | --- | --- |
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item. |
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, role_assigned_by, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item, whose `role_assigned_by` is the caller. |
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
@@ -440,6 +451,7 @@ body, target, `confirm_email`, write. Every refusal writes nothing, not even the
| Status | Body | When |
| --- | --- | --- |
| 409 | the human message | `POST /v1/user/invite` only. "This address already has an account." carries `X-Error-Code: email_has_account`; "There's already a pending invite for this address." carries `X-Error-Code: invite_pending`. |
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
+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.
+1
View File
@@ -43,6 +43,7 @@ type User struct {
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"`
RoleID uint32 `json:"role_id"`
RoleAssignedByID uint32 `json:"role_assigned_by_id"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
@@ -16,6 +16,6 @@ type Repository interface {
ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error)
UpdateRole(id uint32, from uint32, to uint32) (User, error)
UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (User, error)
TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error
}
@@ -25,7 +25,7 @@ func InviteToModel(i invite.Invite) models.Invite {
HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy,
RoleID: DomainToNullableID(i.RoleID),
RoleID: ZeroAsNullID(i.RoleID),
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
@@ -26,7 +26,7 @@ func nullableIDToDomain(value *uint32) uint32 {
return *value
}
func DomainToNullableID(value uint32) *uint32 {
func ZeroAsNullID(value uint32) *uint32 {
if value == 0 {
return nil
}
@@ -43,6 +43,7 @@ func UserToDomain(m models.User) user.User {
DisplayName: m.DisplayName,
IsVerified: m.IsVerified,
RoleID: nullableIDToDomain(m.RoleID),
RoleAssignedByID: nullableIDToDomain(m.RoleAssignedByID),
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration,
@@ -60,7 +61,8 @@ func UserToModel(a user.User) models.User {
Email: a.Email,
DisplayName: a.DisplayName,
IsVerified: a.IsVerified,
RoleID: DomainToNullableID(a.RoleID),
RoleID: ZeroAsNullID(a.RoleID),
RoleAssignedByID: ZeroAsNullID(a.RoleAssignedByID),
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
@@ -13,6 +13,7 @@ type User struct {
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
RoleID *uint32 `gorm:"index"`
RoleAssignedByID *uint32
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
@@ -36,7 +36,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
Email: user.Email,
IsVerified: user.IsVerified,
RoleID: mappers.DomainToNullableID(user.RoleID),
RoleID: mappers.ZeroAsNullID(user.RoleID),
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration,
}
@@ -220,7 +220,7 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil
}
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32) (domainUser.User, error) {
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)
if from == 0 {
@@ -228,7 +228,7 @@ func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32) (domainUs
} else {
query = query.Where("role_id = ?", from)
}
result := query.Update("role_id", to)
result := query.Updates(map[string]any{"role_id": to, "role_assigned_by_id": mappers.ZeroAsNullID(assignedBy)})
if result.Error != nil {
return result.Error
}
@@ -266,14 +266,14 @@ func (u *UserRepository) TransferOwnership(from uint32, to uint32, ownerRoleID u
return domainUser.ErrInvalidTransfer
}
return u.db.Transaction(func(tx *gorm.DB) error {
demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Update("role_id", formerRoleID)
demoted := tx.Model(&entity.User{}).Where("id = ? AND role_id = ?", from, ownerRoleID).Updates(map[string]any{"role_id": formerRoleID, "role_assigned_by_id": nil})
if demoted.Error != nil {
return demoted.Error
}
if demoted.RowsAffected == 0 {
return domainUser.ErrRoleChanged
}
promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Update("role_id", ownerRoleID)
promoted := tx.Model(&entity.User{}).Where("id = ? AND is_verified = ?", to, true).Updates(map[string]any{"role_id": ownerRoleID, "role_assigned_by_id": from})
if promoted.Error != nil {
return promoted.Error
}
@@ -92,12 +92,17 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
return user.User{}, err
}
inviterID, err := a.inviterOf(pending)
if err != nil {
return user.User{}, err
}
createdUser, err := a.inviteRepository.AcceptWithUser(pending.ID, now, user.User{
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
RoleID: assigned.ID,
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
RoleID: assigned.ID,
RoleAssignedByID: inviterID,
})
if errors.Is(err, invite.ErrInviteNotFound) {
return user.User{}, invite.ErrInviteInvalid
@@ -109,6 +114,14 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
return createdUser, nil
}
func (a *AcceptInviteUseCase) inviterOf(pending invite.Invite) (uint32, error) {
assigner, err := resolveRoleAssigner(a.userRepository, pending.InvitedBy)
if err != nil || assigner == nil {
return 0, err
}
return assigner.ID, nil
}
func (a *AcceptInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range a.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
@@ -48,10 +48,9 @@ func (u *ChangeUserRoleUseCase) ChangeRole(ctx context.Context, command ChangeUs
return DirectoryEntry{}, err
}
if held && current.ID == next.ID {
ref := NewRoleRef(next)
return userEntry(target, &ref), nil
return u.entryOf(target, next)
}
return u.write(ctx, target, current, next)
return u.write(ctx, command, target, current, held, next)
}
func (u *ChangeUserRoleUseCase) heldRole(target user.User) (role.Role, bool, error) {
@@ -81,10 +80,10 @@ func (u *ChangeUserRoleUseCase) checkCoverage(command ChangeUserRoleCommand, tar
return ensureCoveredBy(command.Caller, typeKeys, next)
}
func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, current role.Role, next role.Role) (DirectoryEntry, error) {
func (u *ChangeUserRoleUseCase) write(ctx context.Context, command ChangeUserRoleCommand, target user.User, current role.Role, held bool, next role.Role) (DirectoryEntry, error) {
event := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username)
updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID)
updated, err := u.users.UpdateRole(target.ID, target.RoleID, next.ID, command.Caller.UserID)
if errors.Is(err, role.ErrRoleNotFound) {
return DirectoryEntry{}, role.ErrUnknownRole
}
@@ -92,12 +91,22 @@ func (u *ChangeUserRoleUseCase) write(ctx context.Context, target user.User, cur
u.recordWriteFailure(ctx, event, err)
return DirectoryEntry{}, err
}
event.Params[audit.ParamFrom] = current.ID
if held {
event.Params[audit.ParamFrom] = current.ID
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name)
}
event.Params[audit.ParamTo] = next.ID
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldRole, current.Name, next.Name)
u.recorder.Record(ctx, event)
ref := NewRoleRef(next)
return userEntry(updated, &ref), nil
return u.entryOf(updated, next)
}
func (u *ChangeUserRoleUseCase) entryOf(account user.User, assigned role.Role) (DirectoryEntry, error) {
assigner, err := resolveRoleAssigner(u.users, account.RoleAssignedByID)
if err != nil {
return DirectoryEntry{}, err
}
ref := NewRoleRef(assigned)
return userEntry(account, &ref, assigner), nil
}
func (u *ChangeUserRoleUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
@@ -6,16 +6,18 @@ import (
)
type Access struct {
Role role.Role
Permissions []role.Grant
Role role.Role
Permissions []role.Grant
RoleAssignedBy *RoleAssigner
}
type GetMyAccessUseCase struct {
roles role.Repository
users user.Repository
}
func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles}
func NewGetMyAccessUseCase(roles role.Repository, users user.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles, users: users}
}
func resolveRole(roles role.Repository, account user.User) (role.Role, error) {
@@ -30,5 +32,10 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil
access := Access{Role: resolved, Permissions: resolved.EffectiveGrants()}
access.RoleAssignedBy, err = resolveRoleAssigner(u.users, account.RoleAssignedByID)
if err != nil {
return Access{}, err
}
return access, nil
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"errors"
"sort"
"time"
)
@@ -19,34 +20,67 @@ type ListUsersAndInvitesUseCase struct {
roles role.Repository
}
type RoleAssigner struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
func newRoleAssigner(account user.User) *RoleAssigner {
return &RoleAssigner{ID: account.ID, Name: account.NameOrUsername()}
}
func resolveRoleAssigner(users user.Repository, id uint32) (*RoleAssigner, error) {
if id == 0 {
return nil, nil
}
found, err := users.Get(id)
if errors.Is(err, user.ErrUserNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return newRoleAssigner(found), nil
}
type DirectoryEntry struct {
ID uint32
Kind string
Username string
DisplayName string
Email string
Role *RoleRef
Status string
CreatedAt time.Time
ID uint32
Kind string
Username string
DisplayName string
Email string
Role *RoleRef
RoleAssignedBy *RoleAssigner
Status string
CreatedAt time.Time
}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository, roles role.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository, roles}
}
func userEntry(account user.User, found *RoleRef) DirectoryEntry {
func userEntry(account user.User, found *RoleRef, assigner *RoleAssigner) DirectoryEntry {
return DirectoryEntry{
ID: account.ID,
Kind: EntryKindUser,
Username: account.Username,
DisplayName: account.DisplayName,
Email: account.Email,
Role: found,
Status: string(invite.StatusAccepted),
CreatedAt: account.CreatedAt,
ID: account.ID,
Kind: EntryKindUser,
Username: account.Username,
DisplayName: account.DisplayName,
Email: account.Email,
Role: found,
RoleAssignedBy: assigner,
Status: string(invite.StatusAccepted),
CreatedAt: account.CreatedAt,
}
}
func assignerAmong(loaded map[uint32]user.User, id uint32) *RoleAssigner {
found, present := loaded[id]
if id == 0 || !present {
return nil
}
return newRoleAssigner(found)
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, error) {
refs, err := roleRefsByID(g.roles)
if err != nil {
@@ -55,8 +89,13 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, er
now := time.Now()
entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID)))
users := g.userRepository.GetAll()
loaded := make(map[uint32]user.User, len(users))
for _, existingUser := range users {
loaded[existingUser.ID] = existingUser
}
for _, existingUser := range users {
entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID), assignerAmong(loaded, existingUser.RoleAssignedByID)))
}
for _, existingInvite := range g.inviteRepository.GetAll() {
+1 -1
View File
@@ -59,7 +59,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
+1 -1
View File
@@ -77,7 +77,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(e.roles, e.users),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
+1 -1
View File
@@ -116,7 +116,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles, env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(env.roles),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, newSeededMockRoleRepo(), hasher, mail, "https://cms.example.com", recorder),
+1 -1
View File
@@ -59,7 +59,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: start,
+1 -1
View File
@@ -19,7 +19,7 @@ import (
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
+1 -1
View File
@@ -115,7 +115,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
MergeMediaUseCase: newTestMergeMedia(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+1 -1
View File
@@ -496,7 +496,7 @@ func TestInvitationsHttp(t *testing.T) {
if want.kind == "invite" {
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
} else {
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
}
}
if decodedString(t, items[2], "display_name") != "Ines Duarte" {
+1 -1
View File
@@ -75,7 +75,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+497
View File
@@ -0,0 +1,497 @@
package test
import (
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
const rabyKey = "role_assigned_by"
func rabySeedAssigner(w *usersAPIWorld, target uint32, assigner uint32) {
member := w.users.users[target]
member.RoleAssignedByID = assigner
w.users.users[target] = member
}
func rabyAssignerOf(t *testing.T, rec *httptest.ResponseRecorder, container string) any {
t.Helper()
body := usersAPIDecode(t, rec)
if container != "" {
body = body[container].(map[string]any)
}
value, present := body[rabyKey]
if !present {
t.Fatalf("the %s key must always be present: %q", rabyKey, rec.Body.String())
}
return value
}
func rabyWant(id float64, name string) map[string]any {
return map[string]any{"id": id, "name": name}
}
func rabyAcceptKim(t *testing.T, w *usersAPIWorld) (user.User, *httptest.ResponseRecorder) {
t.Helper()
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
if rec.Code >= 300 {
t.Fatalf("the accept must succeed, got %d %q", rec.Code, rec.Body.String())
}
for _, member := range w.users.users {
if member.Username == "kim" {
return member, rec
}
}
t.Fatalf("kim was not created")
return user.User{}, rec
}
func TestRoleAssignedByIsSetOnChangeRole(t *testing.T) {
w := newUsersAPIWorld(t)
rec := w.putRole(t, uaAdmin, "41", w.idText("Writer"))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if got := rabyAssignerOf(t, rec, ""); !reflect.DeepEqual(got, rabyWant(float64(uaAdmin), "jonas")) {
t.Errorf("the answering item must carry the caller, got %v", got)
}
if w.users.users[41].RoleAssignedByID != uaAdmin {
t.Errorf("expected 5 stored, got %d", w.users.users[41].RoleAssignedByID)
}
me := w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
if got := rabyAssignerOf(t, me, "access"); !reflect.DeepEqual(got, rabyWant(float64(uaAdmin), "jonas")) {
t.Errorf("unexpected access.role_assigned_by %v", got)
}
if rec := w.putRole(t, uaOwner, "41", w.idText("Intern")); rec.Code != http.StatusOK {
t.Fatalf("unexpected %d", rec.Code)
}
if w.users.users[41].RoleAssignedByID != uaOwner {
t.Errorf("expected 3 stored, got %d", w.users.users[41].RoleAssignedByID)
}
}
func TestRoleAssignedByUnchangedOnNoOp(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaOwner)
w.users.updateRoleCalls = 0
rec := w.putRole(t, uaAdmin, "41", w.idText("Contributor"))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if w.users.updateRoleCalls != 0 {
t.Errorf("a no-op change must not write")
}
if w.users.users[41].RoleAssignedByID != uaOwner {
t.Errorf("the assigner must stay 3, got %d", w.users.users[41].RoleAssignedByID)
}
if got := rabyAssignerOf(t, rec, ""); !reflect.DeepEqual(got, rabyWant(float64(uaOwner), "marta")) {
t.Errorf("the item must show the existing assigner, got %v", got)
}
}
func TestRoleAssignedByUnchangedOnRefusal(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
role func(w *usersAPIWorld) string
hook func(w *usersAPIWorld)
status int
}{
{"role_locked", uaAdmin, "3", func(w *usersAPIWorld) string { return w.idText("Writer") }, nil, http.StatusForbidden},
{"own_role", uaAdmin, "5", func(w *usersAPIWorld) string { return w.idText("Writer") }, nil, http.StatusForbidden},
{"escalation", uaPeople, "41", func(w *usersAPIWorld) string { return w.idText("Admin") }, nil, http.StatusForbidden},
{"user_not_found", uaAdmin, "999", func(w *usersAPIWorld) string { return w.idText("Writer") }, nil, http.StatusNotFound},
{"invalid_role", uaAdmin, "41", func(*usersAPIWorld) string { return "9999" }, nil, http.StatusBadRequest},
{"user_role_changed", uaAdmin, "41", func(w *usersAPIWorld) string { return w.idText("Writer") }, func(w *usersAPIWorld) {
w.users.beforeUpdateRole = func() {
member := w.users.users[41]
member.RoleID = w.id("Intern")
w.users.users[41] = member
}
}, http.StatusConflict},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
w := newUsersAPIWorld(t)
for _, id := range []uint32{3, 5, 41} {
rabySeedAssigner(w, id, uaViewer)
}
if row.hook != nil {
row.hook(w)
}
rec := w.putRole(t, row.caller, row.target, row.role(w))
if rec.Code != row.status {
t.Fatalf("expected %d, got %d %q", row.status, rec.Code, rec.Body.String())
}
for _, id := range []uint32{3, 5, 41} {
if w.users.users[id].RoleAssignedByID != uaViewer {
t.Errorf("user %d: the assigner must not change, got %d", id, w.users.users[id].RoleAssignedByID)
}
}
})
}
}
func TestRoleAssignedByAtAcceptInvite(t *testing.T) {
t.Run("the inviter becomes the assigner", func(t *testing.T) {
w := newUsersAPIWorld(t)
kim, rec := rabyAcceptKim(t, w)
if kim.RoleAssignedByID != uaAdmin {
t.Errorf("expected 5, got %d", kim.RoleAssignedByID)
}
if strings.Contains(rec.Body.String(), rabyKey) {
t.Errorf("the accept body must not carry the key: %q", rec.Body.String())
}
})
t.Run("a missing inviter gives NULL and still succeeds", func(t *testing.T) {
w := newUsersAPIWorld(t)
delete(w.users.users, uaAdmin)
kim, _ := rabyAcceptKim(t, w)
if kim.RoleAssignedByID != 0 {
t.Errorf("expected NULL, got %d", kim.RoleAssignedByID)
}
})
}
func TestRoleAssignedByAtTransfer(t *testing.T) {
transfer := func(t *testing.T, w *usersAPIWorld) *httptest.ResponseRecorder {
t.Helper()
return w.do(t, uaOwner, http.MethodPost, "/v1/site/transfer-ownership", w.transferBody(uaAdmin, "jonas@renew.example"))
}
t.Run("the new owner is assigned by the former owner and the former owner is cleared", func(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, uaOwner, uaViewer)
rabySeedAssigner(w, uaAdmin, uaViewer)
w.users.transferCalls = 0
if rec := transfer(t, w); rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if w.users.transferCalls != 1 || w.users.users[uaAdmin].RoleAssignedByID != uaOwner || w.users.users[uaOwner].RoleAssignedByID != 0 {
t.Errorf("unexpected assigners %d and %d after %d transaction(s)", w.users.users[uaAdmin].RoleAssignedByID, w.users.users[uaOwner].RoleAssignedByID, w.users.transferCalls)
}
})
t.Run("a failed or stale transaction changes neither", func(t *testing.T) {
for name, arrange := range map[string]func(w *usersAPIWorld){
"failure": func(w *usersAPIWorld) { w.users.fail.failNext("TransferOwnership") },
"stale from": func(w *usersAPIWorld) {
w.users.beforeTransfer = func() { rabySeedRole(w, uaOwner, w.id("Admin")) }
},
} {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, uaOwner, uaViewer)
rabySeedAssigner(w, uaAdmin, uaViewer)
arrange(w)
if rec := transfer(t, w); rec.Code < 400 {
t.Fatalf("%s: expected an error, got %d", name, rec.Code)
}
if w.users.users[uaAdmin].RoleAssignedByID != uaViewer || w.users.users[uaOwner].RoleAssignedByID != uaViewer {
t.Errorf("%s: no assigner may change", name)
}
}
})
}
func rabySeedRole(w *usersAPIWorld, id uint32, roleID uint32) {
member := w.users.users[id]
member.RoleID = roleID
w.users.users[id] = member
}
func TestRoleAssignedByAtRegister(t *testing.T) {
w := newUsersAPIWorld(t)
w.users.users = map[uint32]user.User{}
rec := w.anonymous(t, http.MethodPost, "/v1/auth/register", `{"username": "first", "password": "password1", "email": "first@renew.example"}`)
if rec.Code >= 300 || len(w.users.users) != 1 {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
for _, member := range w.users.users {
if member.RoleAssignedByID != 0 {
t.Errorf("the first user has no assigner, got %d", member.RoleAssignedByID)
}
}
if strings.Contains(rec.Body.String(), rabyKey) {
t.Errorf("the register body must not carry the key")
}
}
func TestRoleAssignedByUnchangedOnReassign(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaOwner)
rec := w.do(t, uaAdmin, http.MethodPost, "/v1/role/"+w.idText("Contributor")+"/reassign", `{"target_role_id": `+w.idText("Intern")+`}`)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if w.users.users[41].RoleID != w.id("Intern") || w.users.users[41].RoleAssignedByID != uaOwner {
t.Errorf("41 must move and keep its assigner, got %+v", w.users.users[41])
}
}
func rabyList(t *testing.T, w *usersAPIWorld, caller uint32) []map[string]any {
t.Helper()
rec := w.do(t, caller, http.MethodGet, "/v1/user", "")
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
var items []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatal(err)
}
return items
}
func TestUserListCarriesRoleAssignedBy(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
items := rabyList(t, w, uaAdmin)
seenUsers, seenInvites := 0, 0
for _, item := range items {
if item["kind"] == "invite" {
seenInvites++
apiKeyAssertExactFields(t, item, "id", "kind", "email", "role", "status", "created_at")
continue
}
seenUsers++
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", rabyKey, "status", "created_at")
switch item["id"] {
case float64(41):
if !reflect.DeepEqual(item[rabyKey], rabyWant(float64(uaAdmin), "jonas")) {
t.Errorf("user 41 must show its assigner, got %v", item[rabyKey])
}
default:
if item[rabyKey] != nil {
t.Errorf("user %v must show null, got %v", item["id"], item[rabyKey])
}
}
}
if seenUsers == 0 || seenInvites == 0 {
t.Fatalf("expected both kinds, got %d users and %d invites", seenUsers, seenInvites)
}
t.Run("no per-row lookup", func(t *testing.T) {
quiet := newUsersAPIWorld(t)
quiet.users.getCalls = 0
rabyList(t, quiet, uaAdmin)
baseline := quiet.users.getCalls
busy := newUsersAPIWorld(t)
for id := range busy.users.users {
rabySeedAssigner(busy, id, uaAdmin)
}
busy.users.getCalls = 0
rabyList(t, busy, uaAdmin)
if busy.users.getCalls != baseline {
t.Errorf("expected %d Get calls like the list without assigners, got %d", baseline, busy.users.getCalls)
}
})
t.Run("a vanished assigner is null", func(t *testing.T) {
gone := newUsersAPIWorld(t)
rabySeedAssigner(gone, 41, 777)
for _, item := range rabyList(t, gone, uaAdmin) {
if item["id"] == float64(41) && item[rabyKey] != nil {
t.Errorf("expected null, got %v", item[rabyKey])
}
}
})
}
func TestRoleAssignedByFollowsRename(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
renamed := w.users.users[uaAdmin]
renamed.DisplayName = "Jonas W."
w.users.users[uaAdmin] = renamed
me := w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
if got := rabyAssignerOf(t, me, "access"); !reflect.DeepEqual(got, rabyWant(float64(uaAdmin), "Jonas W.")) {
t.Errorf("expected the new name, got %v", got)
}
before := usersAPIDecode(t, me)
delete(w.users.users, uaAdmin)
me = w.do(t, 41, http.MethodGet, "/v1/auth/me", "")
if me.Code != http.StatusOK {
t.Fatalf("unexpected %d", me.Code)
}
if got := rabyAssignerOf(t, me, "access"); got != nil {
t.Errorf("a removed assigner must read null, got %v", got)
}
after := usersAPIDecode(t, me)
if !reflect.DeepEqual(before["access"].(map[string]any)["role"], after["access"].(map[string]any)["role"]) {
t.Errorf("nothing else about the response may change")
}
}
func TestAuthMeKeepsOtherShapes(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
keys := func(value any) []string {
raw, _ := json.Marshal(value)
var object map[string]any
_ = json.Unmarshal(raw, &object)
names := []string{}
for key := range object {
names = append(names, key)
}
return names
}
for _, name := range keys(publicuser.New(w.users.users[41])) {
if name == rabyKey {
t.Errorf("PublicUser must not carry %s", rabyKey)
}
}
login := w.anonymous(t, http.MethodPost, "/v1/auth/login", `{"username": "user41", "password": "password1"}`)
verify := w.anonymous(t, http.MethodPost, "/v1/auth/verify", `{"code": "x"}`)
for name, rec := range map[string]*httptest.ResponseRecorder{"login": login, "verify": verify} {
if strings.Contains(rec.Body.String(), rabyKey) {
t.Errorf("%s must not carry the key: %q", name, rec.Body.String())
}
}
me := usersAPIDecode(t, w.do(t, 41, http.MethodGet, "/v1/auth/me", ""))
if _, leaked := me[rabyKey]; leaked {
t.Errorf("the key lives under access only")
}
}
func TestRoleAssignedByNoNewGate(t *testing.T) {
w := newUsersAPIWorld(t)
rabySeedAssigner(w, 41, uaAdmin)
for _, item := range rabyList(t, w, uaViewer) {
if item["kind"] == "user" {
if _, present := item[rabyKey]; !present {
t.Errorf("a users.view caller must read the key")
}
}
}
for _, caller := range []uint32{uaWriter, uaInviter, 41} {
me := w.do(t, caller, http.MethodGet, "/v1/auth/me", "")
if me.Code != http.StatusOK {
t.Fatalf("user %d: unexpected %d", caller, me.Code)
}
rabyAssignerOf(t, me, "access")
}
for _, path := range []string{"/v1/user", "/v1/auth/me"} {
if rec := w.anonymous(t, http.MethodGet, path, ""); rec.Code != http.StatusUnauthorized {
t.Errorf("%s: expected 401, got %d", path, rec.Code)
}
}
}
func TestRoleAssignedByPersistence(t *testing.T) {
t.Run("UpdateRole writes the assigner with the compare-and-swap", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
admin, writer := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID
uaPersistedUser(t, db, 5, uaRoleIDPtr(admin), true)
updated, err := repo.UpdateRole(5, admin, writer, 3)
if err != nil || updated.RoleAssignedByID != 3 || userRow(t, db, 5).RoleAssignedByID == nil || *userRow(t, db, 5).RoleAssignedByID != 3 {
t.Fatalf("unexpected %+v, %v", updated, err)
}
if _, err := repo.UpdateRole(5, admin, writer, 9); err == nil || *userRow(t, db, 5).RoleAssignedByID != 3 {
t.Errorf("a stale swap must change nothing, got %v", err)
}
if _, err := repo.UpdateRole(5, writer, admin, 0); err != nil || userRow(t, db, 5).RoleAssignedByID != nil {
t.Errorf("a zero assigner must store NULL, got %v", err)
}
})
t.Run("TransferOwnership sets and clears the assigners in one transaction", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewUserRepository(db)
owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
uaPersistedUser(t, db, 3, uaRoleIDPtr(owner), true)
uaPersistedUser(t, db, 5, uaRoleIDPtr(admin), true)
uaPersistedUser(t, db, 12, uaRoleIDPtr(admin), false)
db.Exec("UPDATE users SET role_assigned_by_id = 12 WHERE id = 3")
if err := repo.TransferOwnership(3, 12, owner, admin); err == nil {
t.Fatal("an unverified successor must be refused")
}
if row := userRow(t, db, 3); row.RoleAssignedByID == nil || *row.RoleAssignedByID != 12 || roleIDOf(row) != owner {
t.Errorf("a refused transfer must change nothing, got %+v", row)
}
if err := repo.TransferOwnership(3, 5, owner, admin); err != nil {
t.Fatal(err)
}
if row := userRow(t, db, 3); row.RoleAssignedByID != nil {
t.Errorf("the former owner must read NULL")
}
if row := userRow(t, db, 5); row.RoleAssignedByID == nil || *row.RoleAssignedByID != 3 {
t.Errorf("the new owner must be assigned by 3")
}
})
t.Run("AcceptWithUser stores the assigner and Create leaves NULL", func(t *testing.T) {
db := openMigratedRolesDB(t)
admin := storedRole(t, db, "Admin").ID
row := uaPersistedInvite(t, db, "kim@x.example", uaRoleIDPtr(admin), false, false)
created, err := persistence.NewInviteRepository(db).AcceptWithUser(row.ID, time.Now(), user.User{Username: "kim", Email: "kim@x.example", Password: "x", IsVerified: true, RoleID: admin, RoleAssignedByID: 5})
if err != nil || created.RoleAssignedByID != 5 || *userRow(t, db, created.ID).RoleAssignedByID != 5 {
t.Fatalf("unexpected %+v, %v", created, err)
}
bare, err := persistence.NewUserRepository(db).Create(user.User{Username: "b", Email: "b@x.example", Password: "x"})
if err != nil || userRow(t, db, bare.ID).RoleAssignedByID != nil {
t.Errorf("Create must leave NULL, got %v", err)
}
})
t.Run("a bulk reassign leaves the assigner", func(t *testing.T) {
db := openMigratedRolesDB(t)
admin, writer := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID
uaPersistedUser(t, db, 5, uaRoleIDPtr(admin), true)
db.Exec("UPDATE users SET role_assigned_by_id = 3 WHERE id = 5")
if _, err := persistence.NewRoleRepository(db).ReassignMembers(admin, writer); err != nil {
t.Fatal(err)
}
if row := userRow(t, db, 5); roleIDOf(row) != writer || row.RoleAssignedByID == nil || *row.RoleAssignedByID != 3 {
t.Errorf("unexpected %+v", row)
}
})
}
func TestRoleAssignedByAcceptFailsOnInviterLookupError(t *testing.T) {
w := newUsersAPIWorld(t)
w.users.fail.failNextWith("Get", errors.New("inviter lookup failed"))
before := len(w.users.users)
rec := w.anonymous(t, http.MethodPost, "/v1/auth/invite/accept", `{"code": "code3", "username": "kim", "password": "password1"}`)
if rec.Code < 500 {
t.Fatalf("expected a failure, got %d %q", rec.Code, rec.Body.String())
}
if len(w.users.users) != before {
t.Errorf("no user may be created")
}
if w.invites.invites[3].AcceptedAt != nil {
t.Errorf("the invite must stay unaccepted")
}
}
func TestRoleAssignedByChangeRoleAuditOfDanglingRole(t *testing.T) {
w := newUsersAPIWorld(t)
member := w.users.users[41]
member.RoleID = 9999
w.users.users[41] = member
rec := w.putRole(t, uaAdmin, "41", w.idText("Writer"))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
event := w.recorder.events[len(w.recorder.events)-1]
if _, present := event.Params[audit.ParamFrom]; present {
t.Errorf("a role that was not held leaves from absent, got %v", event.Params)
}
if event.Params[audit.ParamTo] != w.ids["Writer"] {
t.Errorf("to = %v", event.Params[audit.ParamTo])
}
if len(event.Changes) != 0 {
t.Errorf("no blank role change expected, got %v", event.Changes)
}
}
+2 -2
View File
@@ -864,7 +864,7 @@ func TestContentTypeTypedGrantCleanup(t *testing.T) {
func TestGetMyAccessTypedGrants(t *testing.T) {
t.Run("the events editor lists typed entries including none, in order", func(t *testing.T) {
world := newRolesAPIWorld(t)
access, err := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[raEventsEditor])
access, err := useCases.NewGetMyAccessUseCase(world.roles, world.users).Get(world.users.users[raEventsEditor])
if err != nil {
t.Fatal(err)
}
@@ -878,7 +878,7 @@ func TestGetMyAccessTypedGrants(t *testing.T) {
world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")})
world.addUser(60, hand.ID, true)
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
access, _ := useCases.NewGetMyAccessUseCase(world.roles, world.users).Get(world.users.users[60])
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
t.Fatalf("unexpected permissions %v", access.Permissions)
}
+2 -2
View File
@@ -117,7 +117,7 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
roles := newSeededMockRoleRepo()
counting := &contentScopeRoleRepo{Repository: roles}
custom, _ := roles.Create(role.Role{Name: "Messy", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "settings.delete_site=all", "content.update:blog=all", "schema.view=own")})
access, err := useCases.NewGetMyAccessUseCase(counting).Get(user.User{ID: 30, RoleID: custom.ID})
access, err := useCases.NewGetMyAccessUseCase(counting, newMockUserRepo()).Get(user.User{ID: 30, RoleID: custom.ID})
if err != nil {
t.Fatal(err)
}
@@ -130,7 +130,7 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
})
t.Run("The Owner holds the 34 keys", func(t *testing.T) {
access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()).Get(user.User{ID: 3, RoleID: ownerRoleID})
access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()).Get(user.User{ID: 3, RoleID: ownerRoleID})
if err != nil || len(access.Permissions) != 34 {
t.Errorf("expected 34 permissions, got %d (%v)", len(access.Permissions), err)
}
+1 -1
View File
@@ -215,7 +215,7 @@ func (w *contentScopeWorld) wire() {
c := w.container
c.GetUserUseCase = useCases.NewGetUserUseCase(w.userRepo)
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
+2 -2
View File
@@ -352,14 +352,14 @@ func TestNewArticlesRecordTheirCreator(t *testing.T) {
func TestGetMyAccessUseCase(t *testing.T) {
t.Run("A user with role_id 0 gets an error and is never promoted", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo())
if _, err := uc.Get(user.User{ID: 11}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("A user whose role cannot be resolved gets an error", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo())
if _, err := uc.Get(user.User{ID: 13, RoleID: 99}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
+1 -1
View File
@@ -138,7 +138,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
+1 -1
View File
@@ -177,7 +177,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
}
+5
View File
@@ -192,6 +192,11 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
}
for _, row := range after.rows["users"] {
delete(row, "role_id")
assignedBy, columnAdded := row["role_assigned_by_id"]
if !columnAdded || assignedBy != nil {
t.Errorf("a legacy user must read a NULL role_assigned_by_id: %v", row)
}
delete(row, "role_assigned_by_id")
}
for _, row := range after.rows["invites"] {
if row["role_id"] == nil {
+1 -1
View File
@@ -40,7 +40,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: rig.start,
+1 -1
View File
@@ -65,7 +65,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
GetUserUseCase: useCases.NewGetUserUseCase(users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder),
}
+1 -1
View File
@@ -60,7 +60,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
+1 -1
View File
@@ -27,7 +27,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu
getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
handler := userhandler.NewHandler(update)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
+2 -2
View File
@@ -56,7 +56,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()))
inviteHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, newSeededMockRoleRepo(), newMockContentTypeRepo(), hasher, f.mail, invitePublicURL, discardRecorder),
nil,
@@ -250,7 +250,7 @@ func TestUserRolesHttp(t *testing.T) {
assertExactKeys(t, item, "id", "kind", "email", "role", "status", "created_at")
continue
}
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
assertExactKeys(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
want := wantRoles[decodedString(t, item, "username")]
var ref struct {
Name string `json:"name"`
+6 -1
View File
@@ -49,6 +49,7 @@ type mockUserRepo struct {
updateDisplayNameCalls int
lastUpdateDisplayName string
getAllCalls int
getCalls int
updateRoleCalls int
transferCalls int
beforeUpdateRole func()
@@ -77,6 +78,7 @@ func (m *mockUserRepo) sortedIds() []uint32 {
}
func (m *mockUserRepo) Get(id uint32) (user.User, error) {
m.getCalls++
if err := m.fail.trigger("Get"); err != nil {
return user.User{}, err
}
@@ -271,7 +273,7 @@ func (m *mockUserRepo) roleExists(id uint32) bool {
return present
}
func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32) (user.User, error) {
func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (user.User, error) {
m.updateRoleCalls++
if err := m.fail.trigger("UpdateRole"); err != nil {
return user.User{}, err
@@ -292,6 +294,7 @@ func (m *mockUserRepo) UpdateRole(id uint32, from uint32, to uint32) (user.User,
return user.User{}, user.ErrRoleChanged
}
stored.RoleID = to
stored.RoleAssignedByID = assignedBy
m.users[id] = stored
return stored, nil
}
@@ -315,7 +318,9 @@ func (m *mockUserRepo) TransferOwnership(from uint32, to uint32, ownerRoleID uin
return user.ErrInvalidTransfer
}
former.RoleID = formerRoleID
former.RoleAssignedByID = 0
successor.RoleID = ownerRoleID
successor.RoleAssignedByID = from
m.users[from] = former
m.users[to] = successor
return nil
+10 -2
View File
@@ -170,7 +170,7 @@ func TestUsersAPIChangeRoleHTTP(t *testing.T) {
if rec.Code != 200 || item["kind"] != "user" || item["status"] != "accepted" {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
if ref := usersAPIRoleRef(t, item); ref["name"] != row.role || ref["id"] != float64(w.ids[row.role]) {
t.Errorf("unexpected role %v", ref)
}
@@ -341,6 +341,9 @@ func TestUsersAPIInviteHTTP(t *testing.T) {
default:
assertRefusal(t, rec, row.status, row.code)
}
if got := rec.Header().Get(apperr.ErrorCodeHeader); got != inviteConflictCodes[row.code] {
t.Errorf("%s = %q, expected %q", apperr.ErrorCodeHeader, got, inviteConflictCodes[row.code])
}
if len(w.mail.SentEmails) != 0 || len(w.invites.invites) != 4 {
t.Errorf("a refusal creates no invite and sends no mail")
}
@@ -565,7 +568,7 @@ func TestUsersAPIListHTTP(t *testing.T) {
seen[key] = true
switch kind {
case "user":
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "status", "created_at")
apiKeyAssertExactFields(t, item, "id", "kind", "username", "display_name", "email", "role", "role_assigned_by", "status", "created_at")
default:
apiKeyAssertExactFields(t, item, "id", "kind", "email", "role", "status", "created_at")
}
@@ -636,3 +639,8 @@ func TestUsersAPIPublicUserShape(t *testing.T) {
}
})
}
var inviteConflictCodes = map[string]string{
apperr.PendingInviteErrorMessage: "invite_pending",
apperr.EmailHasAccountErrorMessage: "email_has_account",
}
+6 -6
View File
@@ -62,20 +62,20 @@ func TestUsersAPIUserRepository(t *testing.T) {
repo := persistence.NewUserRepository(db)
admin, writer, owner := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID, storedRole(t, db, "Owner").ID
uaPersistedUser(t, db, 5, &admin, true)
updated, err := repo.UpdateRole(5, admin, writer)
updated, err := repo.UpdateRole(5, admin, writer, 0)
if err != nil || updated.RoleID != writer || updated.TokenVersion != 0 {
t.Fatalf("unexpected %+v, %v", updated, err)
}
if _, err := repo.UpdateRole(5, admin, owner); !errors.Is(err, user.ErrRoleChanged) {
if _, err := repo.UpdateRole(5, admin, owner, 0); !errors.Is(err, user.ErrRoleChanged) {
t.Fatalf("the second call with the same from must lose, got %v", err)
}
if roleIDOf(userRow(t, db, 5)) != writer {
t.Errorf("the winner must stand")
}
if _, err := repo.UpdateRole(999, admin, writer); !errors.Is(err, user.ErrUserNotFound) {
if _, err := repo.UpdateRole(999, admin, writer, 0); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
if _, err := repo.UpdateRole(5, writer, 999); !errors.Is(err, role.ErrRoleNotFound) {
if _, err := repo.UpdateRole(5, writer, 999, 0); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
if roleIDOf(userRow(t, db, 5)) != writer {
@@ -88,7 +88,7 @@ func TestUsersAPIUserRepository(t *testing.T) {
repo := persistence.NewUserRepository(db)
writer := storedRole(t, db, "Writer").ID
uaPersistedUser(t, db, 5, nil, true)
if _, err := repo.UpdateRole(5, 0, writer); err != nil || roleIDOf(userRow(t, db, 5)) != writer {
if _, err := repo.UpdateRole(5, 0, writer, 0); err != nil || roleIDOf(userRow(t, db, 5)) != writer {
t.Fatalf("unexpected %v", err)
}
})
@@ -98,7 +98,7 @@ func TestUsersAPIUserRepository(t *testing.T) {
repo := persistence.NewUserRepository(db)
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if _, err := repo.UpdateRole(5, 1, 2); err == nil {
if _, err := repo.UpdateRole(5, 1, 2, 0); err == nil {
t.Errorf("expected an error")
}
})
+1
View File
@@ -113,6 +113,7 @@ func (w *usersAPIWorld) wire() {
hasher := &stubHasher{}
url := "https://cms.example.com"
c.ChangeUserRoleUseCase = useCases.NewChangeUserRoleUseCase(w.users, w.roles, w.types, w.recorder)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roles, w.users)
c.TransferOwnershipUseCase = useCases.NewTransferOwnershipUseCase(w.users, w.roles, w.recorder)
c.CreateInviteUseCase = useCases.NewCreateInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder)
c.RenewInviteUseCase = useCases.NewRenewInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder)
@@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDiff from '@/components/ActivityDiff.vue'
import ActivityRoleDiff from '@/components/ActivityRoleDiff.vue'
import ActivityDetailLine from '@/components/ActivityDetailLine.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import {
@@ -16,6 +17,7 @@ import {
targetTypeLabel,
type ActivityItem,
} from '@/lib/activity'
import { roleDiffRows, roleMembersLine } from '@/lib/activityRoleDiff'
import { fullLocalTime, fullUtcTime } from '@/lib/activityTime'
const props = defineProps<{
@@ -43,6 +45,11 @@ watch(
)
const current = computed(() => props.item ?? retained.value)
const hasRoleDetails = computed(
() =>
current.value !== null &&
(roleDiffRows(current.value).length > 0 || roleMembersLine(current.value) !== null),
)
const link = computed(() => targetLink(current.value?.target ?? null))
const side = computed(() => (props.isDesktop ? 'right' : 'bottom'))
const positionOf = computed(() =>
@@ -200,6 +207,17 @@ function onOpenChange(isOpen: boolean) {
</div>
</section>
<section
v-if="hasRoleDetails"
data-testid="activity-role-section"
class="flex flex-col gap-2"
>
<h3 class="text-base font-semibold text-highlighted">
{{ t('What changed') }}
</h3>
<ActivityRoleDiff :item="current" />
</section>
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">
{{ t('Changes') }}
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityItem } from '@/lib/activity'
import { roleDiffLabel, roleDiffRows, roleMembersLine } from '@/lib/activityRoleDiff'
const props = defineProps<{ item: ActivityItem }>()
const rows = computed(() => roleDiffRows(props.item))
const members = computed(() => roleMembersLine(props.item))
</script>
<template>
<div
v-if="rows.length > 0"
data-testid="activity-role-diff"
class="flex flex-col border border-default"
>
<div
v-for="(row, index) in rows"
:key="index"
data-testid="activity-role-change"
class="flex flex-col gap-0.5 border-t border-muted p-3 first:border-t-0"
>
<span class="text-2xs text-muted">{{ roleDiffLabel(row.kind) }}</span>
<span class="text-highlighted">{{ row.text }}</span>
</div>
</div>
<p v-if="members" data-testid="activity-role-members" class="text-muted">{{ members }}</p>
</template>
+13 -1
View File
@@ -2,7 +2,9 @@
import { t } from '@/i18n'
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
import type { RoleRef } from '@/lib/permissions'
import {
creatorLabel,
displayPrefix,
@@ -11,14 +13,20 @@ import {
lastUsedLabel,
} from '@/lib/apiKeys'
defineProps<{
const props = defineProps<{
keys: ApiKey[]
loading: boolean
showCreatedBy: boolean
callerId?: number
callerRole?: RoleRef
}>()
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
function followsCaller(key: ApiKey) {
return !props.showCreatedBy || key.user_id === props.callerId
}
function createdText(key: ApiKey, showCreatedBy: boolean) {
const date = formatKeyDate(key.created_at, '')
return showCreatedBy
@@ -62,6 +70,10 @@ function detailsText(key: ApiKey, showCreatedBy: boolean) {
>{{ key.name }}</span
>
<ApiKeyStatusBadge :status="key.status" />
<span class="flex shrink-0 items-center gap-1.5 text-xs text-muted">
{{ t('Follows role') }}
<RoleBadge :role="followsCaller(key) ? callerRole : null" />
</span>
</div>
<span
class="font-mono text-xs leading-[normal]"
+19
View File
@@ -3,7 +3,9 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
import type { RoleRef } from '@/lib/permissions'
import {
creatorLabel,
displayPrefix,
@@ -16,10 +18,16 @@ const props = defineProps<{
keys: ApiKey[]
loading: boolean
showCreatedBy: boolean
callerId?: number
callerRole?: RoleRef
}>()
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
function followsCaller(key: ApiKey) {
return !props.showCreatedBy || key.user_id === props.callerId
}
type Row = ApiKey | { skeleton: true; id: number }
const skeletonRows: Row[] = Array.from({ length: API_KEY_SKELETON_ROWS }, (_, index) => ({
@@ -65,6 +73,13 @@ const columns = computed(() => [
},
]
: []),
{
id: 'follows_role',
get header() {
return t('Follows role')
},
...fixed('w-28'),
},
{
id: 'created_at',
get header() {
@@ -157,6 +172,10 @@ function mutedClass(row: { original: Row }) {
creatorLabel(key(row))
}}</span>
</template>
<template #follows_role-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-5 w-16 rounded-none bg-accented" />
<RoleBadge v-else :role="followsCaller(key(row)) ? callerRole : null" />
</template>
<template #created_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
formatKeyDate(key(row).created_at, '')
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import RolePicker from '@/components/RolePicker.vue'
import { useRoles } from '@/composables/useRoles'
import { useUsers, type AcceptedUser } from '@/composables/useUsers'
import { useLocalizedToast } from '@/i18n/presentation'
import { isCode, inlineRefusal, isMissingPermission, userToast } from '@/lib/userRefusal'
import { usePermissionsStore } from '@/stores/permissions'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import type { RoleItem } from '@/lib/roles'
const props = defineProps<{ user: AcceptedUser }>()
const emit = defineEmits<{
changed: [user: AcceptedUser]
stale: []
}>()
const open = defineModel<boolean>('open', { default: true })
const { listRoles } = useRoles()
const { changeUserRole } = useUsers()
const permissions = usePermissionsStore()
const toast = useLocalizedToast()
const roles = ref<RoleItem[]>([])
const loading = ref(true)
const selected = ref<number | undefined>(props.user.role?.id)
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const name = computed(() => props.user.display_name || props.user.username)
const currentId = computed(() => props.user.role?.id)
const confirmDisabled = computed(
() => loading.value || selected.value === undefined || selected.value === currentId.value,
)
async function loadRoles() {
loading.value = true
try {
roles.value = await listRoles()
notice.value = null
} catch {
notice.value = { description: t("Couldn't load roles") }
} finally {
loading.value = false
}
}
function announceStale(title: string, description: string) {
toast.add(userToast(`user-role-stale-${props.user.id}`, 'error', title, description))
}
async function handleFailure(error: unknown) {
const refusal = inlineRefusal(error)
if (refusal !== undefined) {
notice.value = refusal
return
}
if (isMissingPermission(error)) {
open.value = false
return
}
if (isCode(error, 409, 'user_role_changed')) {
emit('stale')
announceStale(t('Their role changed meanwhile'), t('The list was refreshed.'))
open.value = false
return
}
if (isCode(error, 404, 'user_not_found')) {
emit('stale')
announceStale(t('That user no longer exists'), t('The list was refreshed.'))
open.value = false
return
}
if (isCode(error, 400, 'invalid_role')) {
selected.value = currentId.value
await loadRoles()
notice.value = { description: t("That role isn't available anymore. Choose another one.") }
return
}
notice.value = { description: t("Couldn't change the role. Try again in a moment.") }
}
async function onConfirm() {
const roleId = selected.value
if (pending.value || confirmDisabled.value || roleId === undefined) {
return
}
pending.value = true
notice.value = null
try {
const updated = await changeUserRole(props.user.id, roleId)
emit('changed', updated)
toast.add(
userToast(
`user-role-changed-${props.user.id}`,
'success',
() => t('Role changed'),
() => t('{0} now has the {1} role.', [name.value, updated.role?.name ?? '']),
),
)
open.value = false
} catch (error) {
await handleFailure(error)
} finally {
pending.value = false
}
}
onMounted(loadRoles)
</script>
<template>
<DirectoryDialog
v-model:open="open"
:title="t('Change role for {0}', [name])"
:description="t('Their API keys follow the new role.')"
:confirm-label="t('Change role')"
:pending-label="t('Changing…')"
tone="neutral"
:pending="pending"
:notice="notice"
:confirm-disabled="confirmDisabled"
@confirm="onConfirm"
>
<div v-if="loading" aria-busy="true" class="flex flex-col gap-2">
<USkeleton v-for="n in 4" :key="n" class="h-14 rounded-none bg-accented" />
</div>
<RolePicker
v-else
v-model:selected="selected"
:roles="roles"
:can-assign="permissions.canAssign"
show-locked
cards
:current-id="currentId"
:label="t('Role')"
/>
</DirectoryDialog>
</template>
+96 -19
View File
@@ -2,27 +2,46 @@
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import RolePicker from '@/components/RolePicker.vue'
import { ApiError } from '@/composables/useApi'
import { useRoles } from '@/composables/useRoles'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
import { inlineRefusal, isCode } from '@/lib/userRefusal'
import { usePermissionsStore } from '@/stores/permissions'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import type { RoleItem } from '@/lib/roles'
const EMAIL_PLACEHOLDER = 'name@example.com'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
const HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
const DEFAULT_ROLE_NAME = 'Writer'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
const { inviteUser } = useUsers()
const { listRoles } = useRoles()
const permissions = usePermissionsStore()
const emailField = ref<{ inputRef?: HTMLInputElement | null } | null>(null)
const email = ref('')
const touched = ref(false)
const focused = ref(false)
const submitting = ref(false)
const serverError = ref('')
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const refusal = ref<DirectoryNotice | null>(null)
const roles = ref<RoleItem[]>([])
const roleId = ref<number | undefined>(undefined)
const rolesLoaded = ref(false)
const showRolePicker = computed(
() => permissions.can('roles.view') && rolesLoaded.value && roles.value.length > 0,
)
const selectedRole = computed(() => roles.value.find((role) => role.id === roleId.value))
const defaultRoleSelected = computed(() => selectedRole.value?.name === DEFAULT_ROLE_NAME)
const sendBlocked = computed(() => showRolePicker.value && roleId.value === undefined)
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
@@ -42,7 +61,28 @@ const alertMessage = computed(() => {
return ''
})
function defaultRoleId() {
const writer = roles.value.find((role) => role.built_in && role.name === DEFAULT_ROLE_NAME)
return writer !== undefined && permissions.canAssign(writer) ? writer.id : undefined
}
async function loadRoles() {
rolesLoaded.value = false
roleId.value = undefined
if (!permissions.can('roles.view')) {
return
}
try {
roles.value = await listRoles()
rolesLoaded.value = true
roleId.value = defaultRoleId()
} catch {
roles.value = []
}
}
function resetForm() {
refusal.value = null
email.value = ''
touched.value = false
focused.value = false
@@ -52,7 +92,7 @@ function resetForm() {
}
function emailInput() {
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
return emailField.value?.inputRef ?? null
}
async function focusEmailField() {
@@ -70,6 +110,7 @@ watch(
}
resetForm()
focusEmailField()
loadRoles()
},
{ immediate: true },
)
@@ -83,15 +124,22 @@ function onBlur() {
focused.value = false
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE
? t(PENDING_INVITE_MESSAGE)
: t(HAS_ACCOUNT_MESSAGE)
}
function showFailure(error: unknown) {
if (error instanceof ApiError && error.status === 409) {
serverError.value = messageForConflict(error)
const refused = inlineRefusal(error)
if (refused !== undefined) {
refusal.value = refused
return
}
if (isCode(error, 400, 'invalid_role')) {
loadRoles()
return
}
if (isCode(error, 409, 'invite_pending')) {
serverError.value = t("There's already a pending invite for this address.")
return
}
if (isCode(error, 409, 'email_has_account')) {
serverError.value = t('This address already has an account.')
return
}
if (error instanceof ApiError && error.status === 400) {
@@ -114,11 +162,19 @@ async function onSubmit() {
return
}
if (sendBlocked.value) {
return
}
serverError.value = ''
formAlert.value = ''
refusal.value = null
submitting.value = true
try {
const invite = await inviteUser(email.value)
const invite = await inviteUser(
email.value,
showRolePicker.value ? roleId.value : undefined,
)
open.value = false
emit('invited', invite)
} catch (error) {
@@ -147,12 +203,8 @@ function onCancel() {
<template>
<UModal
:open="open"
:title="t('Invite a user')"
:description="
t(
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
)
"
:title="t('Invite user')"
:description="t('They get an email with a link to set a password.')"
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
@@ -169,6 +221,17 @@ function onCancel() {
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="refusal"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:title="refusal.title"
:description="refusal.description"
/>
<UAlert
v-if="alertMessage"
role="alert"
@@ -196,6 +259,7 @@ function onCancel() {
</span>
</template>
<UInput
ref="emailField"
v-model="email"
type="email"
:placeholder="EMAIL_PLACEHOLDER"
@@ -209,6 +273,19 @@ function onCancel() {
@blur="onBlur"
/>
</UFormField>
<UFormField v-if="showRolePicker" :label="t('Role')" name="role">
<RolePicker
v-model:selected="roleId"
:roles="roles"
:can-assign="permissions.canAssign"
/>
<div class="mt-1.5 flex flex-col gap-0.5 text-xs/5 text-muted">
<span v-if="selectedRole">{{ selectedRole.description }}</span>
<span v-if="defaultRoleSelected">{{
t('Writer is the default. You can change a role later.')
}}</span>
</div>
</UFormField>
</UForm>
</div>
</template>
@@ -230,7 +307,7 @@ function onCancel() {
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
:disabled="submitting || sendBlocked"
@click="onSubmit"
>
{{ submitting ? t('Sending…') : t('Send invite') }}
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
description?: string
}>()
</script>
@@ -12,7 +12,7 @@ defineProps<{
<h2 class="text-base/5 font-semibold text-highlighted">
{{ title }}
</h2>
<p class="text-pretty text-muted">{{ description }}</p>
<p v-if="description" class="text-pretty text-muted">{{ description }}</p>
</div>
<slot />
</div>
+49 -11
View File
@@ -3,33 +3,71 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import type { RoleItem } from '@/lib/roles'
const props = defineProps<{
roles: RoleItem[]
sourceId: number
canAssign: (role: RoleItem) => boolean
}>()
const props = withDefaults(
defineProps<{
roles: RoleItem[]
sourceId?: number
canAssign: (role: RoleItem) => boolean
showLocked?: boolean
currentId?: number
cards?: boolean
label?: string
}>(),
{ sourceId: 0, showLocked: false, currentId: undefined, cards: false, label: undefined },
)
const selected = defineModel<number | undefined>('selected', { default: undefined })
function reasonFor(role: RoleItem): string | undefined {
if (role.locked) {
return t('There is one owner. Transfer ownership to change who it is.')
}
return props.canAssign(role)
? undefined
: t("Your role can't assign a role with more power than yours.")
}
const items = computed(() =>
props.roles
.filter((role) => !role.locked && role.id !== props.sourceId)
.filter((role) => (props.showLocked || !role.locked) && role.id !== props.sourceId)
.map((role) => {
const allowed = props.canAssign(role)
const reason = reasonFor(role)
return {
label: role.name,
value: role.id,
description: allowed
? role.description
: t("Your role can't assign a role with more power than yours."),
disabled: !allowed,
description: reason ?? role.description,
disabled: reason !== undefined,
current: role.id === props.currentId,
}
}),
)
</script>
<template>
<URadioGroup
v-if="cards"
v-model="selected"
:items="items"
variant="card"
color="primary"
:aria-label="label"
:ui="{ item: 'rounded-none', fieldset: 'gap-2' }"
>
<template #label="{ item }">
<span class="flex items-center gap-2">
{{ item.label }}
<UBadge
v-if="item.current"
color="neutral"
variant="outline"
:label="t('Current')"
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 text-[0.6875rem]' }"
/>
</span>
</template>
</URadioGroup>
<USelectMenu
v-else
v-model="selected"
:items="items"
value-key="value"
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { t } from '@/i18n'
import RolePreviewBody from '@/components/RolePreviewBody.vue'
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
import type { RolePreviewModel } from '@/lib/rolePreview'
defineProps<{
model: RolePreviewModel
roleName: string
mobile: boolean
}>()
const open = defineModel<boolean>('open', { default: false })
</script>
<template>
<USlideover
v-if="mobile"
v-model:open="open"
side="bottom"
:title="t('What you can do')"
:description="t('A summary of your role, {0}. Read-only.', [roleName])"
:ui="BOTTOM_SHEET_UI"
>
<template #body>
<p class="text-muted">{{ t('A summary of your role, {0}. Read-only.', [roleName]) }}</p>
<RolePreviewBody :model="model" />
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center"
@click="open = false"
>
{{ t('Close') }}
</UButton>
</template>
</USlideover>
<UModal
v-else
v-model:open="open"
:title="t('What you can do')"
:description="t('A summary of your role, {0}. Read-only.', [roleName])"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-15 sm:max-w-140 sm:translate-y-0',
header: 'px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<RolePreviewBody :model="model" />
</template>
<template #footer>
<div class="flex w-full justify-end">
<UButton
color="neutral"
variant="outline"
class="h-8 px-3 text-sm text-highlighted"
@click="open = false"
>
{{ t('Close') }}
</UButton>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import { useUsers, type AcceptedUser, type UserListItem } from '@/composables/useUsers'
import { useLocalizedToast } from '@/i18n/presentation'
import { isCode, isMissingPermission, isSessionRequired, userToast } from '@/lib/userRefusal'
import { useSessionStore } from '@/stores/session'
import type { DirectoryNotice } from '@/lib/directoryTypes'
type Step = 'pick' | 'confirm' | 'back'
const props = defineProps<{ users: UserListItem[] }>()
const emit = defineEmits<{ transferred: []; stale: [] }>()
const open = defineModel<boolean>('open', { default: true })
const { transferOwnership } = useUsers()
const session = useSessionStore()
const toast = useLocalizedToast()
const step = ref<Step>('pick')
const chosenId = ref<number | undefined>(undefined)
const typed = ref('')
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const fieldError = ref(false)
function displayName(user: AcceptedUser) {
return user.display_name || user.username
}
const members = computed(() =>
props.users.filter(
(item): item is AcceptedUser =>
item.kind === 'user' && !item.role?.locked && item.id !== session.session?.id,
),
)
const options = computed(() =>
members.value.map((user) => ({
label: displayName(user),
value: user.id,
description: user.email,
})),
)
const picking = computed(() => step.value !== 'confirm')
const chosen = computed(() => members.value.find((user) => user.id === chosenId.value))
const name = computed(() => (chosen.value ? displayName(chosen.value) : ''))
const email = computed(() => chosen.value?.email ?? '')
const title = computed(() =>
picking.value ? t('Transfer ownership') : t('Transfer ownership to {0}', [name.value]),
)
const description = computed(() =>
picking.value
? t('Choose who becomes the owner. Only members can be chosen.')
: t('This takes effect straight away.'),
)
const confirmLabel = computed(() => (picking.value ? t('Continue') : t('Transfer ownership')))
const cancelLabel = computed(() => (picking.value ? t('Cancel') : t('Back')))
const confirmDisabled = computed(() =>
picking.value ? chosen.value === undefined : typed.value !== email.value,
)
const consequences = computed(() => [
t('{0} becomes the owner.', [name.value]),
t("You become an Admin. You can't take ownership back unless they transfer it."),
t('Only the owner can apply updates, delete a site and change activity retention.'),
t('Your API keys follow your new role.'),
])
function onOpenChange(value: boolean) {
if (!value && step.value === 'back') {
step.value = 'pick'
return
}
open.value = value
}
function onCancel() {
if (step.value === 'confirm') {
step.value = 'back'
typed.value = ''
notice.value = null
fieldError.value = false
}
}
async function handleFailure(error: unknown) {
if (isMissingPermission(error)) {
open.value = false
return
}
if (isSessionRequired(error)) {
return
}
if (isCode(error, 400, 'invalid_transfer')) {
step.value = 'pick'
chosenId.value = undefined
typed.value = ''
emit('stale')
notice.value = {
description: t("That person can't become the owner. Choose another member."),
}
return
}
if (isCode(error, 400, 'confirmation_mismatch')) {
fieldError.value = true
return
}
if (isCode(error, 409, 'user_role_changed')) {
await session.refresh()
emit('stale')
toast.add(
userToast(
'ownership-changed-meanwhile',
'error',
() => t('Ownership changed meanwhile'),
() => t('The list was refreshed.'),
),
)
open.value = false
return
}
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
}
async function transfer() {
const target = chosen.value
if (pending.value || target === undefined) {
return
}
pending.value = true
notice.value = null
fieldError.value = false
try {
const result = await transferOwnership(target.id, typed.value)
await session.refresh()
emit('transferred')
toast.add(
userToast(
'ownership-transferred',
'success',
() => t('Ownership transferred'),
() => t('{0} is now the owner. You are an Admin.', [result.owner.name]),
),
)
open.value = false
} catch (error) {
await handleFailure(error)
} finally {
pending.value = false
}
}
function onConfirm() {
if (confirmDisabled.value) {
return
}
if (picking.value) {
step.value = 'confirm'
return
}
transfer()
}
</script>
<template>
<DirectoryDialog
:open="open"
:title="title"
:description="description"
:confirm-label="confirmLabel"
:pending-label="t('Transferring…')"
:cancel-label="cancelLabel"
:tone="picking ? 'neutral' : 'danger'"
:pending="pending"
:notice="notice"
:confirm-disabled="confirmDisabled"
@update:open="onOpenChange"
@cancel="onCancel"
@confirm="onConfirm"
>
<URadioGroup
v-if="picking"
v-model="chosenId"
:items="options"
variant="card"
:aria-label="t('Members')"
:ui="{ item: 'rounded-none', fieldset: 'gap-2' }"
/>
<template v-else>
<ul class="flex list-disc flex-col gap-1.5 ps-5 text-toned">
<li v-for="line in consequences" :key="line">{{ line }}</li>
</ul>
<UFormField
:label="t('Type {0} to confirm', [email])"
:help="t('The email must match exactly.')"
:error="
fieldError ? t('That email does not match. Check it and try again.') : undefined
"
>
<UInput
v-model="typed"
autocomplete="off"
:disabled="pending"
class="w-full"
:ui="{ base: 'h-11 rounded-none md:h-10' }"
/>
</UFormField>
</template>
</DirectoryDialog>
</template>
+18 -5
View File
@@ -4,6 +4,8 @@ import { t } from '@/i18n'
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
import UserRowMenu from '@/components/UserRowMenu.vue'
import type { UserMenuContext } from '@/lib/userMenu'
import UserEmailCell from '@/components/UserEmailCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import {
@@ -21,6 +23,7 @@ defineProps<{
highlightedKey?: string
canManage?: boolean
busyKeys?: string[]
menu?: UserMenuContext
}>()
const emit = defineEmits<{
@@ -53,11 +56,21 @@ function rowKey(item: UserListItem) {
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
>
<UserEmailCell
:email="item.email"
:is-you="item.kind === 'user' && item.id === sessionUserId"
large
/>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<UserEmailCell
:email="item.email"
:is-you="item.kind === 'user' && item.id === sessionUserId"
large
/>
</div>
<UserRowMenu
v-if="item.kind === 'user' && menu"
:user="item"
:context="menu"
:desktop="false"
/>
</div>
<span v-if="userDisplayName(item)" class="text-toned">{{
userDisplayName(item)
}}</span>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
import type { AcceptedUser } from '@/composables/useUsers'
import { userMenuItems, userMenuTitle, type UserMenuContext } from '@/lib/userMenu'
const props = defineProps<{
user: AcceptedUser
context: UserMenuContext
desktop: boolean
}>()
const open = ref(false)
const items = computed(() => userMenuItems(props.user, props.context))
</script>
<template>
<DirectoryRowMenu
v-if="items.length > 0"
v-model:open="open"
:items="items"
:title="userMenuTitle(user)"
:desktop="desktop"
/>
</template>
+10 -1
View File
@@ -4,6 +4,8 @@ import { t } from '@/i18n'
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
import UserRowMenu from '@/components/UserRowMenu.vue'
import type { UserMenuContext } from '@/lib/userMenu'
import UserEmailCell from '@/components/UserEmailCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import {
@@ -24,6 +26,7 @@ defineProps<{
highlightedKey?: string
canManage?: boolean
busyKeys?: string[]
menu?: UserMenuContext
}>()
const emit = defineEmits<{
@@ -85,8 +88,14 @@ function rowKey(item: UserListItem) {
<span><InviteStatusBadge :status="item.status" /></span>
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
<span class="flex justify-end">
<UserRowMenu
v-if="item.kind === 'user' && menu"
:user="item"
:context="menu"
desktop
/>
<UserRowAction
v-if="canManage"
v-else-if="canManage"
:item="item"
:busy="busyKeys?.includes(rowKey(item))"
@cancel="emit('cancel', item)"
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import ProfileCard from '@/components/ProfileCard.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import RoleSummaryDialog from '@/components/RoleSummaryDialog.vue'
import { buildHeldRolePreview } from '@/lib/rolePreview'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 48rem)'
const permissions = usePermissionsStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const summaryOpen = ref(false)
const model = computed(() => buildHeldRolePreview(permissions.grants))
</script>
<template>
<ProfileCard :title="t('Your role')">
<div class="flex flex-wrap items-center gap-2">
<RoleBadge :role="permissions.role" />
<span v-if="permissions.roleAssignedBy" class="text-muted">{{
t('Assigned by {0}', [permissions.roleAssignedBy.name])
}}</span>
</div>
<UButton
color="neutral"
variant="link"
icon="i-lucide-list-checks"
class="self-start p-0 font-medium text-highlighted"
@click="summaryOpen = true"
>
{{ t('See what I can do') }}
</UButton>
<p class="text-pretty text-muted">
{{ t('Your API keys follow this role. If your role changes, they change too.') }}
</p>
<RoleSummaryDialog
v-model:open="summaryOpen"
:model="model"
:role-name="permissions.roleName"
:mobile="!isDesktop"
/>
</ProfileCard>
</template>
@@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import type { AcceptedUser } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
import { adminAccess, customAccess, ownerAccess, roleRefFor } from '@/test/access'
import type { Access } from '@/lib/permissions'
import { CONTRIBUTOR, LEAD, WORLD, WRITER } from './userRolesKit'
import { radiosOf, renderInApp } from './roleKit'
import ChangeRoleDialog from '../ChangeRoleDialog.vue'
const listRoles = vi.fn()
const changeUserRole = vi.fn()
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@/composables/useRoles', () => ({ useRoles: () => ({ listRoles }) }))
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ changeUserRole }) }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const INES: AcceptedUser = {
kind: 'user',
id: 7,
username: 'ines',
display_name: 'Ines Duarte',
email: 'ines.duarte@renew.example',
role: { id: WRITER.id, name: 'Writer', built_in: true, locked: false },
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
}
let wrapper: VueWrapper | null = null
async function mountDialog(access: Access = ownerAccess(), user: AcceptedUser = INES) {
setActivePinia(createPinia())
useSessionStore().session = { id: 1, verified: true, displayName: '', access }
wrapper = renderInApp(ChangeRoleDialog, { user, open: true })
await flushPromises()
}
function dialog() {
return document.body.querySelector('[role="alertdialog"]') as HTMLElement
}
function button(label: string) {
return [...dialog().querySelectorAll('button')].find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined
}
function options() {
return radiosOf(dialog(), 'Role')
}
function labelNamed(name: string) {
return [...dialog().querySelectorAll('label')].find((candidate) =>
candidate.querySelector('[data-slot="label"]')?.textContent?.includes(name),
)!
}
function optionNamed(name: string) {
return labelNamed(name).querySelector<HTMLButtonElement>('[role="radio"]')!
}
async function pick(name: string) {
optionNamed(name).click()
await flushPromises()
}
async function confirm() {
button('Change role')!.click()
await flushPromises()
}
function emitted(name: string) {
return wrapper!.findComponent(ChangeRoleDialog).emitted(name)
}
beforeEach(() => {
toastAdd = vi.fn()
listRoles.mockResolvedValue(WORLD)
changeUserRole.mockResolvedValue({
...INES,
role: { id: CONTRIBUTOR.id, name: 'Contributor', built_in: false, locked: false },
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
listRoles.mockReset()
changeUserRole.mockReset()
})
describe('The dialog', () => {
it('reads the title, the body and the options with their summaries and the Current mark', async () => {
await mountDialog()
const text = dialog().textContent!
expect(text).toContain('Change role for Ines Duarte')
expect(text).toContain('Their API keys follow the new role.')
expect(options().map((radio) => radio.closest('label')?.textContent)).toEqual(
expect.arrayContaining([
expect.stringContaining('Admin'),
expect.stringContaining('Contributor'),
expect.stringContaining('Content lead'),
]),
)
expect(text).toContain(CONTRIBUTOR.description)
expect(labelNamed('Writer').textContent).toContain('Current')
expect(text).toContain('Cancel')
})
it('keeps Change role disabled until an option other than the current one is chosen', async () => {
await mountDialog()
expect(button('Change role')!.disabled).toBe(true)
await pick('Writer')
expect(button('Change role')!.disabled).toBe(true)
await pick('Contributor')
expect(button('Change role')!.disabled).toBe(false)
})
})
describe('Options blocked', () => {
it.each([
['an Admin', adminAccess()],
['the Owner', ownerAccess()],
])('%s sees Owner disabled with the one-owner reason', async (_who, access) => {
await mountDialog(access)
const owner = optionNamed('Owner')
expect(owner.disabled).toBe(true)
expect(labelNamed('Owner').textContent).toContain(
'There is one owner. Transfer ownership to change who it is.',
)
})
const peopleManager = () =>
customAccess(
'users.view, users.change_role, roles.view, content.read, content.update=own',
'People manager',
)
it('disables Content lead for a People manager with the escalation reason and enables Intern', async () => {
await mountDialog(peopleManager())
const lead = optionNamed(LEAD.name)
expect(lead.disabled).toBe(true)
expect(labelNamed(LEAD.name).textContent).toContain(
"Your role can't assign a role with more power than yours.",
)
expect(optionNamed('Contributor').disabled).toBe(false)
})
})
describe('Changing the role', () => {
it('sends the chosen role, locks the dialog while in flight, then closes with the answer', async () => {
let release!: (value: unknown) => void
changeUserRole.mockReturnValue(new Promise((resolve) => (release = resolve)))
await mountDialog()
await pick('Contributor')
await confirm()
expect(changeUserRole).toHaveBeenCalledWith(7, CONTRIBUTOR.id)
expect(button('Changing…')!.disabled).toBe(true)
expect(button('Cancel')!.disabled).toBe(true)
release({ ...INES, role: { id: 4, name: 'Contributor', built_in: false, locked: false } })
await flushPromises()
expect(emitted('changed')?.[0]?.[0]).toMatchObject({ id: 7, role: { name: 'Contributor' } })
expect(emitted('update:open')?.at(-1)).toEqual([false])
expect(toastAdd).toHaveBeenCalledTimes(1)
})
it.each([
['escalation', "You can't grant that", 'You can only grant what your role has.'],
[
'role_locked',
'This role is locked',
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
],
['own_role', "You can't change your own role", 'Ask another owner or admin.'],
] as const)('403 %s stays open with its copy', async (code, title, description) => {
changeUserRole.mockRejectedValue(new ApiError(403, undefined, code, { code }))
await mountDialog()
await pick('Contributor')
await confirm()
expect(dialog().textContent).toContain(title)
expect(dialog().textContent).toContain(description)
expect(emitted('update:open')).toBeUndefined()
})
it('403 missing_permission closes the dialog and leaves the toast to the API client', async () => {
changeUserRole.mockRejectedValue(
new ApiError(403, undefined, 'missing_permission', { code: 'missing_permission' }),
)
await mountDialog()
await pick('Contributor')
await confirm()
expect(emitted('update:open')?.at(-1)).toEqual([false])
expect(toastAdd).not.toHaveBeenCalled()
})
it('409 user_role_changed asks for a re-read, toasts and closes', async () => {
changeUserRole.mockRejectedValue(
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
)
await mountDialog()
await pick('Contributor')
await confirm()
expect(emitted('stale')).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('404 user_not_found asks for a re-read, toasts and closes', async () => {
changeUserRole.mockRejectedValue(
new ApiError(404, undefined, 'user_not_found', { code: 'user_not_found' }),
)
await mountDialog()
await pick('Contributor')
await confirm()
expect(emitted('stale')).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('400 invalid_role re-reads the roles and stays open with an inline error', async () => {
changeUserRole.mockRejectedValue(
new ApiError(400, undefined, 'invalid_role', { code: 'invalid_role' }),
)
await mountDialog()
await pick('Contributor')
await confirm()
expect(listRoles).toHaveBeenCalledTimes(2)
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
expect(emitted('update:open')).toBeUndefined()
})
it('500 stays open with an inline error', async () => {
changeUserRole.mockRejectedValue(new ApiError(500))
await mountDialog()
await pick('Contributor')
await confirm()
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
expect(emitted('update:open')).toBeUndefined()
expect(button('Change role')!.disabled).toBe(false)
})
})
describe('Roles are read through useRoles', () => {
it('reads the role list once on open', async () => {
await mountDialog()
expect(listRoles).toHaveBeenCalledTimes(1)
expect(roleRefFor('admin')).not.toBeNull()
})
})
@@ -1,17 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { createPinia, setActivePinia } from 'pinia'
import inviteEmailCases from '../../../../test/testdata/invite_emails.json'
import { ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { adminAccess, customAccess, ownerAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
import { ADMIN, CONTRIBUTOR, INTERN, LEAD, OWNER, WORLD, WRITER } from './userRolesKit'
import InviteUserModal from '../InviteUserModal.vue'
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
const ESCALATION_REASON = "Your role can't assign a role with more power than yours."
const inviteUser = vi.fn()
const listRoles = vi.fn()
vi.mock('@/composables/useUsers', () => ({
useUsers: () => ({ inviteUser }),
}))
vi.mock('@/composables/useRoles', () => ({
useRoles: () => ({ listRoles }),
}))
let wrapper: VueWrapper | null = null
function emailInput() {
@@ -26,7 +38,10 @@ function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
async function mountOpenModal() {
async function mountOpenModal(access: Access = ownerAccess()) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: 1, verified: true, displayName: '', access }
wrapper = mount(InviteUserModal, {
attachTo: document.body,
props: { open: true },
@@ -35,19 +50,56 @@ async function mountOpenModal() {
await flushPromises()
}
function clickSend() {
const send = [...document.body.querySelectorAll('button')].find(
function sendButton() {
return [...document.body.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Send invite',
)!
send.click()
}
function clickSend() {
sendButton().click()
return flushPromises()
}
async function typeEmail(value: string) {
const input = emailInput()
input.value = value
dispatch(input, 'input')
await flushPromises()
}
function picker() {
return document.body.querySelector<HTMLElement>('[aria-haspopup="listbox"]')
}
async function openPicker() {
picker()!.click()
await flushPromises()
}
function option(name: string) {
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find((candidate) =>
candidate.textContent?.includes(name),
)
}
async function choose(name: string) {
await openPicker()
option(name)!.click()
await flushPromises()
}
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
listRoles.mockResolvedValue(WORLD)
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
inviteUser.mockReset()
listRoles.mockReset()
})
describe('Invite modal email rule', () => {
@@ -71,3 +123,153 @@ describe('Invite modal email rule', () => {
}
})
})
describe('The role picker in the invite dialog', () => {
it('reads the design copy, defaults to Writer with its summary and hint', async () => {
await mountOpenModal()
expect(dialogText()).toContain('Invite user')
expect(dialogText()).toContain('They get an email with a link to set a password.')
expect(picker()?.textContent).toContain('Writer')
expect(dialogText()).toContain(WRITER.description)
expect(dialogText()).toContain('Writer is the default. You can change a role later.')
})
it('does not offer Owner', async () => {
await mountOpenModal()
await openPicker()
expect(option('Owner')).toBeUndefined()
expect(option('Admin')).toBeDefined()
})
it('drops the default hint once another role is chosen and sends its id', async () => {
inviteUser.mockResolvedValue({ id: 9, email: 'lea.martin@renew.example' })
await mountOpenModal()
await choose('Contributor')
expect(dialogText()).not.toContain('Writer is the default.')
await typeEmail('lea.martin@renew.example')
await clickSend()
expect(inviteUser).toHaveBeenCalledWith('lea.martin@renew.example', CONTRIBUTOR.id)
})
it('sends the Writer id by default', async () => {
inviteUser.mockResolvedValue({ id: 9, email: 'lea.martin@renew.example' })
await mountOpenModal()
await typeEmail('lea.martin@renew.example')
await clickSend()
expect(inviteUser).toHaveBeenCalledWith('lea.martin@renew.example', WRITER.id)
})
it('closes on 201 and emits the invite', async () => {
const created = { id: 9, kind: 'invite', email: 'lea.martin@renew.example' }
inviteUser.mockResolvedValue(created)
await mountOpenModal()
await typeEmail('lea.martin@renew.example')
await clickSend()
expect(wrapper!.emitted('invited')?.[0]).toEqual([created])
expect(wrapper!.emitted('update:open')?.at(-1)).toEqual([false])
})
})
describe('The picker follows the no-escalation rule', () => {
it.each([
[OWNER.name, 'Content lead'],
[ADMIN.name, 'Admin'],
])('%s can pick %s', async (who, name) => {
await mountOpenModal(who === 'Owner' ? ownerAccess() : adminAccess())
await openPicker()
expect(option(name)?.getAttribute('aria-disabled')).not.toBe('true')
expect(option(name)?.hasAttribute('data-disabled')).toBe(false)
})
const inviter = () =>
customAccess(
'users.invite, roles.view, content.read=all, content.create, content.update=own, content.publish=own',
'Inviter',
)
it('disables a role above the caller with the reason and enables one they cover', async () => {
await mountOpenModal(inviter())
await openPicker()
expect(option('Writer')?.hasAttribute('data-disabled')).toBe(true)
expect(option('Writer')?.textContent).toContain(ESCALATION_REASON)
expect(option('Intern')?.hasAttribute('data-disabled')).toBe(false)
expect(INTERN.name).toBe('Intern')
})
it('shows Choose a role and keeps Send invite disabled when Writer is above the caller', async () => {
await mountOpenModal(inviter())
expect(picker()?.textContent).toContain('Choose a role')
expect(sendButton().disabled).toBe(true)
await choose('Intern')
expect(sendButton().disabled).toBe(false)
})
})
describe('Inviting without roles.view', () => {
it('does not read roles, shows no picker and sends the email only', async () => {
inviteUser.mockResolvedValue({ id: 9, email: 'lea.martin@renew.example' })
await mountOpenModal(customAccess('users.invite'))
expect(listRoles).not.toHaveBeenCalled()
expect(picker()).toBeNull()
await typeEmail('lea.martin@renew.example')
await clickSend()
expect(inviteUser).toHaveBeenCalledWith('lea.martin@renew.example', undefined)
})
})
describe('Invite answers that concern roles', () => {
async function submitRefused(error: ApiError) {
inviteUser.mockRejectedValue(error)
await mountOpenModal()
await typeEmail('lea.martin@renew.example')
await clickSend()
}
it.each([
['escalation', "You can't grant that", 'You can only grant what your role has.'],
[
'role_locked',
'This role is locked',
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
],
] as const)(
'403 %s shows its copy and keeps the dialog and the address',
async (code, title, description) => {
await submitRefused(new ApiError(403, undefined, code, { code }))
expect(dialogText()).toContain(title)
expect(dialogText()).toContain(description)
expect(emailInput().value).toBe('lea.martin@renew.example')
expect(wrapper!.emitted('invited')).toBeUndefined()
},
)
it('429 shows the rate limit alert and keeps the dialog and the address', async () => {
await submitRefused(new ApiError(429, undefined, undefined, undefined))
expect(dialogText()).toContain('Too many invites. Wait a moment, then try again.')
expect(emailInput().value).toBe('lea.martin@renew.example')
expect(wrapper!.emitted('invited')).toBeUndefined()
})
it('400 invalid_role re-reads the roles and returns the picker to Choose a role', async () => {
await submitRefused(new ApiError(400, undefined, 'invalid_role', { code: 'invalid_role' }))
expect(listRoles).toHaveBeenCalledTimes(2)
expect(emailInput().value).toBe('lea.martin@renew.example')
expect(picker()?.textContent).toContain('Writer')
expect(wrapper!.emitted('invited')).toBeUndefined()
expect(LEAD.name).toBe('Content lead')
})
})
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { renderInApp } from './roleKit'
import { WORLD, WRITER } from './userRolesKit'
import RolePicker from '../RolePicker.vue'
let wrapper: VueWrapper | null = null
async function mountPicker(props: Record<string, unknown> = {}) {
setActivePinia(createPinia())
wrapper = renderInApp(RolePicker, {
roles: WORLD,
canAssign: () => true,
label: 'Role',
...props,
})
await flushPromises()
}
function optionNames() {
return [...document.body.querySelectorAll('[role="option"]')].map((option) =>
option.textContent?.trim(),
)
}
function cardLabels() {
return [...document.body.querySelectorAll('label')].map((label) => label.textContent ?? '')
}
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
describe('menu mode (slice 6 behaviour)', () => {
it('hides the locked role and the source role', async () => {
await mountPicker({ sourceId: WRITER.id })
;(document.body.querySelector('[aria-haspopup="listbox"]') as HTMLElement).click()
await flushPromises()
const names = optionNames().join(' ')
expect(names).not.toContain('Owner')
expect(names).not.toContain('Writer')
expect(names).toContain('Admin')
})
})
describe('opt-in props', () => {
it('lists the locked role disabled with the one-owner reason when showLocked is set', async () => {
await mountPicker({ cards: true, showLocked: true })
const owner = cardLabels().find((text) => text.includes('Owner'))!
expect(owner).toContain('There is one owner. Transfer ownership to change who it is.')
const radio = [...document.body.querySelectorAll('label')]
.find((label) => label.textContent?.includes('Owner'))!
.querySelector<HTMLButtonElement>('[role="radio"]')!
expect(radio.disabled).toBe(true)
})
it('marks the current role', async () => {
await mountPicker({ cards: true, showLocked: true, currentId: WRITER.id })
const marked = cardLabels().filter((text) => text.includes('Current'))
expect(marked).toHaveLength(1)
expect(marked[0]).toContain('Writer')
})
it('shows a role the caller cannot cover as disabled with the escalation reason', async () => {
await mountPicker({
cards: true,
canAssign: (role: { name: string }) => role.name !== 'Admin',
})
const admin = cardLabels().find((text) => text.includes('Admin'))!
expect(admin).toContain("Your role can't assign a role with more power than yours.")
})
})
@@ -0,0 +1,290 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import type { UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
import { ownerAccess, roleRefFor } from '@/test/access'
import { renderInApp } from './roleKit'
import TransferOwnershipDialog from '../TransferOwnershipDialog.vue'
const transferOwnership = vi.fn()
const refresh = vi.fn()
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership }) }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const JONAS_EMAIL = 'jonas@renewbuild.example'
function accepted(id: number, name: string, email: string, role: string): UserListItem {
return {
kind: 'user',
id,
username: name.toLowerCase().split(' ')[0]!,
display_name: name,
email,
role: roleRefFor(role),
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
}
}
const USERS: UserListItem[] = [
{
kind: 'invite',
id: 9,
email: 'pending@renewbuild.example',
status: 'pending',
created_at: '2026-09-27T09:00:00Z',
},
accepted(1, 'Marta Alves', 'marta@renewbuild.example', 'owner'),
accepted(2, 'Jonas Weber', JONAS_EMAIL, 'admin'),
accepted(3, 'Lea Martin', 'lea@renewbuild.example', 'admin'),
]
let wrapper: VueWrapper | null = null
async function mountDialog() {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: 1, verified: true, displayName: '', access: ownerAccess() }
session.refresh = refresh
wrapper = renderInApp(TransferOwnershipDialog, { users: USERS, open: true })
await flushPromises()
}
function dialog() {
return document.body.querySelector('[role="alertdialog"]') as HTMLElement
}
function button(label: string) {
return [...dialog().querySelectorAll('button')].find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined
}
function radio(name: string) {
return [...dialog().querySelectorAll('label')]
.find((candidate) => candidate.textContent?.includes(name))
?.querySelector<HTMLButtonElement>('[role="radio"]')
}
function field() {
return dialog().querySelector('input') as HTMLInputElement
}
async function pickJonas() {
radio('Jonas Weber')!.click()
await flushPromises()
button('Continue')!.click()
await flushPromises()
}
async function type(value: string) {
field().value = value
field().dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function submit() {
await pickJonas()
await type(JONAS_EMAIL)
button('Transfer ownership')!.click()
await flushPromises()
}
function emitted(name: string) {
return wrapper!.findComponent(TransferOwnershipDialog).emitted(name)
}
beforeEach(() => {
toastAdd = vi.fn()
refresh.mockResolvedValue(undefined)
transferOwnership.mockResolvedValue({
owner: { id: 2, name: 'Jonas Weber' },
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
transferOwnership.mockReset()
refresh.mockReset()
})
describe('Pick a member', () => {
it('reads the copy and offers accepted users other than the owner, never invites', async () => {
await mountDialog()
expect(dialog().textContent).toContain('Transfer ownership')
expect(dialog().textContent).toContain(
'Choose who becomes the owner. Only members can be chosen.',
)
expect(radio('Jonas Weber')).toBeDefined()
expect(radio('Lea Martin')).toBeDefined()
expect(radio('Marta Alves')).toBeUndefined()
expect(radio('pending@renewbuild.example')).toBeUndefined()
expect(button('Cancel')).toBeDefined()
})
it('keeps Continue disabled until a member is chosen', async () => {
await mountDialog()
expect(button('Continue')!.disabled).toBe(true)
radio('Lea Martin')!.click()
await flushPromises()
expect(button('Continue')!.disabled).toBe(false)
})
})
describe('Confirm', () => {
it('names the member, lists the consequences and asks for the email', async () => {
await mountDialog()
await pickJonas()
const text = dialog().textContent!
expect(text).toContain('Transfer ownership to Jonas Weber')
expect(text).toContain('This takes effect straight away.')
expect(text).toContain('Jonas Weber becomes the owner.')
expect(text).toContain(
"You become an Admin. You can't take ownership back unless they transfer it.",
)
expect(text).toContain(
'Only the owner can apply updates, delete a site and change activity retention.',
)
expect(text).toContain('Your API keys follow your new role.')
expect(text).toContain(`Type ${JONAS_EMAIL} to confirm`)
expect(text).toContain('The email must match exactly.')
})
it.each([
['', true],
['jonas@renewbuild', true],
['Jonas@renewbuild.example', true],
[` ${JONAS_EMAIL}`, true],
[`${JONAS_EMAIL} `, true],
[JONAS_EMAIL, false],
])('gates Transfer ownership on the exact email %j (disabled: %s)', async (typed, disabled) => {
await mountDialog()
await pickJonas()
await type(typed)
expect(button('Transfer ownership')!.disabled).toBe(disabled)
})
it('goes Back to the pick step with the member still chosen', async () => {
await mountDialog()
await pickJonas()
button('Back')!.click()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog().textContent).toContain('Choose who becomes the owner.')
expect(radio('Jonas Weber')!.getAttribute('aria-checked')).toBe('true')
expect(emitted('update:open')).toBeUndefined()
})
})
describe('Transferring', () => {
it('sends the transfer, locks the dialog in flight, refreshes the session then the list', async () => {
let release!: (value: unknown) => void
transferOwnership.mockReturnValue(new Promise((resolve) => (release = resolve)))
await mountDialog()
await pickJonas()
await type(JONAS_EMAIL)
button('Transfer ownership')!.click()
await flushPromises()
expect(transferOwnership).toHaveBeenCalledWith(2, JONAS_EMAIL)
expect(button('Transferring…')!.disabled).toBe(true)
expect(button('Back')!.disabled).toBe(true)
release({ owner: { id: 2, name: 'Jonas Weber' }, former_owner: { id: 1 } })
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(1)
expect(emitted('transferred')).toHaveLength(1)
expect(emitted('update:open')?.at(-1)).toEqual([false])
expect(toastAdd).toHaveBeenCalledTimes(1)
})
it('400 invalid_transfer returns to the pick step with an inline error and asks for a re-read', async () => {
transferOwnership.mockRejectedValue(
new ApiError(400, undefined, 'invalid_transfer', { code: 'invalid_transfer' }),
)
await mountDialog()
await submit()
expect(dialog().textContent).toContain('Choose who becomes the owner.')
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
expect(emitted('stale')).toHaveLength(1)
expect(emitted('update:open')).toBeUndefined()
})
it('400 confirmation_mismatch shows an error under the field and stays on the step', async () => {
transferOwnership.mockRejectedValue(
new ApiError(400, undefined, 'confirmation_mismatch', {
code: 'confirmation_mismatch',
}),
)
await mountDialog()
await submit()
expect(dialog().textContent).toContain('Transfer ownership to Jonas Weber')
expect(field().getAttribute('aria-invalid')).toBe('true')
expect(emitted('update:open')).toBeUndefined()
})
it('409 user_role_changed re-reads the session and the list, toasts and closes', async () => {
transferOwnership.mockRejectedValue(
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
)
await mountDialog()
await submit()
expect(refresh).toHaveBeenCalledTimes(1)
expect(emitted('stale')).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(emitted('update:open')?.at(-1)).toEqual([false])
})
it('403 missing_permission closes and leaves the toast to the API client', async () => {
transferOwnership.mockRejectedValue(
new ApiError(403, undefined, 'missing_permission', { code: 'missing_permission' }),
)
await mountDialog()
await submit()
expect(emitted('update:open')?.at(-1)).toEqual([false])
expect(toastAdd).not.toHaveBeenCalled()
})
it('403 session_required shows nothing and changes nothing', async () => {
transferOwnership.mockRejectedValue(
new ApiError(403, undefined, 'This action needs a real session.', {
code: 'session_required',
}),
)
await mountDialog()
await submit()
expect(dialog().querySelector('[role="alert"]')).toBeNull()
expect(toastAdd).not.toHaveBeenCalled()
expect(emitted('update:open')).toBeUndefined()
})
it('500 stays open with an inline error', async () => {
transferOwnership.mockRejectedValue(new ApiError(500))
await mountDialog()
await submit()
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
expect(emitted('update:open')).toBeUndefined()
})
})
@@ -0,0 +1,58 @@
import { grantsFrom } from '@/test/access'
import type { RoleItem } from '@/lib/roles'
export function roleItem(overrides: Partial<RoleItem> & Pick<RoleItem, 'id' | 'name'>): RoleItem {
return {
description: '',
built_in: false,
locked: false,
version: 1,
grants: [],
members: 0,
updated_at: null,
updated_by: null,
...overrides,
}
}
export const OWNER = roleItem({
id: 1,
name: 'Owner',
built_in: true,
locked: true,
description: 'Everything.',
})
export const ADMIN = roleItem({
id: 2,
name: 'Admin',
built_in: true,
description: 'Everything except owner-only.',
grants: grantsFrom('content.publish, users.view'),
})
export const WRITER = roleItem({
id: 3,
name: 'Writer',
built_in: true,
description: 'Reads everything and writes content.',
grants: grantsFrom('content.read, content.update'),
})
export const CONTRIBUTOR = roleItem({
id: 4,
name: 'Contributor',
description: 'Creates content and edits only their own.',
grants: grantsFrom('content.read, content.update=own'),
})
export const LEAD = roleItem({
id: 5,
name: 'Content lead',
description: 'Edits and publishes all content.',
grants: grantsFrom('content.read, content.update, content.publish'),
})
export const INTERN = roleItem({
id: 6,
name: 'Intern',
description: 'Reads content.',
grants: grantsFrom('content.read=own'),
})
export const WORLD = [OWNER, ADMIN, WRITER, CONTRIBUTOR, LEAD, INTERN]
@@ -82,6 +82,21 @@ describe('getMe maps the access object', () => {
})
})
describe('getMe maps access.role_assigned_by', () => {
it.each([
['an object', { id: 3, name: 'Marta Alves' }, { id: 3, name: 'Marta Alves' }],
['null', null, null],
['an absent key', undefined, null],
])('reads %s', async (_label, raw, expected) => {
const body = { ...writerAccess(), ...(raw === undefined ? {} : { role_assigned_by: raw }) }
fetchMock.mockResolvedValue(reply(200, { id: 4, is_verified: true, access: body }))
const me = await useApi().getMe()
expect(me.access?.roleAssignedBy).toEqual(expected)
})
})
describe('403 with a code', () => {
it('rejects with the code and the missing permission and shows the toast naming it', async () => {
signIn()
@@ -142,6 +157,32 @@ describe('403 with a code', () => {
expect(toastAdd).not.toHaveBeenCalled()
})
it.each(['email_has_account', 'invite_pending'])(
'reads the X-Error-Code header %s on a 409',
async (code) => {
signIn()
fetchMock.mockResolvedValue(
reply(409, null, { 'X-Error-Code': code }, 'Some server text'),
)
await expect(
useApi().request('/v1/user/invite', { method: 'POST' }),
).rejects.toMatchObject({ status: 409, code, body: 'Some server text' })
expect(toastAdd).not.toHaveBeenCalled()
},
)
it('leaves the code unset for an unknown X-Error-Code header', async () => {
signIn()
fetchMock.mockResolvedValue(reply(409, null, { 'X-Error-Code': 'something_new' }, 'Text'))
const error = await useApi()
.request('/v1/user/invite', { method: 'POST' })
.catch((caught: unknown) => caught)
expect(error).toMatchObject({ status: 409, code: undefined })
})
it.each([
['role_locked', 'role_locked'],
['own_role', 'own_role'],
+109 -2
View File
@@ -1,6 +1,21 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { roleRefFor } from '@/test/access'
import { userRole, type UserListItem } from '@/composables/useUsers'
import { ApiError } from '@/lib/apiError'
import { inlineRefusal, isCode, isMissingPermission, isSessionRequired } from '@/lib/userRefusal'
import { useApi } from '@/composables/useApi'
import { useUsers, userRole, type UserListItem } from '@/composables/useUsers'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const request = vi.fn()
beforeEach(() => {
request.mockReset()
vi.mocked(useApi).mockReturnValue({ request } as unknown as ReturnType<typeof useApi>)
})
describe('userRole', () => {
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (name) => {
@@ -35,3 +50,95 @@ describe('userRole', () => {
},
)
})
describe('useUsers role requests', () => {
it('sends role_id with the invite when given and the email only when not', async () => {
request.mockResolvedValue({ id: 1 })
const { inviteUser } = useUsers()
await inviteUser('lea.martin@renew.example', 3)
await inviteUser('lea.martin@renew.example')
expect(request.mock.calls[0]).toEqual([
'/v1/user/invite',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'lea.martin@renew.example', role_id: 3 }),
}),
])
expect(request.mock.calls[1]![1].body).toBe(
JSON.stringify({ email: 'lea.martin@renew.example' }),
)
})
it('changes a role with PUT /v1/user/{id}/role and returns the answer', async () => {
const answer = { id: 4, kind: 'user', role: roleRefFor('admin') }
request.mockResolvedValue(answer)
const result = await useUsers().changeUserRole(4, 2)
expect(request).toHaveBeenCalledWith(
'/v1/user/4/role',
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ role_id: 2 }) }),
)
expect(result).toBe(answer)
})
it('transfers ownership with POST /v1/site/transfer-ownership and returns the answer', async () => {
const answer = { owner: { id: 2, name: 'Jonas' }, former_owner: { id: 1 } }
request.mockResolvedValue(answer)
const result = await useUsers().transferOwnership(2, 'jonas@renewbuild.example')
expect(request).toHaveBeenCalledWith(
'/v1/site/transfer-ownership',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ user_id: 2, confirm_email: 'jonas@renewbuild.example' }),
}),
)
expect(result).toBe(answer)
})
})
describe('refusal mapping', () => {
it.each([
['escalation', "You can't grant that"],
['role_locked', 'This role is locked'],
['own_role', "You can't change your own role"],
] as const)('shows %s inline', (code, title) => {
const notice = inlineRefusal(new ApiError(403, undefined, code, { code }))
expect(notice?.title).toBe(title)
})
it.each([
'user_role_changed',
'invalid_role',
'invalid_transfer',
'confirmation_mismatch',
'user_not_found',
] as const)('does not show %s inline', (code) => {
expect(inlineRefusal(new ApiError(409, undefined, code, { code }))).toBeUndefined()
})
it('tells missing_permission and session_required apart by status and code', () => {
const missing = new ApiError(403, undefined, 'missing_permission', {
code: 'missing_permission',
})
const session = new ApiError(403, undefined, 'This action needs a real session.', {
code: 'session_required',
})
expect(isMissingPermission(missing)).toBe(true)
expect(isSessionRequired(session)).toBe(true)
expect(
isCode(
new ApiError(400, undefined, 'invalid_role', { code: 'invalid_role' }),
400,
'invalid_role',
),
).toBe(true)
expect(isCode(new ApiError(500), 400, 'invalid_role')).toBe(false)
})
})
+13 -5
View File
@@ -1,10 +1,10 @@
import { ref } from 'vue'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, errorCodeOf } from '@/lib/apiError'
import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError'
import { missingPermissionCopy } from '@/lib/permissionCopy'
import { useLocalizedToast } from '@/i18n/presentation'
import type { Access, Grant, RoleRef } from '@/lib/permissions'
import { parseRoleAssignedBy, type Access, type Grant, type RoleRef } from '@/lib/permissions'
export { ApiError }
@@ -193,7 +193,9 @@ export function useApi() {
}
const body = await readErrorBody(response)
throw new ApiError(response.status, undefined, body, { code: errorCodeOf(body) })
throw new ApiError(response.status, undefined, body, {
code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')),
})
}
return response
@@ -233,7 +235,7 @@ export function useApi() {
username?: string
email?: string
display_name?: string | null
access?: { role: RoleRef; permissions: Grant[] }
access?: { role: RoleRef; permissions: Grant[]; role_assigned_by?: unknown }
author?: MeAuthor | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
@@ -243,7 +245,13 @@ export function useApi() {
email: body.email,
displayName: body.display_name ?? '',
...(body.access
? { access: { role: body.access.role, permissions: body.access.permissions } }
? {
access: {
role: body.access.role,
permissions: body.access.permissions,
roleAssignedBy: parseRoleAssignedBy(body.access.role_assigned_by),
},
}
: {}),
...(body.author ? { author: body.author } : {}),
}
+154
View File
@@ -0,0 +1,154 @@
import { t } from '@/i18n'
import { ref, type Ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, userRowKey } from '@/composables/useUsers'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import { inviteWindow, type CreatedInvite, type UserListItem } from '@/lib/users'
import { inlineRefusal, refusalToast, userToast } from '@/lib/userRefusal'
const AUTH_HANDLED_STATUSES = [401, 403]
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
function isHandledByApiClient(err: unknown) {
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
}
export function useInviteRows(items: Ref<UserListItem[]>, highlightedKey: Ref<string>) {
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const { cancelInvite, renewInvite } = useUsers()
const busyKeys = ref<string[]>([])
function removeRow(key: string) {
items.value = items.value.filter((item) => userRowKey(item.kind, item.id) !== key)
}
function onInvited(invite: CreatedInvite) {
const sameAddress = invite.email.toLowerCase()
const newRow: UserListItem = {
kind: 'invite',
id: invite.id,
email: invite.email,
role: invite.role ?? null,
status: 'pending',
created_at: invite.created_at,
}
items.value = [
newRow,
...items.value.filter(
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
),
]
highlightedKey.value = userRowKey('invite', invite.id)
toast.add(
userToast(
`invite-sent-${invite.id}`,
'success',
() => t('Invite sent'),
() => t('{0} has {1} to accept.', [invite.email, inviteWindow(invite)]),
),
)
}
function confirmCancel(invite: UserListItem) {
const key = userRowKey(invite.kind, invite.id)
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Cancel this invite?')
},
get description() {
return t(
'The link sent to {0} will stop working. You can invite the address again later.',
[invite.email],
)
},
get confirmLabel() {
return t('Cancel invite')
},
get cancelLabel() {
return t('Keep invite')
},
get pendingLabel() {
return t('Cancelling…')
},
tone: 'danger',
action: async () => {
try {
await cancelInvite(invite.id)
} catch (err) {
if (isHandledByApiClient(err)) {
return
}
throw new Error(t(CANCEL_FAILED_COPY))
}
removeRow(key)
toast.add(
userToast(
`invite-cancelled-${invite.id}`,
'success',
() => t('Invite cancelled'),
() => t('{0} can no longer use the link.', [invite.email]),
),
)
},
},
})
.open()
}
async function renew(invite: UserListItem) {
const key = userRowKey(invite.kind, invite.id)
if (busyKeys.value.includes(key)) {
return
}
busyKeys.value = [...busyKeys.value, key]
try {
const renewed = await renewInvite(invite.id)
items.value = [
{
kind: 'invite',
id: renewed.id,
email: renewed.email,
role: renewed.role ?? invite.role ?? null,
status: 'pending',
created_at: renewed.created_at,
},
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
]
highlightedKey.value = userRowKey('invite', renewed.id)
toast.add(
userToast(
`invite-renewed-${renewed.id}`,
'success',
() => t('Invite renewed'),
() =>
t('{0} has {1} to accept the new link.', [
renewed.email,
inviteWindow(renewed),
]),
),
)
} catch (err) {
const refusal = inlineRefusal(err)
if (refusal !== undefined) {
toast.add(refusalToast(`invite-renew-refused-${invite.id}`, refusal))
} else if (!isHandledByApiClient(err)) {
toast.add(
userToast(
`invite-renew-failed-${invite.id}`,
'error',
() => t("Couldn't renew the invite"),
() => t('Try again in a moment.'),
),
)
}
} finally {
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
}
}
return { busyKeys, onInvited, confirmCancel, renew }
}
+25 -33
View File
@@ -1,27 +1,9 @@
import { useApi } from '@/composables/useApi'
import type { LinkableUser } from '@/lib/directoryTypes'
import type { RoleRef } from '@/lib/permissions'
import type { AcceptedUser, CreatedInvite, OwnershipTransfer, UserListItem } from '@/lib/users'
export type UserListItem =
| {
kind: 'user'
id: number
username: string
display_name: string
email: string
role: RoleRef | null
status: 'accepted'
created_at: string
}
| {
kind: 'invite'
id: number
email: string
display_name?: string
role?: RoleRef | null
status: 'pending' | 'expired'
created_at: string
}
export type { AcceptedUser, CreatedInvite, OwnershipTransfer, UserListItem }
export const USER_SKELETON_ROWS = 5
@@ -30,22 +12,14 @@ export function userRowKey(kind: UserListItem['kind'], id: number) {
}
export function userDisplayName(item: UserListItem) {
return item.display_name ?? ''
return item.kind === 'user' ? item.display_name : ''
}
export function userRole(item: UserListItem): RoleRef | null {
return item.role ?? null
}
export interface CreatedInvite {
id: number
kind: 'invite'
email: string
role?: RoleRef | null
status: 'pending'
created_at: string
expires_at: string
}
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useUsers() {
const { request } = useApi()
@@ -71,11 +45,27 @@ export function useUsers() {
)
}
function inviteUser(email: string): Promise<CreatedInvite> {
function inviteUser(email: string, roleId?: number): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
headers: JSON_HEADERS,
body: JSON.stringify(roleId === undefined ? { email } : { email, role_id: roleId }),
})
}
function changeUserRole(id: number, roleId: number): Promise<AcceptedUser> {
return request<AcceptedUser>(`/v1/user/${id}/role`, {
method: 'PUT',
headers: JSON_HEADERS,
body: JSON.stringify({ role_id: roleId }),
})
}
function transferOwnership(userId: number, confirmEmail: string): Promise<OwnershipTransfer> {
return request<OwnershipTransfer>('/v1/site/transfer-ownership', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ user_id: userId, confirm_email: confirmEmail }),
})
}
@@ -93,6 +83,8 @@ export function useUsers() {
listUsers,
listLinkableUsers,
inviteUser,
changeUserRole,
transferOwnership,
cancelInvite,
renewInvite,
}
+1 -1
View File
@@ -18,7 +18,7 @@ const API_KEYS_MESSAGES = [
'Created {0} by {1}',
'Last used {0}',
'Expires {0}',
"Keys act as the user who created them. As an owner you see everyone's keys.",
"Keys act as the user who created them. You see everyone's keys.",
'Keys act as you, with your role. You only see your own keys.',
"Resetting a user's password revokes all of their keys.",
'No API keys yet',
+81 -4
View File
@@ -583,7 +583,8 @@ export const messages = {
"We'll email them a link to choose a username and password. The link expires after 3 days.":
'Nous lui enverrons un lien pour choisir un nom d’utilisateur et un mot de passe. Ce lien expire après 3 jours.',
'Invite sent': 'Invitation envoyée',
'{0} has 3 days to accept.': '{0} a 3 jours pour accepter.',
'{0} has {1} to accept.': '{0} a {1} pour accepter.',
'1 day': '1 jour',
'Too many invites. Wait a moment, then try again.':
'Trop d’invitations. Patientez, puis réessayez.',
"There's already a pending invite for this address.":
@@ -604,7 +605,7 @@ export const messages = {
'Renew invite': 'Renouveler l’invitation',
'Renewing…': 'Renouvellement…',
'Invite renewed': 'Invitation renouvelée',
'{0} has 3 days to accept the new link.': '{0} a 3 jours pour accepter le nouveau lien.',
'{0} has {1} to accept the new link.': '{0} a {1} pour accepter le nouveau lien.',
"Couldn't renew the invite": 'Impossible de renouveler l’invitation',
'Cancel invite': 'Annuler l’invitation',
'Cancel this invite?': 'Annuler cette invitation ?',
@@ -861,8 +862,8 @@ export const messages = {
'Created {0} by {1}': 'Créée le {0} par {1}',
'Last used {0}': 'Dernière utilisation : {0}',
'Expires {0}': 'Expire : {0}',
"Keys act as the user who created them. As an owner you see everyone's keys.":
'Les clés agissent comme l’utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.',
"Keys act as the user who created them. You see everyone's keys.":
'Les clés agissent comme l’utilisateur qui les a créées. Vous voyez les clés de tout le monde.',
'Keys act as you, with your role. You only see your own keys.':
'Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.',
"Resetting a user's password revokes all of their keys.":
@@ -3137,6 +3138,82 @@ export const messages = {
'Install a new version of RenewCMS.': 'Installer une nouvelle version de RenewCMS.',
'How long the activity log is kept.': 'Durée de conservation du journal d’activité.',
'Make another member the owner.': 'Faire d’un autre membre le propriétaire.',
'1 member has this role.': '1 membre a ce rôle.',
"A key can do what its creator's role can do, today. If the person's role changes, their keys change with it.":
'Une clé peut faire ce que le rôle de son créateur peut faire, aujourd’hui. Si le rôle de la personne change, ses clés changent avec lui.',
'A summary of your role, {0}. Read-only.': 'Un résumé de votre rôle, {0}. Lecture seule.',
'Assigned by {0}': 'Attribué par {0}',
'Change role': 'Changer le rôle',
'Change role for {0}': 'Changer le rôle de {0}',
'Changed the role of {0}': 'A changé le rôle de {0}',
'Changing…': 'Modification…',
'Choose who becomes the owner. Only members can be chosen.':
'Choisissez qui devient propriétaire. Seuls les membres peuvent être choisis.',
Continue: 'Continuer',
"Couldn't change the role. Try again in a moment.":
'Impossible de changer le rôle. Réessayez dans un instant.',
"Couldn't transfer ownership. Try again in a moment.":
'Impossible de transférer la propriété. Réessayez dans un instant.',
'Failed to change the role of {0}': 'Échec du changement de rôle de {0}',
'Failed to create the role {0}': 'Échec de la création du rôle {0}',
'Failed to delete the role {0}': 'Échec de la suppression du rôle {0}',
'Failed to edit the role {0}': 'Échec de la modification du rôle {0}',
'Failed to move the members of the role {0}': 'Échec du déplacement des membres du rôle {0}',
'Failed to transfer ownership to {0}': 'Échec du transfert de la propriété à {0}',
'Follows role': 'Suit le rôle',
"Keys follow their creator's role": 'Les clés suivent le rôle de leur créateur',
'Moved the members of the role {0}': 'A déplacé les membres du rôle {0}',
'Only the owner can apply updates, delete a site and change activity retention.':
'Seul le propriétaire peut appliquer les mises à jour, supprimer un site et modifier la conservation de l’activité.',
'Ownership changed meanwhile': 'La propriété a changé entre-temps',
'Ownership transferred': 'Propriété transférée',
'Ownership transferred from {0} to {1}': 'Propriété transférée de {0} à {1}',
'Role changed': 'Rôle modifié',
'Role {0} created': 'Rôle {0} créé',
'Role {0} deleted': 'Rôle {0} supprimé',
'Role {0} edited': 'Rôle {0} modifié',
'See what I can do': 'Voir ce que je peux faire',
'That email does not match. Check it and try again.':
'Cet e-mail ne correspond pas. Vérifiez-le et réessayez.',
"That person can't become the owner. Choose another member.":
'Cette personne ne peut pas devenir propriétaire. Choisissez un autre membre.',
"That role isn't available anymore. Choose another one.":
'Ce rôle n’est plus disponible. Choisissez-en un autre.',
'That user no longer exists': 'Cet utilisateur n’existe plus',
'The email must match exactly.': 'L’e-mail doit correspondre exactement.',
'The list was refreshed.': 'La liste a été actualisée.',
"The owner can't be changed or removed. Transfer ownership first.":
'Le propriétaire ne peut être ni modifié ni retiré. Transférez d’abord la propriété.',
'Their API keys follow the new role.': 'Ses clés d’API suivent le nouveau rôle.',
'Their role changed meanwhile': 'Son rôle a changé entre-temps',
'There is one owner. Transfer ownership to change who it is.':
'Il n’y a qu’un propriétaire. Transférez la propriété pour changer de propriétaire.',
'They get an email with a link to set a password.':
'La personne reçoit un e-mail avec un lien pour définir un mot de passe.',
'This takes effect straight away.': 'Cela prend effet immédiatement.',
'Transfer ownership to {0}': 'Transférer la propriété à {0}',
'Transferring…': 'Transfert…',
'Type {0} to confirm': 'Saisissez {0} pour confirmer',
'What changed': 'Ce qui a changé',
'What you can do': 'Ce que vous pouvez faire',
'Writer is the default. You can change a role later.':
'Writer est le rôle par défaut. Vous pourrez le changer plus tard.',
"You become an Admin. You can't take ownership back unless they transfer it.":
'Vous devenez Admin. Vous ne pourrez pas reprendre la propriété sauf si cette personne vous la transfère.',
"You can't change your own role. Ask another owner or admin.":
'Vous ne pouvez pas changer votre propre rôle. Demandez à un autre propriétaire ou administrateur.',
'You need permission to view roles to change a role.':
'Il faut pouvoir consulter les rôles pour changer un rôle.',
'Your API keys follow this role. If your role changes, they change too.':
'Vos clés d’API suivent ce rôle. Si votre rôle change, elles changent aussi.',
'Your API keys follow your new role.': 'Vos clés d’API suivent votre nouveau rôle.',
'Your role': 'Votre rôle',
'{0} becomes the owner.': '{0} devient propriétaire.',
'{0} is now the owner. You are an Admin.': '{0} est maintenant propriétaire. Vous êtes Admin.',
'{0} members have this role.': '{0} membres ont ce rôle.',
'{0} now has the {1} role.': '{0} a maintenant le rôle {1}.',
'{0}: {1} → {2}': '{0} : {1} → {2}',
"{0}'s role changed from {1} to {2}": 'Le rôle de {0} est passé de {1} à {2}',
} as const
export type MessageKey = keyof typeof messages
+36
View File
@@ -61,3 +61,39 @@ describe('Roles screens copy', () => {
expect(t('Roles')).toBe('Rôles')
})
})
const USERS_ROLES_KEYS = [
'Assigned by {0}',
'Your role',
'See what I can do',
'What you can do',
'A summary of your role, {0}. Read-only.',
'Change role',
'Change role for {0}',
'Their API keys follow the new role.',
'Transfer ownership to {0}',
'Type {0} to confirm',
'The email must match exactly.',
"Keys follow their creator's role",
'Follows role',
'What changed',
'Role {0} created',
'Role {0} edited',
'Role {0} deleted',
"{0}'s role changed from {1} to {2}",
'Ownership transferred from {0} to {1}',
'They get an email with a link to set a password.',
'Writer is the default. You can change a role later.',
] as const
describe('Users, Profile, API keys and Activity copy for roles', () => {
it.each(USERS_ROLES_KEYS)('"%s" has a French entry', (key) => {
expect((messages as Record<string, string>)[key]).toMatch(/\S/)
})
it('renders "Assigned by {0}" in both languages', () => {
expect(t('Assigned by {0}', ['Marta Alves'])).toBe('Assigned by Marta Alves')
setLocale('fr')
expect(t('Assigned by {0}', ['Marta Alves'])).toBe('Attribué par Marta Alves')
})
})
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
describe('activity categories', () => {
it('offers the users and roles categories with a name and an icon', () => {
expect(ACTIVITY_CATEGORIES).toContain('users')
expect(ACTIVITY_CATEGORIES).toContain('roles')
expect(categoryLabel('roles')).toBe('Roles')
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
})
})
@@ -285,6 +285,12 @@ describe('The catalogue is complete in both languages', () => {
'system.restart_requested',
'system.activity_retention_changed',
'system.activity_cleanup',
'role.create',
'role.edit',
'role.delete',
'role.reassign',
'user.change_role',
'user.transfer_ownership',
].sort(),
)
})
@@ -293,3 +299,84 @@ describe('The catalogue is complete in both languages', () => {
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
})
})
describe('role and ownership sentences', () => {
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
it.each([
['role.create', 'Contributor', 'Role Contributor created'],
['role.edit', 'Contributor', 'Role Contributor edited'],
['role.delete', 'Intern', 'Role Intern deleted'],
])('writes %s as a sentence', (action, name, sentence) => {
const event = item({ action, category: 'roles', target: roleTarget(name) })
expect(sentenceText(event)).toBe(sentence)
const strong = composeSentence(event).find((segment) => segment.strong)
expect(strong?.text).toBe(name)
})
it('writes user.change_role with the role before and after', () => {
const event = item({
action: 'user.change_role',
category: 'users',
target: userTarget('Ines Duarte'),
changes: [{ field: 'role', kind: 'text', before: 'Writer', after: 'Content lead' }],
})
expect(sentenceText(event)).toBe("Ines Duarte's role changed from Writer to Content lead")
})
it('falls back for user.change_role without a role change', () => {
const event = item({
action: 'user.change_role',
category: 'users',
target: userTarget('Ines Duarte'),
})
expect(sentenceText(event)).toContain('Ines Duarte')
})
it('writes user.transfer_ownership from the actor to the target', () => {
const event = item({
action: 'user.transfer_ownership',
category: 'users',
security: true,
target: userTarget('Jonas Weber'),
})
expect(sentenceText(event)).toBe('Ownership transferred from Marta Alves to Jonas Weber')
})
it('writes role.reassign and the failed templates without a crash', () => {
const reassign = item({
action: 'role.reassign',
category: 'roles',
target: roleTarget('Contributor'),
params: { from: 4, to: 3, moved: 4 },
})
expect(sentenceText(reassign)).toContain('Contributor')
for (const action of [
'role.create',
'role.edit',
'role.delete',
'role.reassign',
'user.change_role',
'user.transfer_ownership',
]) {
const failed = item({
action,
outcome: 'failed',
category: 'roles',
target: roleTarget('Contributor'),
})
expect(sentenceText(failed)).toContain('Contributor')
expect(sentenceText(failed)).not.toBe(sentenceText({ ...failed, outcome: 'success' }))
}
})
it('keeps an unknown action as its own name', () => {
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
})
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { parseRoleAssignedBy } from '@/lib/permissions'
describe('parseRoleAssignedBy', () => {
it('reads the object', () => {
expect(parseRoleAssignedBy({ id: 3, name: 'Marta Alves' })).toEqual({
id: 3,
name: 'Marta Alves',
})
})
it.each([
[null],
[undefined],
[{}],
[{ id: 3, name: '' }],
[{ id: '3', name: 'Marta' }],
['x'],
])('reads %j as no assigner', (value) => {
expect(parseRoleAssignedBy(value)).toBeNull()
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/lib/apiError'
import { inlineRefusal, refusalToast, userToast } from '@/lib/userRefusal'
import { inviteWindow } from '@/lib/users'
describe('userToast', () => {
it('builds a success toast with the check icon and lazy text', () => {
let title = 'first'
const build = userToast('id-1', 'success', () => title, 'fixed')
const toast = build()
expect(toast).toMatchObject({
id: 'id-1',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'first',
description: 'fixed',
})
title = 'second'
expect(toast.title).toBe('second')
})
it('builds an error toast with the cross icon', () => {
expect(userToast('id-2', 'error', 'Oops', 'Try again')()).toMatchObject({
id: 'id-2',
color: 'error',
icon: 'i-lucide-circle-x',
title: 'Oops',
description: 'Try again',
})
})
})
describe('refusalToast', () => {
it('shows the refusal copy of an inline refusal as an error toast', () => {
const refusal = inlineRefusal(
new ApiError(403, undefined, 'own_role', { code: 'own_role' }),
)!
expect(refusalToast('refused-1', refusal)()).toMatchObject({
id: 'refused-1',
color: 'error',
title: refusal.title,
description: refusal.description,
})
expect(refusal.title).toBeTruthy()
})
})
describe('inviteWindow', () => {
const created = '2026-10-01T09:00:00Z'
it.each([
['2026-10-02T09:00:00Z', '1 day'],
['2026-10-04T09:00:00Z', '3 days'],
['2026-10-08T09:00:00Z', '7 days'],
['2026-10-01T09:00:00Z', '1 day'],
])('words an expiry at %s as %s', (expires, wording) => {
expect(inviteWindow({ created_at: created, expires_at: expires })).toBe(wording)
})
})
+15
View File
@@ -8,6 +8,7 @@ export const ACTIVITY_CATEGORIES = [
'api_keys',
'auth',
'system',
'roles',
] as const
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
@@ -213,6 +214,8 @@ export function categoryLabel(category: ActivityCategory) {
return t('Authentication')
case 'system':
return t('System')
case 'roles':
return t('Roles')
}
}
@@ -230,6 +233,8 @@ export function categoryIcon(category: ActivityCategory) {
return 'i-lucide-shield-check'
case 'system':
return 'i-lucide-server-cog'
case 'roles':
return 'i-lucide-shield'
}
}
@@ -360,6 +365,8 @@ export function targetTypeLabel(type: string) {
return t('API key')
case 'release':
return t('Release')
case 'role':
return t('Role')
default:
return type
}
@@ -377,6 +384,8 @@ export function targetLink(target: ActivityTarget | null) {
return '/users'
case 'api_key':
return '/integration/api-keys'
case 'role':
return `/settings/roles/${target.id}`
default:
return null
}
@@ -394,6 +403,12 @@ export function fieldLabel(field: string) {
return t('File name')
case 'display_name':
return t('Display name')
case 'name':
return t('Name')
case 'description':
return t('Description')
case 'role':
return t('Role')
default:
return field
}
+88
View File
@@ -0,0 +1,88 @@
import { t } from '@/i18n'
import type { ActivityItem } from '@/lib/activity'
import { permissionName } from '@/lib/permissionNames'
import { isScope } from '@/lib/permissions'
export type RoleDiffKind = 'added' | 'removed' | 'changed'
export interface RoleDiffRow {
kind: RoleDiffKind
text: string
}
function entriesOf(value: unknown): Record<string, unknown>[] {
return Array.isArray(value)
? value.filter(
(entry): entry is Record<string, unknown> =>
typeof entry === 'object' && entry !== null,
)
: []
}
function scopeText(value: unknown) {
switch (isScope(value) ? value : undefined) {
case 'none':
return t('None')
case 'own':
return t('Own')
case 'all':
return t('All')
default:
return ''
}
}
function keyName(entry: Record<string, unknown>) {
const key = typeof entry.key === 'string' ? entry.key : ''
return permissionName(key) ?? key
}
function grantText(entry: Record<string, unknown>) {
const name = keyName(entry)
return entry.scope === 'own' ? `${name} (${scopeText('own')})` : name
}
function changedText(entry: Record<string, unknown>) {
return t('{0}: {1} → {2}', [keyName(entry), scopeText(entry.from), scopeText(entry.to)])
}
export function roleDiffRows(item: ActivityItem): RoleDiffRow[] {
if (item.action !== 'role.edit') {
return []
}
return [
...entriesOf(item.params.added).map((entry) => ({
kind: 'added' as const,
text: grantText(entry),
})),
...entriesOf(item.params.removed).map((entry) => ({
kind: 'removed' as const,
text: grantText(entry),
})),
...entriesOf(item.params.changed).map((entry) => ({
kind: 'changed' as const,
text: changedText(entry),
})),
]
}
export function roleMembersLine(item: ActivityItem): string | null {
const members = item.params.members
if (item.action !== 'role.delete' || typeof members !== 'number' || members <= 0) {
return null
}
return members === 1
? t('1 member has this role.')
: t('{0} members have this role.', [members])
}
export function roleDiffLabel(kind: RoleDiffKind) {
switch (kind) {
case 'added':
return t('Added')
case 'removed':
return t('Removed')
case 'changed':
return t('Changed')
}
}
+42 -1
View File
@@ -110,6 +110,30 @@ const CATALOGUE: Record<string, Template> = {
success: 'Accepted the invite for {0}',
failed: 'Failed to accept the invite for {0}',
},
'role.create': {
success: 'Role {0} created',
failed: 'Failed to create the role {0}',
},
'role.edit': {
success: 'Role {0} edited',
failed: 'Failed to edit the role {0}',
},
'role.delete': {
success: 'Role {0} deleted',
failed: 'Failed to delete the role {0}',
},
'role.reassign': {
success: 'Moved the members of the role {0}',
failed: 'Failed to move the members of the role {0}',
},
'user.change_role': {
success: 'Changed the role of {0}',
failed: 'Failed to change the role of {0}',
},
'user.transfer_ownership': {
success: 'Ownership transferred from {0} to {1}',
failed: 'Failed to transfer ownership to {0}',
},
'api_key.create': {
success: 'Created API key {0}',
failed: 'Failed to create API key {0}',
@@ -198,9 +222,19 @@ function retentionValues(params: Record<string, unknown>) {
return changedSize(params) ? [...ages, ...sizes] : ages
}
function roleChangeOf(item: ActivityItem) {
return item.changes.find((change) => change.field === 'role' && change.kind === 'text')
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'user.change_role' && item.outcome === 'success' && roleChangeOf(item)) {
return "{0}'s role changed from {1} to {2}"
}
if (item.action === 'user.transfer_ownership' && item.outcome === 'failed') {
return template.failed ?? template.success
}
if (item.action === 'system.activity_retention_changed') {
return retentionTemplate(item.params)
}
@@ -223,6 +257,13 @@ function slotValues(item: ActivityItem, template: MessageKey) {
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
}
if (item.action === 'user.transfer_ownership' && item.outcome === 'success') {
return [item.actor.name, TARGET_SLOT]
}
const roleChange = roleChangeOf(item)
if (template.includes('{2}') && roleChange?.kind === 'text') {
return [TARGET_SLOT, roleChange.before, roleChange.after]
}
return template.includes('{0}') ? [TARGET_SLOT] : []
}
@@ -232,7 +273,7 @@ function targetName(item: ActivityItem) {
}
function usesTargetSlot(values: string[]) {
return values.length === 1 && values[0] === TARGET_SLOT
return values.includes(TARGET_SLOT)
}
export function composeSentence(item: ActivityItem): SentenceSegment[] {
+11 -1
View File
@@ -13,6 +13,12 @@ export const KNOWN_ERROR_CODES = [
'role_not_found',
'user_role_changed',
'session_required',
'invalid_role',
'invalid_transfer',
'confirmation_mismatch',
'user_not_found',
'email_has_account',
'invite_pending',
] as const
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
@@ -29,7 +35,11 @@ export function errorCodeOf(body: string | undefined): ApiErrorCode | undefined
if (body === SESSION_REQUIRED_MESSAGE) {
return 'session_required'
}
return KNOWN_ERROR_CODES.find((code) => code === body)
return knownErrorCode(body)
}
export function knownErrorCode(value: string | null | undefined): ApiErrorCode | undefined {
return KNOWN_ERROR_CODES.find((code) => code === value)
}
export class ApiError extends Error {
+2 -4
View File
@@ -1,11 +1,9 @@
import { t } from '@/i18n'
import { parseGrantKey, type Scope } from '@/lib/permissions'
import { parseGrantKey, SCOPE_RANK, type Scope } from '@/lib/permissions'
import type { RoleItem } from '@/lib/roles'
export type CallerScope = (key: string, options?: { typeKey?: string }) => Scope
const RANK: Record<Scope, number> = { none: 0, own: 1, all: 2 }
const CONTENT_VERBS: Record<string, () => string> = {
'content.read': () => t('read'),
'content.update': () => t('update'),
@@ -36,6 +34,6 @@ export function canCover(role: Pick<RoleItem, 'grants'>, scopeOf: CallerScope):
return role.grants.every((grant) => {
const { base, typeKey } = parseGrantKey(grant.key)
const held = typeKey === undefined ? scopeOf(base) : scopeOf(base, { typeKey })
return RANK[held] >= RANK[grant.scope]
return SCOPE_RANK[held] >= SCOPE_RANK[grant.scope]
})
}
+4
View File
@@ -55,6 +55,10 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
case 'invalid_reassign':
case 'role_not_found':
case 'session_required':
case 'invalid_role':
case 'invalid_transfer':
case 'confirmation_mismatch':
case 'user_not_found':
return undefined
}
}
+20
View File
@@ -1,5 +1,11 @@
export type Scope = 'none' | 'own' | 'all'
export const SCOPE_RANK: Record<Scope, number> = { none: 0, own: 1, all: 2 }
export function isScope(value: unknown): value is Scope {
return typeof value === 'string' && Object.keys(SCOPE_RANK).includes(value)
}
export interface Grant {
key: string
scope: Scope
@@ -12,9 +18,23 @@ export interface RoleRef {
locked: boolean
}
export interface AssignedBy {
id: number
name: string
}
export interface Access {
role: RoleRef
permissions: Grant[]
roleAssignedBy?: AssignedBy | null
}
export function parseRoleAssignedBy(value: unknown): AssignedBy | null {
if (typeof value !== 'object' || value === null) {
return null
}
const { id, name } = value as { id?: unknown; name?: unknown }
return typeof id === 'number' && typeof name === 'string' && name !== '' ? { id, name } : null
}
export interface CanOptions {
+27 -1
View File
@@ -1,6 +1,7 @@
import { t } from '@/i18n'
import { CONTENT_ACTIONS, contentKey, scopeAt, type GrantMap } from '@/lib/roleForm'
import { parseGrantKey, type Scope } from '@/lib/permissions'
import { parseGrantKey, type Grant, type Scope } from '@/lib/permissions'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
export interface PreviewType {
key: string
@@ -287,3 +288,28 @@ export function buildRolePreview(input: PreviewInput): RolePreviewModel {
members: membersLine(input.members),
}
}
function grantMapOf(grants: readonly Grant[]): GrantMap {
return Object.fromEntries(grants.map((grant) => [grant.key, grant.scope]))
}
function typeKeysOf(grants: readonly Grant[]) {
const keys = grants
.map((grant) => parseGrantKey(grant.key))
.filter(({ base }) => base.startsWith('content.'))
.map(({ typeKey }) => typeKey)
.filter((key): key is string => key !== undefined)
return [...new Set(keys)]
}
export function buildHeldRolePreview(grants: readonly Grant[]): RolePreviewModel {
return buildRolePreview({
grants: grantMapOf(grants),
types: [
{ key: ARTICLE_TYPE_KEY, name: t('Articles') },
...typeKeysOf(grants)
.filter((key) => key !== ARTICLE_TYPE_KEY)
.map((key) => ({ key, name: key })),
],
})
}
+53
View File
@@ -0,0 +1,53 @@
import { t } from '@/i18n'
import type { AcceptedUser } from '@/lib/users'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
export interface UserMenuContext {
sessionUserId?: number
canChangeRole: boolean
canTransfer: boolean
canViewRoles: boolean
changeRole: (user: AcceptedUser, trigger: HTMLElement | null) => void
transfer: (trigger: HTMLElement | null) => void
}
function changeRoleHint(user: AcceptedUser, context: UserMenuContext): string | undefined {
if (user.role?.locked) {
return t("The owner can't be changed or removed. Transfer ownership first.")
}
if (user.id === context.sessionUserId) {
return t("You can't change your own role. Ask another owner or admin.")
}
if (!context.canViewRoles) {
return t('You need permission to view roles to change a role.')
}
return undefined
}
export function userMenuItems(user: AcceptedUser, context: UserMenuContext): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = []
if (context.canChangeRole) {
const hint = changeRoleHint(user, context)
items.push({
key: 'change-role',
label: t('Change role'),
icon: 'i-lucide-user-cog',
unavailable: hint !== undefined,
hint,
onSelect: (trigger) => context.changeRole(user, trigger),
})
}
if (context.canTransfer && user.role?.locked) {
items.push({
key: 'transfer-ownership',
label: t('Transfer ownership'),
icon: 'i-lucide-crown',
onSelect: (trigger) => context.transfer(trigger),
})
}
return items
}
export function userMenuTitle(user: AcceptedUser) {
return user.display_name || user.email
}
+63
View File
@@ -0,0 +1,63 @@
import { ApiError, type ApiErrorCode } from '@/lib/apiError'
import { refusalCopy } from '@/lib/permissionCopy'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const INLINE_REFUSALS: ApiErrorCode[] = ['escalation', 'role_locked', 'own_role']
export function isCode(error: unknown, status: number, code?: ApiErrorCode): error is ApiError {
return (
error instanceof ApiError &&
error.status === status &&
(code === undefined || error.code === code)
)
}
export function inlineRefusal(error: unknown): DirectoryNotice | undefined {
if (!(error instanceof ApiError) || error.status !== 403 || error.code === undefined) {
return undefined
}
if (!INLINE_REFUSALS.includes(error.code)) {
return undefined
}
const copy = refusalCopy(error.code, error.permission)
return copy === undefined
? undefined
: { title: copy.title, description: copy.description ?? '' }
}
type ToastText = string | (() => string)
function resolveText(text: ToastText) {
return typeof text === 'function' ? text() : text
}
export function userToast(
id: string,
color: 'success' | 'error',
title: ToastText,
description: ToastText,
) {
return () => ({
id,
color,
icon: color === 'success' ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
get title() {
return resolveText(title)
},
get description() {
return resolveText(description)
},
})
}
export function refusalToast(id: string, refusal: DirectoryNotice) {
return userToast(id, 'error', refusal.title ?? '', refusal.description)
}
export function isMissingPermission(error: unknown) {
return isCode(error, 403, 'missing_permission')
}
export function isSessionRequired(error: unknown) {
return isCode(error, 403, 'session_required')
}
+48
View File
@@ -0,0 +1,48 @@
import { t } from '@/i18n'
import type { AssignedBy, RoleRef } from '@/lib/permissions'
export type UserListItem =
| {
kind: 'user'
id: number
username: string
display_name: string
email: string
role: RoleRef | null
role_assigned_by: AssignedBy | null
status: 'accepted'
created_at: string
}
| {
kind: 'invite'
id: number
email: string
role?: RoleRef | null
status: 'pending' | 'expired'
created_at: string
}
export interface CreatedInvite {
id: number
kind: 'invite'
email: string
role?: RoleRef | null
status: 'pending'
created_at: string
expires_at: string
}
export type AcceptedUser = Extract<UserListItem, { kind: 'user' }>
export interface OwnershipTransfer {
owner: { id: number; name: string }
former_owner: { id: number; name: string; role: RoleRef }
}
const DAY_MS = 24 * 60 * 60 * 1000
export function inviteWindow(invite: Pick<CreatedInvite, 'created_at' | 'expires_at'>) {
const span = Date.parse(invite.expires_at) - Date.parse(invite.created_at)
const days = Math.max(1, Math.round(span / DAY_MS))
return days === 1 ? t('1 day') : t('{0} days', [days])
}
@@ -84,3 +84,54 @@ describe('session shape', () => {
expect('canManageContent' in session).toBe(false)
})
})
describe('roleAssignedBy', () => {
it('reads the assigner of the session and falls back to null', () => {
const session = useSessionStore()
const store = usePermissionsStore()
session.session = {
id: 7,
verified: true,
displayName: '',
access: {
...customAccess('content.read'),
roleAssignedBy: { id: 3, name: 'Marta Alves' },
},
}
expect(store.roleAssignedBy).toEqual({ id: 3, name: 'Marta Alves' })
session.session = {
id: 7,
verified: true,
displayName: '',
access: customAccess('content.read'),
}
expect(store.roleAssignedBy).toBeNull()
session.session = null
expect(store.roleAssignedBy).toBeNull()
})
})
describe('canAssign(role)', () => {
const role = (spec: string) => ({ grants: customAccess(spec).permissions })
it.each<[string, string, boolean]>([
['content.update=all', 'content.update=all', true],
['content.update=all', 'content.update=own', true],
['content.update=own', 'content.update=own', true],
['content.update=own', 'content.update=all', false],
['content.read', 'content.read, content.update', false],
['content.read', 'content.read', true],
])('with %s, a role granting %s is assignable: %s', (held, granted, expected) => {
expect(withGrants(held).canAssign(role(granted))).toBe(expected)
})
it('assigns nothing without a session unless the role grants nothing', () => {
const store = withGrants(null)
expect(store.canAssign(role('content.read'))).toBe(false)
expect(store.canAssign({ grants: [] })).toBe(true)
})
})
+11 -1
View File
@@ -5,16 +5,22 @@ import {
allows,
effectiveScope,
type CanOptions,
type AssignedBy,
type RoleRef,
type Scope,
type ScopeOptions,
} from '@/lib/permissions'
import { canCover } from '@/lib/grantLocks'
import type { RoleItem } from '@/lib/roles'
export const usePermissionsStore = defineStore('permissions', () => {
const sessionStore = useSessionStore()
const grants = computed(() => sessionStore.session?.access?.permissions ?? [])
const role = computed<RoleRef | undefined>(() => sessionStore.session?.access?.role)
const roleAssignedBy = computed<AssignedBy | null>(
() => sessionStore.session?.access?.roleAssignedBy ?? null,
)
const roleName = computed(() => role.value?.name ?? '')
function can(key: string, options: CanOptions = {}): boolean {
@@ -31,5 +37,9 @@ export const usePermissionsStore = defineStore('permissions', () => {
return effectiveScope(grants.value, key, options)
}
return { grants, role, roleName, can, scopeOf }
function canAssign(target: Pick<RoleItem, 'grants'>): boolean {
return canCover(target, scopeOf)
}
return { grants, role, roleAssignedBy, roleName, can, scopeOf, canAssign }
})
+19 -1
View File
@@ -50,7 +50,7 @@ const anyDialogOpen = computed(
)
const description = computed(() =>
seesEveryKey.value
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
? t("Keys act as the user who created them. You see everyone's keys.")
: t('Keys act as you, with your role. You only see your own keys.'),
)
const sessionName = computed(
@@ -308,6 +308,20 @@ onMounted(loadKeys)
</template>
</UAlert>
<UAlert
color="info"
variant="soft"
icon="i-lucide-info"
:close="false"
:title="t('Keys follow their creator\'s role')"
:description="
t(
'A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it.',
)
"
:ui="{ root: 'items-start gap-2.5 px-3.5 py-2.5', description: 'text-toned' }"
/>
<UEmpty
v-if="isEmpty"
variant="naked"
@@ -332,6 +346,8 @@ onMounted(loadKeys)
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="seesEveryKey"
:caller-id="session.session?.id"
:caller-role="permissions.role"
@revoke="confirmRevoke"
/>
<ApiKeyCardList
@@ -339,6 +355,8 @@ onMounted(loadKeys)
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="seesEveryKey"
:caller-id="session.session?.id"
:caller-role="permissions.role"
@revoke="confirmRevoke"
/>
</template>
+6
View File
@@ -9,8 +9,10 @@ import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import ProfileCard from '@/components/ProfileCard.vue'
import YourRoleCard from '@/components/YourRoleCard.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore, type Session } from '@/stores/session'
const DISPLAY_NAME_MAX = 60
@@ -24,12 +26,14 @@ const SKELETON_CARDS = [
{ titleWidth: 'w-30', inputs: 1 },
{ titleWidth: 'w-22.5', inputs: 2 },
{ titleWidth: 'w-28', inputs: 1 },
{ titleWidth: 'w-20', inputs: 1 },
{ titleWidth: 'w-25', inputs: 1 },
]
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
const session = useSessionStore()
const permissions = usePermissionsStore()
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const { request } = useApi()
@@ -399,6 +403,8 @@ onBeforeRouteLeave(async () => {
</div>
</ProfileCard>
<YourRoleCard v-if="permissions.role" />
<AuthorProfileCard />
<ProfileCard
+87 -151
View File
@@ -3,14 +3,16 @@ import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ChangeRoleDialog from '@/components/ChangeRoleDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import InviteUserModal from '@/components/InviteUserModal.vue'
import UserCardList from '@/components/UserCardList.vue'
import TransferOwnershipDialog from '@/components/TransferOwnershipDialog.vue'
import UsersTable from '@/components/UsersTable.vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
import { useInviteRows } from '@/composables/useInviteRows'
import { useUsers } from '@/composables/useUsers'
import type { AcceptedUser, UserListItem } from '@/lib/users'
import type { UserMenuContext } from '@/lib/userMenu'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
@@ -19,14 +21,9 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
type LoadState = 'loading' | 'ready' | 'error'
const AUTH_HANDLED_STATUSES = [401, 403]
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const permissions = usePermissionsStore()
const { listUsers, cancelInvite, renewInvite } = useUsers()
const { listUsers } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
@@ -34,12 +31,30 @@ const items = ref<UserListItem[]>([])
const showSkeleton = ref(false)
const inviteModalOpen = ref(false)
const highlightedKey = ref('')
const busyKeys = ref<string[]>([])
const { busyKeys, onInvited, confirmCancel, renew } = useInviteRows(items, highlightedKey)
const changeTarget = ref<AcceptedUser | null>(null)
const changeOpen = ref(false)
const transferOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let opener: HTMLElement | null = null
const sessionUserId = computed(() => session.session?.id)
const canInvite = computed(() => permissions.can('users.invite'))
const menu = computed<UserMenuContext | undefined>(() => {
const canChangeRole = permissions.can('users.change_role')
const canTransfer = permissions.can('system.transfer_ownership')
if (!canChangeRole && !canTransfer) {
return undefined
}
return {
sessionUserId: sessionUserId.value,
canChangeRole,
canTransfer,
canViewRoles: permissions.can('roles.view'),
changeRole: openChangeRole,
transfer: openTransfer,
}
})
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
@@ -74,6 +89,47 @@ async function loadUsers() {
}
}
async function reloadQuietly() {
try {
items.value = await listUsers()
} catch {
return
}
}
function rememberOpener(trigger: HTMLElement | null) {
opener =
trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
function openChangeRole(user: AcceptedUser, trigger: HTMLElement | null) {
rememberOpener(trigger)
changeTarget.value = user
changeOpen.value = true
}
function openTransfer(trigger: HTMLElement | null) {
rememberOpener(trigger)
transferOpen.value = true
}
function onOverlayOpenChange(target: 'change' | 'transfer', isOpen: boolean) {
if (target === 'change') {
changeOpen.value = isOpen
} else {
transferOpen.value = isOpen
}
if (!isOpen) {
restoreFocus()
}
}
function onRoleChanged(updated: AcceptedUser) {
items.value = items.value.map((item) =>
item.kind === 'user' && item.id === updated.id ? updated : item,
)
}
function openInviteModal() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
inviteModalOpen.value = true
@@ -92,146 +148,6 @@ function onInviteModalOpenChange(isOpen: boolean) {
}
}
function onInvited(invite: CreatedInvite) {
const sameAddress = invite.email.toLowerCase()
const newRow: UserListItem = {
kind: 'invite',
id: invite.id,
email: invite.email,
display_name: '',
role: invite.role ?? null,
status: 'pending',
created_at: invite.created_at,
}
items.value = [
newRow,
...items.value.filter(
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
),
]
highlightedKey.value = userRowKey('invite', invite.id)
toast.add(() => ({
id: `invite-sent-${invite.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Invite sent')
},
get description() {
return t('{0} has 3 days to accept.', [invite.email])
},
}))
}
function isHandledByApiClient(err: unknown) {
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
}
function removeRow(key: string) {
items.value = items.value.filter((item) => userRowKey(item.kind, item.id) !== key)
}
function confirmCancel(invite: UserListItem) {
const key = userRowKey(invite.kind, invite.id)
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Cancel this invite?')
},
get description() {
return t(
'The link sent to {0} will stop working. You can invite the address again later.',
[invite.email],
)
},
get confirmLabel() {
return t('Cancel invite')
},
get cancelLabel() {
return t('Keep invite')
},
get pendingLabel() {
return t('Cancelling…')
},
tone: 'danger',
action: async () => {
try {
await cancelInvite(invite.id)
} catch (err) {
if (isHandledByApiClient(err)) {
return
}
throw new Error(t(CANCEL_FAILED_COPY))
}
removeRow(key)
toast.add(() => ({
id: `invite-cancelled-${invite.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Invite cancelled')
},
get description() {
return t('{0} can no longer use the link.', [invite.email])
},
}))
},
},
})
.open()
}
async function renew(invite: UserListItem) {
const key = userRowKey(invite.kind, invite.id)
if (busyKeys.value.includes(key)) {
return
}
busyKeys.value = [...busyKeys.value, key]
try {
const renewed = await renewInvite(invite.id)
items.value = [
{
kind: 'invite',
id: renewed.id,
email: renewed.email,
display_name: invite.display_name,
status: 'pending',
created_at: renewed.created_at,
},
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
]
highlightedKey.value = userRowKey('invite', renewed.id)
toast.add(() => ({
id: `invite-renewed-${renewed.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Invite renewed')
},
get description() {
return t('{0} has 3 days to accept the new link.', [renewed.email])
},
}))
} catch (err) {
if (!isHandledByApiClient(err)) {
toast.add(() => ({
id: `invite-renew-failed-${invite.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't renew the invite")
},
get description() {
return t('Try again in a moment.')
},
}))
}
} finally {
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
}
}
onMounted(loadUsers)
onBeforeUnmount(clearSkeletonTimer)
</script>
@@ -293,6 +209,7 @@ onBeforeUnmount(clearSkeletonTimer)
:highlighted-key="highlightedKey"
:can-manage="canInvite"
:busy-keys="busyKeys"
:menu="menu"
@cancel="confirmCancel"
@renew="renew"
/>
@@ -304,6 +221,7 @@ onBeforeUnmount(clearSkeletonTimer)
:highlighted-key="highlightedKey"
:can-manage="canInvite"
:busy-keys="busyKeys"
:menu="menu"
@cancel="confirmCancel"
@renew="renew"
/>
@@ -334,5 +252,23 @@ onBeforeUnmount(clearSkeletonTimer)
@update:open="onInviteModalOpenChange"
@invited="onInvited"
/>
<ChangeRoleDialog
v-if="changeTarget && changeOpen"
:open="changeOpen"
:user="changeTarget"
@update:open="onOverlayOpenChange('change', $event)"
@changed="onRoleChanged"
@stale="reloadQuietly"
/>
<TransferOwnershipDialog
v-if="transferOpen"
:open="transferOpen"
:users="items"
@update:open="onOverlayOpenChange('transfer', $event)"
@transferred="reloadQuietly"
@stale="reloadQuietly"
/>
</DashboardShell>
</template>
+137 -4
View File
@@ -220,12 +220,14 @@ describe('Details: content', () => {
],
}),
)
await vi.waitFor(() => expect(findButton('Show more')).toBeTruthy())
await vi.waitFor(() =>
expect(findButton('Show more')).toBeInstanceOf(HTMLButtonElement),
)
const changes = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
)
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
expect(changes[0]!.querySelector('.line-clamp-2')).not.toBeNull()
expect(changes[1]!.querySelector('button')).toBeNull()
expect(
Array.from(document.body.querySelectorAll('button')).filter(
@@ -235,9 +237,9 @@ describe('Details: content', () => {
await click(findButton('Show more'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeNull()
expect(findButton('Show less')).toBeTruthy()
expect(findButton('Show less')).toBeInstanceOf(HTMLButtonElement)
await click(findButton('Show less'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
expect(changes[0]!.querySelector('.line-clamp-2')).not.toBeNull()
} finally {
if (scrollHeight)
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeight)
@@ -516,3 +518,134 @@ describe('Details: counts', () => {
expect(textOf(panel())).toContain('Event 1 of 41')
})
})
describe('Details: role events', () => {
const role = target('role', '4', 'Contributor')
const roleEvent = (overrides: Partial<ActivityItem>) =>
makeItem({ id: 90, category: 'roles', target: role, ...overrides })
it('lists Added and Removed with human permission names and the scope suffix', async () => {
await openWith(
roleEvent({
action: 'role.edit',
params: {
added: [{ key: 'content.delete', scope: 'own' }],
removed: [{ key: 'content.publish', scope: 'all' }],
changed: [],
members: 4,
},
}),
)
const text = textOf(details())
expect(text).toContain('What changed')
expect(text).toContain('Added')
expect(text).toContain('Delete entries (Own)')
expect(text).toContain('Removed')
expect(text).toContain('Publish entries')
expect(text).not.toContain('content.delete')
expect(text).not.toContain('members have this role')
})
it('shows a changed entry with the human name and the scopes', async () => {
await openWith(
roleEvent({
action: 'role.edit',
params: {
added: [],
removed: [],
changed: [{ key: 'content.update', from: 'own', to: 'all' }],
members: 0,
},
}),
)
const text = textOf(details())
expect(text).toContain('Changed')
expect(text).toContain('Update entries')
expect(text).toContain('Own')
expect(text).toContain('All')
})
it('renders name and description changes through the existing text diff rows', async () => {
await openWith(
roleEvent({
action: 'role.edit',
params: { added: [], removed: [], changed: [], members: 0 },
changes: [
{ field: 'name', kind: 'text', before: 'Writer plus', after: 'Contributor' },
{ field: 'description', kind: 'text', before: 'Old', after: 'New' },
],
}),
)
const changes = details()!.querySelectorAll('[data-testid="activity-change"]')
expect(changes).toHaveLength(2)
expect(textOf(changes[0])).toContain('Name')
expect(textOf(changes[1])).toContain('Description')
expect(document.body.querySelector('[data-testid="activity-role-section"]')).toBeNull()
})
it('shows the members line only on role.delete', async () => {
await openWith(
roleEvent({
action: 'role.delete',
target: target('role', '6', 'Intern', false),
params: { members: 4 },
}),
)
expect(textOf(details())).toContain('4 members have this role.')
})
it('shows the Role field with before and after on user.change_role', async () => {
await openWith(
makeItem({
id: 91,
action: 'user.change_role',
category: 'users',
target: target('user', '9', 'Ines Duarte'),
changes: [{ field: 'role', kind: 'text', before: 'Writer', after: 'Content lead' }],
}),
)
const change = details()!.querySelector('[data-testid="activity-change"]')
expect(textOf(change)).toContain('Role')
expect(textOf(change)).toContain('Writer')
expect(textOf(change)).toContain('Content lead')
})
it('shows the new owner as the target, the security badge and no diff on user.transfer_ownership', async () => {
await openWith(
makeItem({
id: 92,
action: 'user.transfer_ownership',
category: 'users',
security: true,
target: target('user', '2', 'Jonas Weber'),
}),
)
const text = textOf(details())
expect(text).toContain('Ownership transferred from Marta Alves to Jonas Weber')
expect(text).toContain('Security')
expect(
textOf(details()!.querySelector('[data-testid="activity-target-section"]')),
).toContain('Jonas Weber')
expect(details()!.querySelector('[data-testid="activity-diff"]')).toBeNull()
})
it('renders an unknown action as before', async () => {
await openWith(makeItem({ id: 93, action: 'role.mystery' }))
expect(textOf(details())).toContain('role.mystery')
})
it('shows the failed badge on a failed role event', async () => {
await openWith(
roleEvent({ action: 'role.create', outcome: 'failed', failure_reason: 'duplicate' }),
)
expect(textOf(details())).toContain('Failed')
})
})
+69 -16
View File
@@ -425,7 +425,7 @@ describe('Owner: list', () => {
)
expect(document.body.querySelector('h1')?.textContent).toBe('API keys')
expect(document.body.textContent).toContain(
"Keys act as the user who created them. As an owner you see everyone's keys.",
"Keys act as the user who created them. You see everyone's keys.",
)
expect(findButton('Create key')).toBeTruthy()
expect(document.body.textContent).toContain(
@@ -433,12 +433,14 @@ describe('Owner: list', () => {
)
const header = document.body.querySelector('thead tr')!
expect(textOf(header)).toBe('Name Key Status Created by Created Last used Expires')
expect(textOf(header)).toBe(
'Name Key Status Created by Follows role Created Last used Expires',
)
expect(rowTexts()).toEqual([
'Website build rcms_Kf3x… active Marta Alves 12 Mar 2026 3 Oct 2026 None Revoke',
'Nightly media import rcms_Mq7b… active Jonas 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
'Staging preview rcms_Zt2d… expired Marta Alves 14 Jun 2026 20 Aug 2026 30 Aug 2026 Revoke',
'Old migration script rcms_Hn5w… revoked Inès Duarte 3 Apr 2026 9 May 2026 None',
'Website build rcms_Kf3x… active Marta Alves owner 12 Mar 2026 3 Oct 2026 None Revoke',
'Nightly media import rcms_Mq7b… active Jonas — 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
'Staging preview rcms_Zt2d… expired Marta Alves owner 14 Jun 2026 20 Aug 2026 30 Aug 2026 Revoke',
'Old migration script rcms_Hn5w… revoked Inès Duarte — 3 Apr 2026 9 May 2026 None',
])
expect(document.body.textContent).not.toContain('rcms_Kf3x9')
})
@@ -458,17 +460,17 @@ describe('Owner: list', () => {
fixture[0]!.created_by_name = ''
await mountPage({ list: () => Promise.resolve(fixture) })
expect(rowTexts()[0]).toContain('rcms_Kf3x… active — 12 Mar 2026')
expect(rowTexts()[0]).toContain('rcms_Kf3x… active — owner 12 Mar 2026')
})
it('renders the same keys as cards below md', async () => {
await mountPage({}, { desktop: false })
expect(rowTexts()).toEqual([
'Website build active rcms_Kf3x… Created 12 Mar 2026 by Marta Alves · Last used 3 Oct 2026 · Expires None Revoke',
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 by Jonas · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
'Staging preview expired rcms_Zt2d… Created 14 Jun 2026 by Marta Alves · Last used 20 Aug 2026 · Expires 30 Aug 2026 Revoke',
'Old migration script revoked rcms_Hn5w… Created 3 Apr 2026 by Inès Duarte · Last used 9 May 2026 · Expires None',
'Website build active Follows role owner rcms_Kf3x… Created 12 Mar 2026 by Marta Alves · Last used 3 Oct 2026 · Expires None Revoke',
'Nightly media import active Follows role — rcms_Mq7b… Created 2 Apr 2026 by Jonas · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
'Staging preview expired Follows role owner rcms_Zt2d… Created 14 Jun 2026 by Marta Alves · Last used 20 Aug 2026 · Expires 30 Aug 2026 Revoke',
'Old migration script revoked Follows role — rcms_Hn5w… Created 3 Apr 2026 by Inès Duarte · Last used 9 May 2026 · Expires None',
])
expect(document.body.querySelector('table')).toBeNull()
expect(document.body.querySelector('thead')).toBeNull()
@@ -504,11 +506,11 @@ describe('Own keys only', () => {
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { access: ownAccess() })
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
'Name Key Status Created Last used Expires',
'Name Key Status Follows role Created Last used Expires',
)
expect(rowTexts()).toEqual([
'Nightly media import rcms_Mq7b… active 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
'Old CI token rcms_Lp9c… revoked 18 Jan 2026 2 Feb 2026 None',
'Nightly media import rcms_Mq7b… active Key holder 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
'Old CI token rcms_Lp9c… revoked Key holder 18 Jan 2026 2 Feb 2026 None',
])
expect(document.body.textContent).toContain(
'Keys act as you, with your role. You only see your own keys.',
@@ -523,7 +525,7 @@ describe('Own keys only', () => {
)
expect(rowTexts()[0]).toBe(
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
'Nightly media import active Follows role Key holder rcms_Mq7b… Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
)
})
@@ -1177,7 +1179,7 @@ describe('Language: every string has a French entry', () => {
expect(frenchText()).toContain('Clés d’API')
expect(frenchText()).toContain('Les clés agissent comme l’utilisateur qui les a créées.')
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
'Nom Clé Statut Créée par Créé Dernière utilisation Expire le',
'Nom Clé Statut Créée par Suit le rôle Créé Dernière utilisation Expire le',
)
expect(rowTexts()[0]).toContain('active')
expect(rowTexts()[2]).toContain('expirée')
@@ -1224,3 +1226,54 @@ describe('Language: every string has a French entry', () => {
expect(toast.description).toBe('« Website build » ne peut plus être utilisée.')
})
})
describe('Keys follow the role', () => {
const ALERT_TITLE = "Keys follow their creator's role"
const ALERT_BODY =
"A key can do what its creator's role can do, today. If the person's role changes, their keys change with it."
it('shows the info alert with the design copy', async () => {
await mountPage()
const title = [...document.body.querySelectorAll('[data-slot="title"]')].find(
(candidate) => candidate.textContent === ALERT_TITLE,
)
expect(title?.closest('[data-slot="root"]')?.textContent).toContain(ALERT_BODY)
})
it('shows the caller badge on the caller keys and a dash on other creators keys', async () => {
await mountPage()
expect(rowTexts()[0]).toContain('owner')
expect(rowTexts()[1]).toContain('Jonas — 2 Apr')
})
it('shows the caller badge on every row when only own keys are listed', async () => {
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { access: ownAccess() })
rowTexts().forEach((text) => expect(text).toContain('Key holder'))
})
it('labels the badge Follows role on each card below md', async () => {
await mountPage({}, { desktop: false })
rowTexts().forEach((text) => expect(text).toContain('Follows role'))
})
it('follows a refreshed role without reading the keys again', async () => {
await mountPage()
const readsBefore = requestMock.mock.calls.length
useSessionStore().session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
access: customAccess('apikeys.view=all, apikeys.create', 'Contributor'),
}
await flushPromises()
expect(rowTexts()[0]).toContain('Contributor')
expect(requestMock.mock.calls.length).toBe(readsBefore)
})
})
@@ -71,7 +71,13 @@ describe('Profile: the Author profile card sits between Account and Password', (
it.each(['owner', 'admin', 'verified'] as const)('for a %s', async (role) => {
await mountMeta('profile', PROFILE, { backend: backendWith(role) })
expect(cardTitles()).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
expect(cardTitles()).toEqual([
'Display name',
'Account',
'Your role',
'Author profile',
'Password',
])
})
})
+124 -8
View File
@@ -6,8 +6,8 @@ import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor, customAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -85,13 +85,20 @@ interface MountOptions {
getMe?: () => Promise<Session>
handlers?: Record<string, Handler>
path?: string
width?: number
}
let mountedWrapper: VueWrapper | null = null
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
setViewportWidth(DESKTOP_WIDTH)
const {
session = MARTA,
status = 'ready',
handlers = {},
path = '/profile',
width = DESKTOP_WIDTH,
} = options
setViewportWidth(width)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = session ? { ...session } : null
@@ -304,18 +311,24 @@ describe('Default: the usual first visit with no display name', () => {
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the four cards in order Display name, Account, Author profile, Password', async () => {
it('places the cards in order Display name, Account, Your role, Author profile, Password', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
el.textContent?.trim(),
)
expect(titles).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
expect(titles).toEqual([
'Display name',
'Account',
'Your role',
'Author profile',
'Password',
])
})
})
describe('Loading: opened by URL before /auth/me has returned', () => {
it('shows the header and four skeleton cards without AppBoot, then the Default state', async () => {
it('shows the header and five skeleton cards without AppBoot, then the Default state', async () => {
const pending = deferred<Session>()
await mountApp({
session: null,
@@ -328,7 +341,8 @@ describe('Loading: opened by URL before /auth/me has returned', () => {
)
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(4)
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(5)
expect(document.body.textContent).not.toContain('Your role')
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
expect(displayNameField()).toBeNull()
@@ -905,3 +919,105 @@ describe('Reset: failed leaves the button pressable', () => {
},
)
})
const MARTA_ASSIGNER = { id: 3, name: 'Marta Alves' }
const KEYS_SENTENCE = 'Your API keys follow this role. If your role changes, they change too.'
function contributor(assigner?: { id: number; name: string } | null): Session {
const access = customAccess(
'content.read=all, content.create, content.update=own',
'Contributor',
)
return {
...MARTA,
access: assigner === undefined ? access : { ...access, roleAssignedBy: assigner },
}
}
function pageText() {
return document.body.textContent ?? ''
}
function roleRequests(request: ReturnType<typeof vi.fn>) {
return request.mock.calls.filter(([path]) => String(path).startsWith('/v1/role'))
}
describe('Profile: your role', () => {
it('shows the role card with the badge, the assigner and the keys sentence', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
expect(pageText()).toContain('Your role')
expect(pageText()).toContain('Contributor')
expect(pageText()).toContain('Assigned by Marta Alves')
expect(pageText()).toContain(KEYS_SENTENCE)
expect(buttonByText(/^See what I can do$/)).toBeDefined()
})
it.each([
['null', null],
['an absent key', undefined],
])('renders no Assigned by text for %s', async (_label, assigner) => {
await mountApp({ session: contributor(assigner) })
expect(pageText()).toContain('Your role')
expect(pageText()).not.toContain('Assigned by')
})
it('places the card after the Account card', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
const titles = [...document.body.querySelectorAll('h2')].map((h2) => h2.textContent?.trim())
expect(titles.indexOf('Your role')).toBe(titles.indexOf('Account') + 1)
})
it('follows a refreshed session for the assigner and the role', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER) })
useSessionStore().session = {
...contributor({ id: 2, name: 'Jonas Weber' }),
}
await flushPromises()
expect(pageText()).toContain('Assigned by Jonas Weber')
expect(pageText()).not.toContain('Assigned by Marta Alves')
const writer = customAccess('content.read=all', 'Writer')
useSessionStore().session = { ...MARTA, access: writer }
await flushPromises()
expect(pageText()).toContain('Writer')
expect(pageText()).not.toContain('Assigned by')
})
it('opens the summary dialog from the session grants without reading roles, and Close returns focus', async () => {
const { request } = await mountApp({ session: contributor(MARTA_ASSIGNER) })
const opener = buttonByText(/^See what I can do$/)!
opener.focus()
opener.click()
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('What you can do')
expect(dialog.textContent).toContain('A summary of your role, Contributor. Read-only.')
expect(dialog.textContent).not.toContain('Updates as you edit.')
expect(dialog.textContent).toContain('Content')
expect(roleRequests(request)).toHaveLength(0)
const close = [...dialog.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'Close',
)!
close.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(document.activeElement).toBe(opener)
})
it('opens the summary as a bottom sheet on mobile', async () => {
await mountApp({ session: contributor(MARTA_ASSIGNER), width: MOBILE_WIDTH })
buttonByText(/^See what I can do$/)!.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'What you can do',
)
})
})
+507
View File
@@ -0,0 +1,507 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
import { accessFor, adminAccess, customAccess, ownerAccess, roleRefFor } from '@/test/access'
import type { Access } from '@/lib/permissions'
import { CONTRIBUTOR, WORLD } from '@/components/__tests__/userRolesKit'
import UsersView from '../UsersView.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const LIST_PATH = '/v1/user/'
const ROLE_PATH = '/v1/role/'
const JONAS_EMAIL = 'jonas@renewbuild.example'
const OWNER_HINT = "The owner can't be changed or removed. Transfer ownership first."
const OWN_ROW_HINT = "You can't change your own role. Ask another owner or admin."
function accepted(
id: number,
username: string,
name: string,
email: string,
role: string,
extra: Record<string, unknown> = {},
): UserListItem {
return {
kind: 'user',
id,
username,
display_name: name,
email,
role: roleRefFor(role),
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
...extra,
} as UserListItem
}
function fixture(): UserListItem[] {
return [
{
kind: 'invite',
id: 2,
email: 'sam.oliveira@renew.example',
role: { id: 4, name: 'Contributor', built_in: false, locked: false },
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
{
kind: 'invite',
id: 3,
email: 'pending@renew.example',
status: 'pending',
created_at: '2026-09-27T09:00:00Z',
},
accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'member', {
role_assigned_by: { id: 1, name: 'Marta Alves' },
}),
accepted(3, 'lea', 'Lea Martin', 'lea@renewbuild.example', 'admin'),
accepted(2, 'jonas', 'Jonas Weber', JONAS_EMAIL, 'admin'),
accepted(1, 'marta', 'Marta Alves', 'marta@renewbuild.example', 'owner'),
]
}
function without(access: Access, key: string): Access {
return { ...access, permissions: access.permissions.filter((grant) => grant.key !== key) }
}
function setViewport(desktop: boolean) {
window.matchMedia = ((query: string) => ({
matches: desktop,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
let requestMock: ReturnType<typeof vi.fn>
let getMe: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
let answers: Record<string, (options?: RequestInit) => Promise<unknown>> = {}
let listItems: UserListItem[] = []
async function mountPage(access: Access = ownerAccess(), sessionId = 1, desktop = true) {
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'me', id: sessionId, displayName: '', access }
session.status = 'ready'
requestMock = vi.fn((path: string, options?: RequestInit) => {
const key = `${options?.method ?? 'GET'} ${path}`
if (answers[key]) {
return answers[key]!(options)
}
if (key === `GET ${LIST_PATH}`) {
return Promise.resolve(listItems)
}
if (key === `GET ${ROLE_PATH}`) {
return Promise.resolve(WORLD)
}
throw new Error(`Unexpected request: ${key}`)
})
getMe = vi.fn().mockResolvedValue({ verified: true, id: sessionId, access: adminAccess() })
vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock } as unknown as ReturnType<
typeof useApi
>)
await router.push('/users')
wrapper = mount(defineComponent({ render: () => h(UApp, null, () => h(UsersView)) }), {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function calls(key: string) {
return requestMock.mock.calls.filter(
([path, options]) => `${options?.method ?? 'GET'} ${path}` === key,
)
}
function trigger(name: string) {
return document.body.querySelector<HTMLElement>(`[aria-label="Actions for ${name}"]`)
}
async function openMenu(name: string) {
trigger(name)!.click()
await flushPromises()
}
function menuItems() {
return [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
function labelOfItem(item: HTMLElement) {
return item.querySelector('span span')?.textContent?.trim() ?? ''
}
function menuItem(label: string) {
return menuItems().find((item) => labelOfItem(item) === label)
}
function menuLabels() {
return menuItems().map(labelOfItem)
}
function buttonNamed(label: string) {
return [...document.body.querySelectorAll('button')].find(
(candidate) => candidate.textContent?.trim() === label,
)
}
function rowOf(email: string) {
return [...document.body.querySelectorAll<HTMLElement>('[data-testid="user-row"]')].find(
(row) => row.textContent?.includes(email),
)!
}
function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"], [role="dialog"]')
}
async function click(target: HTMLElement | undefined) {
target?.click()
await flushPromises()
}
function radio(name: string) {
return [...dialog()!.querySelectorAll('label')]
.find((label) => label.querySelector('[data-slot="label"]')?.textContent?.includes(name))
?.querySelector<HTMLButtonElement>('[role="radio"]')
}
beforeEach(() => {
toastAdd = vi.fn()
answers = {}
listItems = fixture()
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
document.body.innerHTML = ''
})
describe('What the Users page offers per grant', () => {
it.each([
['users.view', false, false],
['users.view, users.invite', true, false],
['users.view, users.change_role, roles.view', false, true],
['users.view, users.invite, users.change_role, roles.view', true, true],
])('grants %s: invite %s, menu %s', async (grants, invite, menu) => {
await mountPage(customAccess(grants, 'Custom'), 9)
expect(Boolean(buttonNamed('Invite user'))).toBe(invite)
expect(trigger('Ines Duarte') !== null).toBe(menu)
if (menu) {
await openMenu('Ines Duarte')
expect(menuLabels()).toEqual(['Change role'])
}
})
it('offers the Owner the invite button and Change role', async () => {
await mountPage()
expect(buttonNamed('Invite user')).toBeInstanceOf(HTMLButtonElement)
await openMenu('Ines Duarte')
expect(menuLabels()).toEqual(['Change role'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
})
it('keeps invite rows to Cancel invite and Renew invite only', async () => {
await mountPage()
expect(trigger('sam.oliveira@renew.example')).toBeNull()
expect(buttonNamed('Renew invite')).toBeInstanceOf(HTMLButtonElement)
expect(buttonNamed('Cancel invite')).toBeInstanceOf(HTMLButtonElement)
})
it('shows no Assigned by text in the table or the cards', async () => {
await mountPage()
expect(document.body.textContent).not.toContain('Assigned by')
wrapper?.unmount()
await mountPage(ownerAccess(), 1, false)
expect(document.body.textContent).not.toContain('Assigned by')
expect(document.body.textContent).not.toContain('Marta Alves assigned')
})
})
describe('Your own row and the owner row', () => {
it('offers Change role disabled with the reason on the row marked you', async () => {
await mountPage(adminAccess(), 2)
await openMenu('Jonas Weber')
expect(menuLabels()).toEqual(['Change role'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Change role')?.textContent).toContain(OWN_ROW_HINT)
})
it('gives an Admin the owner row with Change role disabled and no Transfer ownership', async () => {
await mountPage(adminAccess(), 2)
await openMenu('Marta Alves')
expect(menuLabels()).toEqual(['Change role'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Change role')?.textContent).toContain(OWNER_HINT)
})
it('gives the Owner on their own row Change role disabled and Transfer ownership enabled', async () => {
await mountPage()
await openMenu('Marta Alves')
expect(menuLabels()).toEqual(['Change role', 'Transfer ownership'])
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
})
it('gives users.view alone no menu on the owner row', async () => {
await mountPage(customAccess('users.view', 'Viewer'), 9)
expect(trigger('Marta Alves')).toBeNull()
})
it('hides Transfer ownership everywhere without system.transfer_ownership', async () => {
await mountPage(without(ownerAccess(), 'system.transfer_ownership'))
await openMenu('Marta Alves')
expect(menuLabels()).toEqual(['Change role'])
})
})
function sheetItems() {
return [...document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]')]
}
function sheetLabels() {
return sheetItems().map((item) => item.textContent?.trim() ?? '')
}
function sheetItem(label: string) {
return sheetItems().find((item) => item.textContent?.trim() === label)
}
describe('The mobile Users menu', () => {
it('lists Change role disabled with the reason on the owner card', async () => {
await mountPage(adminAccess(), 2, false)
expect(document.body.querySelector('[data-testid="user-card"]')).not.toBeNull()
await openMenu('Marta Alves')
expect(sheetLabels()).toEqual(['Change role'])
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(document.body.querySelector('[role="menu"]')?.textContent).toContain(OWNER_HINT)
})
it('offers Transfer ownership to the Owner on their own card and opens the dialog', async () => {
await mountPage(ownerAccess(), 1, false)
await openMenu('Marta Alves')
expect(sheetLabels()).toEqual(['Change role', 'Transfer ownership'])
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
expect(sheetItem('Transfer ownership')?.getAttribute('aria-disabled')).toBeNull()
await click(sheetItem('Transfer ownership'))
expect(dialog()?.textContent).toContain('Transfer ownership')
})
it('offers a plain Change role on another member card', async () => {
await mountPage(ownerAccess(), 1, false)
await openMenu('Ines Duarte')
expect(sheetLabels()).toEqual(['Change role'])
expect(sheetItem('Change role')?.getAttribute('aria-disabled')).toBeNull()
await click(sheetItem('Change role'))
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(1)
})
})
describe('Changing without roles.view', () => {
it('disables Change role with a hint and never reads the roles', async () => {
await mountPage(customAccess('users.view, users.change_role', 'Custom'), 9)
await openMenu('Ines Duarte')
expect(menuItem('Change role')?.getAttribute('aria-disabled')).toBe('true')
await click(menuItem('Change role'))
expect(dialog()).toBeNull()
expect(calls(`GET ${ROLE_PATH}`)).toHaveLength(0)
})
})
describe('Changing the role', () => {
it('updates only that row from the answer and does not reload the list', async () => {
answers['PUT /v1/user/4/role'] = () =>
Promise.resolve(
accepted(4, 'ines', 'Ines Duarte', 'ines.duarte@renew.example', 'admin', {
role: {
id: CONTRIBUTOR.id,
name: 'Contributor',
built_in: false,
locked: false,
},
}),
)
await mountPage()
await openMenu('Ines Duarte')
await click(menuItem('Change role'))
expect(dialog()?.textContent).toContain('Change role for Ines Duarte')
await click(radio('Contributor'))
await click(
[...dialog()!.querySelectorAll('button')].find(
(b) => b.textContent?.trim() === 'Change role',
),
)
expect(calls('PUT /v1/user/4/role')).toHaveLength(1)
expect(JSON.parse(calls('PUT /v1/user/4/role')[0]![1].body)).toEqual({
role_id: CONTRIBUTOR.id,
})
expect(rowOf('ines.duarte@renew.example').textContent).toContain('Contributor')
expect(rowOf(JONAS_EMAIL).textContent).toContain('admin')
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(dialog()).toBeNull()
})
it('re-reads the list and closes on 409 user_role_changed', async () => {
answers['PUT /v1/user/4/role'] = () =>
Promise.reject(
new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' }),
)
await mountPage()
await openMenu('Ines Duarte')
await click(menuItem('Change role'))
await click(radio('Contributor'))
await click(
[...dialog()!.querySelectorAll('button')].find(
(b) => b.textContent?.trim() === 'Change role',
),
)
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
expect(dialog()).toBeNull()
})
})
describe('Transferring ownership', () => {
it('refreshes the session then the list and shows the toast', async () => {
answers['POST /v1/site/transfer-ownership'] = () =>
Promise.resolve({
owner: { id: 2, name: 'Jonas Weber' },
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
})
await mountPage()
await openMenu('Marta Alves')
await click(menuItem('Transfer ownership'))
await click(radio('Jonas Weber'))
await click(
[...dialog()!.querySelectorAll('button')].find(
(b) => b.textContent?.trim() === 'Continue',
),
)
const input = dialog()!.querySelector('input')!
input.value = JONAS_EMAIL
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
await click(
[...dialog()!.querySelectorAll('button')].find(
(b) => b.textContent?.trim() === 'Transfer ownership',
),
)
expect(JSON.parse(calls('POST /v1/site/transfer-ownership')[0]![1].body)).toEqual({
user_id: 2,
confirm_email: JONAS_EMAIL,
})
expect(getMe).toHaveBeenCalledTimes(1)
expect(calls(`GET ${LIST_PATH}`)).toHaveLength(2)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(dialog()).toBeNull()
})
})
describe('Invite rows keep their role', () => {
it('keeps the Contributor role on a renewed invite', async () => {
answers['POST /v1/user/invite/2/renew'] = () =>
Promise.resolve({
id: 2,
kind: 'invite',
email: 'sam.oliveira@renew.example',
status: 'pending',
created_at: '2026-10-01T09:00:00Z',
expires_at: '2026-10-04T09:00:00Z',
})
await mountPage()
await click(buttonNamed('Renew invite'))
const row = rowOf('sam.oliveira@renew.example')
expect(row.textContent).toContain('Contributor')
expect(row.textContent).toContain('Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
it('shows the escalation refusal and leaves the row Expired', async () => {
answers['POST /v1/user/invite/2/renew'] = () =>
Promise.reject(new ApiError(403, undefined, 'escalation', { code: 'escalation' }))
await mountPage()
await click(buttonNamed('Renew invite'))
expect(rowOf('sam.oliveira@renew.example').textContent).toContain('Expired')
expect(toastAdd).toHaveBeenCalledTimes(1)
const toast = toastAdd.mock.calls[0]![0]
const content = typeof toast === 'function' ? toast() : toast
expect(content.title).toBe("You can't grant that")
expect(content.description).toBe('You can only grant what your role has.')
})
it('puts the invite at the top with the chosen role badge', async () => {
answers['POST /v1/user/invite'] = () =>
Promise.resolve({
id: 8,
kind: 'invite',
email: 'lea.martin@renew.example',
role: { id: 3, name: 'Writer', built_in: true, locked: false },
status: 'pending',
created_at: '2026-10-01T09:00:00Z',
expires_at: '2026-10-04T09:00:00Z',
})
await mountPage()
await click(buttonNamed('Invite user'))
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="name@example.com"]',
)!
input.value = 'lea.martin@renew.example'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
await click(buttonNamed('Send invite'))
expect(JSON.parse(calls('POST /v1/user/invite')[0]![1].body)).toEqual({
email: 'lea.martin@renew.example',
role_id: 3,
})
const first = document.body.querySelector<HTMLElement>('[data-testid="user-row"]')!
expect(first.textContent).toContain('lea.martin@renew.example')
expect(first.textContent).toContain('writer')
expect(accessFor('owner')).toBeDefined()
})
})
@@ -50,7 +50,6 @@ function defaultFixture(): UserListItem[] {
kind: 'invite',
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
@@ -281,7 +280,10 @@ describe.each([
expect(iconOf(renew)).toBe('i-lucide-refresh-cw')
expect(renew.className).toContain('text-highlighted')
expect(accepted).toHaveLength(3)
accepted.forEach((row) => expect(buttonsIn(row)).toHaveLength(0))
accepted.forEach((row) => {
expect(buttonIn(row, 'Cancel invite')).toBeUndefined()
expect(buttonIn(row, 'Renew invite')).toBeUndefined()
})
expect(cancel.className).toContain(desktop ? 'h-7' : 'h-11')
})
},
@@ -494,7 +496,7 @@ describe('Renew invite: done', () => {
const [first, second] = rows()
expect(first.textContent).toContain('p.huber@gmail.com')
expect(first.textContent).toContain('Pending')
expect(first.textContent).toContain('Paul Huber')
expect(first.textContent).toContain('p.huber@gmail.com')
expect(first.className).toContain('bg-elevated')
expect(buttonIn(first, 'Cancel invite')).toBeDefined()
expect(second.textContent).toContain('sam.oliveira@renew.example')
+56 -26
View File
@@ -47,7 +47,6 @@ function defaultFixture(): UserListItem[] {
kind: 'invite',
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
role: roleRefFor('admin'),
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
@@ -284,7 +283,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026 Renew invite',
'p.huber@gmail.com — admin Expired 3 Aug 2026 Renew invite',
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
'jonas@renew.example — admin Accepted 2 Apr 2026',
'marta@renew.example you — owner Accepted 12 Mar 2026',
@@ -306,7 +305,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
Array.from(row.querySelectorAll('span')).findLast(
(el) => el.textContent?.trim() === role,
)!
const [sam, huber, , jonas, marta] = rows()
const [sam, huber, ines, jonas, marta] = rows()
expect(badge(marta, 'owner').className).toContain('bg-primary')
expect(badge(marta, 'owner').className).not.toContain('bg-transparent')
@@ -315,10 +314,11 @@ describe('Default — the list loads newest first, with display name, "you" tag
expect(badge(jonas, '—').className).toContain('text-muted')
expect(badge(sam, '—').className).toContain('text-muted')
expect(badge(huber, 'Paul Huber').className).toContain('text-highlighted')
expect(badge(huber, 'Paul Huber').className).not.toContain('text-muted')
expect(badge(huber, '—').className).toContain('text-muted')
expect(badge(ines, 'Inès Duarte').className).toContain('text-highlighted')
expect(badge(ines, 'Inès Duarte').className).not.toContain('text-muted')
expect(badge(marta, 'you')).toBeTruthy()
expect(badge(marta, 'you')).toBeInstanceOf(HTMLElement)
expect(rows().filter((row) => badge(row, 'you'))).toEqual([marta])
})
@@ -326,15 +326,16 @@ describe('Default — the list loads newest first, with display name, "you" tag
await mountPage({}, false)
const spans = (row: Element) => Array.from(row.querySelectorAll('span'))
const [, huber, , jonas, marta] = rows()
const [, huber, ines, jonas, marta] = rows()
const find = (row: Element, text: string) =>
spans(row).findLast((el) => el.textContent?.trim() === text)
expect(find(marta, 'owner')!.className).toContain('bg-primary')
expect(find(jonas, 'admin')!.className).toContain('bg-transparent')
expect(find(jonas, '—')).toBeUndefined()
expect(find(huber, 'Paul Huber')).toBeTruthy()
expect(find(marta, 'you')).toBeTruthy()
expect(find(ines, 'Inès Duarte')).toBeInstanceOf(HTMLElement)
expect(find(huber, '—')).toBeUndefined()
expect(find(marta, 'you')).toBeInstanceOf(HTMLElement)
expect(find(jonas, 'you')).toBeUndefined()
})
@@ -343,7 +344,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026 Cancel invite',
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026 Renew invite',
'p.huber@gmail.com admin Expired Created 3 Aug 2026 Renew invite',
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
'jonas@renew.example admin Accepted Created 2 Apr 2026',
'marta@renew.example you owner Accepted Created 12 Mar 2026',
@@ -515,10 +516,8 @@ describe('Invite: default — opening the modal', () => {
await mountPage()
await openModal()
expect(dialog()?.textContent).toContain('Invite a user')
expect(dialog()?.textContent).toContain(
"We'll email them a link to choose a username and password. The link expires after 3 days.",
)
expect(dialog()?.textContent).toContain('Invite user')
expect(dialog()?.textContent).toContain('They get an email with a link to set a password.')
expect(emailInput().value).toBe('')
expect(document.activeElement).toBe(emailInput())
expect(findButton('Cancel')).toBeTruthy()
@@ -616,7 +615,7 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
expect(texts.slice(1)).toEqual([
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026 Renew invite',
'p.huber@gmail.com — admin Expired 3 Aug 2026 Renew invite',
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
'jonas@renew.example — admin Accepted 2 Apr 2026',
'marta@renew.example you — owner Accepted 12 Mar 2026',
@@ -632,6 +631,27 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
})
})
it.each([
[1, 'has 1 day to accept.'],
[7, 'has 7 days to accept.'],
])('words a %s-day expiry from expires_at', async (days, wording) => {
const created = new Date()
await mountPage({
invite: () =>
Promise.resolve({
...inviteFixture(),
created_at: created.toISOString(),
expires_at: new Date(
created.getTime() + days * 24 * 60 * 60 * 1000,
).toISOString(),
}),
})
await openModal()
await submitWith(VALID_EMAIL)
expect(toastAdd.mock.calls[0]![0].description).toBe(`${VALID_EMAIL} ${wording}`)
})
it('returns focus to Invite user', async () => {
await mountPage()
await openModal()
@@ -670,36 +690,46 @@ describe('Invite sent into the Only-you state keeps a coherent list', () => {
})
})
describe('Invite: already a user (409) — the field error depends on the response text', () => {
describe('Invite: 409 conflicts are told apart by the error code', () => {
it.each([
{
email: 'jonas@renew.example',
body: 'This address already has an account.',
code: 'email_has_account' as const,
error: 'This address already has an account.',
},
{
email: 'sam.oliveira@renew.example',
body: "There's already a pending invite for this address.",
code: 'invite_pending' as const,
error: "There's already a pending invite for this address.",
},
{
email: 'jonas@renew.example',
body: 'Something else',
error: 'This address already has an account.',
},
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
])('shows "$error" for the code $code', async ({ email, code, error }) => {
await mountPage({
invite: () => Promise.reject(new ApiError(409, undefined, body)),
invite: () => Promise.reject(new ApiError(409, undefined, 'ignored body', { code })),
})
await openModal()
await submitWith(email)
expect(dialog()).toBeTruthy()
expect(dialog()).not.toBeNull()
expect(emailInput().value).toBe(email)
expect(emailInput().disabled).toBe(false)
expect(dialog()?.textContent).toContain(error)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('ignores the server wording and falls back to the generic alert on an unknown 409', async () => {
await mountPage({
invite: () =>
Promise.reject(
new ApiError(409, undefined, 'This address already has an account.'),
),
})
await openModal()
await submitWith('jonas@renew.example')
expect(alertText()).toContain(FAILED_ALERT)
expect(dialog()?.textContent).not.toContain('This address already has an account.')
expect(emailInput().disabled).toBe(false)
})
})
describe("A 400 that got past this form's own check", () => {