refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types
This commit is contained in:
1 parent
5d6f378fce
commit
b39a41be88
124 files changed
+8774
-481
No files matched your search
@@ -104,6 +104,7 @@ type UseCases struct {
|
||||
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
|
||||
RemoveUserUseCase *useCases.RemoveUserUseCase
|
||||
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
@@ -421,6 +422,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
|
||||
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
|
||||
|
||||
@@ -119,6 +119,7 @@ var refusals = []refusal{
|
||||
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
|
||||
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
|
||||
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
|
||||
{domain.ErrReassignTargetNotFound, http.StatusConflict, apperr.ReassignTargetNotFoundErrorMessage},
|
||||
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
|
||||
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
|
||||
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/role"
|
||||
domain "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type RemoveHandler struct {
|
||||
RemoveUserUseCase *useCases.RemoveUserUseCase
|
||||
}
|
||||
|
||||
func NewRemoveHandler(removeUser *useCases.RemoveUserUseCase) *RemoveHandler {
|
||||
return &RemoveHandler{RemoveUserUseCase: removeUser}
|
||||
}
|
||||
|
||||
func (h *RemoveHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expectedRoleID, err := strconv.ParseUint(r.URL.Query().Get("expected_role_id"), 10, 32)
|
||||
if err != nil || expectedRoleID == 0 {
|
||||
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.RemoveUserUseCase.Remove(r.Context(), useCases.RemoveUserCommand{
|
||||
Caller: access.CallerOf(r),
|
||||
TargetID: uint32(targetID),
|
||||
ExpectedRoleID: uint32(expectedRoleID),
|
||||
})
|
||||
if err != nil {
|
||||
writeRemoveError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeRemoveError(w http.ResponseWriter, err error) {
|
||||
if writeRoleRefusal(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, role.ErrOwnRole):
|
||||
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, domain.ErrUserNotFound):
|
||||
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrRoleChanged):
|
||||
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ const InvalidLocaleErrorMessage = "invalid_locale"
|
||||
const SiteImageNotFoundErrorMessage = "image_not_found"
|
||||
const UseSetDefaultErrorMessage = "use_set_default"
|
||||
const InvalidReassignErrorMessage = "invalid_reassign"
|
||||
const ReassignTargetNotFoundErrorMessage = "target_role_not_found"
|
||||
const InvalidSocialTypeErrorMessage = "invalid_social_type"
|
||||
|
||||
const AuthorNotFoundErrorMessage = "author_not_found"
|
||||
|
||||
@@ -166,6 +166,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
|
||||
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -197,6 +198,17 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Use(container.require(role.KeyUsersChangeRole))
|
||||
r.Put("/{id}/role", roleHandler.Change)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(container.require(role.KeyUsersRemove))
|
||||
r.Delete("/{id}", removeHandler.Remove)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -74,7 +74,7 @@ never changes the response or the change itself.
|
||||
| --- | --- |
|
||||
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
||||
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
||||
| `authors` | `author.create`, `author.edit`, `author.delete` |
|
||||
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
||||
@@ -382,7 +382,8 @@ Refusals are plain text, one snake_case code per body:
|
||||
| Status | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
|
||||
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, the Owner role, unknown). |
|
||||
| 409 | `target_role_not_found` | `POST /v1/role/{id}/reassign` when the target role does not exist (any longer). |
|
||||
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, 0, a locked role, a role the caller cannot cover). |
|
||||
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
|
||||
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
|
||||
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
|
||||
@@ -398,7 +399,7 @@ at the same version and records nothing. Members see an edit on their next reque
|
||||
|
||||
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
|
||||
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
|
||||
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` (users and open invites together) and is recorded only when
|
||||
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to`, `to_name` (the destination role's name at move time; absent on older events) and `moved` (users and open invites together) and is recorded only when
|
||||
`moved` is above 0. A refusal records nothing.
|
||||
|
||||
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
|
||||
@@ -429,6 +430,7 @@ login, verify, register and invite-accept bodies do not change.
|
||||
| --- | --- | --- | --- |
|
||||
| `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. |
|
||||
| `DELETE /v1/user/{id}?expected_role_id=N` | `users.remove` | yes | Removes an accepted user (204, no body). `expected_role_id` is required (a whole number above 0, else 400 `invalid_role`) and must be the id of the role the target still holds: a compare-and-swap (`DeleteIfRole`), 409 `user_role_changed` otherwise. Order of checks: session, permission, id, `expected_role_id`, target (404 `user_not_found`), target is the caller (403 `own_role`), stale role (409), target role locked (403 `role_locked`), target role covered (403 `escalation`), delete. The removed user's cookie sessions and API keys answer 401 afterwards; articles, entries, media, invites and audit events they created stay. Records `user.remove` (category `users`, target the user); a refusal records nothing and a storage failure records it with outcome `failed`. Invites are cancelled with `DELETE /v1/user/invite/{id}`. |
|
||||
| `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. |
|
||||
@@ -460,7 +462,7 @@ body, target, `confirm_email`, write. Every refusal writes nothing, not even the
|
||||
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
|
||||
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
|
||||
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
|
||||
| 409 | `user_role_changed` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
|
||||
| 409 | `user_role_changed` | `DELETE /v1/user/{id}` as well: the target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
|
||||
|
||||
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
|
||||
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
|
||||
|
||||
@@ -21,7 +21,7 @@ Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: f
|
||||
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
|
||||
|
||||
## Pass 4: custom content types
|
||||
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 11 groups, 97 states. 1 Schema list 7, 2 Schema builder 19, 3 Review and delete 9, 4 Field types 7, 5 Content and entry list 12, 6 Entry editor 23, 7 Relations 3, 8 Permissions 2, 9 Export and import 3, 10 Media picker for a field 8, 11 Media library usage 4. The Media field type (pass 5) is specified in `media-field-changes.md`; the exact copy and measurements are there, and the boards are the visual reference. Plus tables: scope, routes, new components with their Nuxt UI mapping, errors to copy, needs backend (4 rows), decisions to confirm, accessibility. Specs: screens/schema.md, content.md, entry.md, schema-later.md, media.md, media-picker.md, and media-field-changes.md for the Media field type.
|
||||
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 11 groups, 98 states. 1 Schema list 7, 2 Schema builder 19, 3 Review and delete 9, 4 Field types 7, 5 Content and entry list 12, 6 Entry editor 23, 7 Relations 4, 8 Permissions 2, 9 Export and import 3, 10 Media picker for a field 8, 11 Media library usage 4. The Media field type (pass 5) is specified in `media-field-changes.md`; the exact copy and measurements are there, and the boards are the visual reference. Plus tables: scope, routes, new components with their Nuxt UI mapping, errors to copy, needs backend (4 rows), decisions to confirm, accessibility. Specs: screens/schema.md, content.md, entry.md, schema-later.md, media.md, media-picker.md, and media-field-changes.md for the Media field type.
|
||||
- AppFrame gains a Schema item (owner and admin) and a Content group (sidebar and mobile drawer). Nothing else in AppFrame changed.
|
||||
- Backend gaps still open: built-in types and fields, display names for created_by and updated_by, a type icon, per-change entry counts.
|
||||
- Not done: Back-office and Prototype boards were not compared visually after the sidebar change (see Checks below).
|
||||
@@ -140,4 +140,6 @@ Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 40
|
||||
- The sandbox cannot export; no files were bundled.
|
||||
|
||||
## Pass 6: roles and permissions
|
||||
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 11 groups, 61 states, all Available now. Spec `screens/roles.md`; additions in components.md, states.md, routes.md, change-list.md. The Permissions grid is folded into the role editor and `/settings/permissions` is retired. Writer frames use AppFrame's existing restricted `user` navigation. AppFrame gained the Access group with a Roles item (sidebar and drawer). Not done: Schema board group 8 (Permissions) should be marked superseded.
|
||||
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 15 groups, 90 states, all Available now (groups 12 to 15 and the changes to groups 5, 8 and 9 are listed in `change-log-roles-sync.md`). Spec `screens/roles.md`; additions in components.md, states.md, routes.md, change-list.md. The Permissions grid is folded into the role editor and `/settings/permissions` is retired. Writer frames use AppFrame's existing restricted `user` navigation. AppFrame gained the Access group with a Roles item (sidebar and drawer). Not done: Schema board group 8 (Permissions) should be marked superseded.
|
||||
|
||||
The Schema board group 7 (Relations) now has 4 states; the Integration page and the Activity category filter follow the roles sync (see `change-log-roles-sync.md`).
|
||||
@@ -0,0 +1,118 @@
|
||||
# Change log: roles sync (pass 6)
|
||||
|
||||
Diff this against the code. "Old" is the previous design copy, "New" is the copy now in the design. EN is the design language; FR is in the board table "Final copy: toasts and errors, EN and FR" (screens/roles.md, same title). Every state is drawn light and dark, 1440×900 and 390×844, on `RenewCMS Screens Roles.dc.html` (data `roles-spec.js`, frame `RolesFrame.dc.html`) unless stated. Roles board: 61 → 90 states, 11 → 15 groups.
|
||||
|
||||
## 3. Differences adopted
|
||||
|
||||
### 3.1 Neutral wording ("they")
|
||||
| File · state | Old | New |
|
||||
|---|---|---|
|
||||
| roles-spec.js, screens/roles.md, RolesFrame · Users: Change role dialog and Change role: options blocked | Her API keys follow the new role. | Their API keys follow the new role. |
|
||||
| same · Users: Transfer ownership: confirm | You become an Admin. You can't take ownership back unless he transfers it. | You become an Admin. You can't take ownership back unless they transfer it. |
|
||||
Searched all .md, .js, .dc.html and email files for he, she, his, her, him, hers. No other occurrence.
|
||||
|
||||
### 3.2 Event names
|
||||
| File | Old | New |
|
||||
|---|---|---|
|
||||
| routes.md, screens/roles.md (Needs backend), roles-spec.js | role.created, role.updated, role.deleted, user.role_changed, site.ownership_transferred | role.create, role.update, role.delete, role.reassign (new), user.role_change, site.ownership_transfer |
|
||||
| states.md (new section "Roles sync"), screens/activity.md (new section) | event names and sentences were not written down | table of name, sentence, failed sentence |
|
||||
Sentences themselves are unchanged ("Role Contributor created", and so on).
|
||||
|
||||
### 3.3 Invite endpoint
|
||||
| File | Old | New |
|
||||
|---|---|---|
|
||||
| screens/roles.md group 5, roles-spec.js group 5 | POST /v1/invite | POST /v1/user/invite |
|
||||
routes.md, components.md and the other screen files never cited `/v1/invite`. IntegrationPage already said `/v1/user/invite`.
|
||||
|
||||
## 1. Copy
|
||||
All EN and FR strings: roles board, table "Final copy: toasts and errors, EN and FR", and group 12 (toasts) and 13 (inline errors).
|
||||
| State | EN |
|
||||
|---|---|
|
||||
| Toast: role changed (group 12) | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. |
|
||||
| Toast: role edited elsewhere, 409 | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload |
|
||||
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. |
|
||||
| Toast: member's role changed before a change | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload |
|
||||
| Toast: target's role changed before a transfer | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload |
|
||||
| Toast: member's role changed before a removal | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload |
|
||||
| Role editor: save failed (new) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again |
|
||||
| Change role: failed (new) | Couldn't change the role · Nothing was changed. Try again in a moment. |
|
||||
| Delete role: target deleted (new) | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. |
|
||||
| Delete role: role gained members (new) | Role still has members · Intern now has 1 member. Move them to another role first. |
|
||||
| Delete role: move failed (new) | Couldn't move the members · Nothing was changed. Try again in a moment. |
|
||||
| Invite: invalid email (new) | Enter a valid email address. |
|
||||
| Invite: account exists, 409 (new) | This address already has an account. |
|
||||
| Invite: role above yours (new) | Your role can't assign a role with more power than yours. |
|
||||
| Invite: send failed (new) | Couldn't send the invite · Try again in a moment. |
|
||||
| Activity, role.reassign | Moved 3 members from Intern to Writer |
|
||||
| Activity, failed sentences | Failed to {verb} {object}; one per event in the table "Activity events: failed sentences, EN and FR" |
|
||||
| Activity filter, category names | Articles · Media · Users and invites (old: Users & invites) · Roles (new) · API keys · Authentication · System |
|
||||
Unchanged and still correct: Name required, Name already taken, Escalation blocked, Conflict banner, Permission lowered.
|
||||
|
||||
## 2. Profile role card (group 9)
|
||||
Decision: the summary line is removed. "Assigned by Marta Alves" is removed with it, because `access.role` has no such field either. "See what I can do" stays; its summary is computed from the permission list in GET /v1/me.
|
||||
| State | Old | New |
|
||||
|---|---|---|
|
||||
| Role card: custom role | Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role… | Your role · [Contributor badge] [Custom tag] · See what I can do · Your API keys follow this role. If your role changes, they change too. |
|
||||
| Role card: Owner (new) | none | Your role · [Owner badge] [Locked] [Built-in] · See what I can do · same key line |
|
||||
Layout, top to bottom, inside the existing card: title; one row of badges (role badge with icon, then Locked if `locked`, then Built-in or Custom from `built_in`); link row with the list-checks icon; muted key line. Gap 12, as every card.
|
||||
|
||||
## 4. Behaviour
|
||||
### Read = Own (group 14, new, 5 states; also group 2 Role editor and group 3 preview via the same editor)
|
||||
| State | Content |
|
||||
|---|---|
|
||||
| Role editor: Read set to Own | New info alert under the grid: Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. The preview sentence for Read = Own is now type-specific: "Can read online articles and their own drafts" (Articles) and "Can read only entries they created" (custom types). Old preview text was "read only their own". |
|
||||
| Articles list: Read = Own | Junior writer: 3 online articles by others and 2 own drafts. Tabs All 5, Online 3, Draft 2. Banner: Drafts by others are hidden. |
|
||||
| Articles list: no drafts of their own | Draft tab, counts All 3, Online 3, Draft 0. You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. |
|
||||
| Entry list: Read = Own | Only own entries, "3 entries", banner Entries by others aren't shown. |
|
||||
| Entry list: no entries of their own | You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. |
|
||||
Decisions table row 4 now says Update and Delete set to Own keep the View only rows, Read set to Own hides them.
|
||||
|
||||
### 409 conflicts (group 15, new, 5 states)
|
||||
| State | Where | Edits kept | Recovery |
|
||||
|---|---|---|---|
|
||||
| Stale save: banner (existing Conflict state, copy unchanged) | Banner above the form | Yes, Save off | Reload role, or Review differences |
|
||||
| Stale save: review differences (new) | Dialog over the editor | Yes | Keep my edits (reload and re-apply edits on top, Save on) or Use the saved role (drop edits) |
|
||||
| Stale save: edits kept after reload (new) | Info toast, form shows saved role plus edits | Yes | Review, then Save |
|
||||
| Change role: member's role changed (new) | Alert inside the dialog; toast if closed | Selection kept; Current mark moves | Change role again |
|
||||
| Transfer ownership: target's role changed (new) | Alert inside the dialog; toast if closed | Typed email kept; primary off | Reload member, then Transfer ownership |
|
||||
|
||||
### Integration page (IntegrationPage.dc.html, Back-office board group Integration)
|
||||
| Item | Old | New |
|
||||
|---|---|---|
|
||||
| Authentication, API key card | …with that user's role (owner or admin). A key never has more rights than its creator. | …with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it. |
|
||||
| Errors table, 403 | "This action needs a real session.", "Only the owner can do this.", "Only owners and admins can do this." or "You must verify your account to access this resource." | "This action needs a real session.", `missing_permission` (code style, literal body), or "You must verify your account to access this resource." Note: `missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article. |
|
||||
| Access chip on 9 routes (content types create, update, delete; built-in types list, get; media purge settings get, patch, preview, purge) | Owner or admin | Needs permission. The route description ends with Needs the permission "…". Proposed permissions: Create and edit content types; Delete content types; View content types; Delete files (purge routes). |
|
||||
| Per-route 403 line on those routes | Only owners and admins can do this. | missing_permission (code style) |
|
||||
| Content types section intro | An owner or admin creates a type… | A user whose role can create and edit content types defines a type… |
|
||||
"Owner only" chips are kept for the owner-only routes.
|
||||
|
||||
### Relation picker (SchemaFrame, Schema board group 7, Later)
|
||||
Sample data: 25 venues per page (the design had 3 sample results, never 10), footer "Showing 25 of 62" with Load more. New state "Relation picker: loading more": skeleton rows, Loading… on a disabled button. Group 7: 3 → 4 states.
|
||||
|
||||
## Files touched
|
||||
roles-spec.js, RolesFrame.dc.html, screens/roles.md (regenerated from the spec), routes.md, states.md, screens/activity.md, screens/integration.md, screens/schema-later.md, schema-spec.js, SchemaFrame.dc.html, IntegrationPage.dc.html, README.md.
|
||||
|
||||
## Open items for you
|
||||
See the end of this file's companion list in the chat reply; repeated here.
|
||||
1. Relation picker: the design never had a "Load more" frame or a 10-result sample. Both were added, not updated.
|
||||
2. Activity event names: states.md and screens/activity.md did not contain event names; they only existed in roles.md, roles-spec.js and routes.md. I added the table to the first two.
|
||||
3. `user.role_changed` and `site.ownership_transferred` became `user.role_change` and `site.ownership_transfer`. Your brief said "the same present-tense pattern for the other role events". Confirm those two are included.
|
||||
4. `/v1/invite` appeared only in screens/roles.md and roles-spec.js. routes.md, components.md and the other screens already had `/v1/user/invite` or no citation.
|
||||
5. The 403 row in the design did not list "missing_permission". It listed "Only the owner can do this." and "Only owners and admins can do this.", which I removed. Check that owner-only routes now also answer `missing_permission`.
|
||||
6. Status naming: you say All / Online / Draft; the design already used Online and Draft, so no change.
|
||||
7. "Role deleted by someone else (409)": a deleted role normally answers 404. I drew 404 for the roles list and an inline 409 only where the deleted role was a reassign target. Confirm the codes.
|
||||
8. "Changing a role that changed meanwhile": I read it as the member's role having changed, not the role definition. Confirm.
|
||||
9. Profile: `access.role` has no assigned_by, so "Assigned by" was removed along with the summary. Confirm that the permission summary can be built from /v1/me.
|
||||
10. Activity categories: the filter had six categories. I added Roles and renamed "Users & invites" to "Users and invites" to match the permission group. Authors, Sites and Content types have activity in the product but no category in the filter; I did not add them.
|
||||
11. Purge routes: the catalogue has no permission for media purge. I used Delete files. Pick the real one.
|
||||
12. Not changed, but now out of date: IntegrationPage Entries intro still says "Any verified user can write entries"; screens/schema.md and the Schema frames say "Owner or admin" for Schema; states.md "Sites and authors: roles" and the 403 owner_only rows still describe fixed roles; screens/profile.md Display name copy. Say if you want these aligned.
|
||||
13. Decision 4 in "Decisions to confirm" was narrowed (Read = Own hides rows). Direct link to a hidden item answers the existing 404 page; this is proposed.
|
||||
14. French role names: built-in names (Owner, Admin, Writer) stay English in FR copy until you decide.
|
||||
15. Toast bodies use two short sentences in places; the glossary says one. Kept for clarity.
|
||||
|
||||
## Corrections made while importing into the repo
|
||||
These were checked against the backend and applied to the repo copy of the design (the Claude Design copy still has the old values).
|
||||
1. Event names: the backend emits `role.edit` (not `role.update`), `user.change_role` (not `user.role_change`) and `user.transfer_ownership` (not `site.ownership_transfer`). The other three, `role.create`, `role.delete` and `role.reassign`, match.
|
||||
2. Media purge permissions: `GET /v1/media/purge-settings` needs "View settings", `PATCH /v1/media/purge-settings` needs "Edit general settings", and `GET /v1/media/purge-preview` and `POST /v1/media/purge` need "Delete files".
|
||||
3. Open item 7 (deleted role): the backend answers 404 for a deleted role and 409 for a stale role save, as drawn.
|
||||
4. Open item 8: "changing a role that changed meanwhile" means the member's role, as read.
|
||||
@@ -73,4 +73,4 @@ Needs backend:
|
||||
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
|
||||
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
|
||||
| Version field | role.version for the conflict state (409 role_conflict). |
|
||||
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
|
||||
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. |
|
||||
@@ -93,3 +93,20 @@ Route: `/activity`. API: GET /v1/activity (server scoped to the user).
|
||||
| 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | |
|
||||
| 2 | Empty | The user has not done anything yet. | |
|
||||
| 3 | Details | Same slide-over, own event. | |
|
||||
|
||||
## Roles sync (pass 6): event names, failed sentences, categories
|
||||
|
||||
Event names are present tense, as the backend emits them: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership. The outcome is a field; a failed event keeps its name and uses the failed sentence.
|
||||
|
||||
| Event | Sentence | Failed sentence |
|
||||
|---|---|---|
|
||||
| role.create | Role Contributor created | Failed to create the role Contributor |
|
||||
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
|
||||
| role.delete | Role Intern deleted | Failed to delete the role Intern |
|
||||
| role.reassign | Moved 3 members from Intern to Writer (one member: Moved 1 member from Intern to Writer) | Failed to move 3 members from Intern to Writer |
|
||||
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
|
||||
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
|
||||
|
||||
Failed template: "Failed to {verb} {object}". A failed row shows the Failed badge after the sentence; the details panel shows "Why it failed" with the reason from the API.
|
||||
|
||||
Category filter values: Articles, Media, Users and invites (was "Users & invites"), Roles (new), API keys, Authentication, System. French and the failed sentences for every event are in screens/roles.md, group 8 and the table "Activity events: failed sentences, EN and FR". Frames: Roles board, group "8 Activity".
|
||||
@@ -12,7 +12,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
||||
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||
- Base URL: label "Base URL", a CodeSnippet with one tab ("Base URL") and the button "Copy" / "Copied" (2 s, announced).
|
||||
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
|
||||
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
|
||||
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
|
||||
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
- Groups, in index order: Authentication, Articles (7), Search and sharing (2), Content types (5), Entries (5), Built-in types (2), Media (10), Sites and authors (2), Images and variants, Users and invites (6), Settings (1), Session only (35 routes in 6 sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants, Activity; every `/v1/system` route is in this group), Errors, Notes. Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
|
||||
@@ -23,7 +23,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
||||
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
|
||||
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response in one CodeSnippet with Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
|
||||
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
|
||||
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
|
||||
- Chips: access "Public", "Verified user", "Needs permission" (routes gated by a role permission; the permission name is in the route description), "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
|
||||
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
|
||||
|
||||
## States
|
||||
@@ -34,7 +34,7 @@ Desktop 1440×900, mobile 390×844, light and dark.
|
||||
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
|
||||
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." Image requests (`/media/photo.png?v=`) answer 400 with a JSON body (`status`, `error`, `message`, `detail`) in two cases: `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }` for an unknown or disabled variant, and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }` for any other query parameter (`width`, `height`, `format`, `quality`). Both are rows of the "Responses" table in "Images and variants".
|
||||
- 401: "You must be logged in to access this resource."
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource." Note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / `missing_permission` (the role of the user, or of the key's creator, lacks the permission the route needs) / "You must verify your account to access this resource." Note under the 403 row: "`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
|
||||
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" / `site_not_found` (GET /v1/site/{key}/sitemap only; body is the code, not a sentence)
|
||||
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
|
||||
- 500: "Internal server error"
|
||||
|
||||
+295
-13
@@ -208,7 +208,7 @@ Copy: Delete Writer? · Built-in roles can't be deleted. · Writer is built in
|
||||
|
||||
## 5 Users: roles and ownership
|
||||
|
||||
Route: /users · API: PUT /v1/user/{id}/role, POST /v1/invite, POST /v1/site/transfer-ownership · Auth: Users: view; change role, invite, remove; owner for transfer
|
||||
Route: /users · API: PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership · Auth: Users: view; change role, invite, remove; owner for transfer
|
||||
|
||||
The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.
|
||||
|
||||
@@ -234,7 +234,7 @@ Copy: Change role (disabled) · Transfer ownership · Remove user (disabled) ·
|
||||
|
||||
Trigger: Each option shows the role's one-line summary. The current role is marked.
|
||||
|
||||
Copy: Change role for Ines Duarte · Her API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role
|
||||
Copy: Change role for Ines Duarte · Their API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role
|
||||
|
||||
### Change role: options blocked (Available now)
|
||||
|
||||
@@ -258,7 +258,7 @@ Copy: Transfer ownership · Choose who becomes the owner. Only members can be ch
|
||||
|
||||
Trigger: Consequences listed. The action stays off until the email is retyped exactly.
|
||||
|
||||
Copy: Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can't take ownership back unless he transfers it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership
|
||||
Copy: Transfer ownership to Jonas Weber · This takes effect straight away. · 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. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership
|
||||
|
||||
### Transfer ownership: done (Available now)
|
||||
|
||||
@@ -352,9 +352,9 @@ New event types. The detail panel summarises a role diff as Added and Removed li
|
||||
|
||||
### List with role events (Available now)
|
||||
|
||||
Trigger: Five new events.
|
||||
Trigger: Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.
|
||||
|
||||
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Role Intern deleted
|
||||
Copy: Role Contributor created · Role Contributor edited · Ines Duarte's role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Moved 3 members from Intern to Writer · Role Intern deleted
|
||||
|
||||
### Detail: role diff (Available now)
|
||||
|
||||
@@ -362,17 +362,41 @@ Trigger: Open "Role Contributor edited".
|
||||
|
||||
Copy: Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.
|
||||
|
||||
### Failed role events (Available now)
|
||||
|
||||
Trigger: The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.
|
||||
|
||||
Copy: Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed
|
||||
|
||||
### Failed event: details (Available now)
|
||||
|
||||
Trigger: Open "Failed to edit the role Contributor". The reason comes from the API.
|
||||
|
||||
Copy: Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.
|
||||
|
||||
### Category filter (Available now)
|
||||
|
||||
Trigger: The Category select open. Roles is a new category; Users & invites is renamed Users and invites.
|
||||
|
||||
Copy: All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System
|
||||
|
||||
## 9 Profile: your role
|
||||
|
||||
Route: /profile · API: GET /v1/me · Auth: Any
|
||||
|
||||
The person sees their own role and a read-only summary.
|
||||
|
||||
### Role card (Available now)
|
||||
### Role card: custom role (Available now)
|
||||
|
||||
Trigger: Any signed-in user.
|
||||
Trigger: Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.
|
||||
|
||||
Copy: Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role. If your role changes, they change too.
|
||||
Copy: Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.
|
||||
|
||||
### Role card: Owner (Available now)
|
||||
|
||||
Trigger: The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.
|
||||
|
||||
Copy: Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.
|
||||
|
||||
### See what I can do (Available now)
|
||||
|
||||
@@ -434,6 +458,180 @@ Trigger: Toast, and the bar closes.
|
||||
|
||||
Copy: Role created · Contributor is ready. Assign it from Users.
|
||||
|
||||
## 12 Toasts: role changed and stale state
|
||||
|
||||
Route: Any page · API: PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership · Auth: As the action
|
||||
|
||||
Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".
|
||||
|
||||
### Role changed (Available now)
|
||||
|
||||
Trigger: 200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.
|
||||
|
||||
Copy: Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.
|
||||
|
||||
### Role edited elsewhere (409) (Available now)
|
||||
|
||||
Trigger: 409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.
|
||||
|
||||
Copy: Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload
|
||||
|
||||
### Role deleted elsewhere (Available now)
|
||||
|
||||
Trigger: Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.
|
||||
|
||||
Copy: That role no longer exists · It was deleted somewhere else, so we removed it from the list.
|
||||
|
||||
### Role not changed: member's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.
|
||||
|
||||
Copy: Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload
|
||||
|
||||
### Ownership not transferred: target's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Transfer ownership was confirmed after the target's role changed, and the dialog was closed in the meantime. Nothing was transferred.
|
||||
|
||||
Copy: Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload
|
||||
|
||||
### User not removed: member's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Remove user was confirmed after the member's role changed. Nothing was removed.
|
||||
|
||||
Copy: User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload
|
||||
|
||||
## 13 Inline errors: editor and dialogs
|
||||
|
||||
Route: Role editor, Change role, Delete role, Invite · API: POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite · Auth: As the action
|
||||
|
||||
Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.
|
||||
|
||||
### Role editor: save failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.
|
||||
|
||||
Copy: Couldn't save the role · Your changes are still here. Try again in a moment. · Try again
|
||||
|
||||
### Change role: failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure. The choice is kept.
|
||||
|
||||
Copy: Couldn't change the role · Nothing was changed. Try again in a moment.
|
||||
|
||||
### Delete role: target role deleted (Available now)
|
||||
|
||||
Trigger: 409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.
|
||||
|
||||
Copy: Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.
|
||||
|
||||
### Delete role: role gained members (Available now)
|
||||
|
||||
Trigger: 409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.
|
||||
|
||||
Copy: Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)
|
||||
|
||||
### Delete role: move failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure on reassign. The chosen role is kept.
|
||||
|
||||
Copy: Couldn't move the members · Nothing was changed. Try again in a moment.
|
||||
|
||||
### Invite: invalid email (Available now)
|
||||
|
||||
Trigger: 400 or client check. Shown under Email.
|
||||
|
||||
Copy: Enter a valid email address.
|
||||
|
||||
### Invite: address already has an account (409) (Available now)
|
||||
|
||||
Trigger: The address belongs to a user. Shown under Email. Same text as the API.
|
||||
|
||||
Copy: This address already has an account.
|
||||
|
||||
### Invite: role above yours (403) (Available now)
|
||||
|
||||
Trigger: An admin picked a role with more power than their own. Shown under Role.
|
||||
|
||||
Copy: Your role can't assign a role with more power than yours.
|
||||
|
||||
### Invite: send failed (500) (Available now)
|
||||
|
||||
Trigger: 500 or network failure. Email and role are kept.
|
||||
|
||||
Copy: Couldn't send the invite · Try again in a moment.
|
||||
|
||||
## 14 Read = Own: what the person sees
|
||||
|
||||
Route: /articles, /content/:key, /settings/roles/:id · API: GET /v1/article/, GET /v1/content-type/{key}/entry · Auth: As the role
|
||||
|
||||
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people's rows are still listed with a View only mark.
|
||||
|
||||
### Role editor: Read set to Own (Available now)
|
||||
|
||||
Trigger: Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.
|
||||
|
||||
Copy: Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.
|
||||
|
||||
### Articles list: Read = Own (Available now)
|
||||
|
||||
Trigger: Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.
|
||||
|
||||
Copy: Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article
|
||||
|
||||
### Articles list: no drafts of their own (Available now)
|
||||
|
||||
Trigger: The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.
|
||||
|
||||
Copy: You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0
|
||||
|
||||
### Entry list: Read = Own (Available now)
|
||||
|
||||
Trigger: Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.
|
||||
|
||||
Copy: Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry
|
||||
|
||||
### Entry list: no entries of their own (Available now)
|
||||
|
||||
Trigger: The person has created none. Other people's entries exist but are not hinted at.
|
||||
|
||||
Copy: You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. · New entry
|
||||
|
||||
## 15 Role 409 conflicts: stale save, change and transfer
|
||||
|
||||
Route: /settings/roles/:id, /users · API: PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership · Auth: As the action
|
||||
|
||||
Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.
|
||||
|
||||
### Stale save: banner (Available now)
|
||||
|
||||
Trigger: 409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.
|
||||
|
||||
Copy: This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload
|
||||
|
||||
### Stale save: review differences (Available now)
|
||||
|
||||
Trigger: Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.
|
||||
|
||||
Copy: How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits
|
||||
|
||||
### Stale save: edits kept after reload (Available now)
|
||||
|
||||
Trigger: After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.
|
||||
|
||||
Copy: Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes
|
||||
|
||||
### Change role: member's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.
|
||||
|
||||
Copy: Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role
|
||||
|
||||
### Transfer ownership: target's role changed first (409) (Available now)
|
||||
|
||||
Trigger: Transfer confirmed after the target's role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.
|
||||
|
||||
Copy: Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)
|
||||
|
||||
## Tables
|
||||
|
||||
The board lists, and this file repeats, the tables below.
|
||||
@@ -480,6 +678,7 @@ The board lists, and this file repeats, the tables below.
|
||||
| Row the person can read but not edit (Own) | View only mark on the row; Edit and Delete disabled in the menu | You can only edit content you created. |
|
||||
| Settings page they can read | Read-only: locked fields, banner, Save off | Admin on General without Edit general settings |
|
||||
| Owner-only action visible to an admin | Disabled, Owner only tag | Apply update |
|
||||
| Item the role may not read (Read = Own) | Not listed and not counted. No View only mark, no hint that it exists. | Another person's draft article, or an entry created by someone else |
|
||||
|
||||
### HTTP status to copy (additions)
|
||||
|
||||
@@ -490,6 +689,9 @@ The board lists, and this file repeats, the tables below.
|
||||
| role_has_members | 409 | Role still has members | {n} members have this role. Move them to another role first. |
|
||||
| role_name_taken | 409 | Name already used | A role named "{name}" already exists. Choose a different name. |
|
||||
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
|
||||
| role_conflict (change role) | 409 | Role not changed | Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed. |
|
||||
| role_conflict (remove, transfer) | 409 | User not removed / Ownership not transferred | {user}'s role changed in the meantime, so nothing was removed (transferred). Check it and try again. |
|
||||
| role_not_found | 404 | That role no longer exists | It was deleted somewhere else, so we removed it from the list. |
|
||||
| escalation | 403 | You can't grant that | You can only grant what your role has. |
|
||||
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
|
||||
|
||||
@@ -528,8 +730,8 @@ The board lists, and this file repeats, the tables below.
|
||||
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
|
||||
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
|
||||
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
|
||||
| Version field | role.version for the conflict state (409 role_conflict). |
|
||||
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
|
||||
| Version field | role.version for the conflict state (409 role_conflict). The same 409 covers a change of role or a removal when the member's role changed first, and an ownership transfer when the target's role changed first. |
|
||||
| Activity events | role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. The Failed outcome is a field on the event, not a separate name. |
|
||||
|
||||
### Existing screens that change
|
||||
|
||||
@@ -548,6 +750,87 @@ The board lists, and this file repeats, the tables below.
|
||||
| Activity and filters | New event types, role diff in the detail panel. |
|
||||
| Error and 403 copy | Two codes and the page-level state added. |
|
||||
|
||||
### Activity events: sentences, EN and FR
|
||||
|
||||
| Event name | EN | FR |
|
||||
|---|---|---|
|
||||
| role.create | Role Contributor created | Rôle Contributor créé |
|
||||
| role.edit | Role Contributor edited | Rôle Contributor modifié |
|
||||
| role.delete | Role Intern deleted | Rôle Intern supprimé |
|
||||
| role.reassign | Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer) | 3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer) |
|
||||
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Le rôle d'Ines Duarte est passé de Writer à Content lead |
|
||||
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Propriété transférée de Marta Alves à Jonas Weber |
|
||||
|
||||
### Activity events: failed sentences, EN and FR
|
||||
|
||||
| Event name (outcome failed) | EN | FR |
|
||||
|---|---|---|
|
||||
| Template | Failed to {verb} {object} | Échec de {action} {objet} |
|
||||
| role.create | Failed to create the role Contributor | Échec de la création du rôle Contributor |
|
||||
| role.edit | Failed to edit the role Contributor | Échec de la modification du rôle Contributor |
|
||||
| role.delete | Failed to delete the role Intern | Échec de la suppression du rôle Intern |
|
||||
| role.reassign | Failed to move 3 members from Intern to Writer | Échec du déplacement de 3 membres de Intern vers Writer |
|
||||
| user.change_role | Failed to change the role of Ines Duarte | Échec du changement de rôle d'Ines Duarte |
|
||||
| user.transfer_ownership | Failed to transfer ownership to Jonas Weber | Échec du transfert de la propriété à Jonas Weber |
|
||||
| user.invite | Failed to invite sofia@renewbuild.example | Échec de l'invitation de sofia@renewbuild.example |
|
||||
| user.remove | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte |
|
||||
| Reason: role_conflict | Jonas Weber saved changes to Contributor first, so this edit was refused. | Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée. |
|
||||
| Reason: role_locked | The Owner role can't be changed or deleted. | Le rôle Owner ne peut être ni modifié ni supprimé. |
|
||||
| Reason: missing_permission | The role of this person doesn't include that permission. | Le rôle de cette personne n'inclut pas cette autorisation. |
|
||||
|
||||
### Activity filter: category names, EN and FR
|
||||
|
||||
| Value | EN | FR |
|
||||
|---|---|---|
|
||||
| (none) | All categories | Toutes les catégories |
|
||||
| articles | Articles | Articles |
|
||||
| media | Media | Médias |
|
||||
| users | Users and invites (was "Users & invites") | Utilisateurs et invitations |
|
||||
| roles (new, to confirm) | Roles | Rôles |
|
||||
| api_keys | API keys | Clés API |
|
||||
| auth | Authentication | Authentification |
|
||||
| system | System | Système |
|
||||
|
||||
### Final copy: toasts and errors, EN and FR
|
||||
|
||||
| State | EN | FR |
|
||||
|---|---|---|
|
||||
| Toast: role changed | Role changed · Ines Duarte is now Content lead, and their API keys follow the new role. | Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle. |
|
||||
| Toast: role edited elsewhere (409) | Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload | Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
|
||||
| Toast: role deleted elsewhere | That role no longer exists · It was deleted somewhere else, so we removed it from the list. | Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste. |
|
||||
| Toast: member's role changed before a change of role (409) | Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload | Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger |
|
||||
| Toast: target's role changed before a transfer (409) | Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload | Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger |
|
||||
| Toast: member's role changed before a removal (409) | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload | Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger |
|
||||
| Editor: name required | Give the role a name. | Donnez un nom au rôle. |
|
||||
| Editor: name taken (409) | A role named "Writer" already exists. Choose a different name. | Un rôle nommé « Writer » existe déjà. Choisissez un autre nom. |
|
||||
| Editor: save failed (500) | Couldn't save the role · Your changes are still here. Try again in a moment. · Try again | Impossible d'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer |
|
||||
| Editor: stale save banner (409) | This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences | Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences |
|
||||
| Editor: review differences | How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits | Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications |
|
||||
| Editor: edits kept after reload | Role reloaded · Your 2 changes are still here. Review them, then save. | Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez. |
|
||||
| Editor: Read = Own note | Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. | Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle. |
|
||||
| Change role: member's role changed (409) | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. | Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez. |
|
||||
| Change role: failed (500) | Couldn't change the role · Nothing was changed. Try again in a moment. | Impossible de modifier le rôle · Rien n'a été modifié. Réessayez dans un instant. |
|
||||
| Change role: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
|
||||
| Delete role: target deleted | Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to. | Writer n'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres. |
|
||||
| Delete role: role gained members (409) | Role still has members · Intern now has 1 member. Move them to another role first. | Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle. |
|
||||
| Delete role: move failed (500) | Couldn't move the members · Nothing was changed. Try again in a moment. | Impossible de déplacer les membres · Rien n'a été modifié. Réessayez dans un instant. |
|
||||
| Invite: invalid email | Enter a valid email address. | Saisissez une adresse e-mail valide. |
|
||||
| Invite: account exists (409) | This address already has an account. | Cette adresse a déjà un compte. |
|
||||
| Invite: role above yours | Your role can't assign a role with more power than yours. | Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre. |
|
||||
| Invite: send failed (500) | Couldn't send the invite · Try again in a moment. | Impossible d'envoyer l'invitation · Réessayez dans un instant. |
|
||||
| Transfer: target's role changed (409) | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member | Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre |
|
||||
|
||||
### Read = Own: what each surface returns
|
||||
|
||||
| Surface | Read = All | Read = Own |
|
||||
|---|---|---|
|
||||
| Articles list | Every article, including drafts by others | Online articles plus the person's own drafts |
|
||||
| Status counts (All, Online, Draft) | Counted over every article | Counted over the visible articles only: Draft counts own drafts, Online counts every online article |
|
||||
| Custom type entry list | Every entry | Only entries the person created. The count line and pager use that number. |
|
||||
| API list calls and X-Total-Count | Every item | Only the visible items. The total does not reveal hidden ones. |
|
||||
| Direct link to a hidden item (proposed) | Opens | The existing 404 page: Entry not found. No hint that the item exists. |
|
||||
| Empty state | Existing empty states | You have no drafts · You haven't created an event yet. New article or New entry stays available when Create is on. |
|
||||
|
||||
### Decisions to confirm
|
||||
|
||||
| # | Decision made | Alternative |
|
||||
@@ -555,11 +838,10 @@ The board lists, and this file repeats, the tables below.
|
||||
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
|
||||
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
|
||||
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
|
||||
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
|
||||
| 4 | Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted. | Hide other people's entries for Update and Delete too. |
|
||||
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
|
||||
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
|
||||
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
|
||||
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
|
||||
|
||||
|
||||
State count: 61.
|
||||
State count: 90.
|
||||
@@ -4,7 +4,7 @@ All states on this page are **Later**: relations, permissions and schema export
|
||||
|
||||
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Relations", "Permissions", "Export and import". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
|
||||
|
||||
### 7 Relation field and picker (group "7 Relations", 3 states)
|
||||
### 7 Relation field and picker (group "7 Relations", 4 states)
|
||||
|
||||
Route: `/schema/:key, /content/:key/:id`. API: not built. Auth: Owner or admin (picker), any verified user (form).
|
||||
|
||||
@@ -13,8 +13,9 @@ All later. The model reads as two sentences, with a live preview of both sides.
|
||||
| # | State | Trigger | Copy | Scope |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Relation in the builder | A seventh type, Relation, in the type gallery. | Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events" | Later |
|
||||
| 2 | Relation in the entry form | Search and pick; chips for many. | Venue · Speakers · Search to add · An event can have many speakers. | Later |
|
||||
| 3 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later |
|
||||
| 2 | Relation in the entry form | Search and pick; chips for many. The picker loads 25 results per page (sample: 25 of 62 venues for "rue"); the list scrolls and ends with a footer. | Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more | Later |
|
||||
| 3 | Relation picker: loading more | "Load more" pressed. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, the footer count stays. Focus stays on the button, then moves to the first new row. | Showing 25 of 62 · Loading… · (skeleton rows) | Later |
|
||||
| 4 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later |
|
||||
|
||||
### 8 Permissions grid (group "8 Permissions", 2 states)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.</span>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it.</span>
|
||||
<dc-import name="CodeSnippet" tabs="{{ authSnips }}" hint-size="100%,160px"></dc-import>
|
||||
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
|
||||
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
|
||||
@@ -191,7 +191,7 @@ class Component extends DCLogic {
|
||||
const i404 = [404, 'Invite not found']
|
||||
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const j = o => JSON.stringify(o, null, 2)
|
||||
const eAd = [403, 'Only owners and admins can do this.'], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
|
||||
const eAd = [403, 'missing_permission', true], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
|
||||
const Pp = s => s.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t)
|
||||
const F = (key, label, type, o = {}) => ({ key, label, type, required: false, unique: false, default: null, min: null, max: null, options: null, relation: null, ...o })
|
||||
const evF = [F('title', 'Title', 'text', { required: true, min: 3, max: 120 }), F('starts_on', 'Starts on', 'date', { required: true }), F('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }), F('online', 'Online', 'boolean'), F('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }), F('cover', 'Cover', 'media')]
|
||||
@@ -215,12 +215,12 @@ class Component extends DCLogic {
|
||||
A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }),
|
||||
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
|
||||
] },
|
||||
ctypes: { title: 'Content types', desc: 'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.', note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.', ft: 1, snip: [{ k: 'field', label: 'Field object', code: j(evF[2]) }], eps: [
|
||||
ctypes: { title: 'Content types', desc: 'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.', note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.', ft: 1, snip: [{ k: 'field', label: 'Field object', code: j(evF[2]) }], eps: [
|
||||
A('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', 1, [e401], [], { r: j([{ ...evT, fields: evF.slice(0, 3) }]) }),
|
||||
A('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', 1, [e401, ctnf], [], { r: j(evT) }),
|
||||
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', 'Owner or admin', 1, [[400, mm], e401, eAd, [409, 'content_type_key_taken', true]], [S('key', 'string', 'Yes', '2 to 40 characters. Lowercase letters, digits and hyphens. Can\'t be changed later.'), S('name', 'string', 'Yes', 'Up to 100 characters.'), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { key: 'events', name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
|
||||
A('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change.', 'Owner or admin', 1, [[400, mm], e401, eAd, ctnf, [409, 'schema_incompatible', true, 'Some existing entries don\'t fit the new fields. Nothing was changed.']], [S('name', 'string', 'Yes', nameRule), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
|
||||
A('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it.', 'Owner or admin', 1, [e401, eAd, ctnf, [409, 'content_type_has_entries', true], [409, 'content_type_referenced', true]], [], { r: '204 No Content' })
|
||||
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, [409, 'content_type_key_taken', true]], [S('key', 'string', 'Yes', '2 to 40 characters. Lowercase letters, digits and hyphens. Can\'t be changed later.'), S('name', 'string', 'Yes', 'Up to 100 characters.'), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { key: 'events', name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
|
||||
A('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".', 'Needs permission', 1, [[400, mm], e401, eAd, ctnf, [409, 'schema_incompatible', true, 'Some existing entries don\'t fit the new fields. Nothing was changed.']], [S('name', 'string', 'Yes', nameRule), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
|
||||
A('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".', 'Needs permission', 1, [e401, eAd, ctnf, [409, 'content_type_has_entries', true], [409, 'content_type_referenced', true]], [], { r: '204 No Content' })
|
||||
] },
|
||||
entries: { title: 'Entries', desc: 'Read and write the content of a custom type. Any verified user can write entries.', note: 'An entry\'s `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).', snip: [{ k: 'entry', label: 'Entry', code: j(ent) }], eps: [
|
||||
A('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', 1, [[400, 'Invalid page, size or sort.'], e401, ctnf], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.'), S('sort', 'string', 'No', 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.')], { qp: 1, qs: '?page=1&size=25&sort=updated_at:desc', r: j({ items: [ent], total: 1, page: 1, size: 25 }) }),
|
||||
@@ -230,8 +230,8 @@ class Component extends DCLogic {
|
||||
A('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', 1, [e401, enf, ctnf], [], { r: '204 No Content' })
|
||||
] },
|
||||
builtin: { title: 'Built-in types', desc: 'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.', note: 'Built-in types are read-only: they cannot be changed through the API.', eps: [
|
||||
A('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', 'Owner or admin', 1, [e401, eAd], [], { r: j([{ key: 'article', name: 'Articles', fields: btA }, { key: 'author', name: 'Authors', fields: btU }]) }),
|
||||
A('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'Owner or admin', 1, [e401, eAd, [404, 'builtin_type_not_found', true]], [], { r: j({ key: 'author', name: 'Authors', fields: btU }) })
|
||||
A('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd], [], { r: j([{ key: 'article', name: 'Articles', fields: btA }, { key: 'author', name: 'Authors', fields: btU }]) }),
|
||||
A('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".', 'Needs permission', 1, [e401, eAd, [404, 'builtin_type_not_found', true]], [], { r: j({ key: 'author', name: 'Authors', fields: btU }) })
|
||||
] },
|
||||
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
||||
A('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||
@@ -240,10 +240,10 @@ class Component extends DCLogic {
|
||||
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
|
||||
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
|
||||
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
|
||||
A('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', 'Owner or admin', 1, [e401, eAd], [], { r: j({ auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
|
||||
A('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Owner or admin', 1, [[400, mm], e401, eAd], [S('auto_purge_enabled', 'boolean', 'No', 'Turns the automatic purge on or off.'), S('auto_purge_interval_days', 'integer', 'No', '1 to 365. Days between automatic purges.')], { body: { auto_purge_enabled: true, auto_purge_interval_days: 14 }, r: j({ auto_purge_enabled: true, auto_purge_interval_days: 14, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
|
||||
A('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete.', 'Owner or admin', 1, [e401, eAd], [], { r: j({ fingerprint: 'c41f9a7e', count: 2, items: [{ id: 9, filename: 'old-wall.jpg' }, { id: 11, filename: 'old-wall-2.jpg' }] }) }),
|
||||
A('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged.', 'Owner or admin', 1, [e401, eAd], [S('fingerprint', 'string', 'No', 'Optional. Taken from purge-preview.')], { body: { fingerprint: 'c41f9a7e' }, r: '{ "groups_total": 3, "groups_purged": 2, "merged": 2, "articles_updated": 5, "sites_updated": 0, "authors_updated": 1, "failed_groups": 1 }' })
|
||||
A('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images. Needs the permission "View settings".', 'Needs permission', 1, [e401, eAd], [], { r: j({ auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
|
||||
A('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images. Needs the permission "Edit general settings".', 'Needs permission', 1, [[400, mm], e401, eAd], [S('auto_purge_enabled', 'boolean', 'No', 'Turns the automatic purge on or off.'), S('auto_purge_interval_days', 'integer', 'No', '1 to 365. Days between automatic purges.')], { body: { auto_purge_enabled: true, auto_purge_interval_days: 14 }, r: j({ auto_purge_enabled: true, auto_purge_interval_days: 14, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
|
||||
A('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [], { r: j({ fingerprint: 'c41f9a7e', count: 2, items: [{ id: 9, filename: 'old-wall.jpg' }, { id: 11, filename: 'old-wall-2.jpg' }] }) }),
|
||||
A('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".', 'Needs permission', 1, [e401, eAd], [S('fingerprint', 'string', 'No', 'Optional. Taken from purge-preview.')], { body: { fingerprint: 'c41f9a7e' }, r: '{ "groups_total": 3, "groups_purged": 2, "merged": 2, "articles_updated": 5, "sites_updated": 0, "authors_updated": 1, "failed_groups": 1 }' })
|
||||
] },
|
||||
sites: { title: 'Sites and authors', desc: 'Read the sites and authors your content belongs to. Creating and changing them needs a session.', eps: [
|
||||
A('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', 1, [e401], [], { r: j([{ id: 1, key: 'renew', name: 'Renew', default: true }]) }),
|
||||
@@ -283,7 +283,7 @@ class Component extends DCLogic {
|
||||
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||
const cp = this.state.copied === e.id
|
||||
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
|
||||
const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Owner or admin' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
|
||||
const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Needs permission' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
|
||||
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips,
|
||||
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
|
||||
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
|
||||
@@ -323,7 +323,7 @@ class Component extends DCLogic {
|
||||
errRows: [
|
||||
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
|
||||
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
|
||||
{ c: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this.", "Only owners and admins can do this." or "You must verify your account to access this resource."' },
|
||||
{ c: 403, m: 'Forbidden', n: '`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), `missing_permission` (the role of the user, or of the key\'s creator, lacks the permission the route needs) or "You must verify your account to access this resource."' },
|
||||
{ c: 404, m: 'Not found', n: 'Text in code style is the literal body of the response, not a sentence.', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Content types and entries answer `content_type_not_found` or `entry_not_found`. Drafts answer 404 to anonymous callers.' },
|
||||
{ c: 409, m: 'Conflict', w: 'Content types: `content_type_key_taken`, `content_type_has_entries`, `content_type_referenced` or `schema_incompatible`. Entries: \`invalid_entry: <reason>\`, only when a unique field clashes. Media and users: "Some of the images to merge changed. Refresh and try again." or "This address already has an account." Articles: `slug_taken`.' },
|
||||
{ c: 422, m: 'Unprocessable', w: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.' },
|
||||
|
||||
@@ -81,14 +81,15 @@ class Component extends DCLogic {
|
||||
writer: { name: 'Writer', desc: 'Reads everything and writes content. Can\'t delete or publish.', built: 1, members: 5, g: { schema: '100', media: '11n', authors: '10o0', activity: 'o0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1ann', t: mkT('a1ann') },
|
||||
contrib: { name: 'Contributor', desc: 'Creates content and edits only their own.', members: 4, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'a1onn', t: mkT('a1onn', { 'Case studies': 'a0nnn' }) },
|
||||
lead: { name: 'Content lead', desc: 'Edits and publishes all content.', members: 1, g: { schema: '100', media: '11a', authors: '10a0', activity: 'a1', users: '1000', settings: '100n000', roles: '100', system: '000' }, all: 'a1aaa', t: mkT('a1aaa') },
|
||||
junior: { name: 'Junior writer', desc: 'Writes articles and reads online ones and their own drafts.', members: 2, g: { schema: '100', media: '11n', authors: '10o0', activity: 'n0', users: '0000', settings: '000n000', roles: '000', system: '000' }, all: 'o1onn', t: mkT('o1onn') },
|
||||
blank: { name: '', desc: '', members: 0, g: Object.fromEntries(GEN.map(g => [g.id, g.items.map(it => it[3] === 's' ? 'n' : '0').join('')])), all: 'n0nnn', t: mkT('n0nnn') }
|
||||
}
|
||||
const ON = c => c !== '0' && c !== 'n'
|
||||
const cnt = (R, g) => R.g[g.id].split('').filter(ON).length
|
||||
const j = a => a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]
|
||||
const phr = c => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [f(c[0], 'read'), c[1] === '1' ? 'create entries' : null, f(c[2], 'edit'), f(c[3], 'delete'), f(c[4], 'publish')]; const cl = can.filter(Boolean), no = ['read', 'create', 'edit', 'delete', 'publish'].filter((x, i) => !can[i]); return cl.length ? 'Can ' + j(cl) + '.' + (no.length ? ' Can\'t ' + j(no) + '.' : '') : 'No access to entries.' }
|
||||
const phr = (c, art) => { const f = (ch, vb) => ch === 'a' ? vb + ' any entry' : ch === 'o' ? vb + ' only their own' : null; const can = [c[0] === 'o' ? (art ? 'read online articles and their own drafts' : 'read only entries they created') : f(c[0], 'read'), c[1] === '1' ? 'create entries' : null, f(c[2], 'edit'), f(c[3], 'delete'), f(c[4], 'publish')]; const cl = can.filter(Boolean), no = ['read', 'create', 'edit', 'delete', 'publish'].filter((x, i) => !can[i]); return cl.length ? 'Can ' + j(cl) + '.' + (no.length ? ' Can\'t ' + j(no) + '.' : '') : 'No access to entries.' }
|
||||
const pv = R => {
|
||||
const groups = {}; TY.forEach(t => { const p = phr(R.t[t]); (groups[p] = groups[p] || []).push(t) })
|
||||
const groups = {}; TY.forEach(t => { const p = phr(R.t[t], t === 'Articles'); (groups[p] = groups[p] || []).push(t) })
|
||||
const content = Object.entries(groups).map(([p, ts]) => ({ pre: ts.length === TY.length ? 'All content types' : j(ts), txt: p }))
|
||||
const other = [], none = []
|
||||
GEN.forEach(g => { const say = []; g.items.forEach((it, i) => { const ch = R.g[g.id][i]; if (it[3] === 'o' || !ON(ch)) return; say.push(it[3] === 's' ? (ch === 'o' ? it[2] + ', only their own' : it[2]) : it[2]) }); if (say.length) other.push({ pre: g.name, txt: 'Can ' + j(say) + '.' }); else if (g.id !== 'system') none.push(g.name) })
|
||||
@@ -119,17 +120,18 @@ class Component extends DCLogic {
|
||||
const chg = o.chg || [], esc = o.esc, base = R.all
|
||||
const uni = i => TY.every(t => R.t[t][i] === R.t[TY[0]][i]) ? R.t[TY[0]][i] : null
|
||||
const cell = (t, i, v) => { const a = AL[i], key = t + '.' + a, bl = esc && a === 'publish' ? 'a' : ''; const ctl = i === 1 ? OnOff(v === '1', { dis: o.ro }) : Tri(v, { dis: o.ro, block: bl, ring: o.ring === key }); return D('position:relative;display:flex;align-items:center', chg.includes(key) && S('position:absolute;left:-8px;top:50%;margin-top:-3px;width:6px;height:6px;background:var(--ui-info)'), ctl) }
|
||||
const rdNote = TY.some(t => R.t[t][0] === 'o') && Alert('info', 'info', 'Read: Own', 'Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.')
|
||||
const tcol = '118px 84px 118px 118px 118px'
|
||||
const nameCell = (t, custom) => D('display:flex;flex-direction:column;gap:2px;min-width:0', D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', S('font-weight:500;color:var(--ui-text-highlighted)', t), t === 'Articles' && Bd('Built-in', 'built', 'lock'), custom && Bd('Custom', 'warn'), custom && Link('Reset')), t === 'Articles' && S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Schema locked'))
|
||||
const presets = D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('color:var(--ui-text-muted);margin-right:4px', 'Quick presets'), Pill('Read only', base === 'a0nnn'), Pill('Write own', base === 'a1onn'), Pill('Full content', base === 'a1aaa'))
|
||||
if (m) {
|
||||
const card = (t, v, custom, all) => D('border:1px solid var(--ui-border);padding:12px;display:flex;flex-direction:column;gap:10px;background:' + (all ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), all ? S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types') : nameCell(t, custom), ...AL.map((a, i) => D('display:flex;align-items:center;gap:10px', S('width:64px;color:var(--ui-text-toned);flex-shrink:0', ALL[a]), i === 1 ? OnOff(v[i] === '1', { dis: o.ro }) : Tri(v[i], { fill: true, dis: o.ro, block: esc && a === 'publish' ? 'a' : '' }))))
|
||||
return D('display:flex;flex-direction:column;gap:10px', presets, card('', base, false, true), ...TY.map(t => card(t, R.t[t], R.t[t] !== base)))
|
||||
return D('display:flex;flex-direction:column;gap:10px', presets, card('', base, false, true), ...TY.map(t => card(t, R.t[t], R.t[t] !== base)), rdNote)
|
||||
}
|
||||
const hd = D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;align-items:center', S('', 'Content type'), ...AL.map(a => S('display:flex;align-items:center;gap:4px', ALL[a], !o.ro && I('chevron-down', 12))))
|
||||
const row = (t, v, custom, all) => D('display:grid;grid-template-columns:minmax(120px,1fr) ' + tcol + ';gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);align-items:center;background:' + (all ? 'var(--ui-bg-muted)' : 'var(--ui-bg)'), all ? D('display:flex;flex-direction:column', S('font-weight:600;color:var(--ui-text-highlighted)', 'All content types'), S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Sets every row')) : nameCell(t, custom), ...AL.map((a, i) => v[i] === null ? D('display:flex;align-items:center;gap:6px', Tri(null, { dis: o.ro }), Bd('Mixed', 'warn')) : cell(t, i, v[i])))
|
||||
const allv = AL.map((a, i) => uni(i))
|
||||
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative', hd, row('', allv, false, true), ...TY.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip)))
|
||||
return D('display:flex;flex-direction:column;gap:12px', !o.ro && presets, D('border:1px solid var(--ui-border);position:relative', hd, row('', allv, false, true), ...TY.map(t => row(t, R.t[t], R.t[t] !== base, false)), o.tip && Tip2(o.tip)), rdNote)
|
||||
}
|
||||
const Tip2 = t => D('position:absolute;z-index:30;right:8px;top:78px;width:272px;padding:10px 12px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
|
||||
// ---------- general permissions
|
||||
@@ -167,7 +169,7 @@ class Component extends DCLogic {
|
||||
const RL = ['owner', 'admin', 'writer', 'contrib', 'lead']
|
||||
const k = s.k, v = s.v
|
||||
if (v === 'list') {
|
||||
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px', noEdit = k === 'noedit', rows = k === 'empty' ? RL.slice(0, 3) : RL
|
||||
crumbs('Settings', 'Roles'); const cols = '200px minmax(0,1fr) 90px 100px 40px', noEdit = k === 'noedit', rows = k === 'empty' ? RL.slice(0, 3) : k === 'tgone' ? RL.filter(r => r !== 'contrib') : RL
|
||||
const sk = (w) => Skel(w, 14)
|
||||
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Role', 'Description', 'Members', 'Type', ''),
|
||||
...(k === 'loading' ? [1, 2, 3, 4, 5].map(() => Tr(cols, {}, sk('120px'), sk('70%'), sk('24px'), sk('64px'), sk('16px'))) : rows.map((r, i) => { const R = ROLES[r]; return Tr(cols, { sel: k === 'menu' && i === 3 }, D('display:flex;align-items:center;gap:8px', Hi(R.name), R.lock && Bd('Locked', 'lock', 'lock')), Mu(R.desc), S('', R.members), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own'), Dots(k === 'menu' && i === 3 || k === 'menub' && i === 2)) })),
|
||||
@@ -180,6 +182,8 @@ class Component extends DCLogic {
|
||||
noEdit && Banner('info', 'lock', 'You can see roles but not change them', 'Ask an owner or admin if a role needs to change.'),
|
||||
k === 'empty' && Banner('info', 'info', 'No custom roles yet', 'The three built-in roles cover most sites. Create a role when someone needs something in between.', 'New role'),
|
||||
list, m && Fab('New role'))
|
||||
if (k === 'tedit') ov.push(Toast('warning', 'triangle-alert', 'Role changed elsewhere', 'Jonas Weber edited Contributor, so nothing was saved. Reload and try again.', 'Reload'))
|
||||
if (k === 'tgone') ov.push(Toast('info', 'info', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.'))
|
||||
} else if (v === 'editor') {
|
||||
const flow = s.flow
|
||||
let R = ROLES.contrib, o = {}
|
||||
@@ -200,6 +204,10 @@ class Component extends DCLogic {
|
||||
else if (k === 'general') { o = { onlyGeneral: false, scroll: 360, open: ['activity', 'users', 'settings'], showHead: false } ; R = ROLES.lead }
|
||||
else if (k === 'owneronly') { shell.display = 'Jonas Weber'; shell.role = 'admin'; R = ROLES.admin; o = { scroll: 300, open: ['roles', 'system'], showHead: false, ro: false, alerts: null } }
|
||||
else if (k === 'custom-row') { R = ROLES.contrib; o = { open: [], chg: ['Case studies.read'], pvChg: false } }
|
||||
else if (k === 'readown') { R = ROLES.junior; crumbs('Settings', 'Roles', 'Junior writer'); o = { open: [], ring: 'Articles.read' } }
|
||||
else if (k === 'review') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], alerts: [Banner('warning', 'triangle-alert', 'This role changed while you were editing', 'Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits.', 'Reload role', 'Review differences')], bar: SaveBar(2, { sdis: true, msg: '2 unsaved changes. Save is off until you reload' }), over: Shell({ title: 'How the saved role differs from your edits', desc: 'Jonas Weber saved changes to Contributor at 11:02.', close: true, cancel: 'Use the saved role', confirm: 'Keep my edits', top: 100, w: 520 }, D('border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:minmax(0,1fr) 72px 72px;gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Setting'), S('', 'Saved'), S('', 'Yours')), ...[['Events · Update', 'Own', 'All'], ['Events · Delete', 'None', 'Own']].map(r => D('display:grid;grid-template-columns:minmax(0,1fr) 72px 72px;gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)', S('font-weight:500', r[0]), S('', r[1]), S('font-weight:600', r[2]))))) } }
|
||||
else if (k === 'kept') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], pvChg: true, bar: SaveBar(2), over: Toast('info', 'info', 'Role reloaded', 'Your 2 changes are still here. Review them, then save.') } }
|
||||
else if (k === 'saveerr') { R = { ...R, t: { ...R.t, Events: 'a1aan' } }; o = { open: [], chg: ['Events.update', 'Events.delete'], alerts: [Banner('error', 'circle-x', 'Couldn\'t save the role', 'Your changes are still here. Try again in a moment.', 'Try again')], bar: SaveBar(2) } }
|
||||
else if (flow) {
|
||||
const st = flow
|
||||
if (st === 2) { R = { ...ROLES.blank, name: 'Contributor', desc: 'Writes posts and edits only their own' }; o = { isNew: true, focus: 'desc', open: [], bar: SaveBar(2) }; shell.mTitle = 'New role' }
|
||||
@@ -229,25 +237,43 @@ class Component extends DCLogic {
|
||||
if (k === 'blocked' || k === 'picker') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: true, tone: 'danger', top: 140 }, [pick(null, { open: k === 'picker' }), k === 'picker' && D('border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);background:var(--ui-bg);margin-top:-8px', rp('Writer', 'Reads everything and writes content. Can\'t delete or publish.'), rp('Content lead', 'Edits and publishes all content.'), rp('Admin', 'Everything except owner-only settings.', true, 'Your role can\'t assign a role with more power than yours.'))]))
|
||||
if (k === 'confirm') ov.push(Shell({ title: 'Delete Intern?', desc: 'No one has this role. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete role', tone: 'danger', top: 140 }, [Mu('Deleting a role doesn\'t affect content.')]))
|
||||
if (k === 'builtin') ov.push(Shell({ title: 'Delete Writer?', desc: 'Built-in roles can\'t be deleted.', close: true, cancel: 'Close', confirm: 'Delete role', cdis: true, tone: 'danger', top: 140 }, [Banner('info', 'lock', 'Writer is built in', 'Duplicate it to make a custom role you can change or delete.', 'Duplicate role')]))
|
||||
const pk = (R2, o2) => pick(R2, o2)
|
||||
if (k === 'gone') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: true, tone: 'danger', top: 100 }, [Alert('error', 'circle-x', 'Writer no longer exists', 'It was deleted somewhere else. Choose another role to move the members to.'), pk(null, {})]))
|
||||
if (k === 'newmem') ov.push(Shell({ title: 'Delete Intern?', close: true, cancel: 'Cancel', confirm: 'Move 1 member and delete role', cdis: true, tone: 'danger', top: 100 }, [Alert('warning', 'triangle-alert', 'Role still has members', 'Intern now has 1 member. Move them to another role first.'), pk(null, {})]))
|
||||
if (k === 'moveerr') ov.push(Shell({ title: 'Delete Contributor?', desc: '4 members have this role. Move them to another role first. Then Contributor can be deleted.', close: true, cancel: 'Cancel', confirm: 'Move 4 members and delete role', cdis: false, tone: 'danger', top: 100 }, [Alert('error', 'circle-x', 'Couldn\'t move the members', 'Nothing was changed. Try again in a moment.'), pk(null, { val: 'Writer' })]))
|
||||
} else if (v === 'users') {
|
||||
shell.nav = 'users'; shell.sideOpen = false; crumbs('Users'); const cols = 'minmax(0,1fr) minmax(0,1.2fr) 150px 110px 40px'
|
||||
const U = [['Marta Alves', 'marta@renewbuild.example', 'Owner', 'Active'], ['Jonas Weber', 'jonas@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active'], ['Pedro Lima', 'pedro@renewbuild.example', 'Contributor', 'Active'], ['sofia@renewbuild.example', 'Invited', 'Content lead', 'Pending']]
|
||||
if (k === 'rolechanged') U[2][2] = 'Content lead'
|
||||
const rows = U.map((u, i) => i === 4 ? [u[0], '', u[2], 'Invite pending'] : u)
|
||||
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border);position:relative', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...rows.map((u, i) => Tr(cols, { sel: k === 'menu' && i === 2 }, Hi(u[0]), Mu(u[1]), D('display:flex;align-items:center;gap:6px', RB(u[2]), i === 0 && I('lock', 14, 'color:var(--ui-text-muted)')), Mu(u[3]), Dots(k === 'menu' && i === 2 || k === 'ownermenu' && i === 0))),
|
||||
k === 'menu' && Menu(24, 190, [['user-cog', 'Change role', false, true], ['mail', 'Send password reset'], ['user-x', 'Remove user', false, false, true]]),
|
||||
k === 'ownermenu' && Menu(24, 96, [['user-cog', 'Change role', true], ['crown', 'Transfer ownership', false, true], ['user-x', 'Remove user', true]]), k === 'ownermenu' && D('position:absolute;z-index:31;right:260px;top:140px;width:250px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', 'The owner can\'t be changed or removed. Transfer ownership first.'))
|
||||
const cards = D('display:flex;flex-direction:column;gap:8px', ...rows.map(u => Cd({}, D('display:flex;gap:8px;align-items:center', Hi(u[0]), D('flex:1'), IB('ellipsis')), u[1] && Mu(u[1]), D('display:flex;gap:8px;align-items:center', RB(u[2]), Mu(u[3])))))
|
||||
main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14 })), m ? cards : tbl, m && Fab('Invite user'))
|
||||
const radio = (n, sum, on, dis, why) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 56 : 52) + 'px', S('margin-top:2px;width:16px;height:16px;flex-shrink:0;border:1px solid var(--ui-border-accented);border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ui-bg)', on && S('width:8px;height:8px;border-radius:50%;background:var(--ui-primary)')), D('flex:1;display:flex;flex-direction:column;gap:1px', D('display:flex;gap:6px;align-items:center', S('font-weight:600', n), dis && I('lock', 12), on && S('font-size:12px;font-weight:500', '· Current')), S('font-size:12px;line-height:18px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), dis ? why : sum)))
|
||||
const radio = (n, sum, on, dis, why, sel) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid ' + (on || sel ? 'var(--ui-primary)' : 'var(--ui-border)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.5 : 1) + ';min-height:' + (m ? 56 : 52) + 'px', S('margin-top:2px;width:16px;height:16px;flex-shrink:0;border:1px solid var(--ui-border-accented);border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ui-bg)', (on || sel) && S('width:8px;height:8px;border-radius:50%;background:var(--ui-primary)')), D('flex:1;display:flex;flex-direction:column;gap:1px', D('display:flex;gap:6px;align-items:center', S('font-weight:600', n), dis && I('lock', 12), on && S('font-size:12px;font-weight:500', '· Current')), S('font-size:12px;line-height:18px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), dis ? why : sum)))
|
||||
const SUM = { Owner: ROLES.owner.desc, Admin: ROLES.admin.desc, Writer: ROLES.writer.desc, Contributor: ROLES.contrib.desc, 'Content lead': ROLES.lead.desc }
|
||||
if (k === 'change') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Her API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead']))))
|
||||
if (k === 'changeblock') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Her API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin, false, false), radio('Owner', SUM.Owner, false, true, 'There is one owner. Transfer ownership to change who it is.'), radio('Writer', SUM.Writer, true), radio('Content lead', SUM['Content lead'], false, true, 'It can publish. Your role can\'t grant that.')))) }
|
||||
if (k === 'change') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead']))))
|
||||
if (k === 'changeblock') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', cdis: true, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Admin', SUM.Admin, false, false), radio('Owner', SUM.Owner, false, true, 'There is one owner. Transfer ownership to change who it is.'), radio('Writer', SUM.Writer, true), radio('Content lead', SUM['Content lead'], false, true, 'It can publish. Your role can\'t grant that.')))) }
|
||||
if (k === 'invite') ov.push(Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 120 }, [F('Email', 'sofia@renewbuild.example', {}), F('Role', 'Writer', { sel: true, help: SUM.Writer }), Mu('Writer is the default. You can change a role later.')]))
|
||||
const chOpts = [radio('Admin', SUM.Admin, true), radio('Writer', SUM.Writer), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead'], false, false, '', true)]
|
||||
if (k === 'changestale') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('warning', 'triangle-alert', 'Role changed in the meantime', 'Ines Duarte is now Admin. Your choice is kept. Check it and try again.'), ...chOpts)))
|
||||
if (k === 'changeerr') ov.push(Shell({ title: 'Change role for Ines Duarte', desc: 'Their API keys follow the new role.', close: true, cancel: 'Cancel', confirm: 'Change role', top: 60 }, D('display:flex;flex-direction:column;gap:8px', Alert('error', 'circle-x', 'Couldn\'t change the role', 'Nothing was changed. Try again in a moment.'), radio('Admin', SUM.Admin), radio('Writer', SUM.Writer, true), radio('Contributor', SUM.Contributor), radio('Content lead', SUM['Content lead'], false, false, '', true))))
|
||||
const inv = (email, eerr, role, rerr, alert) => Shell({ title: 'Invite user', desc: 'They get an email with a link to set a password.', close: true, cancel: 'Cancel', confirm: 'Send invite', top: 80 }, [alert, F('Email', email, { err: eerr }), F('Role', role, { sel: true, err: rerr, help: rerr ? null : SUM[role] }), Mu('Writer is the default. You can change a role later.')].filter(Boolean))
|
||||
if (k === 'invbad') ov.push(inv('sofia@renewbuild', 'Enter a valid email address.', 'Writer'))
|
||||
if (k === 'invdup') ov.push(inv('ines@renewbuild.example', 'This address already has an account.', 'Writer'))
|
||||
if (k === 'invesc') { shell.role = 'admin'; shell.display = 'Jonas Weber'; ov.push(inv('sofia@renewbuild.example', null, 'Content lead', 'Your role can\'t assign a role with more power than yours.')) }
|
||||
if (k === 'inverr') ov.push(inv('sofia@renewbuild.example', null, 'Writer', null, Alert('error', 'circle-x', 'Couldn\'t send the invite', 'Try again in a moment.')))
|
||||
if (k === 'tr2stale') ov.push(Shell({ title: 'Transfer ownership to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 40 }, [Alert('warning', 'triangle-alert', 'Role changed in the meantime', 'Jonas Weber is now Writer. What you typed is kept. Check the change, then try again.', 'Reload member'), D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless they transfer it.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild.example', { focus: true, help: 'The email must match exactly.' })]))
|
||||
if (k === 'rolechanged') ov.push(Toast('success', 'circle-check', 'Role changed', 'Ines Duarte is now Content lead, and their API keys follow the new role.'))
|
||||
if (k === 'tstale') ov.push(Toast('warning', 'triangle-alert', 'Role not changed', 'Ines Duarte\'s role changed in the meantime, so nothing was saved. Reload and try again.', 'Reload'))
|
||||
if (k === 'ttr') ov.push(Toast('warning', 'triangle-alert', 'Ownership not transferred', 'Jonas Weber\'s role changed in the meantime, so nothing was transferred. Check it and try again.', 'Reload'))
|
||||
if (k === 'trem') ov.push(Toast('warning', 'triangle-alert', 'User not removed', 'Ines Duarte\'s role changed in the meantime, so nothing was removed. Check it and try again.', 'Reload'))
|
||||
if (k === 'pending') { shell.display = 'Marta Alves'; ov.push(D('display:contents')) }
|
||||
if (k === 'tr1' || k === 'tr2' || k === 'tr3') {
|
||||
const tit = 'Transfer ownership'
|
||||
if (k === 'tr1') ov.push(Shell({ title: tit, desc: 'Choose who becomes the owner. Only members can be chosen.', close: true, cancel: 'Cancel', confirm: 'Continue', cdis: false, top: 100 }, D('display:flex;flex-direction:column;gap:8px', radio('Jonas Weber', 'jonas@renewbuild.example · Admin', true), radio('Ines Duarte', 'ines@renewbuild.example · Writer'), radio('Pedro Lima', 'pedro@renewbuild.example · Contributor'))))
|
||||
if (k === 'tr2') ov.push(Shell({ title: tit + ' to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 80 }, [D('display:flex;flex-direction:column;gap:6px', ...['Jonas Weber becomes the owner.', 'You become an Admin. You can\'t take ownership back unless he transfers it.', 'Only the owner can apply updates, delete a site and change activity retention.', 'Your API keys follow your new role.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild', { focus: true, help: 'The email must match exactly.' })]))
|
||||
if (k === 'tr2') ov.push(Shell({ title: tit + ' to Jonas Weber', desc: 'This takes effect straight away.', close: true, cancel: 'Back', confirm: 'Transfer ownership', cdis: true, tone: 'danger', top: 80 }, [D('display:flex;flex-direction:column;gap:6px', ...['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.', 'Your API keys follow your new role.'].map(t => D('display:flex;gap:8px', IC('arrow-right'), S('text-wrap:pretty;color:var(--ui-text-highlighted)', t)))), F('Type jonas@renewbuild.example to confirm', 'jonas@renewbuild', { focus: true, help: 'The email must match exactly.' })]))
|
||||
if (k === 'tr3') { main = Page(m ? '16px 16px 88px' : '24px', Title('Users', '5 members and invites', B('Invite user', { k: 'primary', icon: 'user-plus', px: 14, dis: false })), m ? cards : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Name', 'Email', 'Role', 'Status', ''), ...[['Jonas Weber', 'jonas@renewbuild.example', 'Owner', 'Active'], ['Marta Alves', 'marta@renewbuild.example', 'Admin', 'Active'], ['Ines Duarte', 'ines@renewbuild.example', 'Writer', 'Active']].map(u => Tr(cols, {}, Hi(u[0]), Mu(u[1]), RB(u[2]), Mu(u[3]), Dots())))); shell.role = 'admin'; ov.push(Toast('success', 'circle-check', 'Ownership transferred', 'Jonas Weber is now the owner. You are an Admin.')) }
|
||||
}
|
||||
} else if (v === 'shell') {
|
||||
@@ -281,6 +307,26 @@ class Component extends DCLogic {
|
||||
const r = (ic, t, ex) => D('display:flex;gap:8px;align-items:flex-start', IC(ic), D('display:flex;flex-direction:column;gap:2px', Hi(t), Mu(ex)))
|
||||
main = Page(m ? '16px' : '24px', Title('Home', 'Pedro Lima · Contributor'), D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:16px', col('Hidden', 'The person can never use it.', r('eye-off', 'Sections of the sidebar', 'Schema, Users, Integration and Settings are not listed.'), r('eye-off', 'Buttons for another area', 'No Publish button when the role can\'t publish.')), col('Disabled, with a reason', 'The person sees the thing but can\'t act on it.', r('ban', 'Delete on an entry they can see', 'Your role can\'t delete articles.'), r('ban', 'Edit on someone else\'s entry', 'You can only edit content you created.')), col('Read-only', 'The person may look but not change.', r('lock', 'Settings pages for an admin', 'A banner says who can edit.'))))
|
||||
}
|
||||
} else if (v === 'rdown') {
|
||||
shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false
|
||||
const ent = k === 'ent' || k === 'ent0'
|
||||
shell.nav = ent ? 'content' : 'articles'; if (ent) shell.contentOn = 'events'
|
||||
crumbs(...(ent ? ['Content', 'Events'] : ['Articles']))
|
||||
const Tabs = (items, on) => D('display:flex;gap:' + (m ? 16 : 24) + 'px;border-bottom:1px solid var(--ui-border);flex-shrink:0', ...items.map(([tt, n]) => D('display:flex;align-items:center;gap:6px;height:' + (m ? 44 : 36) + 'px;margin-bottom:-1px;border-bottom:2px solid ' + (on === tt ? 'var(--ui-primary)' : 'transparent') + ';color:' + (on === tt ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';font-weight:' + (on === tt ? 600 : 500), tt, S('font-size:12px;font-weight:400;color:var(--ui-text-muted)', String(n)))))
|
||||
if (!ent) {
|
||||
const cols = 'minmax(0,1fr) 140px 110px 120px 40px'
|
||||
const A = [['How we price a bathroom renovation', 'Ines Duarte', 'Online'], ['Retrofit grants in 2027', 'Jonas Weber', 'Online'], ['Winter checklist for timber windows', 'Marta Alves', 'Online'], ['Choosing lime plaster', 'Pedro Lima', 'Draft'], ['Site safety checklist', 'Pedro Lima', 'Draft']]
|
||||
const d0 = k === 'draft0'
|
||||
const data = d0 ? [] : A
|
||||
const body = d0 ? Empty('file-text', 'You have no drafts', 'Drafts you create appear here. Your role can\'t see drafts by other people.', 'New article') : m ? D('display:flex;flex-direction:column;gap:8px', ...data.map(r => Cd({}, Hi(r[0]), D('display:flex;gap:8px;align-items:center', Bd(r[2], r[2] === 'Online' ? 'ok' : 'req'), Mu(r[1]))))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Title', 'Created by', 'Status', 'Updated', ''), ...data.map(r => Tr(cols, {}, Hi(r[0]), Mu(r[1]), Bd(r[2], r[2] === 'Online' ? 'ok' : 'req'), Mu('Oct 14'), Dots())))
|
||||
main = Page(m ? '16px 16px 88px' : '24px', Title('Articles', 'Online articles and the drafts you created.', B('New article', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'eye-off', 'Drafts by others are hidden', 'Your role reads online articles and the drafts you created. The counts follow the same rule.'), Tabs(d0 ? [['All', 3], ['Online', 3], ['Draft', 0]] : [['All', 5], ['Online', 3], ['Draft', 2]], d0 ? 'Draft' : 'All'), body, m && Fab('New article'))
|
||||
} else {
|
||||
const cols = 'minmax(0,1fr) 140px 120px 40px'
|
||||
const En = [['Lime plaster workshop', '21 Nov 2026', 'Oct 14'], ['Tool library evening', '5 Dec 2026', 'Oct 12'], ['Site safety talk', '18 Dec 2026', 'Oct 9']]
|
||||
const e0 = k === 'ent0'
|
||||
const body = e0 ? Empty('files', 'You haven\'t created an event yet', 'Your role shows only entries you created, so other people\'s events aren\'t listed here. Use New entry to add one.', 'New entry') : D('display:flex;flex-direction:column;gap:8px', Mu('3 entries'), m ? D('display:flex;flex-direction:column;gap:8px', ...En.map(r => Cd({}, Hi(r[0]), Mu(r[1] + ' · updated ' + r[2])))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, 'Title', 'Starts on', 'Updated', ''), ...En.map(r => Tr(cols, {}, Hi(r[0]), Mu(r[1]), Mu(r[2]), Dots()))))
|
||||
main = Page(m ? '16px 16px 88px' : '24px', Title('Events', 'Your role shows only entries you created.', B('New entry', { k: 'primary', icon: 'plus', px: 14 })), Banner('info', 'eye-off', 'Entries by others aren\'t shown', 'Your role reads only entries you created. Ask an owner or admin if you need to see more.'), body, m && Fab('New entry'))
|
||||
}
|
||||
} else if (v === 'admin') {
|
||||
shell.role = 'admin'; shell.display = 'Jonas Weber'; shell.nav = 'settings'; shell.sideOpen = true
|
||||
if (k === 'ro') { crumbs('Settings', 'General'); main = Page(m ? '16px 16px 88px' : '24px', Title('General', 'Site name, language and defaults.'), Banner('info', 'lock', 'You can read these settings but not change them', 'Ask the owner to change them, or ask for a role that can edit settings.'), Card('Site', null, null, F('Site name', 'Renew Build', { lock: true }), F('Default language', 'English', { lock: true, sel: false })), D('display:flex;gap:8px;opacity:0.4', B('Save changes', { k: 'primary' }))) }
|
||||
@@ -292,15 +338,20 @@ class Component extends DCLogic {
|
||||
if (k === 'locked') { shell.role = 'admin'; shell.display = 'Jonas Weber'; crumbs('Settings', 'Roles', 'Owner'); main = Page(m ? '16px' : '24px', Title('Owner'), F('Name', 'Owner', { lock: true })); ov.push(Toast('error', 'lock', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')) }
|
||||
} else if (v === 'act') {
|
||||
shell.nav = 'activity'; shell.sideOpen = false; crumbs('Activity'); const cols = '28px minmax(0,1fr) 140px 120px 40px'
|
||||
const E = [['shield', 'Role Contributor created', 'Marta Alves', 'Oct 14, 10:41'], ['pencil', 'Role Contributor edited', 'Marta Alves', 'Oct 14, 10:52'], ['user-cog', 'Ines Duarte\'s role changed from Writer to Content lead', 'Marta Alves', 'Oct 14, 11:02'], ['crown', 'Ownership transferred from Marta Alves to Jonas Weber', 'Marta Alves', 'Oct 15, 09:10'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
|
||||
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, '', 'Event', 'By', 'When', ''), ...E.map((e, i) => Tr(cols, { sel: k === 'detail' && i === 1 }, I(e[0], 16, 'color:var(--ui-text-toned)'), Hi(e[1]), Mu(e[2]), Mu(e[3]), I('chevron-right', 16, 'color:var(--ui-text-muted)'))))
|
||||
const cards = D('display:flex;flex-direction:column;gap:8px', ...E.map(e => Cd({}, D('display:flex;gap:8px', IC(e[0]), Hi(e[1])), Mu(e[2] + ' · ' + e[3]))))
|
||||
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), m ? cards : tbl)
|
||||
if (k === 'detail') { const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi('Role Contributor edited'), Mu('Marta Alves · Oct 14, 10:52')), D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-weight:600;color:var(--ui-text-highlighted)', 'What changed'), D('display:flex;gap:8px', Bd('Added', 'ok', 'plus'), S('color:var(--ui-text-highlighted)', 'Delete entries (Own)')), D('display:flex;gap:8px', Bd('Removed', 'err', 'minus'), S('color:var(--ui-text-highlighted)', 'Publish'))), Mu('4 members have this role.')); ov.push(m ? Sheet({ title: 'Activity details' }, body, [B('Close', { full: true })]) : D('position:absolute;inset:0;z-index:40;background:var(--rc-overlay);display:flex;justify-content:flex-end', D('width:440px;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('padding:20px 24px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), IB('x')), D('padding:20px 24px', body)))) }
|
||||
const OKR = [['shield', 'Role Contributor created', 'Marta Alves', 'Oct 14, 10:41'], ['pencil', 'Role Contributor edited', 'Marta Alves', 'Oct 14, 10:52'], ['user-cog', 'Ines Duarte\'s role changed from Writer to Content lead', 'Marta Alves', 'Oct 14, 11:02'], ['crown', 'Ownership transferred from Marta Alves to Jonas Weber', 'Marta Alves', 'Oct 15, 09:10'], ['users', 'Moved 3 members from Intern to Writer', 'Marta Alves', 'Oct 15, 09:29'], ['trash-2', 'Role Intern deleted', 'Marta Alves', 'Oct 15, 09:30']]
|
||||
const FLR = [['shield', 'Failed to create the role Contributor', 'Marta Alves', 'Oct 16, 09:02', 1], ['pencil', 'Failed to edit the role Contributor', 'Marta Alves', 'Oct 16, 09:20', 1], ['user-cog', 'Failed to change the role of Ines Duarte', 'Jonas Weber', 'Oct 16, 10:05', 1], ['crown', 'Failed to transfer ownership to Jonas Weber', 'Marta Alves', 'Oct 16, 10:30', 1], ['users', 'Failed to move 3 members from Intern to Writer', 'Marta Alves', 'Oct 16, 11:12', 1], ['trash-2', 'Failed to delete the role Intern', 'Marta Alves', 'Oct 16, 11:13', 1]]
|
||||
const E = k === 'failed' || k === 'faildetail' ? FLR : OKR
|
||||
const hot = (k === 'detail' || k === 'faildetail') ? 1 : -1
|
||||
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', Th(cols, '', 'Event', 'By', 'When', ''), ...E.map((e, i) => Tr(cols, { sel: i === hot }, I(e[0], 16, 'color:var(--ui-text-toned)'), D('display:flex;align-items:center;gap:8px;min-width:0', Hi(e[1]), e[4] && Bd('Failed', 'err', 'circle-x')), Mu(e[2]), Mu(e[3]), I('chevron-right', 16, 'color:var(--ui-text-muted)'))))
|
||||
const cards = D('display:flex;flex-direction:column;gap:8px', ...E.map(e => Cd({}, D('display:flex;gap:8px', IC(e[0]), Hi(e[1])), D('display:flex;gap:8px;align-items:center', e[4] && Bd('Failed', 'err', 'circle-x'), Mu(e[2] + ' · ' + e[3])))))
|
||||
const CATS = ['Articles', 'Media', 'Users and invites', 'Roles', 'API keys', 'Authentication', 'System']
|
||||
const fbar = k === 'cats' && D('position:relative;flex-shrink:0;height:' + (m ? 44 : 32) + 'px;width:' + (m ? '100%' : '240px'), F('', 'Roles', { sel: true, ico: 'layout-list', focus: true }), D('position:absolute;z-index:30;left:0;right:0;top:' + (m ? 48 : 36) + 'px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;color:var(--ui-text-muted)', 'All categories'), ...CATS.map(c => D('min-height:' + (m ? 44 : 32) + 'px;padding:6px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);background:' + (c === 'Roles' ? 'var(--ui-bg-elevated)' : 'transparent'), S('flex:1', c), c === 'Roles' && I('check', 16)))))
|
||||
main = Page(m ? '16px' : '24px', Title('Activity', 'Everything that happened on this site.'), fbar, m ? cards : tbl)
|
||||
if (k === 'detail' || k === 'faildetail') { const fd = k === 'faildetail'; const body = D('display:flex;flex-direction:column;gap:12px', D('display:flex;flex-direction:column;gap:2px', Hi(fd ? 'Failed to edit the role Contributor' : 'Role Contributor edited'), Mu(fd ? 'Marta Alves · Oct 16, 09:20' : 'Marta Alves · Oct 14, 10:52')), fd ? Alert('error', 'circle-x', 'Why it failed', 'Jonas Weber saved changes to Contributor first, so this edit was refused.') : D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-weight:600;color:var(--ui-text-highlighted)', 'What changed'), D('display:flex;gap:8px', Bd('Added', 'ok', 'plus'), S('color:var(--ui-text-highlighted)', 'Delete entries (Own)')), D('display:flex;gap:8px', Bd('Removed', 'err', 'minus'), S('color:var(--ui-text-highlighted)', 'Publish'))), Mu(fd ? 'The role was not changed.' : '4 members have this role.')); ov.push(m ? Sheet({ title: 'Activity details' }, body, [B('Close', { full: true })]) : D('position:absolute;inset:0;z-index:40;background:var(--rc-overlay);display:flex;justify-content:flex-end', D('width:440px;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('padding:20px 24px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), IB('x')), D('padding:20px 24px', body)))) }
|
||||
} else if (v === 'prof') {
|
||||
shell.nav = 'profile'; shell.role = 'user'; shell.display = 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
|
||||
const R = ROLES.contrib
|
||||
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account.'), Card('Account', null, null, F('Name', 'Pedro Lima', {}), F('Email', 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', RB('Contributor'), Mu('Assigned by Marta Alves')), Mu(R.desc), D('display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500', I('list-checks', 16), Link('See what I can do')), Mu('Your API keys follow this role. If your role changes, they change too.')))
|
||||
shell.nav = 'profile'; shell.role = k === 'owner' ? 'owner' : 'user'; shell.display = k === 'owner' ? 'Marta Alves' : 'Pedro Lima'; shell.sideOpen = false; crumbs('Profile')
|
||||
const R = k === 'owner' ? ROLES.owner : ROLES.contrib
|
||||
main = Page(m ? '16px' : '24px', Title('Profile', 'Your account.'), Card('Account', null, null, F('Name', shell.display, {}), F('Email', k === 'owner' ? 'marta@renewbuild.example' : 'pedro@renewbuild.example', { lock: true })), Card('Your role', null, null, D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', RB(R.name), R.lock && Bd('Locked', 'lock', 'lock'), R.built ? Bd('Built-in', 'built') : Bd('Custom', 'own')), D('display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);font-weight:500', I('list-checks', 16), Link('See what I can do')), Mu('Your API keys follow this role. If your role changes, they change too.')))
|
||||
if (k === 'sheet') { const body = [PvBody(R, { ro: true })]; ov.push(m ? PvSheet(R, { ro: true, title: 'What you can do', sub: 'This is a summary of your role.', foot: [B('Close', { full: true })] }) : Shell({ title: 'What you can do', desc: 'A summary of your role, Contributor. Read-only.', close: true, cancel: 'Close', top: 60 }, body)) }
|
||||
} else if (v === 'keys') {
|
||||
shell.nav = 'settings'; shell.sideOpen = true; crumbs('Settings', 'API keys'); const cols = 'minmax(0,1fr) 140px 130px 110px 40px'
|
||||
|
||||
@@ -400,12 +400,19 @@ class Component extends DCLogic {
|
||||
D('display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--ui-bg-elevated)', S('', 'Each Event has', sel('one'), 'Venue'), S('', 'Each Venue has', sel('many'), 'Events', S('color:var(--ui-text-muted)', ' · shown on Venue as the field "Events"'))),
|
||||
D('display:flex;gap:12px;flex-direction:' + (m ? 'column' : 'row'), card_('Event', S('color:var(--ui-text-muted);font-size:12px', 'Venue'), D('display:flex;gap:6px', chipR('Rue Verte hall'))), card_('Venue', S('color:var(--ui-text-muted);font-size:12px', 'Events'), D('display:flex;gap:6px;flex-wrap:wrap', chipR('Open day'), chipR('Lime plaster workshop'), S('font-size:12px;color:var(--ui-text-muted);align-self:center', '+1')))))
|
||||
main = Page(pad, Title('Edit content type', 'Events · Relation field, shown as it will work later.'), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;overflow:hidden', insp))
|
||||
} else if (k === 'form') {
|
||||
} else if (k === 'form' || k === 'more') {
|
||||
shell.nav = 'content'; shell.crumbs = ['Content', 'Events', 'Lime plaster workshop']; shell.mTitle = 'Lime plaster workshop'; shell.contentOn = 'events'
|
||||
const res = D('position:absolute;left:0;right:0;top:64px;z-index:5;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', ...[['Rue Verte hall', 'Lyon · 120 seats'], ['Rue Verte yard', 'Lyon · outdoor'], ['Rue du Port studio', 'Nantes · 24 seats']].map(([a, b], i) => D('min-height:' + (m ? 48 : 40) + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', a), S('font-size:12px;color:var(--ui-text-muted)', b))))
|
||||
const NM = ['Rue Verte hall', 'Rue Verte yard', 'Rue du Port studio', 'Rue Verte annex', 'Rue de la Gare depot', 'Rue Verte workshop', 'Rue du Port loft', 'Rue des Halles hall', 'Rue Verte garden room', 'Rue du Moulin barn', 'Rue de Nantes studio', 'Rue Verte gallery', 'Rue du Port quay', 'Rue des Tanneurs hall', 'Rue Verte cellar', 'Rue du Four kitchen', 'Rue Verte terrace', 'Rue du Port warehouse', 'Rue des Écoles hall', 'Rue Verte pavilion', 'Rue du Lac studio', 'Rue Verte shed', 'Rue de la Paix room', 'Rue Verte forge', 'Rue du Port atelier']
|
||||
const VN = NM.map((n, i) => [n, ['Lyon', 'Nantes', 'Lille'][i % 3] + ' · ' + (24 + (i * 17) % 120) + ' seats'])
|
||||
const more = k === 'more', rh = m ? 48 : 40
|
||||
const rw = ([x, y], i) => D('min-height:' + rh + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 && !more ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', x), S('font-size:12px;color:var(--ui-text-muted)', y))
|
||||
const sk = w => D('min-height:' + rh + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;gap:6px', S('display:block;height:12px;width:' + w + ';background:var(--ui-bg-accented)'), S('display:block;height:10px;width:40%;background:var(--ui-bg-accented)'))
|
||||
const rows = more ? [...VN.slice(22).map(rw), sk('55%'), sk('70%'), sk('45%')] : VN.slice(0, 5).map(rw)
|
||||
const lm = S('height:' + (m ? 44 : 28) + 'px;padding:0 10px;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-weight:500;font-size:12px;white-space:nowrap;opacity:' + (more ? 0.4 : 1), more && I('loader-circle', 14), more ? 'Loading…' : 'Load more')
|
||||
const res = D('position:absolute;left:0;right:0;top:64px;z-index:5;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop)', D('position:relative;padding:4px', ...rows, !more && S('position:absolute;right:3px;top:6px;width:4px;height:' + rh * 1.5 + 'px;background:var(--ui-bg-accented)')), D('display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border-top:1px solid var(--ui-border)', S('font-size:12px;color:var(--ui-text-muted)', 'Showing 25 of 62'), lm))
|
||||
const chipX = t => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:6px;background:var(--rc-sand);color:var(--rc-on-sand);font-weight:500', t, I('x', 14))
|
||||
main = Page(pad, Title('Lime plaster workshop', 'Events · Entry 42', Later('Relation field: later')), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:' + (m ? 16 : 20) + 'px;max-width:760px;overflow:visible;flex:1;min-height:0', Ctl(EV[0], { v: 'Lime plaster workshop', count: '21 / 80' }),
|
||||
D('display:flex;flex-direction:column;gap:6px;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:176px')),
|
||||
D('display:flex;flex-direction:column;gap:6px;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:' + (m ? 300 : 280) + 'px')),
|
||||
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Speakers'), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center;min-height:' + ih + 'px;padding:4px 8px;border:1px solid var(--ui-border-accented)', chipX('Inès Duarte'), chipX('Jonas Pereira'), S('color:var(--ui-text-muted);margin-left:4px', 'Search to add')), S('color:var(--ui-text-muted);font-size:12px', 'An event can have many speakers.'))))
|
||||
} else {
|
||||
shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = 'events'
|
||||
|
||||
@@ -42,15 +42,15 @@ export const groups = [
|
||||
st('Confirm: no members', N, 'Delete Intern, 0 members.', { v: 'del', k: 'confirm' }, 'Delete Intern? · No one has this role. This can\'t be undone. · Deleting a role doesn\'t affect content. · Cancel · Delete role'),
|
||||
st('Built-in role', N, 'Delete is disabled in the menu. If the dialog is reached by link it shows this state.', { v: 'del', k: 'builtin' }, 'Delete Writer? · Built-in roles can\'t be deleted. · Writer is built in · Duplicate it to make a custom role you can change or delete. · Duplicate role · Close')
|
||||
] },
|
||||
{ label: '5 Users', title: '5 Users: roles and ownership', route: '/users', api: 'PUT /v1/user/{id}/role, POST /v1/invite, POST /v1/site/transfer-ownership', auth: 'Users: view; change role, invite, remove; owner for transfer', note: 'The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.', states: [
|
||||
{ label: '5 Users', title: '5 Users: roles and ownership', route: '/users', api: 'PUT /v1/user/{id}/role, POST /v1/user/invite, POST /v1/site/transfer-ownership', auth: 'Users: view; change role, invite, remove; owner for transfer', note: 'The Role column shows a role badge with an icon: crown (Owner), shield (Admin), pencil (Writer), person (custom). The owner row is protected. Pending invites show their role.', states: [
|
||||
st('List with roles', N, 'Owner opens /users.', { v: 'users', k: 'list' }, 'Users · 5 members and invites · Invite user · Name · Email · Role · Status · Owner · Admin · Writer · Contributor · Content lead · Active · Invite pending'),
|
||||
st('Row menu', N, 'Open the menu on Ines Duarte.', { v: 'users', k: 'menu' }, 'Change role · Send password reset · Remove user'),
|
||||
st('Owner row menu', N, 'Owner row: Change role and Remove are disabled; Transfer ownership appears.', { v: 'users', k: 'ownermenu' }, 'Change role (disabled) · Transfer ownership · Remove user (disabled) · The owner can\'t be changed or removed. Transfer ownership first.'),
|
||||
st('Change role dialog', N, 'Each option shows the role\'s one-line summary. The current role is marked.', { v: 'users', k: 'change' }, 'Change role for Ines Duarte · Her API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role'),
|
||||
st('Change role dialog', N, 'Each option shows the role\'s one-line summary. The current role is marked.', { v: 'users', k: 'change' }, 'Change role for Ines Duarte · Their API keys follow the new role. · Admin · Writer · · Current · Contributor · Content lead · Cancel · Change role'),
|
||||
st('Change role: options blocked', N, 'An admin opens the dialog. Roles with more power are disabled with a reason.', { v: 'users', k: 'changeblock' }, 'Owner · There is one owner. Transfer ownership to change who it is. · Content lead · It can publish. Your role can\'t grant that.'),
|
||||
st('Invite dialog', N, 'Role picker defaults to Writer and shows its summary.', { v: 'users', k: 'invite' }, 'Invite user · They get an email with a link to set a password. · Email · Role · Writer · Writer is the default. You can change a role later. · Cancel · Send invite'),
|
||||
st('Transfer ownership: pick member', N, 'Owner menu > Transfer ownership. Only members can be chosen.', { v: 'users', k: 'tr1' }, 'Transfer ownership · Choose who becomes the owner. Only members can be chosen. · Cancel · Continue'),
|
||||
st('Transfer ownership: confirm', N, 'Consequences listed. The action stays off until the email is retyped exactly.', { v: 'users', k: 'tr2' }, 'Transfer ownership to Jonas Weber · This takes effect straight away. · Jonas Weber becomes the owner. · You become an Admin. You can\'t take ownership back unless he transfers it. · Only the owner can apply updates, delete a site and change activity retention. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership'),
|
||||
st('Transfer ownership: confirm', N, 'Consequences listed. The action stays off until the email is retyped exactly.', { v: 'users', k: 'tr2' }, 'Transfer ownership to Jonas Weber · This takes effect straight away. · 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. · Your API keys follow your new role. · Type jonas@renewbuild.example to confirm · The email must match exactly. · Back · Transfer ownership'),
|
||||
st('Transfer ownership: done', N, 'The former owner is now Admin. The list shows the new owner.', { v: 'users', k: 'tr3' }, 'Ownership transferred · Jonas Weber is now the owner. You are an Admin.')
|
||||
] },
|
||||
{ label: '6 Role-aware shell', title: '6 Role-aware shell', route: 'Every page', api: 'GET /v1/me returns role and permission keys', auth: 'Any', note: 'Rule: hide whole areas the person can never use; disable, with a reason, an action that is blocked on something they can see; show read-only pages with a banner. Writer in these frames is the built-in Writer; Contributor shows "own" scope.', states: [
|
||||
@@ -69,11 +69,15 @@ export const groups = [
|
||||
st('Action: role_locked', N, 'An attempt to edit or delete the Owner role.', { v: 'forbid', k: 'locked' }, 'This role is locked · The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.')
|
||||
] },
|
||||
{ label: '8 Activity', title: '8 Activity: role events', route: '/activity', api: 'GET /v1/activity', auth: 'Activity: own or all', note: 'New event types. The detail panel summarises a role diff as Added and Removed lines using the human permission names.', states: [
|
||||
st('List with role events', N, 'Five new events.', { v: 'act', k: 'list' }, 'Role Contributor created · Role Contributor edited · Ines Duarte\'s role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Role Intern deleted'),
|
||||
st('Detail: role diff', N, 'Open "Role Contributor edited".', { v: 'act', k: 'detail' }, 'Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.')
|
||||
st('List with role events', N, 'Six role events. Event names: role.create, role.edit, user.change_role, user.transfer_ownership, role.reassign, role.delete.', { v: 'act', k: 'list' }, 'Role Contributor created · Role Contributor edited · Ines Duarte\'s role changed from Writer to Content lead · Ownership transferred from Marta Alves to Jonas Weber · Moved 3 members from Intern to Writer · Role Intern deleted'),
|
||||
st('Detail: role diff', N, 'Open "Role Contributor edited".', { v: 'act', k: 'detail' }, 'Activity details · What changed · Added · Delete entries (Own) · Removed · Publish · 4 members have this role.'),
|
||||
st('Failed role events', N, 'The same six events with outcome Failed. The sentence starts with "Failed to" and the Failed badge follows it.', { v: 'act', k: 'failed' }, 'Failed to create the role Contributor · Failed to edit the role Contributor · Failed to change the role of Ines Duarte · Failed to transfer ownership to Jonas Weber · Failed to move 3 members from Intern to Writer · Failed to delete the role Intern · Failed'),
|
||||
st('Failed event: details', N, 'Open "Failed to edit the role Contributor". The reason comes from the API.', { v: 'act', k: 'faildetail' }, 'Activity details · Why it failed · Jonas Weber saved changes to Contributor first, so this edit was refused. · The role was not changed.'),
|
||||
st('Category filter', N, 'The Category select open. Roles is a new category; Users & invites is renamed Users and invites.', { v: 'act', k: 'cats' }, 'All categories · Articles · Media · Users and invites · Roles · API keys · Authentication · System')
|
||||
] },
|
||||
{ label: '9 Profile', title: '9 Profile: your role', route: '/profile', api: 'GET /v1/me', auth: 'Any', note: 'The person sees their own role and a read-only summary.', states: [
|
||||
st('Role card', N, 'Any signed-in user.', { v: 'prof', k: 'card' }, 'Your role · Contributor · Assigned by Marta Alves · Creates content and edits only their own. · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
|
||||
st('Role card: custom role', N, 'Any signed-in user with a custom role. The card shows only what access.role carries (id, name, built_in, locked): the name as a role badge and a Custom tag. The one-line summary and Assigned by were removed.', { v: 'prof', k: 'card' }, 'Your role · Contributor · Custom · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
|
||||
st('Role card: Owner', N, 'The owner. Same card; the Owner badge is followed by Locked (locked is true) and Built-in.', { v: 'prof', k: 'owner' }, 'Your role · Owner · Locked · Built-in · See what I can do · Your API keys follow this role. If your role changes, they change too.'),
|
||||
st('See what I can do', N, 'Opens the summary. Dialog on desktop, sheet on mobile.', { v: 'prof', k: 'sheet' }, 'What you can do · A summary of your role, Contributor. Read-only. · Close')
|
||||
] },
|
||||
{ label: '10 API keys', title: '10 API keys: inherited role', route: '/settings/api-keys', api: 'GET /v1/api-key', auth: 'Settings: API keys', note: 'Keys follow their creator\'s role live. The fact is stated in an info alert and each row shows the creator\'s role.', states: [
|
||||
@@ -86,6 +90,39 @@ export const groups = [
|
||||
E('4 Check General permissions', 'flow', 'Optional. Schema view and Media view and upload are on by default for the preset. Check the preview.', 'What this role can do · Can read any entry, create entries and edit only their own. Can\'t delete and publish.', { flow: 4 }),
|
||||
E('5 Save', 'flow', 'Save changes. The role appears in the list and in the invite picker.', 'Save changes', { flow: 5 }),
|
||||
E('6 Done', 'flow', 'Toast, and the bar closes.', 'Role created · Contributor is ready. Assign it from Users.', { flow: 6 })
|
||||
] },
|
||||
{ label: '12 Toasts', title: '12 Toasts: role changed and stale state', route: 'Any page', api: 'PUT /v1/user/{id}/role, PUT /v1/role/{id}, DELETE /v1/user/{id}, POST /v1/site/transfer-ownership', auth: 'As the action', note: 'Warning toasts say that the thing the person acted on changed first, that nothing was changed, and offer Reload. They stay until dismissed or Reload is pressed. Info toasts (404) close after 6 s. Success toasts close after 6 s. Final EN and FR copy is in the table "Final copy: toasts and errors, EN and FR".', states: [
|
||||
st('Role changed', N, '200 on PUT /v1/user/{id}/role. The row updates in place and a success toast confirms.', { v: 'users', k: 'rolechanged', toastB: 80 }, `Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.`),
|
||||
st('Role edited elsewhere (409)', N, '409 on an action from the roles list (Duplicate, Delete, Move members) because the role version changed after the list loaded. Inside the role editor the Conflict banner is used instead. Reload refetches GET /v1/role.', { v: 'list', k: 'tedit', toastB: 80 }, `Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload`),
|
||||
st('Role deleted elsewhere', N, 'Any action on a role deleted by someone else. The row is removed from the list. A target role deleted during Move members or Change role shows an inline error instead.', { v: 'list', k: 'tgone', toastB: 80 }, `That role no longer exists · It was deleted somewhere else, so we removed it from the list.`),
|
||||
st('Role not changed: member\'s role changed first (409)', N, 'Change role was confirmed after someone else had changed the same member. The dialog is closed and the list is stale. Reload refetches GET /v1/user/.', { v: 'users', k: 'tstale', toastB: 80 }, `Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload`),
|
||||
st('Ownership not transferred: target\'s role changed first (409)', N, 'Transfer ownership was confirmed after the target\'s role changed, and the dialog was closed in the meantime. Nothing was transferred.', { v: 'users', k: 'ttr', toastB: 80 }, `Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload`),
|
||||
st('User not removed: member\'s role changed first (409)', N, 'Remove user was confirmed after the member\'s role changed. Nothing was removed.', { v: 'users', k: 'trem', toastB: 80 }, `User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload`)
|
||||
] },
|
||||
{ label: '13 Inline errors', title: '13 Inline errors: editor and dialogs', route: 'Role editor, Change role, Delete role, Invite', api: 'POST/PUT /v1/role, PUT /v1/user/{id}/role, POST /v1/role/{id}/reassign, POST /v1/user/invite', auth: 'As the action', note: 'Errors stay in the dialog or form that caused them, above the fields or under the field they belong to. The dialog stays open and keeps what the person entered. The primary button stays on so the same action can be tried again, except when the person has to choose again.', states: [
|
||||
st('Role editor: save failed (500)', N, '500 or network failure on POST or PUT /v1/role. Every edit is kept and Save stays on.', { v: 'editor', k: 'saveerr' }, `Couldn't save the role · Your changes are still here. Try again in a moment. · Try again`),
|
||||
st('Change role: failed (500)', N, '500 or network failure. The choice is kept.', { v: 'users', k: 'changeerr' }, `Couldn't change the role · Nothing was changed. Try again in a moment.`),
|
||||
st('Delete role: target role deleted', N, '409 because the chosen target role was deleted meanwhile. The picker is cleared and the action is off until a role is chosen.', { v: 'del', k: 'gone' }, `Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.`),
|
||||
st('Delete role: role gained members', N, '409 role_has_members when a role that had no members got one after the dialog opened. The dialog turns into the blocked state.', { v: 'del', k: 'newmem' }, `Role still has members · Intern now has 1 member. Move them to another role first. · Move 1 member and delete role (disabled until a role is chosen)`),
|
||||
st('Delete role: move failed (500)', N, '500 or network failure on reassign. The chosen role is kept.', { v: 'del', k: 'moveerr' }, `Couldn't move the members · Nothing was changed. Try again in a moment.`),
|
||||
st('Invite: invalid email', N, '400 or client check. Shown under Email.', { v: 'users', k: 'invbad' }, `Enter a valid email address.`),
|
||||
st('Invite: address already has an account (409)', N, 'The address belongs to a user. Shown under Email. Same text as the API.', { v: 'users', k: 'invdup' }, `This address already has an account.`),
|
||||
st('Invite: role above yours (403)', N, 'An admin picked a role with more power than their own. Shown under Role.', { v: 'users', k: 'invesc', role: 'admin' }, `Your role can't assign a role with more power than yours.`),
|
||||
st('Invite: send failed (500)', N, '500 or network failure. Email and role are kept.', { v: 'users', k: 'inverr' }, `Couldn't send the invite · Try again in a moment.`)
|
||||
] },
|
||||
{ label: '14 Read = Own', title: '14 Read = Own: what the person sees', route: '/articles, /content/:key, /settings/roles/:id', api: 'GET /v1/article/, GET /v1/content-type/{key}/entry', auth: 'As the role', note: 'A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so nothing reveals how many hidden items exist. This is different from Update or Delete set to Own, where other people\'s rows are still listed with a View only mark.', states: [
|
||||
E('Role editor: Read set to Own', 'readown', 'Junior writer has Read: Own on every type. A note under the grid explains what Own means for Read. The preview panel uses type-specific sentences.', `Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule. · Can read online articles and their own drafts, create entries and edit only their own. · Can read only entries they created, create entries and edit only their own.`),
|
||||
st('Articles list: Read = Own', N, 'Pedro Lima, Junior writer. Three online articles by others and his two own drafts. Counts: All 5, Online 3, Draft 2. Drafts by others are not listed and not counted.', { v: 'rdown', k: 'art' }, `Articles · Online articles and the drafts you created. · Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule. · All 5 · Online 3 · Draft 2 · New article`),
|
||||
st('Articles list: no drafts of their own', N, 'The Draft tab when the person owns nothing. Counts: All 3, Online 3, Draft 0.', { v: 'rdown', k: 'draft0' }, `You have no drafts · Drafts you create appear here. Your role can't see drafts by other people. · New article · All 3 · Online 3 · Draft 0`),
|
||||
st('Entry list: Read = Own', N, 'Events with Read: Own. Only entries the person created, no pager, count line shows only their own entries.', { v: 'rdown', k: 'ent' }, `Events · Your role shows only entries you created. · Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more. · 3 entries · New entry`),
|
||||
st('Entry list: no entries of their own', N, 'The person has created none. Other people\'s entries exist but are not hinted at.', { v: 'rdown', k: 'ent0' }, `You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one. · New entry`)
|
||||
] },
|
||||
{ label: '15 409 conflicts', title: '15 Role 409 conflicts: stale save, change and transfer', route: '/settings/roles/:id, /users', api: 'PUT /v1/role/{id}, PUT /v1/user/{id}/role, POST /v1/site/transfer-ownership', auth: 'As the action', note: 'Roles carry a version. A stale request returns 409 and nothing is changed. The message appears where the person is working (banner in the editor, alert inside a dialog), never as a toast while that surface is open. The form always keeps what the person entered. Recovery: in the editor, review the differences and either keep the edits on top of the saved role or use the saved role; in a dialog, reload the member and try again.', states: [
|
||||
E('Stale save: banner', 'conflict', '409 role_conflict on PUT (version mismatch). Banner above the form. Edits are kept. Save is off until the person reloads or reviews.', `This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences · 2 unsaved changes. Save is off until you reload`),
|
||||
E('Stale save: review differences', 'review', 'Review differences. A dialog lists only the settings where the saved role and the edits disagree. Keep my edits reloads the role and puts the edits back on top (Save turns on). Use the saved role drops the edits. Reload role in the banner is the same as Use the saved role.', `How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02. · Setting · Saved · Yours · Events · Update · Own · All · Events · Delete · None · Own · Use the saved role · Keep my edits`),
|
||||
E('Stale save: edits kept after reload', 'kept', 'After Keep my edits. The form shows the saved role with the edits on top, changed cells marked, Save on.', `Role reloaded · Your 2 changes are still here. Review them, then save. · 2 unsaved changes`),
|
||||
st('Change role: member\'s role changed first (409)', N, 'Change role confirmed after someone else changed the same member. The dialog stays open, the current role now shows the new value, and the choice is kept. Change role tries again against the new version.', { v: 'users', k: 'changestale' }, `Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. · Change role`),
|
||||
st('Transfer ownership: target\'s role changed first (409)', N, 'Transfer confirmed after the target\'s role changed. The typed email is kept, the primary action is off until Reload member refreshes the target.', { v: 'users', k: 'tr2stale' }, `Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member · Transfer ownership (disabled until reloaded)`)
|
||||
] }
|
||||
]
|
||||
|
||||
@@ -117,13 +154,17 @@ T('Hidden or disabled', '240px 1fr 1fr', ['Case', 'Treatment', 'Example'], [
|
||||
['Action that only exists above their level', 'Hidden', 'Publish for a Writer; status shows Draft and "Ask an editor to publish."'],
|
||||
['Row the person can read but not edit (Own)', 'View only mark on the row; Edit and Delete disabled in the menu', 'You can only edit content you created.'],
|
||||
['Settings page they can read', 'Read-only: locked fields, banner, Save off', 'Admin on General without Edit general settings'],
|
||||
['Owner-only action visible to an admin', 'Disabled, Owner only tag', 'Apply update']]),
|
||||
['Owner-only action visible to an admin', 'Disabled, Owner only tag', 'Apply update'],
|
||||
['Item the role may not read (Read = Own)', 'Not listed and not counted. No View only mark, no hint that it exists.', 'Another person\'s draft article, or an entry created by someone else']]),
|
||||
T('HTTP status to copy (additions)', '180px 120px 1fr 1fr', ['Code', 'Status', 'Title', 'Body'], [
|
||||
['missing_permission', '403', 'You don\'t have permission to do that', 'Your role doesn\'t include {permission name}. Ask an owner or admin.'],
|
||||
['role_locked', '403', 'This role is locked', 'The Owner role can\'t be changed or deleted. Transfer ownership from Users instead.'],
|
||||
['role_has_members', '409', 'Role still has members', '{n} members have this role. Move them to another role first.'],
|
||||
['role_name_taken', '409', 'Name already used', 'A role named "{name}" already exists. Choose a different name.'],
|
||||
['role_conflict', '409', 'This role changed while you were editing', '{name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits.'],
|
||||
['role_conflict (change role)', '409', 'Role not changed', 'Dialog alert: {user} is now {role}. Your choice is kept. Check it and try again. Toast if the dialog was closed.'],
|
||||
['role_conflict (remove, transfer)', '409', 'User not removed / Ownership not transferred', '{user}\'s role changed in the meantime, so nothing was removed (transferred). Check it and try again.'],
|
||||
['role_not_found', '404', 'That role no longer exists', 'It was deleted somewhere else, so we removed it from the list.'],
|
||||
['escalation', '403', 'You can\'t grant that', 'You can only grant what your role has.'],
|
||||
['(page level)', '403', 'You don\'t have access to this page', 'Your role, {role}, doesn\'t include it. Ask an owner or admin if you need it.']]),
|
||||
T('Components (new) and Nuxt UI v4 mapping', '200px 1fr 1fr', ['Component', 'Used for', 'Nuxt UI mapping'], [
|
||||
@@ -150,8 +191,8 @@ T('Needs backend', '240px 1fr', ['Item', 'Detail'], [
|
||||
['Endpoints', 'GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue)'],
|
||||
['created_by', 'Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data.'],
|
||||
['Enforcement', 'Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator\'s current role on each request.'],
|
||||
['Version field', 'role.version for the conflict state (409 role_conflict).'],
|
||||
['Activity events', 'role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred.']]),
|
||||
['Version field', 'role.version for the conflict state (409 role_conflict). The same 409 covers a change of role or a removal when the member\'s role changed first, and an ownership transfer when the target\'s role changed first.'],
|
||||
['Activity events', 'role.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. The Failed outcome is a field on the event, not a separate name.']]),
|
||||
T('Existing screens that change', '220px 1fr', ['Screen', 'Change'], [
|
||||
['Sidebar and mobile drawer (AppFrame)', 'Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles.'],
|
||||
['Settings navigation', 'New "Roles" item. "Permissions" is removed.'],
|
||||
@@ -165,11 +206,72 @@ T('Existing screens that change', '220px 1fr', ['Screen', 'Change'], [
|
||||
['Profile', 'Your role card and See what I can do.'],
|
||||
['Activity and filters', 'New event types, role diff in the detail panel.'],
|
||||
['Error and 403 copy', 'Two codes and the page-level state added.']]),
|
||||
T('Activity events: sentences, EN and FR', '190px 1fr 1fr', ['Event name','EN','FR'], [
|
||||
['role.create', 'Role Contributor created', 'Rôle Contributor créé'],
|
||||
['role.edit', 'Role Contributor edited', 'Rôle Contributor modifié'],
|
||||
['role.delete', 'Role Intern deleted', 'Rôle Intern supprimé'],
|
||||
['role.reassign', 'Moved 3 members from Intern to Writer (1 member: Moved 1 member from Intern to Writer)', '3 membres déplacés de Intern vers Writer (1 membre : 1 membre déplacé de Intern vers Writer)'],
|
||||
['user.change_role', 'Ines Duarte\'s role changed from Writer to Content lead', 'Le rôle d\'Ines Duarte est passé de Writer à Content lead'],
|
||||
['user.transfer_ownership', 'Ownership transferred from Marta Alves to Jonas Weber', 'Propriété transférée de Marta Alves à Jonas Weber']]),
|
||||
T('Activity events: failed sentences, EN and FR', '190px 1fr 1fr', ['Event name (outcome failed)','EN','FR'], [
|
||||
['Template', 'Failed to {verb} {object}', 'Échec de {action} {objet}'],
|
||||
['role.create', 'Failed to create the role Contributor', 'Échec de la création du rôle Contributor'],
|
||||
['role.edit', 'Failed to edit the role Contributor', 'Échec de la modification du rôle Contributor'],
|
||||
['role.delete', 'Failed to delete the role Intern', 'Échec de la suppression du rôle Intern'],
|
||||
['role.reassign', 'Failed to move 3 members from Intern to Writer', 'Échec du déplacement de 3 membres de Intern vers Writer'],
|
||||
['user.change_role', 'Failed to change the role of Ines Duarte', 'Échec du changement de rôle d\'Ines Duarte'],
|
||||
['user.transfer_ownership', 'Failed to transfer ownership to Jonas Weber', 'Échec du transfert de la propriété à Jonas Weber'],
|
||||
['user.invite', 'Failed to invite sofia@renewbuild.example', 'Échec de l\'invitation de sofia@renewbuild.example'],
|
||||
['user.remove', 'Failed to remove Ines Duarte', 'Échec du retrait d\'Ines Duarte'],
|
||||
['Reason: role_conflict', 'Jonas Weber saved changes to Contributor first, so this edit was refused.', 'Jonas Weber a enregistré des modifications de Contributor avant, donc cette modification a été refusée.'],
|
||||
['Reason: role_locked', 'The Owner role can\'t be changed or deleted.', 'Le rôle Owner ne peut être ni modifié ni supprimé.'],
|
||||
['Reason: missing_permission', 'The role of this person doesn\'t include that permission.', 'Le rôle de cette personne n\'inclut pas cette autorisation.']]),
|
||||
T('Activity filter: category names, EN and FR', '190px 1fr 1fr', ['Value','EN','FR'], [
|
||||
['(none)', 'All categories', 'Toutes les catégories'],
|
||||
['articles', 'Articles', 'Articles'],
|
||||
['media', 'Media', 'Médias'],
|
||||
['users', 'Users and invites (was "Users & invites")', 'Utilisateurs et invitations'],
|
||||
['roles (new, to confirm)', 'Roles', 'Rôles'],
|
||||
['api_keys', 'API keys', 'Clés API'],
|
||||
['auth', 'Authentication', 'Authentification'],
|
||||
['system', 'System', 'Système']]),
|
||||
T('Final copy: toasts and errors, EN and FR', '230px 1fr 1fr', ['State','EN','FR'], [
|
||||
['Toast: role changed', 'Role changed · Ines Duarte is now Content lead, and their API keys follow the new role.', 'Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle.'],
|
||||
['Toast: role edited elsewhere (409)', 'Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload', 'Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n\'a été enregistré. Rechargez et réessayez. · Recharger'],
|
||||
['Toast: role deleted elsewhere', 'That role no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Ce rôle n\'existe plus · Il a été supprimé ailleurs, nous l\'avons donc retiré de la liste.'],
|
||||
['Toast: member\'s role changed before a change of role (409)', 'Role not changed · Ines Duarte\'s role changed in the meantime, so nothing was saved. Reload and try again. · Reload', 'Rôle non modifié · Le rôle d\'Ines Duarte a changé entre-temps, donc rien n\'a été enregistré. Rechargez et réessayez. · Recharger'],
|
||||
['Toast: target\'s role changed before a transfer (409)', 'Ownership not transferred · Jonas Weber\'s role changed in the meantime, so nothing was transferred. Check it and try again. · Reload', 'Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n\'a été transféré. Vérifiez-le et réessayez. · Recharger'],
|
||||
['Toast: member\'s role changed before a removal (409)', 'User not removed · Ines Duarte\'s role changed in the meantime, so nothing was removed. Check it and try again. · Reload', 'Utilisateur non retiré · Le rôle d\'Ines Duarte a changé entre-temps, donc rien n\'a été retiré. Vérifiez-le et réessayez. · Recharger'],
|
||||
['Editor: name required', 'Give the role a name.', 'Donnez un nom au rôle.'],
|
||||
['Editor: name taken (409)', 'A role named "Writer" already exists. Choose a different name.', 'Un rôle nommé « Writer » existe déjà. Choisissez un autre nom.'],
|
||||
['Editor: save failed (500)', 'Couldn\'t save the role · Your changes are still here. Try again in a moment. · Try again', 'Impossible d\'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant. · Réessayer'],
|
||||
['Editor: stale save banner (409)', 'This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits. · Reload role · Review differences', 'Ce rôle a changé pendant que vous le modifiiez · Jonas Weber a enregistré des modifications de Contributor à 11:02. Rechargez pour les voir, ou comparez-les à vos modifications. · Recharger le rôle · Voir les différences'],
|
||||
['Editor: review differences', 'How the saved role differs from your edits · Setting · Saved · Yours · Use the saved role · Keep my edits', 'Comment le rôle enregistré diffère de vos modifications · Réglage · Enregistré · Vous · Utiliser le rôle enregistré · Garder mes modifications'],
|
||||
['Editor: edits kept after reload', 'Role reloaded · Your 2 changes are still here. Review them, then save.', 'Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.'],
|
||||
['Editor: Read = Own note', 'Read: Own · Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.', 'Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n\'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle.'],
|
||||
['Change role: member\'s role changed (409)', 'Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again.', 'Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.'],
|
||||
['Change role: failed (500)', 'Couldn\'t change the role · Nothing was changed. Try again in a moment.', 'Impossible de modifier le rôle · Rien n\'a été modifié. Réessayez dans un instant.'],
|
||||
['Change role: role above yours', 'Your role can\'t assign a role with more power than yours.', 'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.'],
|
||||
['Delete role: target deleted', 'Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.', 'Writer n\'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.'],
|
||||
['Delete role: role gained members (409)', 'Role still has members · Intern now has 1 member. Move them to another role first.', 'Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d\'abord vers un autre rôle.'],
|
||||
['Delete role: move failed (500)', 'Couldn\'t move the members · Nothing was changed. Try again in a moment.', 'Impossible de déplacer les membres · Rien n\'a été modifié. Réessayez dans un instant.'],
|
||||
['Invite: invalid email', 'Enter a valid email address.', 'Saisissez une adresse e-mail valide.'],
|
||||
['Invite: account exists (409)', 'This address already has an account.', 'Cette adresse a déjà un compte.'],
|
||||
['Invite: role above yours', 'Your role can\'t assign a role with more power than yours.', 'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.'],
|
||||
['Invite: send failed (500)', 'Couldn\'t send the invite · Try again in a moment.', 'Impossible d\'envoyer l\'invitation · Réessayez dans un instant.'],
|
||||
['Transfer: target\'s role changed (409)', 'Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. · Reload member', 'Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre']]),
|
||||
T('Read = Own: what each surface returns', '230px 1fr 1fr', ['Surface','Read = All','Read = Own'], [
|
||||
['Articles list', 'Every article, including drafts by others', 'Online articles plus the person\'s own drafts'],
|
||||
['Status counts (All, Online, Draft)', 'Counted over every article', 'Counted over the visible articles only: Draft counts own drafts, Online counts every online article'],
|
||||
['Custom type entry list', 'Every entry', 'Only entries the person created. The count line and pager use that number.'],
|
||||
['API list calls and X-Total-Count', 'Every item', 'Only the visible items. The total does not reveal hidden ones.'],
|
||||
['Direct link to a hidden item (proposed)', 'Opens', 'The existing 404 page: Entry not found. No hint that the item exists.'],
|
||||
['Empty state', 'Existing empty states', 'You have no drafts · You haven\'t created an event yet. New article or New entry stays available when Create is on.']]),
|
||||
T('Decisions to confirm', '60px 1fr 1fr', ['#', 'Decision made', 'Alternative'], [
|
||||
['1', 'Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid.', 'General first, as in the brief.'],
|
||||
['2', 'Writer gets Authors: view and edit own, and Activity: own.', 'No Authors or Activity access.'],
|
||||
['3', 'A new role starts with nothing granted; presets fill the grid.', 'Start from a copy of Writer.'],
|
||||
['4', 'Own-scope rows others created show "View only" with a lock icon, not a hidden row.', 'Hide other people\'s entries.'],
|
||||
['4', 'Update or Delete set to Own: rows others created show "View only" with a lock icon, not a hidden row. Read set to Own: those rows are not returned, so they are hidden and not counted.', 'Hide other people\'s entries for Update and Delete too.'],
|
||||
['5', 'Reset to default exists for built-in roles edited by the owner.', 'No reset.'],
|
||||
['6', 'Activity own/all scope uses the same None / Own / All control.', 'Toggle plus a separate scope select.'],
|
||||
['7', 'Transfer ownership takes effect at once, no email to the new owner.', 'The new owner must accept.'],
|
||||
|
||||
@@ -86,7 +86,8 @@ export const groups = [
|
||||
] },
|
||||
{ label: '7 Relations', title: '7 Relation field and picker', route: '/schema/:key, /content/:key/:id', api: 'not built', auth: 'Owner or admin (picker), any verified user (form)', note: 'All later. The model reads as two sentences, with a live preview of both sides. Three shapes: one to one, one to many, many to many.', states: [
|
||||
st('Relation in the builder', L, 'A seventh type, Relation, in the type gallery.', { v: 'rel', k: 'builder' }, 'Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events"'),
|
||||
st('Relation in the entry form', L, 'Search and pick; chips for many.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers.'),
|
||||
st('Relation in the entry form', L, 'Search and pick; chips for many. The picker loads 25 results per page; the list scrolls and ends with Load more.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers. · Showing 25 of 62 · Load more'),
|
||||
st('Relation picker: loading more', L, 'Load more pressed. The next page of 25 is requested. Skeleton rows follow the last loaded result, the button shows a loader and is disabled, and the footer count stays until the new rows arrive. Focus stays on the button, then moves to the first new row.', { v: 'rel', k: 'more' }, 'Showing 25 of 62 · Loading… · (skeleton rows)'),
|
||||
st('Relation in the list', L, 'One chip, two chips and +N, or a dash.', { v: 'rel', k: 'list' }, 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.')
|
||||
] },
|
||||
{ label: '8 Permissions', title: '8 Permissions grid', route: '/settings/permissions', api: 'not built', auth: 'Owner or admin', note: 'All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.', states: [
|
||||
|
||||
+32
-2
@@ -95,7 +95,7 @@ Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sit
|
||||
|
||||
|
||||
## Custom content types
|
||||
Groups on `RenewCMS Screens Schema.dc.html`: 1 Schema list (6), 2 Schema builder (16), 3 Review and delete (7), 4 Field types (6), 5 Content and entry list (9), 6 Entry editor (13), 7 Relations (3), 8 Permissions (2), 9 Export and import (3). 65 states, 56 available now and 9 later. Each has a 1440 and a 390 frame; light and dark follow the theme switch. Nothing is clock-sensitive. Tag per state: "Available now" or "Later". Copy per state: screens/schema.md, content.md, entry.md, schema-later.md.
|
||||
Groups on `RenewCMS Screens Schema.dc.html`: 1 Schema list (6), 2 Schema builder (16), 3 Review and delete (7), 4 Field types (6), 5 Content and entry list (9), 6 Entry editor (13), 7 Relations (4), 8 Permissions (2), 9 Export and import (3). 66 states, 56 available now and 10 later. Each has a 1440 and a 390 frame; light and dark follow the theme switch. Nothing is clock-sensitive. Tag per state: "Available now" or "Later". Copy per state: screens/schema.md, content.md, entry.md, schema-later.md.
|
||||
|
||||
Roles: owners and admins see Schema and edit types. Any verified user sees the Content group and reads and writes entries. A user who is not owner or admin and opens /schema gets "You don't have access to Schema".
|
||||
|
||||
@@ -110,7 +110,7 @@ Copy and states are in `media-field-changes.md` (sections 2 to 6). Entry rules f
|
||||
|
||||
## Roles and permissions
|
||||
|
||||
61 states, all Available now, listed with trigger and copy in `screens/roles.md`. Groups: Roles list 7, Role editor 15, Plain-language preview 4, Delete role 4, Users 9, Role-aware shell 8, No access 3, Activity 2, Profile 2, API keys 1, Create-a-role flow 6. None is clock-sensitive (times are fixed strings).
|
||||
90 states, all Available now, listed with trigger and copy in `screens/roles.md`. Groups: Roles list 7, Role editor 15, Plain-language preview 4, Delete role 4, Users 9, Role-aware shell 8, No access 3, Activity 5, Profile 3, API keys 1, Create-a-role flow 6, Toasts 6, Inline errors 9, Read = Own 5, 409 conflicts 5. None is clock-sensitive (times are fixed strings).
|
||||
|
||||
Hidden or disabled:
|
||||
| Case | Treatment | Example |
|
||||
@@ -141,3 +141,33 @@ HTTP status to copy (additions):
|
||||
| role_conflict | 409 | This role changed while you were editing | {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
|
||||
| escalation | 403 | You can't grant that | You can only grant what your role has. |
|
||||
| (page level) | 403 | You don't have access to this page | Your role, {role}, doesn't include it. Ask an owner or admin if you need it. |
|
||||
|
||||
## Roles sync (pass 6)
|
||||
|
||||
### Wording
|
||||
People are referred to as "they" everywhere: "Their API keys follow the new role." "You can't take ownership back unless they transfer it." No "he", "she", "his" or "her" in any frame or copy table. French uses "vous"; possessives agree with the noun, so they stay neutral ("ses clés API"). Built-in role names stay in English in the French copy until the translation of Owner, Admin and Writer is decided; custom names are user data and are never translated.
|
||||
|
||||
### Activity event names
|
||||
Present tense, as the backend emits them. The outcome (succeeded or failed) is a field, so a failed event keeps its name.
|
||||
| Event | Sentence | Failed sentence |
|
||||
|---|---|---|
|
||||
| role.create | Role Contributor created | Failed to create the role Contributor |
|
||||
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
|
||||
| role.delete | Role Intern deleted | Failed to delete the role Intern |
|
||||
| role.reassign | Moved 3 members from Intern to Writer | Failed to move 3 members from Intern to Writer |
|
||||
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
|
||||
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
|
||||
Failed template: "Failed to {verb} {object}". Category names in the filter: Articles, Media, Users and invites, Roles, API keys, Authentication, System. French for every string is in the board table "Final copy: toasts and errors, EN and FR" and in screens/roles.md.
|
||||
|
||||
### Stale state
|
||||
Nothing is changed when a request is stale. The message names who or what changed, says nothing was changed, and ends with the recovery. Where it shows: banner in the role editor, alert inside a dialog, toast only when the surface is no longer open. The form keeps what the person entered. Recovery: Reload (toast), Reload role or Review differences (editor), Reload member (transfer), retry (change role).
|
||||
| Case | Status | Where | Copy |
|
||||
|---|---|---|---|
|
||||
| Stale role save | 409 role_conflict | Banner in the editor | This role changed while you were editing · {name} saved changes to {role} at {time}. Reload to see them, or review how they differ from your edits. |
|
||||
| Change of role, member's role changed first | 409 | Alert in the dialog, or toast if it closed | Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again. |
|
||||
| Transfer, target's role changed first | 409 | Alert in the dialog, or toast if it closed | Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again. |
|
||||
| Removal, member's role changed first | 409 | Toast | User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. |
|
||||
| Role deleted by someone else | 404 | Info toast | That role no longer exists · It was deleted somewhere else, so we removed it from the list. |
|
||||
|
||||
### Read = Own
|
||||
A role with Read set to Own sees online articles plus its own drafts. For custom content types it sees only entries it created. Counts, totals and pagination follow the same rule, so hidden items are not hinted at. Update or Delete set to Own still list other people's rows with the View only mark. Frames: group "14 Read = Own" on the Roles board.
|
||||
@@ -0,0 +1,57 @@
|
||||
Feature: Relation picker "loading more" state
|
||||
|
||||
As an editor filling a relation field
|
||||
I want the picker to show that the next page is loading and to keep my place
|
||||
So that pressing "Load more" never loses focus or shows a blank list
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/schema-later.md group 7 states 2 and 3, and docs/design/change-log-roles-sync.md "Relation picker"
|
||||
And the picker is RelationField.vue over useRelationSearch.ts, which already requests GET /v1/content-type/{key}/entry?page=N&size=25 and has loading, loadingMore, hasMore and total
|
||||
And the sample is 25 venues loaded of 62 for "rue", footer "Showing 25 of 62" with "Load more"
|
||||
And the design marks the group "Later" but the picker exists in the app; this file specifies the missing state only
|
||||
And the number of skeleton rows is read from the board frame SchemaFrame.dc.html, not fixed here
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
|
||||
Rule: Loading more
|
||||
|
||||
Scenario: Pressing Load more
|
||||
Given 25 of 62 results are listed for "rue"
|
||||
When "Load more" is pressed
|
||||
Then GET /v1/content-type/venues/entry?page=2&size=25 is sent
|
||||
And skeleton rows follow the last loaded result
|
||||
And the button reads "Loading…" with a loader and is disabled
|
||||
And the footer still reads "Showing 25 of 62" and the loaded results stay in place
|
||||
And a second press while loading sends no request
|
||||
|
||||
Scenario: The page arrives
|
||||
When the page answers 25 more items
|
||||
Then the skeleton rows are replaced by the new results, the footer reads "Showing 50 of 62", the button returns to "Load more" and is enabled
|
||||
And focus moves to the first new row
|
||||
|
||||
Scenario: The last page
|
||||
Given 12 items remain
|
||||
When the page arrives
|
||||
Then the footer reads "Showing 62 of 62" and the button is gone
|
||||
|
||||
Scenario: The next page fails
|
||||
When the request answers 500 or the network fails
|
||||
Then the skeleton rows disappear, the loaded results stay, the button is enabled again with its label "Load more"
|
||||
And focus returns to the button
|
||||
And the existing error treatment of the picker is unchanged (design gap G2)
|
||||
|
||||
Scenario: A new search while loading more
|
||||
When the person types a new term
|
||||
Then the pending page is ignored, the skeleton of the first page shows as for any search, and the footer follows the new total
|
||||
|
||||
Scenario: Focus handling
|
||||
Given the keyboard user pressed Load more with Enter
|
||||
Then focus stays on the button while loading and moves to the first new row when the page arrives
|
||||
And a screen reader hears "Loading…" and then the new count, announced politely
|
||||
|
||||
Scenario: Reduced motion
|
||||
Given prefers-reduced-motion is set
|
||||
Then the skeleton rows do not animate
|
||||
|
||||
Scenario: Mobile
|
||||
Given a 390 px viewport
|
||||
Then the skeleton rows and the button are full width with 44 px targets
|
||||
@@ -0,0 +1,30 @@
|
||||
# QA procedure: Relation picker loading more
|
||||
|
||||
Spec: features/relation_picker_loading_more.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked in the style of views/__tests__/EntryEditorRelationStates.spec.ts (a `search` callback per page).
|
||||
|
||||
## Honesty rules
|
||||
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures.
|
||||
3. The skeleton row count is read from the board frame; assert it only if the frame gives it.
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| views/__tests__/EntryEditorRelationStates.spec.ts (extend "Load more") | request URL `page=2&size=25`; during the pending promise skeleton rows exist after the last option, the button is disabled with text "Loading…", the footer text unchanged, a second click sends nothing; after resolve rows replaced, footer "Showing 50 of 62", button enabled; last page footer and no button; failure restores the button and keeps the results; focus on the first new option; focus back on the button after a failure |
|
||||
| composables/__tests__/useRelationSearch.spec.ts (extend) | loadingMore true only for page above 1, a stale page is ignored after a new search, hasMore and total |
|
||||
| i18n/roleMessages.spec.ts or the messages spec (extend) | "Loading…" EN and FR |
|
||||
|
||||
Mocks to reuse: 62 venues for "rue", 25 per page, the board's venue names from the frame script.
|
||||
|
||||
## Visual (task visual:check)
|
||||
Run `task visual:check -- schema-later` (screen name as the script lists it), states "Relation picker: loading more" and "Relation in the entry form". Sample data: the board's 25 venues for "rue", "Showing 25 of 62". The pending state needs a never-resolving mock for the second page. Light, dark, desktop, mobile within 10 %.
|
||||
|
||||
## Manual checks
|
||||
1. A disabled button loses keyboard focus in some browsers: verify in Chrome, Firefox and Safari that focus stays on the button (or moves as designed) and a screen reader announces the change; if not, use aria-disabled and document it.
|
||||
2. Throttle the network to see the skeleton; reduced motion on.
|
||||
3. Mobile 390 px, dark, FR.
|
||||
4. `task lint:web` green; `task test:web` exit 0.
|
||||
|
||||
## Design gaps and notes
|
||||
- G1 The design says "Focus stays on the button, then moves to the first new row"; disabled-button focus is browser-dependent (manual item 1).
|
||||
- G2 The failure of the next page is not drawn.
|
||||
- G3 The group is "Later" in the design (API "not built") but the app has the picker; the coder reuses it.
|
||||
@@ -0,0 +1,157 @@
|
||||
Feature: Role 409 conflicts: stale save, change role, transfer ownership and a missing reassign target (group 15)
|
||||
|
||||
As a person editing a role, changing a member's role or handing over ownership
|
||||
I want a stale request to say who or what changed, to keep what I entered and to offer the next step
|
||||
So that nothing is overwritten and I never lose my edits
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/roles.md groups 12, 13 and 15, the table "HTTP status to copy" rows role_conflict, and docs/design/states.md "Stale state"
|
||||
And the backend contract is, after this slice:
|
||||
| call | status | body |
|
||||
| PUT /v1/role/{id} with a stale version | 409 | role_conflict |
|
||||
| PUT /v1/user/{id}/role | 409 | user_role_changed |
|
||||
| POST /v1/site/transfer-ownership | 409 | user_role_changed |
|
||||
| DELETE /v1/user/{id} | 409 | user_role_changed |
|
||||
| DELETE /v1/role/{id} while it has members | 409 | role_has_members |
|
||||
| POST /v1/role/{id}/reassign, target missing | 409 | target_role_not_found |
|
||||
| POST /v1/role/{id}/reassign, same role, locked or uncovered target | 400 | invalid_reassign |
|
||||
| any action on a deleted role (the path role) | 404 | role_not_found |
|
||||
And a 409 body is plain text and names nobody: the SPA re-reads GET /v1/role/{id} (updated_by, updated_at) for a stale role save, and GET /v1/user for the member's current role
|
||||
And the removal route and its 409 are specified in features/roles_sync_remove_user.feature
|
||||
And the message appears where the person is working (banner in the editor, alert in a dialog) and never as a toast while that surface is open; a toast shows only when the surface is gone
|
||||
And a warning toast stays until dismissed or Reload is pressed; an info toast closes after 6 s
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
|
||||
Rule: Backend: a missing reassign target is a distinct 409
|
||||
|
||||
Background for this Rule (house style of roles_permissions_roles_api.feature):
|
||||
Given the new domain sentinel role.ErrReassignTargetNotFound and the new apperr string ReassignTargetNotFoundErrorMessage = "target_role_not_found"
|
||||
And ReassignRoleMembersUseCase.checkTarget returns it when roles.Get(target) answers role.ErrRoleNotFound, instead of role.ErrInvalidReassign
|
||||
And api/handlers/role maps it to 409 with the plain-text body, in the existing refusals table
|
||||
And target 0, target equal to the source, a locked target and a target the caller cannot cover still answer 400 invalid_reassign
|
||||
And a missing source role still answers 404 role_not_found
|
||||
And nothing is moved and no event is recorded on this refusal (a refusal records nothing)
|
||||
And docs/api.md documents the 409 on POST /v1/role/{id}/reassign
|
||||
|
||||
Scenario Outline: Reassign target answers
|
||||
When the Owner sends POST /v1/role/<source>/reassign {"target_role_id": <target>}
|
||||
Then the answer is <status> <body>
|
||||
And no member moved
|
||||
Examples:
|
||||
| source | target | status | body |
|
||||
| Contributor | an id that no longer exists | 409 | target_role_not_found |
|
||||
| Contributor | Contributor (itself) | 400 | invalid_reassign |
|
||||
| Contributor | 0 | 400 | invalid_reassign |
|
||||
| Contributor | Owner (locked) | 400 | invalid_reassign |
|
||||
| Contributor | Admin, caller is a People manager | 400 | invalid_reassign |
|
||||
| 9999 | Writer | 404 | role_not_found |
|
||||
|
||||
Rule: Stale role save (editor)
|
||||
|
||||
Scenario: The banner
|
||||
Given Contributor was loaded at version 2 and the person made 2 edits
|
||||
When PUT /v1/role/4 answers 409 role_conflict and GET /v1/role/4 answers updated_by "Jonas Weber", updated_at 11:02
|
||||
Then a banner above the form reads "This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits." with "Reload role" and "Review differences"
|
||||
And the edits are kept, Save is off, and the bar reads "2 unsaved changes. Save is off until you reload"
|
||||
|
||||
Scenario: Review differences
|
||||
Given the banner is shown with edits Events Update Own to All and Events Delete None to Own against the saved role
|
||||
When "Review differences" is pressed
|
||||
Then a dialog "How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02." lists only the settings where the two disagree, as a table Setting, Saved, Yours: Events Update Own All; Events Delete None Own
|
||||
And it offers "Use the saved role" and "Keep my edits"
|
||||
And in FR the title is "Comment le rôle enregistré diffère de vos modifications" with "Réglage", "Enregistré", "Vous", "Utiliser le rôle enregistré", "Garder mes modifications"
|
||||
|
||||
Scenario Outline: Resolving
|
||||
When the person presses <action>
|
||||
Then <effect>
|
||||
Examples:
|
||||
| action | effect |
|
||||
| Keep my edits | GET /v1/role/4 replaces the saved role and its version, the edits are re-applied on top, changed cells carry the square marker, Save is on, the dialog and the banner close, an info toast reads "Role reloaded · Your 2 changes are still here. Review them, then save." and the bar reads "2 unsaved changes" |
|
||||
| Use the saved role | the edits are dropped, the form shows the saved role, Save is off, no toast about edits |
|
||||
| Reload role (banner) | the same as "Use the saved role" |
|
||||
| Escape or close | the dialog closes and the banner and kept edits stay |
|
||||
And "Keep my edits" followed by Save sends PUT /v1/role/4 with the reloaded version
|
||||
And in FR the toast reads "Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez."
|
||||
|
||||
Scenario: A second conflict
|
||||
Given edits were kept on top of version 3
|
||||
When the save answers 409 role_conflict again
|
||||
Then the banner returns with the newest updated_by and time, and the edits are still kept
|
||||
|
||||
Scenario: The name taken is not a conflict
|
||||
When the save answers 409 role_name_taken
|
||||
Then the existing field error shows and no banner
|
||||
|
||||
Scenario: The time is not clock-sensitive
|
||||
Given updated_at is a fixed string and the time zone is pinned in the test and in the visual setup
|
||||
Then "11:02" is the formatted time of that string, not the current time
|
||||
|
||||
Rule: Change role when the member's role changed first
|
||||
|
||||
Scenario: The alert in the dialog
|
||||
Given the Change role dialog for Ines Duarte (Writer) is open with Contributor chosen
|
||||
When PUT /v1/user/{id}/role answers 409 user_role_changed and GET /v1/user answers Ines Duarte with role Admin
|
||||
Then the dialog stays open with an alert "Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again."
|
||||
And Contributor stays chosen, the "Current" mark moves from Writer to Admin, and "Change role" stays enabled
|
||||
And pressing "Change role" again sends PUT /v1/user/{id}/role {"role_id": <Contributor id>}
|
||||
And in FR "Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez."
|
||||
And no toast shows while the dialog is open
|
||||
|
||||
Scenario: The chosen role became the current one
|
||||
Given the re-read shows Ines Duarte now holds Contributor, the role the person chose
|
||||
Then the alert still shows and "Change role" is disabled because the chosen role is now the current one
|
||||
|
||||
Scenario: The member disappeared
|
||||
When GET /v1/user no longer lists Ines Duarte, or PUT answers 404 user_not_found
|
||||
Then the dialog closes, the list is refreshed and the existing "no longer exists" toast of slice 7 shows
|
||||
|
||||
Scenario: The dialog is gone when the answer arrives (component level)
|
||||
Given the dialog component was unmounted or its open state set to false before the 409 arrived
|
||||
Then a warning toast reads "Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload"
|
||||
And "Reload" refetches GET /v1/user and dismisses the toast
|
||||
And in FR "Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger"
|
||||
And this is specified at component level only, no page-level trigger exists because the dialog cannot be dismissed while sending
|
||||
|
||||
Rule: Transfer ownership when the target's role changed first
|
||||
|
||||
Scenario: The alert in the dialog
|
||||
Given the Owner confirmed Jonas Weber and typed "jonas@renewbuild.example"
|
||||
When POST /v1/site/transfer-ownership answers 409 user_role_changed and GET /v1/user answers Jonas Weber with role Writer
|
||||
Then an alert in the dialog reads "Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again." with "Reload member"
|
||||
And the typed email is kept and "Transfer ownership" is disabled until "Reload member" refreshes the target
|
||||
And "Reload member" re-reads GET /v1/user, updates the shown role of the target and re-enables "Transfer ownership" with the typed email still valid
|
||||
And in FR "Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre"
|
||||
|
||||
Scenario: The dialog is gone when the answer arrives (component level)
|
||||
Then a warning toast reads "Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload"
|
||||
And in FR "Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger"
|
||||
|
||||
Scenario Outline: Other transfer answers keep the slice 7 behaviour
|
||||
When the server answers <status> <code>
|
||||
Then the behaviour of features/roles_spa_users.feature "Transfer answers" holds
|
||||
Examples:
|
||||
| status | code |
|
||||
| 400 | invalid_transfer |
|
||||
| 400 | confirmation_mismatch |
|
||||
| 403 | missing_permission |
|
||||
| 500 | (network) |
|
||||
|
||||
Rule: Role list actions
|
||||
|
||||
Scenario: Role edited elsewhere (409 on a list action)
|
||||
Given the roles list was loaded and Contributor was edited by Jonas Weber afterwards
|
||||
When Duplicate, Delete or Move members answers 409 role_conflict
|
||||
Then a warning toast reads "Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload"
|
||||
And "Reload" refetches GET /v1/role
|
||||
And in FR "Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger"
|
||||
|
||||
Scenario: Role deleted elsewhere (404)
|
||||
When an action answers 404 role_not_found
|
||||
Then the row is removed from the list and an info toast reads "That role no longer exists · It was deleted somewhere else, so we removed it from the list." and closes after 6 s
|
||||
And in FR "Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste."
|
||||
|
||||
Scenario: Reassign target deleted (409 target_role_not_found)
|
||||
Given the Delete role dialog of Intern is open with Writer chosen
|
||||
When POST /v1/role/{id}/reassign answers 409 target_role_not_found
|
||||
Then the inline error "Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to." of features/roles_sync_copy_activity.feature shows in the dialog, never a toast
|
||||
And GET /v1/role is re-read so the picker no longer offers Writer
|
||||
@@ -0,0 +1,55 @@
|
||||
# QA procedure: Role 409 conflicts (roles sync pass 6)
|
||||
|
||||
Spec: features/roles_sync_conflicts.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked through the existing kits (the useApi mock used in views/__tests__), access built with web/src/test/access.ts. The backend Rule adds Go tests. No live server, no real SMTP.
|
||||
|
||||
## Honesty rules
|
||||
0. Baseline `task test:web` and `task test` first (known Go failure from slice 7: TestRouterRoutesAreInTheCatalogue).
|
||||
1. A runner that cannot execute: its items are NOT RUN. 2. Report new and edited specs and baseline failures.
|
||||
3. Assert only the copy the design gives. "at 11:02" is rendered from a fixed `updated_at` through a pinned time zone; no spec freezes the system date.
|
||||
4. The closed-surface toasts are component-level specs (user decision), with no page-level trigger.
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| views/__tests__/RoleEditorView.spec.ts (extend) | banner copy with updated_by and time, Save off, Reload role == Use the saved role, Review differences dialog rows (only disagreeing settings), Keep my edits (reload, edits on top, markers, Save on, info toast "Role reloaded · Your 2 changes..." and counter), Use the saved role, Escape keeps banner, a second 409, name taken is not a conflict |
|
||||
| components/__tests__/RoleDifferencesDialog.spec.ts (new) | table Setting/Saved/Yours, EN/FR, the two actions, focus on open and return on close |
|
||||
| lib/__tests__/roleDiff.spec.ts (new) | settings that disagree, tri-state and Create switch, custom type rows, grid order |
|
||||
| components/__tests__/ChangeRoleDialog.spec.ts (extend) | 409 keeps the dialog open, alert copy with the re-read role, choice kept, Current mark moves, button enabled / disabled when the chosen role became current, second try sends the same role_id, no toast while open, member gone closes and refreshes, unmounted-dialog toast with Reload (component level) |
|
||||
| components/__tests__/TransferOwnershipDialog.spec.ts (extend) | 409 alert copy with Reload member, typed email kept, primary off until reload, reload re-enables, unmounted-dialog toast, other answers unchanged |
|
||||
| views/__tests__/RolesListView.spec.ts (extend) | 409 role_conflict on Duplicate, Delete, Move members: warning toast with Reload that refetches and dismisses; 404 removes the row and shows the info toast closing at 6 s (fake timers) |
|
||||
| components/__tests__/DeleteRoleDialog.spec.ts (extend) | 409 target_role_not_found: inline error, picker cleared and re-read, no toast; 400 invalid_reassign unchanged |
|
||||
| composables/__tests__/useRoles.spec.ts (extend) | refusal mapping adds target_role_not_found |
|
||||
| composables/__tests__/useUsers.spec.ts (extend) | user_role_changed leaves the re-read to the caller |
|
||||
| i18n/roleMessages.spec.ts (extend) | every group 12, 13 and 15 key exists in EN and FR, placeholders match |
|
||||
|
||||
Mocks to reuse: Contributor (version 2, 3), Jonas Weber (updated_by, 11:02), Ines Duarte Writer then Admin, Jonas Weber Writer, the board's difference rows (Events Update Own/All, Events Delete None/Own).
|
||||
|
||||
## Automated (Go, httptest, in-memory fakes) in test/
|
||||
Build the role handler with `NewHandler(...)` over the use cases and the in-memory fakes of role.Repository, user.Repository and the audit recorder of the existing roles API tests. Mount on a fresh `chi.NewRouter()` mirroring api/router.go with `jwtauth.Verifier` + `Authenticator` and a test `jwtauth.New("HS256", …)`. Call `validator.InitValidator()`. No SQLite file, no SMTP. Unique fake names.
|
||||
|
||||
| Test | Steps and assertions |
|
||||
|---|---|
|
||||
| TestReassignMissingTargetIsConflict | the Owner reassigns Contributor to an id that does not exist: 409, body `target_role_not_found`, no member moved, no event recorded |
|
||||
| TestReassignInvalidTargetsStay400 | table: itself, 0, Owner (locked), an uncovered role for a People manager: 400 `invalid_reassign`, nothing moved |
|
||||
| TestReassignMissingSourceIs404 | unknown source: 404 `role_not_found` |
|
||||
| TestReassignTargetSentinelLayering | the use case returns role.ErrReassignTargetNotFound (errors.Is); internal/domain/role imports no gorm, chi or infrastructure |
|
||||
|
||||
Update the existing tests of test/roles_api_* that expected 400 for a missing target. Mutation scope: `MUTATE_FILES=ReassignRoleMembers.go MUTATE_RUN='TestReassign' task mutate -- ./internal/infrastructure/useCases`. The existing 409 tests of ChangeUserRole and TransferOwnership (test/users_api_*) are the regression for `user_role_changed`.
|
||||
|
||||
## Visual (task visual:check)
|
||||
`task visual:check -- roles` for the group 15 states: Stale save banner, Stale save review differences, Stale save edits kept after reload, Change role member's role changed, Transfer ownership target's role changed; and the group 12 toasts. Board sample data as above. Light, dark, desktop, mobile within 10 %. The banner's "11:02" is a fixed frame string and the setup pins the time zone, so it is not clock-sensitive.
|
||||
|
||||
## Manual checks
|
||||
1. Two browsers: an admin edits Contributor, the owner saves first, the admin's save shows the banner with the real name and time; Keep my edits, then Save succeeds.
|
||||
2. Two browsers: an owner changes a member's role while an admin has Change role open, then confirms; the alert shows the member's real new role.
|
||||
3. Same for two simultaneous ownership transfers.
|
||||
4. Two browsers: delete Writer in one, in the other confirm "Move members" to Writer; the inline error shows.
|
||||
5. Focus: the alert is announced politely and focus returns to the dialog's primary or "Reload member"; Escape blocked while sending.
|
||||
6. Mobile 390 px: the difference table scrolls or stacks; 44 px targets; FR; dark.
|
||||
7. `task lint:web` green; `task test:web` and `task test` exit 0.
|
||||
|
||||
## Design gaps and notes
|
||||
- G1 The design draws a 409 for a deleted reassign target; the backend now matches through `target_role_not_found` (the body name is a proposal, not in the design).
|
||||
- G2 "Review differences" lists Setting / Saved / Yours rows; how a name or description difference is shown is not drawn.
|
||||
- G3 When the chosen role became current after the re-read, the design does not say what the button does; the draft disables it.
|
||||
- G4 Backend change here: one sentinel, one apperr string, one refusals-table row, docs/api.md.
|
||||
@@ -0,0 +1,169 @@
|
||||
Feature: Roles sync pass 6: final copy, neutral wording and role activity events
|
||||
|
||||
As a person using roles, users and activity in EN or FR
|
||||
I want the final toast, error and activity wording of the roles-sync design
|
||||
So that every message names what happened, says what was kept and says what to do next
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/roles.md groups 8, 12 and 13, the tables "Activity events: sentences", "Activity events: failed sentences", "Activity filter: category names" and "Final copy: toasts and errors, EN and FR", docs/design/states.md "Roles sync (pass 6)", docs/design/screens/activity.md "Roles sync (pass 6)" and docs/design/change-log-roles-sync.md section 1
|
||||
And the stale-state toasts, banners and dialog alerts of group 15 are in features/roles_sync_conflicts.feature, not here
|
||||
And the removal of a user (route, dialog, toast, user.remove event) is in features/roles_sync_remove_user.feature
|
||||
And the audit actions are role.create, role.edit, role.delete, role.reassign, user.change_role and user.transfer_ownership (internal/domain/audit/auditDomain.go), the outcome is a field and a failed event keeps its name
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
And a string is written "Title · Body": the first part is the toast or alert title, the second its description
|
||||
And French is asserted only for the strings of the "Final copy" table and the activity tables; strings the design gives in EN only are listed in the qa as design gaps
|
||||
And the "Why it failed" reasons of the design (role_conflict, role_locked, missing_permission) are out of this slice
|
||||
|
||||
Rule: Neutral wording
|
||||
|
||||
Scenario: No gendered pronoun in the role copy
|
||||
Then the Change role dialog reads "Their API keys follow the new role."
|
||||
And the Transfer ownership confirmation reads "You become an Admin. You can't take ownership back unless they transfer it."
|
||||
And no EN string of the roles, users, profile, API keys and activity screens contains he, she, his, her or him as a word
|
||||
|
||||
Rule: Role changed (group 12)
|
||||
|
||||
Scenario: The success toast
|
||||
Given Ines Duarte holds Writer and the Change role dialog is open
|
||||
When the person picks Content lead and PUT /v1/user/{id}/role answers 200
|
||||
Then a success toast reads "Role changed · Ines Duarte is now Content lead, and their API keys follow the new role."
|
||||
And it closes after 6 s
|
||||
And in FR it reads "Rôle modifié · Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle."
|
||||
|
||||
Rule: Role editor inline errors (group 13)
|
||||
|
||||
Scenario Outline: Save failed
|
||||
Given the person edited Contributor and presses Save changes
|
||||
When the server answers <failure>
|
||||
Then the editor shows "Couldn't save the role · Your changes are still here. Try again in a moment." with the action "Try again"
|
||||
And every edit is kept, Save changes stays enabled and "Try again" sends the same save again
|
||||
And in FR it reads "Impossible d'enregistrer le rôle · Vos modifications sont toujours là. Réessayez dans un instant." with "Réessayer"
|
||||
And it applies to POST /v1/role (new role) and PUT /v1/role/{id}
|
||||
Examples:
|
||||
| failure |
|
||||
| 500 |
|
||||
| a network failure |
|
||||
|
||||
Scenario: Replaces the generic error
|
||||
Then the placeholder wording of the previous slice for a failed save is gone from EN and FR
|
||||
|
||||
Rule: Change role inline error (group 13)
|
||||
|
||||
Scenario: Change role failed
|
||||
Given the Change role dialog is open with Contributor chosen
|
||||
When PUT /v1/user/{id}/role answers 500 or the network fails
|
||||
Then an alert in the dialog reads "Couldn't change the role · Nothing was changed. Try again in a moment."
|
||||
And the choice is kept and "Change role" stays enabled
|
||||
And in FR it reads "Impossible de modifier le rôle · Rien n'a été modifié. Réessayez dans un instant."
|
||||
|
||||
Scenario: Role above the caller
|
||||
Then a role above the caller's reads "Your role can't assign a role with more power than yours." in the picker and in the dialog
|
||||
And in FR "Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre."
|
||||
|
||||
Rule: Delete role inline errors (group 13)
|
||||
|
||||
Scenario Outline: Delete answers inside the dialog
|
||||
Given the Delete role dialog of <role> is open
|
||||
When <trigger>
|
||||
Then <effect>
|
||||
Examples:
|
||||
| role | trigger | effect |
|
||||
| Intern | POST /v1/role/{id}/reassign answers 409 target_role_not_found for the chosen target Writer | alert "Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to.", the picker is cleared and "Move 1 member and delete role" is disabled until a role is chosen |
|
||||
| Intern | DELETE answers 409 role_has_members and the re-read of GET /v1/role shows 1 member | the dialog turns into the blocked state with "Role still has members · Intern now has 1 member. Move them to another role first." and "Move 1 member and delete role" disabled until a role is chosen |
|
||||
| Intern | POST /v1/role/{id}/reassign answers 500 or the network fails with Writer chosen | alert "Couldn't move the members · Nothing was changed. Try again in a moment.", Writer stays chosen, the action stays enabled, DELETE is not sent |
|
||||
| Intern | POST /v1/role/{id}/reassign answers 400 invalid_reassign (same role, locked or uncovered target) | the existing refusal copy of features/roles_spa_screens.feature, the picker is cleared |
|
||||
And in FR the first three read "Writer n'existe plus · Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.", "Le rôle a encore des membres · Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle." and "Impossible de déplacer les membres · Rien n'a été modifié. Réessayez dans un instant."
|
||||
|
||||
Rule: Invite inline errors (group 13)
|
||||
|
||||
Scenario Outline: Invite answers
|
||||
Given the Invite user dialog holds "lea.martin@renew.example" and Writer
|
||||
When <trigger>
|
||||
Then <effect>
|
||||
And the dialog stays open and keeps the email and the role
|
||||
And in FR it reads <french>
|
||||
Examples:
|
||||
| trigger | effect | french |
|
||||
| the client check or a 400 rejects the address | under Email: "Enter a valid email address." | "Saisissez une adresse e-mail valide." |
|
||||
| POST /v1/user/invite answers 409 (existing account) | under Email: "This address already has an account." | "Cette adresse a déjà un compte." |
|
||||
| POST /v1/user/invite answers 403 escalation | under Role: "Your role can't assign a role with more power than yours." | "Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre." |
|
||||
| POST /v1/user/invite answers 500 or the network fails | alert "Couldn't send the invite · Try again in a moment." | "Impossible d'envoyer l'invitation · Réessayez dans un instant." |
|
||||
And the existing 409 "pending invite" state of users.md is unchanged
|
||||
|
||||
Rule: Final copy parity
|
||||
|
||||
Scenario: Every string of the "Final copy" table exists in EN and FR
|
||||
Then each key of the table exists in i18n/messages.ts with matching placeholders
|
||||
And roleMessages.spec.ts lists them
|
||||
|
||||
Rule: Backend: the destination name of a reassignment
|
||||
|
||||
Background for this Rule (house style of roles_permissions_roles_api.feature):
|
||||
Given the audit event role.reassign carries the params from (source role id), to (destination role id) and moved
|
||||
And this slice adds one additive param, to_name: the destination role's name at move time, a string
|
||||
And the event target keeps naming the source role, so the source name stays available after the source is deleted
|
||||
And no new route, no new permission and no schema change: params are stored as the existing JSON map
|
||||
And the layer touched is internal/infrastructure/useCases/ReassignRoleMembers.go and its audit param constant in internal/domain/audit; docs/api.md gains one line
|
||||
|
||||
Scenario: The param is recorded
|
||||
When POST /v1/role/{id}/reassign moves 4 members from Contributor to Writer
|
||||
Then the recorded role.reassign event has params from, to, moved 4 and to_name "Writer"
|
||||
And a reassignment that moves 0 members still records nothing
|
||||
And a refusal still records nothing
|
||||
|
||||
Scenario: Old events
|
||||
Given a stored role.reassign event without to_name
|
||||
Then GET /v1/activity returns it unchanged and the SPA uses the fallback below
|
||||
|
||||
Rule: Activity sentences for the six events
|
||||
|
||||
Scenario Outline: Success sentences
|
||||
Given GET /v1/activity returns an event <action> with outcome success
|
||||
Then the sentence reads <sentence> and in FR <french>
|
||||
Examples:
|
||||
| action | data | sentence | french |
|
||||
| role.create | target role Contributor | Role Contributor created | Rôle Contributor créé |
|
||||
| role.edit | target role Contributor | Role Contributor edited | Rôle Contributor modifié |
|
||||
| role.delete | target role Intern | Role Intern deleted | Rôle Intern supprimé |
|
||||
| role.reassign | target role Intern, params to_name Writer, moved 3 | Moved 3 members from Intern to Writer | 3 membres déplacés de Intern vers Writer |
|
||||
| role.reassign | target role Intern, params to_name Writer, moved 1 | Moved 1 member from Intern to Writer | 1 membre déplacé de Intern vers Writer |
|
||||
| user.change_role | target Ines Duarte, changes role before Writer after Content lead | Ines Duarte's role changed from Writer to Content lead | Le rôle d'Ines Duarte est passé de Writer à Content lead |
|
||||
| user.transfer_ownership | actor Marta Alves, target Jonas Weber | Ownership transferred from Marta Alves to Jonas Weber | Propriété transférée de Marta Alves à Jonas Weber |
|
||||
And the sentences "Moved the members of the role {0}" and "Changed the role of {0}" are no longer the output for these events
|
||||
|
||||
Scenario Outline: Failed sentences
|
||||
Given the event <action> has the outcome failed
|
||||
Then the sentence reads <sentence> followed by the Failed badge, and in FR <french>
|
||||
Examples:
|
||||
| action | sentence | french |
|
||||
| role.create | Failed to create the role Contributor | Échec de la création du rôle Contributor |
|
||||
| role.edit | Failed to edit the role Contributor | Échec de la modification du rôle Contributor |
|
||||
| role.delete | Failed to delete the role Intern | Échec de la suppression du rôle Intern |
|
||||
| role.reassign | Failed to move 3 members from Intern to Writer | Échec du déplacement de 3 membres de Intern vers Writer |
|
||||
| user.change_role | Failed to change the role of Ines Duarte | Échec du changement de rôle d'Ines Duarte |
|
||||
| user.transfer_ownership | Failed to transfer ownership to Jonas Weber | Échec du transfert de la propriété à Jonas Weber |
|
||||
|
||||
Scenario Outline: role.reassign without the destination name or the count (old events, failed events)
|
||||
Given a role.reassign event with outcome <outcome> whose params lack <missing>
|
||||
Then the sentence reads <sentence>
|
||||
And it never contains "undefined" or an empty name
|
||||
Examples:
|
||||
| outcome | missing | sentence |
|
||||
| success | to_name | Moved 3 members from Intern to another role |
|
||||
| success | moved | Moved the members of the role Intern |
|
||||
| failed | to_name, moved | Failed to move the members of the role Intern |
|
||||
And these fallback wordings are a design gap (the design draws only the full sentences); the coder uses them and the qa lists them
|
||||
|
||||
Scenario: Unknown action
|
||||
Given an event whose action the catalogue does not know
|
||||
Then it renders as before and does not crash
|
||||
|
||||
Rule: Activity category names
|
||||
|
||||
Scenario: The category select
|
||||
When the Category select is open
|
||||
Then the options read All categories, Articles, Media, Users and invites, Roles, API keys, Authentication, System, in that order
|
||||
And in FR Toutes les catégories, Articles, Médias, Utilisateurs et invitations, Rôles, Clés API, Authentification, Système
|
||||
And "Users & invites" no longer appears in the Activity filter, the permission groups or the API catalogue
|
||||
And choosing Roles requests category roles and shows the role events
|
||||
And choosing Users and invites keeps requesting category users
|
||||
@@ -0,0 +1,56 @@
|
||||
# QA procedure: Roles sync pass 6, final copy and role activity events
|
||||
|
||||
Spec: features/roles_sync_copy_activity.feature. Vitest + Vue Test Utils under `task test:web`, /v1 mocked with the existing kits (useApi mock of views/__tests__, access bodies from web/src/test/access.ts, activityFixtures.ts). The backend Rule adds a Go test. No live server, no real SMTP.
|
||||
|
||||
## Honesty rules
|
||||
0. First run `task test:web` and `task test` on the unmodified tree and record exit codes and failing specs (known Go failure from slice 7: TestRouterRoutesAreInTheCatalogue).
|
||||
1. If a runner cannot execute, every item of that side is NOT RUN, never passing.
|
||||
2. Report each new or edited spec and which already failed in the baseline.
|
||||
3. Assert only copy the design gives. FR is asserted only where the design table gives it. The fallback sentences of the feature are a design gap and are asserted as the coder writes them, flagged in the report.
|
||||
4. No spec freezes the system date; fixed `created_at` strings; no relative-date assertion.
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| lib/__tests__/activitySentence.spec.ts (extend) | the six success sentences EN/FR, role.reassign with moved 3 and 1 and to_name, the six failed sentences EN/FR, the three fallback rows (no "undefined"), unknown action |
|
||||
| lib/__tests__/activityCategories.spec.ts (extend) | category list and order, "Users and invites", "Roles" requests `category=roles`, "Users & invites" absent |
|
||||
| views/__tests__/ActivityRoles.spec.ts (extend) | list with the six events, the six failed rows with the Failed badge |
|
||||
| views/__tests__/ActivityFilters.spec.ts (extend) | the select options EN/FR |
|
||||
| components/__tests__/ChangeRoleDialog.spec.ts (extend) | success toast EN/FR with member and role names, "Couldn't change the role" alert for 500 and network, choice kept, button enabled |
|
||||
| views/__tests__/RoleEditorView.spec.ts (extend) | save failed (500, network) for POST and PUT: alert, "Try again", edits kept, Save enabled |
|
||||
| components/__tests__/DeleteRoleDialog.spec.ts (extend) | 409 target_role_not_found: alert, picker cleared, action off; 400 invalid_reassign keeps the existing refusal; role_has_members with the live count; 500 on reassign keeps the choice and sends no DELETE |
|
||||
| components/__tests__/InviteUserModal.spec.ts (extend) | the four invite errors placement (under Email, under Role, alert), email and role kept |
|
||||
| i18n/roleMessages.spec.ts (extend) | every key of the "Final copy" table exists in EN and FR, placeholders match |
|
||||
| i18n/neutralWording.spec.ts (new) | no EN value in the roles, users, profile, api-key and activity keys matches \b(he\|she\|his\|her\|him)\b |
|
||||
|
||||
Mocks to reuse: Ines Duarte, Jonas Weber, Marta Alves; roles Owner, Admin, Writer, Contributor, Content lead, Intern (1 member).
|
||||
|
||||
## Automated (Go, httptest, in-memory fakes) in test/
|
||||
Build the role handler with `NewHandler(...)` over the use cases and the in-memory fakes of role.Repository, user.Repository and the audit recorder of the existing roles API tests. Mount on a fresh `chi.NewRouter()` mirroring api/router.go with `jwtauth.Verifier` + `Authenticator` and a test `jwtauth.New("HS256", …)`. Call `validator.InitValidator()`. No SQLite file, no SMTP. New fake names must be unique across the package.
|
||||
|
||||
| Test | Steps and assertions |
|
||||
|---|---|
|
||||
| TestReassignRecordsDestinationName | POST /v1/role/{Contributor}/reassign {target_role_id: Writer} with 4 members: the recorded event params are from, to, moved 4 and to_name "Writer" |
|
||||
| TestReassignZeroMovedRecordsNothing | 0 members: 200 `{"moved":0}`, no event (unchanged) |
|
||||
| TestReassignRefusalRecordsNothing | a refusal (400, 403, 404, 409) records no event |
|
||||
|
||||
Extend, do not duplicate, test/roles_api_usecases_test.go line ~735 (the existing params assertion). Mutation scope for the touched use case: `MUTATE_FILES=ReassignRoleMembers.go MUTATE_RUN='TestReassign' task mutate -- ./internal/infrastructure/useCases`. docs/api.md gains one line for `to_name`. `task lint:go` and `gofmt -l` apply.
|
||||
|
||||
## Visual (task visual:check)
|
||||
Screens `roles` and `activity`; states from docs/design/screens/roles.md groups 8, 12 and 13. Sample data is the board's: Ines Duarte, Jonas Weber, Marta Alves, Contributor, Intern, Writer, Content lead, 3 members moved. Gate: every capture within 10 % in light and dark, desktop and mobile. `skip: clock-sensitive` for any activity row whose rendering depends on relative time; the role rows use fixed timestamps from the frame and are checked.
|
||||
States: Role changed toast, Role editor save failed, Change role failed, Delete role target deleted / role gained members / move failed, Invite invalid email / account exists / role above yours / send failed, Activity role events list, failed role events, category filter open.
|
||||
|
||||
## Manual checks
|
||||
1. Toast durations (6 s; warnings stay until dismissed or Reload), polite announcement.
|
||||
2. Keyboard: focus lands on "Try again", and on the field with the error in the invite dialog.
|
||||
3. Real browser, FR: read every message of the table once.
|
||||
4. Real flow: move members of a role to another and read the event in /activity with the destination name.
|
||||
5. `task lint:web` (eslint and vue-tsc) green; `task test:web` and `task test` exit 0.
|
||||
|
||||
## Design gaps and notes
|
||||
- G1 FR for strings the design gives in EN only (Read = Own banners and empty states, Profile card tags, Integration copy) is proposed by the coder and not asserted.
|
||||
- G2 The activity reasons ("Why it failed") are out of this slice by decision.
|
||||
- G3 Fallback wordings for role.reassign events without to_name or moved ("Moved 3 members from Intern to another role", "Moved the members of the role Intern", "Failed to move the members of the role Intern") are not in the design.
|
||||
- G4 Singular "Failed to move 1 member" is derived from the success sentence; the design gives the failed sentence for 3 only. A failed reassign does not record `moved`, so the failed full sentence appears only if the event carries it.
|
||||
- G5 The user.remove sentences are in features/roles_sync_remove_user.feature.
|
||||
- Backend change: the additive `to_name` param only.
|
||||
@@ -0,0 +1,87 @@
|
||||
Feature: Integration page: permission-gated routes and role-aware copy
|
||||
|
||||
As a developer reading the API reference
|
||||
I want routes that need a permission to say so, and the authentication copy to describe roles as they are now
|
||||
So that a key's owner knows which permission a route needs
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/integration.md (Page copy, EndpointCard states, Error messages) and docs/design/change-log-roles-sync.md "Integration page"
|
||||
And the catalogue is web/src/data/apiCatalogue.ts and its search index; routes are gated in api/router.go by container.require(...) with the permission keys of internal/domain/role
|
||||
And the real gating is:
|
||||
| route | permission label (lib/permissionNames.ts) |
|
||||
| POST /v1/content-type/ | Create and edit content types |
|
||||
| PUT /v1/content-type/{key} | Create and edit content types |
|
||||
| DELETE /v1/content-type/{key} | Delete content types |
|
||||
| GET /v1/builtin-type/ | View content types |
|
||||
| GET /v1/builtin-type/{key} | View content types |
|
||||
| GET /v1/media/purge-settings | View settings |
|
||||
| PATCH /v1/media/purge-settings | Edit general settings |
|
||||
| GET /v1/media/purge-preview | Delete files |
|
||||
| POST /v1/media/purge | Delete files |
|
||||
And DELETE /v1/user/{id} ("Remove users") is added by features/roles_sync_remove_user.feature with the same chip
|
||||
And a missing permission answers 403 with the literal plain-text body `missing_permission`
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
|
||||
Rule: Access chip
|
||||
|
||||
Scenario Outline: The nine routes
|
||||
Given the endpoint card <route>
|
||||
Then its access chip reads "Needs permission"
|
||||
And its description ends with the sentence Needs the permission "<permission>".
|
||||
And the chip is not "Owner or admin"
|
||||
Examples:
|
||||
| route | permission |
|
||||
| POST /v1/content-type/ | Create and edit content types |
|
||||
| PUT /v1/content-type/{key} | Create and edit content types |
|
||||
| DELETE /v1/content-type/{key} | Delete content types |
|
||||
| GET /v1/builtin-type/ | View content types |
|
||||
| GET /v1/builtin-type/{key} | View content types |
|
||||
| GET /v1/media/purge-settings | View settings |
|
||||
| PATCH /v1/media/purge-settings | Edit general settings |
|
||||
| GET /v1/media/purge-preview | Delete files |
|
||||
| POST /v1/media/purge | Delete files |
|
||||
|
||||
Scenario: The access level type
|
||||
Then the access levels are Public, Verified user, Needs permission, Owner only
|
||||
And "Owner or admin" is no longer a chip anywhere in the catalogue
|
||||
And "Owner only" stays on the routes that are owner-only
|
||||
|
||||
Scenario: The permission names stay in step with the product
|
||||
Then each permission named in a description equals the label returned by lib/permissionNames.ts for the key the router requires
|
||||
|
||||
Rule: Per-route 403 line
|
||||
|
||||
Scenario: missing_permission
|
||||
Given any of the nine expanded cards
|
||||
Then the Errors list shows a 403 chip with the caption `missing_permission` in code style
|
||||
And "Only owners and admins can do this." no longer appears
|
||||
|
||||
Scenario: The Errors table
|
||||
Then the 403 row lists "This action needs a real session.", `missing_permission` (code style, literal body) and "You must verify your account to access this resource."
|
||||
And the note under the row reads "`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article."
|
||||
And "Only the owner can do this." and "Only owners and admins can do this." no longer appear
|
||||
|
||||
Rule: Section copy
|
||||
|
||||
Scenario: Authentication, API key card
|
||||
Then the card reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator's role lacks it."
|
||||
And "(owner or admin)" no longer appears
|
||||
|
||||
Scenario: Content types section intro
|
||||
Then it reads "Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes."
|
||||
And it no longer says "An owner or admin creates a type"
|
||||
|
||||
Scenario: Entries section intro
|
||||
Then it reads "Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role."
|
||||
And it no longer says "Any verified user can write entries"
|
||||
|
||||
Scenario: Sentences that still mention owner or admin
|
||||
Then no sentence of the Authentication section or the content-type and built-in groups says that owner or admin routes answer 403 to a key whose creator is neither
|
||||
|
||||
Rule: Search
|
||||
|
||||
Scenario: The search index follows the catalogue
|
||||
When the person types "permission" in the palette
|
||||
Then the permission-gated routes match through the access chip and the description, and the live region announces the count
|
||||
And typing "missing_permission" matches the error code of those routes
|
||||
And "owner or admin" no longer matches the nine routes
|
||||
@@ -0,0 +1,29 @@
|
||||
# QA procedure: Integration catalogue and role-aware copy (roles sync pass 6)
|
||||
|
||||
Spec: features/roles_sync_integration_catalogue.feature. Vitest + Vue Test Utils under `task test:web`. Static data, no /v1 call, no live server.
|
||||
|
||||
## Honesty rules
|
||||
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures.
|
||||
3. The two section intros are the user-approved proposals, not design copy; mark them as such in the report.
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| data/__tests__/apiCatalogue.spec.ts (extend) | the nine routes have chip "Needs permission" and a description ending `Needs the permission "<label>"`; no route keeps "Owner or admin"; Owner only routes keep their chip; the 403 caption is `missing_permission` for the nine; the Errors table 403 row and its note; the Authentication card sentence; the two intros; the permission label equals lib/permissionNames.ts for the key the router requires |
|
||||
| views/__tests__/IntegrationView.spec.ts or the existing integration spec (extend) | the chip renders, mobile shows it in the chips line, search "permission" and "missing_permission" returns the routes, "owner or admin" does not |
|
||||
| data/__tests__/apiGating.spec.ts (new) | a fixed table of the nine routes (plus DELETE /v1/user/{id}) and the keys of api/router.go; fails if a label changes |
|
||||
|
||||
## Visual (task visual:check)
|
||||
Run `task visual:check -- integration`. States of the "Integration" group: 2 (Media group, POST /v1/media/), 4 Authentication, 6 Errors, 9a to 9j Custom content types, and the search states 8c-8e. Board sample data: the origin `https://cms.renew.example`, the board's media list and purge samples. Light, dark, desktop, mobile within 10 %. Not clock-sensitive. The board is from before the new DELETE /v1/user/{id} card and the Users group count of 7, which may exceed 10 % on that group (see G5).
|
||||
|
||||
## Manual checks
|
||||
1. Real server: call each gated route with a key whose creator's role lacks the permission and read the 403 body `missing_permission`.
|
||||
2. Search palette keyboard flow with the new chip.
|
||||
3. `task lint:web` green; `task test:web` exit 0.
|
||||
|
||||
## Design gaps and notes
|
||||
- G1 screens/integration.md "Custom content types round" still says "Owner or admin" and "Only owners and admins can do this."; pass 6 supersedes it. The user should refresh that file.
|
||||
- G2 Open item 5 of the change log (do owner-only routes answer `missing_permission`?) is not verified here; "Owner only" chips are unchanged.
|
||||
- G3 Both intros are proposals approved by the user, not from the design.
|
||||
- G4 Left alone, out of scope: screens/schema.md and the Schema frames ("Owner or admin" for Schema), states.md "Sites and authors: roles" and the 403 owner_only rows, screens/profile.md Display name copy.
|
||||
- G5 The DELETE /v1/user/{id} card is not on the Integration board.
|
||||
@@ -0,0 +1,55 @@
|
||||
Feature: Profile role card of the roles-sync design (group 9)
|
||||
|
||||
As a signed-in person
|
||||
I want my role card to show my role, whether it is locked, built-in or custom, and what I can do
|
||||
So that I can understand my access without reading who assigned it
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/roles.md group 9 and docs/design/change-log-roles-sync.md section 2
|
||||
And features/roles_spa_users.feature "Profile, your role" is amended by this file: the summary line and "Assigned by" are removed
|
||||
And GET /v1/auth/me answers access.role {id, name, built_in, locked} and access.permissions; the design says GET /v1/me, the real route is /v1/auth/me
|
||||
And the card layout, top to bottom, inside the existing card with a gap of 12: the title, one row of badges, the link row with the list-checks icon, the muted key line
|
||||
And the backend keeps answering role_assigned_by (slice 7); the SPA ignores it
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
|
||||
Rule: The card
|
||||
|
||||
Scenario Outline: Role card states
|
||||
Given GET /v1/auth/me answers access.role <role>
|
||||
When the person opens /profile
|
||||
Then the card "Your role" shows the RoleBadge <badge>, then <tags>, in one row
|
||||
And the link row shows "See what I can do" with the list-checks icon
|
||||
And the muted line reads "Your API keys follow this role. If your role changes, they change too."
|
||||
And "Locked" is absent when locked is false and "Custom" is absent when built_in is true
|
||||
Examples:
|
||||
| role | badge | tags |
|
||||
| {"name": "Contributor", "built_in": false, "locked": false} | Contributor | Custom |
|
||||
| {"name": "Owner", "built_in": true, "locked": true} | Owner | Locked, then Built-in |
|
||||
| {"name": "Writer", "built_in": true, "locked": false} | Writer | Built-in |
|
||||
|
||||
Scenario: Removed content
|
||||
Given access.role_assigned_by is {"id": 3, "name": "Marta Alves"}
|
||||
Then the card shows no summary line such as "Creates content and edits only their own."
|
||||
And the card shows no "Assigned by" text and no empty placeholder
|
||||
And the Users table and cards still show no role_assigned_by
|
||||
|
||||
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 its body is built from the permission list of the session, with no GET /v1/role call
|
||||
And Close returns focus to the link
|
||||
|
||||
Scenario: The card follows the session
|
||||
Given the Profile is open
|
||||
When the session is refreshed and access.role becomes Writer, built-in
|
||||
Then the badge reads "Writer", the tags become Built-in and the summary dialog follows
|
||||
|
||||
Scenario: While the session loads
|
||||
Given the session has not loaded
|
||||
Then the existing profile skeleton shows with the role card inside it and no role text
|
||||
|
||||
Rule: Backend
|
||||
|
||||
Scenario: No change
|
||||
Then GET /v1/auth/me and GET /v1/user keep answering role_assigned_by
|
||||
And access.role already carries built_in and locked (lib/permissions.ts), so no endpoint changes
|
||||
@@ -0,0 +1,28 @@
|
||||
# QA procedure: Profile role card (roles sync pass 6)
|
||||
|
||||
Spec: features/roles_sync_profile_role_card.feature. Vitest + Vue Test Utils under `task test:web`, access from web/src/test/access.ts (ownerAccess, customAccess, writerAccess).
|
||||
|
||||
## Honesty rules
|
||||
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures. 3. EN strings are asserted; FR for "Custom", "Locked", "Built-in" is proposed by the coder (gap G1).
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| views/__tests__/ProfileView.spec.ts (edit) | the three card states and their tag rows and order, no summary line, no "Assigned by" with an object, null and an absent key (replaces the slice 7 case that asserted "Assigned by Marta Alves"), See what I can do desktop dialog and mobile sheet, Close and focus return, live role change, skeleton, no GET /v1/role call |
|
||||
| lib/__tests__/permissions.spec.ts (edit) | role_assigned_by is still parsed but not used by Profile |
|
||||
| i18n/roleMessages.spec.ts (extend) | keys for Locked, Built-in, Custom in EN and FR |
|
||||
| components/__tests__/RoleBadge.spec.ts (extend if it exists) | the Owner crown icon and the locked variant |
|
||||
|
||||
## Visual (task visual:check)
|
||||
Run `task visual:check -- profile`. States: Role card custom role, Role card Owner. Board sample data: Contributor (Custom) and Owner (Locked, Built-in), key line as in the design. Light, dark, desktop, mobile within 10 %. The card sits after the Account card (G8 of slice 7; the coder confirms against the board).
|
||||
|
||||
## Manual checks
|
||||
1. Tab order: the link is reachable after the badges; focus returns after Close.
|
||||
2. Mobile 390 px: the badge row wraps without overflow, 44 px target for the link.
|
||||
3. `task lint:web` green; `task test:web` exit 0.
|
||||
|
||||
## Design gaps and notes
|
||||
- G1 FR copy for the tags is not in the design.
|
||||
- G2 Backend `role_assigned_by` stays (user decision); the SPA ignores it.
|
||||
- G3 The design routes cite GET /v1/me; the real one is /v1/auth/me.
|
||||
- Leftover, out of scope: screens/profile.md Display name copy is stale.
|
||||
@@ -0,0 +1,101 @@
|
||||
Feature: Read = Own surfaces of the roles-sync design (group 14)
|
||||
|
||||
As a person whose role reads only their own content
|
||||
I want the editor, the Articles list and the entry list to say what I can see
|
||||
So that hidden items are not hinted at and the counts match what I can open
|
||||
|
||||
Background:
|
||||
Given the backend contract of features/roles_permissions_content_scope.feature: a caller with content.read=own gets online articles plus their own drafts, and only the entries they created, with counts, total, page and size computed over the visible set
|
||||
And the spec of record is docs/design/screens/roles.md group 14, its table "Read = Own: what each surface returns", and docs/design/change-log-roles-sync.md section 4
|
||||
And the SPA never computes visibility: every count, total and pager value shown comes from the API (GET /v1/article counts and total, GET /v1/content-type/{key}/entry total)
|
||||
And the SPA knows a person reads Own through scopeOf("content.read", type) of stores/permissions.ts
|
||||
And Update or Delete set to Own keep the "View only" rows of slice 5; only Read = Own hides rows
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
|
||||
Rule: Role editor
|
||||
|
||||
Scenario: Info alert when Read is Own
|
||||
Given the editor shows a role where Read is Own on at least one type row
|
||||
Then an info alert under the grid reads "Read: Own · Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule."
|
||||
And in FR "Lecture : Les siennes · Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle."
|
||||
And it is absent when no Read cell is Own, and it appears and disappears live as the cell changes
|
||||
|
||||
Scenario Outline: Type-specific preview sentences
|
||||
Given the Read cell of <row> is Own
|
||||
Then the preview line for <row> reads <sentence>
|
||||
Examples:
|
||||
| row | sentence |
|
||||
| Articles | Can read online articles and their own drafts, create entries and edit only their own. |
|
||||
| a custom type (Events) | Can read only entries they created, create entries and edit only their own. |
|
||||
| All content types | one line per kind of type: the Articles sentence and the custom-type sentence |
|
||||
And "read only their own" is gone from the preview
|
||||
And a row with Read All still reads "Can read any entry, ..." as before
|
||||
|
||||
Scenario: Read-only editor
|
||||
Given the person holds roles.view only and opens a role whose Read is Own
|
||||
Then the alert and the sentences render the same way, with no Save bar
|
||||
|
||||
Rule: Articles list
|
||||
|
||||
Scenario: Read = Own with drafts of their own
|
||||
Given the person is Pedro Lima, Junior writer, with content.read=own
|
||||
And GET /v1/article answers 3 online articles by others and 2 own drafts, counts all 5, online 3, draft 2
|
||||
When the person opens /articles
|
||||
Then the header reads "Articles" with "Online articles and the drafts you created."
|
||||
And a banner reads "Drafts by others are hidden · Your role reads online articles and the drafts you created. The counts follow the same rule."
|
||||
And the tabs read All 5, Online 3, Draft 2, taken from the counts of the answer
|
||||
And "New article" is enabled when content.create allows it
|
||||
|
||||
Scenario: No drafts of their own
|
||||
Given GET /v1/article?status=draft answers no items and counts all 3, online 3, draft 0
|
||||
When the person opens the Draft tab
|
||||
Then the empty state reads "You have no drafts · Drafts you create appear here. Your role can't see drafts by other people." with "New article"
|
||||
And the tabs read All 3, Online 3, Draft 0
|
||||
|
||||
Scenario Outline: The banner and the empty state depend on Read being Own
|
||||
Given the person has content.read=<scope>
|
||||
Then the banner "Drafts by others are hidden" is <banner>
|
||||
And an empty Draft tab shows <empty>
|
||||
Examples:
|
||||
| scope | banner | empty |
|
||||
| own | shown | "You have no drafts" |
|
||||
| all | absent | the existing empty state of the articles list |
|
||||
|
||||
Rule: Entry list of a custom type
|
||||
|
||||
Scenario: Read = Own with entries of their own
|
||||
Given content.read=own on Events and GET /v1/content-type/events/entry answers 3 items, total 3
|
||||
When the person opens /content/events
|
||||
Then the header reads "Events" with "Your role shows only entries you created."
|
||||
And a banner reads "Entries by others aren't shown · Your role reads only entries you created. Ask an owner or admin if you need to see more."
|
||||
And the count line reads "3 entries" from total, and no pager shows when total fits one page
|
||||
And "New entry" is enabled when content.create allows it
|
||||
|
||||
Scenario: No entries of their own
|
||||
Given the answer has no items and total 0
|
||||
Then the empty state reads "You haven't created an event yet · Your role shows only entries you created, so other people's events aren't listed here. Use New entry to add one." with "New entry"
|
||||
And nothing hints that other people's entries exist
|
||||
|
||||
Scenario Outline: The singular noun is derived from the type name
|
||||
Given the content type is named <name>
|
||||
Then the empty state title reads <title>
|
||||
And the body reads "Your role shows only entries you created, so other people's <plural> aren't listed here. Use New entry to add one."
|
||||
Examples:
|
||||
| name | title | plural |
|
||||
| Events | You haven't created an event yet | events |
|
||||
| Recipes | You haven't created a recipe yet | recipes |
|
||||
| Press mentions | You haven't created a press mention yet | press mentions |
|
||||
| Categories | You haven't created a category yet | categories |
|
||||
And the derivation reuses the helper that already singularises type names for the relation sentences (lib/relationSentences.ts), lowercased
|
||||
And irregular names (for example "People", "Series") are a design gap of the qa
|
||||
|
||||
Scenario: Direct link to a hidden item
|
||||
Given GET /v1/content-type/events/entry/1 answers 404 entry_not_found
|
||||
Then the existing "Entry not found" page shows, with no hint that the entry exists
|
||||
And GET /v1/article/{id} of someone else's draft answers 404 and shows the existing article-not-found page
|
||||
|
||||
Rule: Scope of the change
|
||||
|
||||
Scenario: The SPA does not recompute
|
||||
Given the API answers counts all 2, online 2, draft 0 for a reader with Read = Own
|
||||
Then the tabs read exactly those numbers whatever the person's scope
|
||||
@@ -0,0 +1,34 @@
|
||||
# QA procedure: Read = Own surfaces (roles sync pass 6)
|
||||
|
||||
Spec: features/roles_sync_read_own.feature. Vitest + Vue Test Utils under `task test:web`. /v1 mocked through the existing kits; sessions from web/src/test/access.ts (customAccess with content.read=own). No live server.
|
||||
|
||||
## Honesty rules
|
||||
0. Baseline `task test:web` first. 1. Runner cannot execute: NOT RUN. 2. Report new and edited specs and baseline failures. 3. EN is asserted; FR only for the editor note (the design gives it); FR for the banners and empty states is proposed (gap G1). 4. No spec freezes the date.
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| views/__tests__/RoleEditorView.spec.ts (extend) | info alert present/absent with Read own/all, live toggle, read-only editor, EN/FR text |
|
||||
| lib/__tests__/rolePreview.spec.ts (extend) | Articles and custom-type sentences for Read own, All-types grouping, "read only their own" gone, Read all unchanged |
|
||||
| views/__tests__/ArticlesReadOwn.spec.ts (new; the articles list specs today are ArticlesPagination, ArticlesSitesAuthors, SeoArticles) | banner and header for Read own, tabs from counts (5/3/2), Draft tab empty state with counts 3/3/0, banner absent and existing empty state for Read all, counts taken as returned |
|
||||
| views/__tests__/ContentList.spec.ts (extend) | Read own header, banner, "3 entries", no pager; empty state with the singular noun (Events, Recipes, Press mentions, Categories); Read all unchanged |
|
||||
| views/__tests__/ContentListNavigation.spec.ts or an entry-not-found spec (extend) | direct link 404 shows the existing page and no hint |
|
||||
| lib/__tests__/relationSentences.spec.ts (extend) | the singular helper on the four names of the feature |
|
||||
| i18n/roleMessages.spec.ts (extend) | the new keys exist in EN and FR, placeholders match |
|
||||
|
||||
Mocks to reuse: Pedro Lima (Junior writer), the board's 3 online articles and 2 drafts; Events with 3 entries and with none.
|
||||
|
||||
## Visual (task visual:check)
|
||||
All under the `roles` screen (user decision): "Role editor: Read set to Own", "Articles list: Read = Own", "Articles list: no drafts of their own", "Entry list: Read = Own", "Entry list: no entries of their own". web/visual/states.ts gets the setups. Board data: Pedro Lima, All 5 / Online 3 / Draft 2, All 3 / Online 3 / Draft 0, Events "3 entries". Light, dark, desktop, mobile within 10 %.
|
||||
|
||||
## Manual checks
|
||||
1. Real flow: create "Junior writer" with Read Own, assign it to a user, open /articles and /content/events as that user, compare tabs with the database.
|
||||
2. Two browsers: the owner publishes a draft of someone else while the Junior writer has the list open; reload shows the new online count.
|
||||
3. FR read-through.
|
||||
4. `task lint:web` green; `task test:web` exit 0.
|
||||
|
||||
## Design gaps and notes
|
||||
- G1 FR for the banners and empty states is not in the design.
|
||||
- G2 The singular noun is derived from the type name; irregular names ("People", "Series") produce a wrong noun. A per-type singular name is a possible later field.
|
||||
- G3 The mobile layout of the banner is read from the board frame.
|
||||
- Backend: no change; counts and totals already come from the API (CountVisibleTo).
|
||||
@@ -0,0 +1,147 @@
|
||||
Feature: Remove a user, guarded by the member's role (backend route and SPA action)
|
||||
|
||||
As an owner, or a person whose role holds "Remove users"
|
||||
I want to take someone's access away
|
||||
So that a removal never hits a member whose role changed since I looked, and never exceeds my own power
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/roles.md group 5 (row menu "Remove user", owner row "Remove user (disabled)"), group 12 (toast "User not removed") and the tables "HTTP status to copy" and "No-escalation feedback"
|
||||
And the permission is users.remove ("Remove users", an on/off toggle of the catalogue, internal/domain/role KeyUsersRemove), already known to the catalogue and to the role editor
|
||||
And the guards mirror ChangeUserRole and TransferOwnership (internal/infrastructure/useCases): ensureCoveredBy for the caller's power, role.ErrOwnRole, role.ErrRoleLocked, user.ErrRoleChanged, user.ErrUserNotFound
|
||||
And a refusal is plain text: user_role_changed, own_role, role_locked, escalation (X-Escalation-Key), missing_permission (X-Missing-Permission), user_not_found, invalid_role
|
||||
And the dialog copy and the success toast copy of a removal are not in the design (design gap G1, open question Q-RM1): they are specced as behaviour, never asserted as text
|
||||
And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code
|
||||
|
||||
Rule: Backend: DELETE /v1/user/{id}
|
||||
|
||||
Background for this Rule (house style of roles_permissions_users_api.feature):
|
||||
Given the route is DELETE /v1/user/{id}?expected_role_id=<N>, mounted under /v1/user inside the JWT group of api/router.go, gated by container.require(role.KeyUsersRemove)
|
||||
And expected_role_id is the id of the role the client believes the member holds (the id of the role of the list item the person saw); it is required, a uint32 greater than 0
|
||||
And a query parameter is used because a DELETE body is dropped by some proxies; no request body is read
|
||||
And the layers touched are: internal/domain/user (Repository gains DeleteIfRole(id, roleID uint32) error, a compare-and-swap that deletes only if the stored role_id still equals roleID and answers ErrRoleChanged otherwise, ErrUserNotFound when the row is gone), internal/domain/audit (ActionUserRemove = "user.remove"), the new use case internal/infrastructure/useCases/RemoveUser.go (RemoveUserUseCase, RemoveUserCommand{Caller, TargetID, ExpectedRoleID}), persistence/repositories userRepository.go, a new handler method on api/handlers/user, the route in api/router.go, api/dependecyInjection.go, the integration catalogue and docs/api*.md
|
||||
And no handler reaches a repository directly, and no domain package imports gorm
|
||||
And the order of checks is: gate users.remove (403 missing_permission), id format (400 with the existing uint32 message), expected_role_id (400 invalid_role), target lookup (404 user_not_found), target is the caller (403 own_role), target role differs from expected_role_id (409 user_role_changed), target role locked (403 role_locked), target role not covered by the caller (403 escalation), then DeleteIfRole
|
||||
And only accepted users are removable; invites are cancelled through DELETE /v1/user/invite/{id}, unchanged
|
||||
|
||||
Scenario Outline: Answers
|
||||
Given the world of features/roles_permissions_users_api.feature (Owner Marta Alves, Admin Jonas Weber, Ines Duarte Writer, Lea Martin Content lead, a People manager with users.view and users.remove)
|
||||
When <caller> sends DELETE /v1/user/<target>?expected_role_id=<expected>
|
||||
Then the answer is <status> <body>
|
||||
And <effect>
|
||||
Examples:
|
||||
| caller | target | expected | status | body | effect |
|
||||
| the Owner | Ines Duarte | Writer id | 204 | (empty) | the user is gone from GET /v1/user and one user.remove event is recorded |
|
||||
| an Admin | Ines Duarte | Writer id | 204 | (empty) | the same |
|
||||
| a People manager | Ines Duarte | Writer id | 204 | (empty) | the same when the manager's role covers Writer |
|
||||
| the Owner | Ines Duarte | Admin id | 409 | user_role_changed | nothing removed, no event |
|
||||
| the Owner | Ines Duarte | (absent) | 400 | invalid_role | nothing removed |
|
||||
| the Owner | Ines Duarte | 0 | 400 | invalid_role | nothing removed |
|
||||
| the Owner | abc | Writer id | 400 | the uint32 id message | nothing removed |
|
||||
| the Owner | 9999 | Writer id | 404 | user_not_found | nothing removed |
|
||||
| the Owner | the Owner (self) | Owner id | 403 | own_role | nothing removed |
|
||||
| an Admin | Jonas Weber (self) | Admin id | 403 | own_role | nothing removed |
|
||||
| an Admin | Marta Alves | Owner id | 403 | role_locked | the owner is never removable; nothing removed |
|
||||
| a People manager | Lea Martin | Content lead id | 403 | escalation | X-Escalation-Key names the first key the manager lacks; nothing removed |
|
||||
| a People manager | Jonas Weber | Admin id | 403 | escalation | a role above the caller's power cannot be removed |
|
||||
| a user without users.remove | Ines Duarte | Writer id | 403 | missing_permission | X-Missing-Permission is users.remove; the target is not even looked up |
|
||||
| no cookie | Ines Duarte | Writer id | 401 | the existing 401 text | nothing removed |
|
||||
And a stale role beats the coverage check: a target whose role changed answers 409 before 403 escalation or role_locked
|
||||
And a refusal (400, 403, 404, 409) records no audit event and changes nothing
|
||||
|
||||
Scenario: A race between the check and the delete
|
||||
Given the target's role changes after the use case read it and before DeleteIfRole runs
|
||||
Then DeleteIfRole answers ErrRoleChanged, the route answers 409 user_role_changed and the user remains
|
||||
|
||||
Scenario: A failed write
|
||||
Given the repository fails with an error that is not a refusal
|
||||
Then the route answers 500 "Internal server error", the user remains and one user.remove event with outcome failed is recorded
|
||||
|
||||
Scenario: What a removal revokes
|
||||
Given user 41 has a session cookie and an API key rcms_… that user 41 created
|
||||
When user 41 is removed
|
||||
Then the next request with user 41's cookie answers 401 "You must be logged in to access this resource."
|
||||
And the next request with user 41's key answers 401 (a key whose user was deleted)
|
||||
And no other user's session or key changes
|
||||
|
||||
Scenario: What a removal leaves
|
||||
Then articles, entries and media created by user 41 stay, with their created_by id unchanged
|
||||
And invites sent by user 41 stay
|
||||
And the audit events whose actor is user 41 stay and keep naming them
|
||||
|
||||
Scenario: The audit event
|
||||
When user 41 "ines" is removed by the Owner
|
||||
Then one event is recorded with action user.remove, category users, outcome success, actor the caller, target {type "user", id 41, name "ines"}
|
||||
And the event is shown in /activity with the category "Users and invites"
|
||||
|
||||
Scenario: Layering
|
||||
Then internal/domain/user imports no gorm, chi or infrastructure, the handler imports no persistence, and RemoveUserUseCase depends only on domain interfaces
|
||||
|
||||
Rule: Activity sentences for user.remove
|
||||
|
||||
Scenario Outline: Sentences
|
||||
Given GET /v1/activity returns an event user.remove, target Ines Duarte, with outcome <outcome>
|
||||
Then the sentence reads <sentence> and in FR <french>
|
||||
Examples:
|
||||
| outcome | sentence | french |
|
||||
| failed | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte |
|
||||
| success | Removed Ines Duarte | Ines Duarte retiré |
|
||||
And the success sentence and its FR are not in the design (design gap G2); the failed pair is the design's
|
||||
And the security chip does not show on user.remove
|
||||
|
||||
Rule: Row menu and gating
|
||||
|
||||
Scenario Outline: What the row menu offers
|
||||
Given the session grants <grants>
|
||||
When the person opens the menu on <row>
|
||||
Then "Remove user" is <state>
|
||||
Examples:
|
||||
| grants | row | state |
|
||||
| users.view, users.remove | Ines Duarte (accepted) | enabled, below "Change role" when that is offered |
|
||||
| users.view, users.change_role | Ines Duarte (accepted) | absent |
|
||||
| users.view, users.remove | the owner row | disabled with "The owner can't be changed or removed. Transfer ownership first." |
|
||||
| users.view, users.remove | the row marked "you" | disabled with a reason (copy TBD, gap G1) |
|
||||
| users.view, users.remove | a pending or expired invite | absent (invites keep "Cancel invite" and "Renew invite") |
|
||||
| the Owner | Ines Duarte (accepted) | enabled |
|
||||
And a person with users.view and users.remove only sees a menu with "Remove user" and nothing else
|
||||
And "Send password reset" stays out of this slice
|
||||
|
||||
Scenario: Roles that cannot be covered
|
||||
Given the session cannot cover Content lead
|
||||
Then "Remove user" is disabled on a Content lead row with the reason "Your role can't assign a role with more power than yours." (reuse of the no-escalation sentence, to confirm in Q-RM1)
|
||||
|
||||
Rule: Removing
|
||||
|
||||
Scenario: The confirmation dialog (behaviour only)
|
||||
When "Remove user" is chosen on Ines Duarte (Writer)
|
||||
Then a confirmation dialog opens with Cancel and a destructive confirm action
|
||||
And it names Ines Duarte (title and body copy TBD, gap G1)
|
||||
|
||||
Scenario: Confirming
|
||||
When the person confirms
|
||||
Then DELETE /v1/user/<Ines id>?expected_role_id=<id of the role of the member's list item> is sent
|
||||
And while in flight the dialog cannot be dismissed and both buttons are disabled
|
||||
And on 204 the dialog closes, the row disappears without reloading the whole list, and a success toast shows (copy TBD, gap G1)
|
||||
And the count line of the Users page follows
|
||||
|
||||
Scenario Outline: Remove answers
|
||||
When the server answers <status> <code>
|
||||
Then <effect>
|
||||
Examples:
|
||||
| status | code | effect |
|
||||
| 409 | user_role_changed | the dialog closes, GET /v1/user is re-read and a warning toast reads "User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload" |
|
||||
| 404 | user_not_found | the list is re-read, the row is gone, the dialog closes and the existing toast "That user no longer exists" / "The list was refreshed." shows |
|
||||
| 403 | escalation | "You can't grant that" / "You can only grant what your role has." (shared refusal copy) and the dialog closes |
|
||||
| 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." (shared refusal copy, wording gap G3) |
|
||||
| 403 | missing_permission | the shared refusal toast "You don't have permission to do that" / "Your role doesn't include Remove users. Ask an owner or admin." and the dialog closes |
|
||||
| 500 | (network) | the dialog stays open with an inline error (copy TBD, gap G1) and the confirm action stays enabled |
|
||||
And in FR the warning toast reads "Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger"
|
||||
And "Reload" refetches GET /v1/user and dismisses the toast
|
||||
And the warning toast stays until dismissed or Reload is pressed
|
||||
|
||||
Rule: The API reference
|
||||
|
||||
Scenario: The route stays out of the API reference until the design draws it
|
||||
Then DELETE /v1/user/{id} is not added to web/src/data/apiCatalogue.ts, because the Integration board has no card for it
|
||||
And "DELETE /v1/user/{id}" is added to routesNotYetInTheCatalogue in test/api_catalogue_drift_test.go so TestRouterRoutesAreInTheCatalogue stays satisfied
|
||||
And the "Users and invites" group still has 6 routes
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA procedure: Remove a user (roles sync pass 6)
|
||||
|
||||
Spec: features/roles_sync_remove_user.feature. This slice touches Go and the SPA. Go: `task test` (go test -tags nofrontend ./test/... -count=1), `task lint:go`, `gofmt -l`, `go vet -tags nofrontend`, and the mutation scope of `task mutate` for RemoveUser.go. SPA: Vitest + Vue Test Utils under `task test:web`. No live server, no real SQLite file in the Go tests, no real SMTP.
|
||||
|
||||
## Honesty rules
|
||||
0. First run `task test` and `task test:web` on the unmodified tree and record exit codes and failing specs (known Go failure: TestRouterRoutesAreInTheCatalogue; it must pass at the end because the route is added to the catalogue).
|
||||
1. A runner that cannot execute: its items are NOT RUN, never passing.
|
||||
2. Report each new or edited test and which already failed in the baseline.
|
||||
3. The removal dialog, success toast, inline 500 error, own-row reason and the user.remove success sentence are not in the design: tests check behaviour (the element exists, opens, closes, is disabled) and never assert their text. They are listed as design gaps.
|
||||
4. No spec freezes the date; fixed `created_at` strings.
|
||||
|
||||
## Automated (Go, httptest, in-memory fakes) in test/
|
||||
Build the handler with `NewHandler(...)` (or the named constructor of the user handler) over RemoveUserUseCase with in-memory fakes of user.Repository (extended with DeleteIfRole, which can be told to answer ErrRoleChanged or an error), role.Repository, contenttype.Repository and an audit recorder. Mount on a fresh `chi.NewRouter()` mirroring api/router.go: the route inside `jwtauth.Verifier` + `Authenticator` with a test `jwtauth.New("HS256", …)` and the require(users.remove) middleware built as in api/router.go. Call `validator.InitValidator()`. Unique fake names in package test.
|
||||
|
||||
| Test | Steps and assertions |
|
||||
|---|---|
|
||||
| TestRemoveUserAnswers | the table of the feature (15 rows): status, body, user still listed or gone, X-Escalation-Key and X-Missing-Permission headers, 401 without cookie |
|
||||
| TestRemoveUserCheckOrder | stale role beats role_locked and escalation; own_role beats stale; missing_permission is answered before the target lookup (the fake counts Get calls) |
|
||||
| TestRemoveUserExpectedRoleIDValidation | absent, 0, non-numeric, negative: 400 invalid_role; bad id: the uint32 message |
|
||||
| TestRemoveUserRaceAnswers409 | the fake changes the stored role between Get and DeleteIfRole: 409, user present, no event |
|
||||
| TestRemoveUserWriteFailure | non-refusal error: 500, user present, one failed user.remove event |
|
||||
| TestRemoveUserAudit | success event: action user.remove, category users, target {user, id, username}, actor the caller; refusals record nothing |
|
||||
| TestRemoveUserRevokesAccess | after removal, a request with that user's cookie answers 401, and an API key of that user answers 401; another user's cookie and key still work |
|
||||
| TestRemoveUserLeavesContent | the removed user's articles, entries and invites remain with created_by unchanged |
|
||||
| TestRemoveUserOnlyAcceptedUsers | an invite id is not a user: 404 user_not_found; invites stay cancellable through DELETE /v1/user/invite/{id} |
|
||||
| TestRemoveUserLayering | internal/domain/user imports no gorm, chi or infrastructure; the handler imports no persistence; the use case depends on domain interfaces only (extend the existing layering test) |
|
||||
| TestRouterRoutesAreInTheCatalogue (existing) | passes: DELETE /v1/user/{id} is listed in routesNotYetInTheCatalogue (the Integration board has no card for it yet) |
|
||||
| persistence level (only if the suite has an in-memory persistence test; otherwise MANUAL item 5) | DeleteIfRole deletes when the role matches, answers ErrRoleChanged when it differs and ErrUserNotFound when absent, on SQLite and, with `task test:mysql` / `task test:postgres`, on the other drivers (SQL must stay portable) |
|
||||
|
||||
Mutation scope: `MUTATE_FILES=RemoveUser.go MUTATE_RUN='TestRemoveUser' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`. docs/api.md and docs/api*.md document the route, its query parameter, the order of checks, the 409 and what a removal revokes and leaves.
|
||||
|
||||
## Automated (Vitest)
|
||||
| Spec | Cases |
|
||||
|---|---|
|
||||
| composables/__tests__/useUsers.spec.ts (extend) | removeUser sends DELETE /v1/user/{id}?expected_role_id=N, resolves on 204; refusal mapping of user_role_changed, user_not_found, escalation, role_locked, own_role, missing_permission, invalid_role |
|
||||
| views/__tests__/UsersRowActions.spec.ts (extend) | "Remove user" per grant (the table of the feature), owner row disabled with the design reason, own row disabled, absent for invites, a menu with only "Remove user", "Send password reset" still absent, disabled on a role the caller cannot cover |
|
||||
| components/__tests__/RemoveUserDialog.spec.ts (new) | opens on the action, names the member, Cancel closes, confirm sends the request with the role id of the list item, in-flight lock, 204 closes and removes the row, each answer of the table (409 warning toast with Reload that refetches and dismisses, 404 refresh and existing toast, 403 shared refusals, 500 inline error keeps the dialog); texts of the dialog and success toast are not asserted |
|
||||
| views/__tests__/UsersView.spec.ts (extend) | the row disappears after removal without a full reload, the count line follows |
|
||||
| lib/__tests__/activitySentence.spec.ts (extend) | user.remove failed "Failed to remove Ines Duarte" EN and FR; success is covered with the coder's wording, flagged as a gap |
|
||||
| data/__tests__/apiCatalogue.spec.ts (extend) | the catalogue does not contain DELETE /v1/user/{id}; "Users and invites" still has 6 routes |
|
||||
| i18n/roleMessages.spec.ts (extend) | the "User not removed" keys exist in EN and FR with matching placeholders |
|
||||
|
||||
Mocks to reuse: Marta Alves (Owner), Jonas Weber (Admin, jonas@renewbuild.example), Ines Duarte (Writer), Lea Martin (Content lead), a pending and an expired invite; access from ownerAccess() / adminAccess() / customAccess().
|
||||
|
||||
## Visual (task visual:check)
|
||||
`task visual:check -- roles` for: "Users: Row menu" (menu with Remove user), "Users: Owner row menu" (Remove user disabled), and the group 12 toast "User not removed: member's role changed first (409)". Board sample data: Ines Duarte, the Owner row, the toast copy of the frame. The removal dialog has no board frame, so no visual state exists for it and none is invented. Light, dark, desktop, mobile within 10 %.
|
||||
|
||||
## Manual checks
|
||||
1. Two browsers: an owner changes Ines Duarte's role while an admin has the removal confirmation open, then the admin confirms: the toast "User not removed" shows and the list reloads with the new role.
|
||||
2. A removed user's open browser tab: the next action answers 401 and returns to the login; their API key answers 401.
|
||||
3. Remove a user who owns articles; the articles still show their author correctly (see Q-RM2).
|
||||
4. Keyboard: menu item reachable, dialog focus order, Escape blocked while sending, focus returns to the row menu.
|
||||
5. Upgrade check on a real SQLite file and, with `task test:mysql` / `task test:postgres`, the other drivers: DeleteIfRole compare-and-swap.
|
||||
6. Mobile 390 px: card list menu, 44 px targets; FR; dark.
|
||||
7. `task lint:go`, `gofmt -l`, `go vet -tags nofrontend`, `task lint:web`, `task test`, `task test:web` all green.
|
||||
|
||||
## Design gaps and backend notes
|
||||
- G1 The design has the menu item "Remove user" and the 409 toast, but no confirmation dialog, no success toast, no 500 copy, no reason for the own row. They are open question Q-RM1.
|
||||
- G2 The design has no success sentence for user.remove; "Removed {0}" / "{0} retiré" is the coder's proposal.
|
||||
- G3 The own_role refusal copy "You can't change your own role" is reused for a removal; a removal-specific sentence is not in the design.
|
||||
- G4 The design says the users group has 6 routes; with this route it has 7.
|
||||
- Backend change (new): DELETE /v1/user/{id}?expected_role_id=N, the DeleteIfRole repository method, ActionUserRemove, RemoveUserUseCase, handler method, route, DI wiring, docs.
|
||||
@@ -105,6 +105,7 @@ const (
|
||||
ActionUserUpdateProfile = "user.update_profile"
|
||||
ActionUserChangeRole = "user.change_role"
|
||||
ActionUserTransferOwnership = "user.transfer_ownership"
|
||||
ActionUserRemove = "user.remove"
|
||||
ActionInviteCreate = "invite.create"
|
||||
ActionInviteCancel = "invite.cancel"
|
||||
ActionInviteRenew = "invite.renew"
|
||||
@@ -135,6 +136,7 @@ const (
|
||||
ParamRemoved = "removed"
|
||||
ParamFrom = "from"
|
||||
ParamTo = "to"
|
||||
ParamToName = "to_name"
|
||||
ParamMaxGBFrom = "max_gb_from"
|
||||
ParamMaxGBTo = "max_gb_to"
|
||||
ParamMerged = "merged"
|
||||
|
||||
@@ -26,6 +26,7 @@ var (
|
||||
ErrUnknownContentType = errors.New("unknown content type")
|
||||
ErrNotBuiltIn = errors.New("role is not built in")
|
||||
ErrInvalidReassign = errors.New("invalid reassign target")
|
||||
ErrReassignTargetNotFound = errors.New("reassign target not found")
|
||||
ErrEscalation = errors.New("grants more than the caller holds")
|
||||
ErrUnknownRole = errors.New("unknown role")
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ type Repository interface {
|
||||
FindByIDs(ids []uint32) ([]User, error)
|
||||
Create(user User) (User, error)
|
||||
Delete(id uint32) error
|
||||
DeleteIfRole(id uint32, roleID uint32) error
|
||||
UpdateVerificationStatus(userId uint32, isVerified bool) (User, error)
|
||||
UpdatePassword(userId uint32, password string) (User, error)
|
||||
UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (User, error)
|
||||
|
||||
@@ -52,6 +52,26 @@ func (u *UserRepository) Delete(id uint32) error {
|
||||
return deleteOrNotFound(result, domainUser.ErrUserNotFound)
|
||||
}
|
||||
|
||||
func (u *UserRepository) DeleteIfRole(id uint32, roleID uint32) error {
|
||||
return u.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Where("id = ? AND role_id = ?", id, roleID).Delete(&entity.User{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
return nil
|
||||
}
|
||||
var users int64
|
||||
if err := tx.Model(&entity.User{}).Where("id = ?", id).Count(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if users == 0 {
|
||||
return domainUser.ErrUserNotFound
|
||||
}
|
||||
return domainUser.ErrRoleChanged
|
||||
})
|
||||
}
|
||||
|
||||
func (u *UserRepository) GetAll() ([]domainUser.User, error) {
|
||||
var users []entity.User
|
||||
if err := u.db.Model(&entity.User{}).Find(&users).Error; err != nil {
|
||||
|
||||
@@ -34,7 +34,8 @@ func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command Reass
|
||||
if err := u.checkSource(command.Caller, source); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := u.checkTarget(command, source); err != nil {
|
||||
target, err := u.checkTarget(command, source)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command Reass
|
||||
event.Target = roleTarget(source)
|
||||
event.Params[audit.ParamFrom] = source.ID
|
||||
event.Params[audit.ParamTo] = command.TargetID
|
||||
event.Params[audit.ParamToName] = target.Name
|
||||
event.Params[audit.ParamMoved] = 0
|
||||
moved, err := u.roles.ReassignMembers(source.ID, command.TargetID)
|
||||
if err != nil {
|
||||
@@ -68,19 +70,22 @@ func (u *ReassignRoleMembersUseCase) checkSource(caller ContentCaller, source ro
|
||||
return ensureCovered(caller, u.contentTypes, source)
|
||||
}
|
||||
|
||||
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) error {
|
||||
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) (role.Role, error) {
|
||||
if command.TargetID == 0 || command.TargetID == source.ID {
|
||||
return role.ErrInvalidReassign
|
||||
return role.Role{}, role.ErrInvalidReassign
|
||||
}
|
||||
target, err := u.roles.Get(command.TargetID)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return role.ErrInvalidReassign
|
||||
return role.Role{}, role.ErrReassignTargetNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return role.Role{}, err
|
||||
}
|
||||
if target.Locked {
|
||||
return role.ErrInvalidReassign
|
||||
return role.Role{}, role.ErrInvalidReassign
|
||||
}
|
||||
return ensureCovered(command.Caller, u.contentTypes, target)
|
||||
if err := ensureCovered(command.Caller, u.contentTypes, target); err != nil {
|
||||
return role.Role{}, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type RemoveUserUseCase struct {
|
||||
users user.Repository
|
||||
roles role.Repository
|
||||
contentTypes contenttype.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type RemoveUserCommand struct {
|
||||
Caller ContentCaller
|
||||
TargetID uint32
|
||||
ExpectedRoleID uint32
|
||||
}
|
||||
|
||||
func NewRemoveUserUseCase(users user.Repository, roles role.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *RemoveUserUseCase {
|
||||
return &RemoveUserUseCase{users: users, roles: roles, contentTypes: contentTypes, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *RemoveUserUseCase) Remove(ctx context.Context, command RemoveUserCommand) error {
|
||||
if command.ExpectedRoleID == 0 {
|
||||
return role.ErrUnknownRole
|
||||
}
|
||||
target, err := u.users.Get(command.TargetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.check(command, target); err != nil {
|
||||
return err
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionUserRemove, audit.CategoryUsers)
|
||||
event.Target = audit.NewTarget(audit.TargetUser, target.ID, target.Username)
|
||||
if err := u.users.DeleteIfRole(target.ID, command.ExpectedRoleID); err != nil {
|
||||
u.recordWriteFailure(ctx, event, err)
|
||||
return err
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *RemoveUserUseCase) check(command RemoveUserCommand, target user.User) error {
|
||||
if target.ID == command.Caller.UserID {
|
||||
return role.ErrOwnRole
|
||||
}
|
||||
if target.RoleID != command.ExpectedRoleID {
|
||||
return user.ErrRoleChanged
|
||||
}
|
||||
held, err := resolveRole(u.roles, target)
|
||||
if errors.Is(err, role.ErrRoleNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if held.Locked {
|
||||
return role.ErrRoleLocked
|
||||
}
|
||||
typeKeys, err := allowedTypeKeys(u.contentTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureCoveredBy(command.Caller, typeKeys, held)
|
||||
}
|
||||
|
||||
func (u *RemoveUserUseCase) recordWriteFailure(ctx context.Context, event audit.Event, err error) {
|
||||
if errors.Is(err, user.ErrRoleChanged) || errors.Is(err, user.ErrUserNotFound) {
|
||||
return
|
||||
}
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func routerRoutes(t *testing.T) map[string]bool {
|
||||
|
||||
var routesNotYetInTheCatalogue = []string{
|
||||
"DELETE /v1/role/{id}",
|
||||
"DELETE /v1/user/{id}",
|
||||
"GET /v1/user/lookup",
|
||||
"GET /v1/permission",
|
||||
"GET /v1/role",
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestRolesAPIReassigning(t *testing.T) {
|
||||
{"Role manager moves to Intern", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "200"},
|
||||
{"Admin moves to Owner", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Owner") }, "400 invalid_reassign"},
|
||||
{"Admin moves to the same role", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Contributor") }, "400 invalid_reassign"},
|
||||
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "400 invalid_reassign"},
|
||||
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "409 target_role_not_found"},
|
||||
{"Admin sends no target", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{}` }, "400"},
|
||||
{"Admin moves from Writer", raAdmin, "Writer", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "409 role_built_in"},
|
||||
{"Admin moves from Owner", raAdmin, "Owner", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 role_locked"},
|
||||
|
||||
@@ -732,7 +732,7 @@ func TestReassignRoleMembersUseCase(t *testing.T) {
|
||||
t.Fatal("only role_id may change")
|
||||
}
|
||||
event := world.recorder.events[0]
|
||||
if event.Action != "role.reassign" || event.Params[audit.ParamFrom] != world.ids["Contributor"] || event.Params[audit.ParamTo] != world.ids["Writer"] || event.Params[audit.ParamMoved] != 4 {
|
||||
if event.Action != "role.reassign" || event.Params[audit.ParamFrom] != world.ids["Contributor"] || event.Params[audit.ParamTo] != world.ids["Writer"] || event.Params[audit.ParamMoved] != 4 || event.Params[audit.ParamToName] != "Writer" {
|
||||
t.Fatalf("unexpected event %+v", event)
|
||||
}
|
||||
if err := world.remove(raAdmin, "Contributor"); err != nil {
|
||||
@@ -758,7 +758,7 @@ func TestReassignRoleMembersUseCase(t *testing.T) {
|
||||
{"above the caller's grants", raRoleManager, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Admin"] }, role.ErrEscalation},
|
||||
{"owner as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Owner"] }, role.ErrInvalidReassign},
|
||||
{"same role as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Contributor"] }, role.ErrInvalidReassign},
|
||||
{"unknown target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return raUnknownRoleID }, role.ErrInvalidReassign},
|
||||
{"unknown target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return raUnknownRoleID }, role.ErrReassignTargetNotFound},
|
||||
{"zero target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return 0 }, role.ErrInvalidReassign},
|
||||
{"built-in source", raAdmin, "Writer", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrBuiltInRole},
|
||||
{"owner source", raAdmin, "Owner", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrRoleLocked},
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const removeManagerID uint32 = 60
|
||||
|
||||
func newRemoveWorld(t *testing.T) *usersAPIWorld {
|
||||
t.Helper()
|
||||
w := newUsersAPIWorld(t)
|
||||
manager, _ := w.roles.Create(role.Role{Name: "Remover", Version: 1, Grants: grantRows("users.view=all", "users.remove=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all")})
|
||||
w.ids["Remover"] = manager.ID
|
||||
w.addMember(removeManagerID, "remover", "remover@renew.example", manager.ID, true)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) remove(t *testing.T, caller uint32, target string, expected string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
path := "/v1/user/" + target
|
||||
if expected != "" {
|
||||
path += "?expected_role_id=" + expected
|
||||
}
|
||||
return w.do(t, caller, http.MethodDelete, path, "")
|
||||
}
|
||||
|
||||
func TestRemoveUserAnswers(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
target string
|
||||
expected string
|
||||
status int
|
||||
code string
|
||||
key string
|
||||
gone bool
|
||||
}{
|
||||
{"Owner removes Ines", uaOwner, "9", "Writer", 204, "", "", true},
|
||||
{"Admin removes Ines", uaAdmin, "9", "Writer", 204, "", "", true},
|
||||
{"People manager removes an Intern", removeManagerID, "46", "Intern", 204, "", "", true},
|
||||
{"Owner with a stale role", uaOwner, "9", "Admin", 409, apperr.UserRoleChangedErrorMessage, "", false},
|
||||
{"absent expected_role_id", uaOwner, "9", "", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"zero expected_role_id", uaOwner, "9", "0", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"negative expected_role_id", uaOwner, "9", "-1", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"text expected_role_id", uaOwner, "9", "abc", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"bad id", uaOwner, "abc", "Writer", 400, apperr.IdUint32ErrorMessage, "", false},
|
||||
{"unknown user", uaOwner, "9999", "Writer", 404, apperr.UserNotFoundErrorMessage, "", false},
|
||||
{"Owner removes self", uaOwner, "3", "Owner", 403, apperr.OwnRoleErrorMessage, "", false},
|
||||
{"Admin removes self", uaAdmin, "5", "Admin", 403, apperr.OwnRoleErrorMessage, "", false},
|
||||
{"Admin removes the Owner", uaAdmin, "3", "Owner", 403, apperr.RoleLockedErrorMessage, "", false},
|
||||
{"People manager removes a Content lead", removeManagerID, "45", "Content lead", 403, "escalation", "content.update", false},
|
||||
{"People manager removes an Admin", removeManagerID, "5", "Admin", 403, "escalation", "", false},
|
||||
{"Writer lacks users.remove", uaWriter, "46", "Intern", 403, "missing_permission", "", false},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
expected := row.expected
|
||||
if _, err := strconv.Atoi(expected); err != nil && expected != "" {
|
||||
if id, ok := w.ids[expected]; ok {
|
||||
expected = strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
}
|
||||
targetID, _ := strconv.ParseUint(row.target, 10, 32)
|
||||
rec := w.remove(t, row.caller, row.target, expected)
|
||||
switch {
|
||||
case row.status == 204:
|
||||
if rec.Code != 204 || rec.Body.Len() != 0 {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
case row.code == "missing_permission":
|
||||
usersAPIAssertMissing(t, rec, "users.remove")
|
||||
case row.code == "escalation":
|
||||
assertEscalation(t, rec, row.key)
|
||||
default:
|
||||
assertRefusal(t, rec, row.status, row.code)
|
||||
}
|
||||
_, present := w.users.users[uint32(targetID)]
|
||||
if _, existed := newRemoveWorld(t).users.users[uint32(targetID)]; existed && present == row.gone {
|
||||
t.Errorf("target presence %v, gone expected %v", present, row.gone)
|
||||
}
|
||||
wantEvents := 0
|
||||
if row.gone {
|
||||
wantEvents = 1
|
||||
}
|
||||
if len(w.recorder.events) != wantEvents {
|
||||
t.Errorf("expected %d events, got %v", wantEvents, w.recorder.actions())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no cookie", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
rec := w.anonymous(t, http.MethodDelete, "/v1/user/9?expected_role_id="+w.idText("Writer"), "")
|
||||
usersAPIAssertPlain(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
|
||||
if _, present := w.users.users[9]; !present {
|
||||
t.Error("nothing may be removed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveUserCheckOrder(t *testing.T) {
|
||||
t.Run("a stale role beats role_locked and escalation", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
assertRefusal(t, w.remove(t, uaAdmin, "3", w.idText("Writer")), 409, apperr.UserRoleChangedErrorMessage)
|
||||
rec := w.remove(t, removeManagerID, "45", w.idText("Admin"))
|
||||
assertRefusal(t, rec, 409, apperr.UserRoleChangedErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("own_role beats a stale role", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
assertRefusal(t, w.remove(t, uaAdmin, "5", w.idText("Writer")), 403, apperr.OwnRoleErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("missing_permission is answered before the target lookup", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
before := w.users.getCalls
|
||||
usersAPIAssertMissing(t, w.remove(t, uaWriter, "9", w.idText("Writer")), "users.remove")
|
||||
if w.users.getCalls != before+1 {
|
||||
t.Errorf("only the caller may be looked up, got %d extra Get calls", w.users.getCalls-before-1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the id is read before expected_role_id", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
usersAPIAssertPlain(t, w.remove(t, uaOwner, "abc", ""), 400, apperr.IdUint32ErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("expected_role_id is read before the target lookup", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
assertRefusal(t, w.remove(t, uaOwner, "9999", ""), 400, apperr.InvalidRoleErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveUserRaceAnswers409(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.users.beforeDeleteIfRole = func() {
|
||||
stored := w.users.users[9]
|
||||
stored.RoleID = w.id("Admin")
|
||||
w.users.users[9] = stored
|
||||
}
|
||||
assertRefusal(t, w.remove(t, uaOwner, "9", w.idText("Writer")), 409, apperr.UserRoleChangedErrorMessage)
|
||||
if _, present := w.users.users[9]; !present || len(w.recorder.events) != 0 {
|
||||
t.Error("the user must remain and nothing be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserWriteFailure(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.users.fail.failNextWith("DeleteIfRole", errors.New("disk full"))
|
||||
usersAPIAssertPlain(t, w.remove(t, uaOwner, "9", w.idText("Writer")), 500, "Internal server error")
|
||||
if _, present := w.users.users[9]; !present {
|
||||
t.Error("the user must remain")
|
||||
}
|
||||
if len(w.recorder.events) != 1 || w.recorder.events[0].Action != audit.ActionUserRemove || w.recorder.events[0].Outcome != audit.OutcomeFailed {
|
||||
t.Errorf("expected one failed user.remove event, got %+v", w.recorder.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserAudit(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
if rec := w.remove(t, uaAdmin, "9", w.idText("Writer")); rec.Code != 204 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
if len(w.recorder.events) != 1 {
|
||||
t.Fatalf("expected one event, got %d", len(w.recorder.events))
|
||||
}
|
||||
event := w.recorder.events[0]
|
||||
if event.Action != "user.remove" || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess {
|
||||
t.Errorf("unexpected event %+v", event)
|
||||
}
|
||||
if event.Target.Type != audit.TargetUser || event.Target.ID != "9" || event.Target.Name != "ines" {
|
||||
t.Errorf("unexpected target %+v", event.Target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserRevokesAccess(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.container.LookupUserNamesUseCase = useCases.NewLookupUserNamesUseCase(w.users)
|
||||
w.rebuildRouter()
|
||||
removedKey, _ := w.issueKey(uaWriter, "ines")
|
||||
otherKey, _ := w.issueKey(uaContributor, "other")
|
||||
if rec := w.do(t, uaWriter, http.MethodGet, "/v1/user/lookup?ids=9", ""); rec.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("the session must work before the removal, got %d", rec.Code)
|
||||
}
|
||||
if rec := w.remove(t, uaOwner, "9", w.idText("Writer")); rec.Code != 204 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
usersAPIAssertPlain(t, w.do(t, uaWriter, http.MethodGet, "/v1/user/lookup?ids=9", ""), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
|
||||
if rec := w.withKey(t, http.MethodGet, "/v1/user/lookup?ids=9", "", removedKey); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("the removed user's key must answer 401, got %d", rec.Code)
|
||||
}
|
||||
if rec := w.withKey(t, http.MethodGet, "/v1/user/lookup?ids=9", "", otherKey); rec.Code == http.StatusUnauthorized {
|
||||
t.Errorf("another user's key must keep working")
|
||||
}
|
||||
if rec := w.do(t, uaContributor, http.MethodGet, "/v1/user/lookup?ids=9", ""); rec.Code == http.StatusUnauthorized {
|
||||
t.Errorf("another user's session must keep working")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserLeavesContentAndInvites(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.articles.articles[1] = article.Article{ID: 1, Title: "Kept", CreatedBy: uaWriter}
|
||||
invitesBefore, _ := w.invites.GetAll()
|
||||
if rec := w.remove(t, uaOwner, "9", w.idText("Writer")); rec.Code != 204 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
if w.articles.articles[1].CreatedBy != uaWriter {
|
||||
t.Errorf("the article must keep its created_by")
|
||||
}
|
||||
invitesAfter, _ := w.invites.GetAll()
|
||||
if len(invitesAfter) != len(invitesBefore) {
|
||||
t.Errorf("invites must stay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserOnlyAcceptedUsers(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
invites, _ := w.invites.GetAll()
|
||||
inviteID := invites[0].ID + 1000
|
||||
assertRefusal(t, w.remove(t, uaOwner, strconv.FormatUint(uint64(inviteID), 10), w.idText("Writer")), 404, apperr.UserNotFoundErrorMessage)
|
||||
if rec := w.do(t, uaOwner, http.MethodDelete, "/v1/user/invite/"+strconv.FormatUint(uint64(invites[0].ID), 10), ""); rec.Code != 204 {
|
||||
t.Errorf("invites stay cancellable, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserDeleteIfRolePersistence(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, &admin, true)
|
||||
uaPersistedUser(t, db, 6, &writer, true)
|
||||
if err := repo.DeleteIfRole(5, writer); !errors.Is(err, user.ErrRoleChanged) {
|
||||
t.Fatalf("expected ErrRoleChanged, got %v", err)
|
||||
}
|
||||
if err := repo.DeleteIfRole(99, writer); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
if err := repo.DeleteIfRole(5, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Get(5); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("user 5 must be gone, got %v", err)
|
||||
}
|
||||
if _, err := repo.Get(6); err != nil {
|
||||
t.Errorf("user 6 must remain, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassignTargetNotFoundAndToName(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
contributor := w.idText("Contributor")
|
||||
path := "/v1/role/" + contributor + "/reassign"
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
target string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"missing target", uaOwner, "9999", 409, apperr.ReassignTargetNotFoundErrorMessage},
|
||||
{"itself", uaOwner, contributor, 400, apperr.InvalidReassignErrorMessage},
|
||||
{"locked", uaOwner, strconv.FormatUint(uint64(ownerRoleID), 10), 400, apperr.InvalidReassignErrorMessage},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
assertRefusal(t, w.do(t, row.caller, http.MethodPost, path, `{"target_role_id": `+row.target+`}`), row.status, row.code)
|
||||
if len(w.recorder.events) != 0 || w.storedRoleID(41) != w.id("Contributor") {
|
||||
t.Error("a refusal moves nobody and records nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("missing source", func(t *testing.T) {
|
||||
assertRefusal(t, w.do(t, uaOwner, http.MethodPost, "/v1/role/9999/reassign", `{"target_role_id": `+w.idText("Writer")+`}`), 404, apperr.RoleNotFoundErrorMessage)
|
||||
})
|
||||
}
|
||||
@@ -53,6 +53,8 @@ type mockUserRepo struct {
|
||||
updateRoleCalls int
|
||||
transferCalls int
|
||||
beforeUpdateRole func()
|
||||
deleteIfRoleCalls int
|
||||
beforeDeleteIfRole func()
|
||||
beforeTransfer func()
|
||||
beforeClearVerification func()
|
||||
knownRoles *mockRoleRepo
|
||||
@@ -162,6 +164,27 @@ func (m *mockUserRepo) Delete(id uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) DeleteIfRole(id uint32, roleID uint32) error {
|
||||
m.deleteIfRoleCalls++
|
||||
if err := m.fail.trigger("DeleteIfRole"); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.beforeDeleteIfRole != nil {
|
||||
hook := m.beforeDeleteIfRole
|
||||
m.beforeDeleteIfRole = nil
|
||||
hook()
|
||||
}
|
||||
stored, ok := m.users[id]
|
||||
if !ok {
|
||||
return user.ErrUserNotFound
|
||||
}
|
||||
if stored.RoleID != roleID {
|
||||
return user.ErrRoleChanged
|
||||
}
|
||||
delete(m.users, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) {
|
||||
if err := m.fail.trigger("UpdateVerificationStatus"); err != nil {
|
||||
return user.User{}, err
|
||||
|
||||
@@ -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.RemoveUserUseCase = useCases.NewRemoveUserUseCase(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)
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, 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 {
|
||||
isCode,
|
||||
inlineRefusal,
|
||||
isMissingPermission,
|
||||
staleToast,
|
||||
userToast,
|
||||
} from '@/lib/userRefusal'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const SUCCESS_TOAST_MS = 6000
|
||||
|
||||
const props = defineProps<{ user: AcceptedUser }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [user: AcceptedUser]
|
||||
refreshed: [user: AcceptedUser]
|
||||
stale: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { listRoles } = useRoles()
|
||||
const { changeUserRole } = useUsers()
|
||||
const { changeUserRole, listUsers } = useUsers()
|
||||
const permissions = usePermissionsStore()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const roles = ref<RoleItem[]>([])
|
||||
const loading = ref(true)
|
||||
const currentRole = ref(props.user.role)
|
||||
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 currentId = computed(() => currentRole.value?.id)
|
||||
const confirmDisabled = computed(
|
||||
() => loading.value || selected.value === undefined || selected.value === currentId.value,
|
||||
)
|
||||
@@ -49,10 +60,74 @@ async function loadRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
|
||||
function announceStale(title: string, description: string) {
|
||||
toast.add(userToast(`user-role-stale-${props.user.id}`, 'error', title, description))
|
||||
}
|
||||
|
||||
function announceStaleAfterClose() {
|
||||
const id = `user-role-stale-${props.user.id}`
|
||||
toast.add(
|
||||
staleToast(
|
||||
id,
|
||||
() => t('Role not changed'),
|
||||
() =>
|
||||
elideDe(
|
||||
t(
|
||||
"{0}'s role changed in the meantime, so nothing was saved. Reload and try again.",
|
||||
[name.value],
|
||||
),
|
||||
name.value,
|
||||
),
|
||||
() => {
|
||||
void listUsers()
|
||||
toast.remove(id)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function readMember(): Promise<AcceptedUser | undefined> {
|
||||
const users = await listUsers()
|
||||
return users.find(
|
||||
(item): item is AcceptedUser => item.kind === 'user' && item.id === props.user.id,
|
||||
)
|
||||
}
|
||||
|
||||
async function handleRoleChangedFirst() {
|
||||
if (disposed || !open.value) {
|
||||
announceStaleAfterClose()
|
||||
return
|
||||
}
|
||||
let member: AcceptedUser | undefined
|
||||
try {
|
||||
member = await readMember()
|
||||
} catch {
|
||||
notice.value = {
|
||||
title: t("Couldn't change the role"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
return
|
||||
}
|
||||
if (member === undefined) {
|
||||
emit('stale')
|
||||
announceStale(t('That user no longer exists'), t('The list was refreshed.'))
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
currentRole.value = member.role
|
||||
emit('refreshed', member)
|
||||
notice.value = {
|
||||
title: t('Role changed in the meantime'),
|
||||
tone: 'warning',
|
||||
description: t('{0} is now {1}. Your choice is kept. Check it and try again.', [
|
||||
name.value,
|
||||
member.role?.name ?? '',
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailure(error: unknown) {
|
||||
const refusal = inlineRefusal(error)
|
||||
if (refusal !== undefined) {
|
||||
@@ -64,9 +139,7 @@ async function handleFailure(error: unknown) {
|
||||
return
|
||||
}
|
||||
if (isCode(error, 409, 'user_role_changed')) {
|
||||
emit('stale')
|
||||
announceStale(t('Their role changed meanwhile'), t('The list was refreshed.'))
|
||||
open.value = false
|
||||
await handleRoleChangedFirst()
|
||||
return
|
||||
}
|
||||
if (isCode(error, 404, 'user_not_found')) {
|
||||
@@ -81,7 +154,10 @@ async function handleFailure(error: unknown) {
|
||||
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.") }
|
||||
notice.value = {
|
||||
title: t("Couldn't change the role"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
@@ -99,7 +175,12 @@ async function onConfirm() {
|
||||
`user-role-changed-${props.user.id}`,
|
||||
'success',
|
||||
() => t('Role changed'),
|
||||
() => t('{0} now has the {1} role.', [name.value, updated.role?.name ?? '']),
|
||||
() =>
|
||||
t('{0} is now {1}, and their API keys follow the new role.', [
|
||||
name.value,
|
||||
updated.role?.name ?? '',
|
||||
]),
|
||||
SUCCESS_TOAST_MS,
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
@@ -111,10 +192,14 @@ async function onConfirm() {
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
v-model:open="open"
|
||||
:title="t('Change role for {0}', [name])"
|
||||
:description="t('Their API keys follow the new role.')"
|
||||
@@ -123,6 +208,7 @@ onMounted(loadRoles)
|
||||
tone="neutral"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="notice ? 15 : 25"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
import { t, formatNumber, locale } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
@@ -7,6 +7,7 @@ import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ContentEmpty from '@/components/ContentEmpty.vue'
|
||||
import ContentState from '@/components/ContentState.vue'
|
||||
import ContentToolbar from '@/components/ContentToolbar.vue'
|
||||
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
@@ -18,6 +19,7 @@ import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useEntryRowDelete } from '@/composables/useEntryRowDelete'
|
||||
import type { ContentListPage } from '@/composables/useContentListPage'
|
||||
import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages'
|
||||
import { entryNounOf } from '@/lib/relationSentences'
|
||||
|
||||
const props = defineProps<{ page: ContentListPage }>()
|
||||
|
||||
@@ -56,6 +58,17 @@ const createNote = computed(() =>
|
||||
: '',
|
||||
)
|
||||
|
||||
const headerLine = computed(() =>
|
||||
access.readsOwnOnly.value
|
||||
? t('Your role shows only entries you created.')
|
||||
: createNote.value || subtitle.value,
|
||||
)
|
||||
const ownEmptyNouns = computed(() => {
|
||||
if (locale.value === 'fr') {
|
||||
return { singular: name.value, plural: name.value }
|
||||
}
|
||||
return entryNounOf(name.value)
|
||||
})
|
||||
const singlePageLabel = computed(() =>
|
||||
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
|
||||
)
|
||||
@@ -82,7 +95,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ name }}
|
||||
</h1>
|
||||
<p v-if="type" class="text-muted">{{ createNote || subtitle }}</p>
|
||||
<p v-if="type" class="text-muted">{{ headerLine }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
@@ -97,7 +110,18 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
</div>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="access.updatesOwnOnly.value && !failed"
|
||||
v-if="access.readsOwnOnly.value && !failed"
|
||||
icon="i-lucide-eye-off"
|
||||
:title="t('Entries by others aren\'t shown')"
|
||||
:description="
|
||||
t(
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-else-if="access.updatesOwnOnly.value && !failed"
|
||||
:title="t('Entries by others are view only')"
|
||||
:description="t('Your role lets you edit entries you created.')"
|
||||
/>
|
||||
@@ -137,6 +161,20 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
|
||||
<EntrySkeleton v-if="showSkeleton" :rows="skeletonRows" :cards="!isDesktop" />
|
||||
|
||||
<OwnScopeEmpty
|
||||
v-else-if="view === 'empty' && access.readsOwnOnly.value"
|
||||
icon="i-lucide-files"
|
||||
:title="t('You haven\'t created {0} yet', [ownEmptyNouns.singular])"
|
||||
:description="
|
||||
t(
|
||||
'Your role shows only entries you created, so other people\'s {0} aren\'t listed here. Use New entry to add one.',
|
||||
[ownEmptyNouns.plural],
|
||||
)
|
||||
"
|
||||
:action="access.canCreate.value ? t('New entry') : undefined"
|
||||
@action="createEntry"
|
||||
/>
|
||||
|
||||
<ContentEmpty v-else-if="view === 'empty'" @create="createEntry" />
|
||||
|
||||
<template v-else-if="showBody">
|
||||
|
||||
@@ -19,16 +19,24 @@ const emit = defineEmits<{
|
||||
deleted: [role: RoleItem]
|
||||
gone: [role: RoleItem]
|
||||
duplicate: [role: RoleItem]
|
||||
conflict: [role: RoleItem]
|
||||
refreshed: [roles: RoleItem[]]
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { reassignRole, deleteRole } = useRoles()
|
||||
const { reassignRole, deleteRole, listRoles } = useRoles()
|
||||
|
||||
const target = ref<number | undefined>(undefined)
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const members = ref(props.role.members)
|
||||
const missingTargets = ref<number[]>([])
|
||||
const gainedMembers = ref(false)
|
||||
|
||||
const targets = computed(() =>
|
||||
props.roles.filter((item) => !missingTargets.value.includes(item.id)),
|
||||
)
|
||||
|
||||
const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
|
||||
if (props.role.built_in) {
|
||||
@@ -37,9 +45,14 @@ const stage = computed<'builtin' | 'blocked' | 'confirm'>(() => {
|
||||
return members.value > 0 ? 'blocked' : 'confirm'
|
||||
})
|
||||
|
||||
const deleteTop = computed(() => (notice.value ? 25 : 35))
|
||||
|
||||
const title = computed(() => t('Delete {0}?', [props.role.name]))
|
||||
|
||||
const description = computed(() => {
|
||||
if (gainedMembers.value) {
|
||||
return ''
|
||||
}
|
||||
switch (stage.value) {
|
||||
case 'builtin':
|
||||
return t("Built-in roles can't be deleted.")
|
||||
@@ -58,7 +71,9 @@ const confirmLabel = computed(() => {
|
||||
return t('Duplicate role')
|
||||
}
|
||||
return stage.value === 'blocked'
|
||||
? t('Move {0} members and delete role', [members.value])
|
||||
? members.value === 1
|
||||
? t('Move 1 member and delete role')
|
||||
: t('Move {0} members and delete role', [members.value])
|
||||
: t('Delete role')
|
||||
})
|
||||
|
||||
@@ -66,14 +81,65 @@ const pendingLabel = computed(() =>
|
||||
stage.value === 'blocked' ? t('Moving and deleting…') : t('Deleting…'),
|
||||
)
|
||||
|
||||
function showRefusal(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
notice.value = { description: t("Couldn't delete the role. Try again in a moment.") }
|
||||
const GENERIC_FAILURE = "Couldn't delete the role. Try again in a moment."
|
||||
|
||||
async function liveMemberCount() {
|
||||
try {
|
||||
const roles = await listRoles()
|
||||
const live = roles.find((item) => item.id === props.role.id)
|
||||
return Math.max(live?.members ?? 0, 1)
|
||||
} catch {
|
||||
return Math.max(members.value, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function showGainedMembers() {
|
||||
const count = await liveMemberCount()
|
||||
members.value = count
|
||||
gainedMembers.value = true
|
||||
target.value = undefined
|
||||
notice.value = {
|
||||
tone: 'warning',
|
||||
title: t('Role still has members'),
|
||||
description:
|
||||
count === 1
|
||||
? t('{0} now has 1 member. Move them to another role first.', [props.role.name])
|
||||
: t('{0} now has {1} members. Move them to another role first.', [
|
||||
props.role.name,
|
||||
count,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
async function showTargetGone() {
|
||||
const missing = props.roles.find((item) => item.id === target.value)
|
||||
if (missing) {
|
||||
missingTargets.value = [...missingTargets.value, missing.id]
|
||||
}
|
||||
notice.value = {
|
||||
title: t('{0} no longer exists', [missing?.name ?? '']),
|
||||
description: t(
|
||||
'It was deleted somewhere else. Choose another role to move the members to.',
|
||||
),
|
||||
}
|
||||
target.value = undefined
|
||||
try {
|
||||
emit('refreshed', await listRoles())
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_has_members') {
|
||||
members.value = Math.max(members.value, 1)
|
||||
target.value = undefined
|
||||
}
|
||||
|
||||
function showMoveFailure() {
|
||||
notice.value = {
|
||||
title: t("Couldn't move the members"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
function showRefusal(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
notice.value = { description: t(GENERIC_FAILURE) }
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_not_found') {
|
||||
@@ -81,13 +147,43 @@ function showRefusal(error: unknown) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_conflict') {
|
||||
emit('conflict', props.role)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
const copy = error.code === undefined ? undefined : refusalCopy(error.code, error.permission)
|
||||
notice.value =
|
||||
copy === undefined
|
||||
? { description: t("Couldn't delete the role. Try again in a moment.") }
|
||||
? { description: t(GENERIC_FAILURE) }
|
||||
: { title: copy.title, description: copy.description ?? '' }
|
||||
}
|
||||
|
||||
async function showMoveRefusal(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
showMoveFailure()
|
||||
return
|
||||
}
|
||||
if (error.code === 'target_role_not_found') {
|
||||
await showTargetGone()
|
||||
return
|
||||
}
|
||||
if (error.code === 'invalid_reassign') {
|
||||
target.value = undefined
|
||||
showRefusal(error)
|
||||
return
|
||||
}
|
||||
if (error.code === 'role_conflict' || error.code === 'role_not_found') {
|
||||
showRefusal(error)
|
||||
return
|
||||
}
|
||||
if (error.code === undefined || refusalCopy(error.code, error.permission) === undefined) {
|
||||
showMoveFailure()
|
||||
return
|
||||
}
|
||||
showRefusal(error)
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (stage.value === 'builtin') {
|
||||
emit('duplicate', props.role)
|
||||
@@ -101,13 +197,22 @@ async function onConfirm() {
|
||||
notice.value = null
|
||||
try {
|
||||
if (stage.value === 'blocked' && target.value !== undefined) {
|
||||
try {
|
||||
await reassignRole(props.role.id, target.value)
|
||||
} catch (error) {
|
||||
await showMoveRefusal(error)
|
||||
return
|
||||
}
|
||||
members.value = 0
|
||||
}
|
||||
await deleteRole(props.role.id)
|
||||
emit('deleted', props.role)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'role_has_members') {
|
||||
await showGainedMembers()
|
||||
return
|
||||
}
|
||||
showRefusal(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
@@ -117,6 +222,7 @@ async function onConfirm() {
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
@@ -126,17 +232,19 @@ async function onConfirm() {
|
||||
:tone="stage === 'builtin' ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="deleteTop"
|
||||
:confirm-disabled="stage === 'blocked' && target === undefined"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template v-if="stage === 'blocked'">
|
||||
<UFormField
|
||||
:label="t('Move members to')"
|
||||
required
|
||||
:help="t('They keep their API keys. Keys follow the new role.')"
|
||||
>
|
||||
<RolePicker
|
||||
v-model:selected="target"
|
||||
:roles="roles"
|
||||
:roles="targets"
|
||||
:source-id="role.id"
|
||||
:can-assign="canAssign"
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,8 @@ const props = withDefaults(
|
||||
confirmBlocked?: boolean
|
||||
wide?: boolean
|
||||
closeGutter?: boolean
|
||||
top?: 10 | 15 | 20 | 25 | 30 | 35
|
||||
reviewWidth?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
@@ -31,6 +33,8 @@ const props = withDefaults(
|
||||
confirmBlocked: false,
|
||||
wide: false,
|
||||
closeGutter: false,
|
||||
top: undefined,
|
||||
reviewWidth: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -39,10 +43,22 @@ const emit = defineEmits<{ confirm: []; close: []; cancel: [] }>()
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const titleElement = ref<HTMLElement | null>(null)
|
||||
const contentClass = computed(
|
||||
() =>
|
||||
`${SHEET_CONTENT_CLASS} ${props.wide ? 'sm:top-15 sm:max-w-140' : 'sm:top-35 sm:max-w-115'}`,
|
||||
)
|
||||
const TOP_CLASSES = {
|
||||
10: 'sm:top-10',
|
||||
15: 'sm:top-15',
|
||||
20: 'sm:top-20',
|
||||
25: 'sm:top-25',
|
||||
30: 'sm:top-30',
|
||||
35: 'sm:top-35',
|
||||
}
|
||||
const contentClass = computed(() => {
|
||||
const top = TOP_CLASSES[props.top ?? (props.wide ? 15 : 35)]
|
||||
let width = props.wide ? 'sm:max-w-140' : 'sm:max-w-115'
|
||||
if (props.reviewWidth) {
|
||||
width = 'sm:max-w-130'
|
||||
}
|
||||
return `${SHEET_CONTENT_CLASS} ${top} ${width}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.pending,
|
||||
@@ -66,7 +82,7 @@ const headerClass = computed(() => {
|
||||
})
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
? { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
? { class: 'top-5 end-6 size-7 justify-center text-muted max-md:top-8 max-md:size-11' }
|
||||
: false,
|
||||
)
|
||||
|
||||
@@ -121,9 +137,11 @@ function onCancel() {
|
||||
<UAlert
|
||||
v-if="notice"
|
||||
role="alert"
|
||||
color="error"
|
||||
:color="notice.tone ?? 'error'"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:icon="
|
||||
notice.tone === 'warning' ? 'i-lucide-triangle-alert' : 'i-lucide-circle-x'
|
||||
"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
:ui="{
|
||||
@@ -131,7 +149,21 @@ function onCancel() {
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
>
|
||||
<template v-if="notice.action" #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 px-2.5 text-xs sm:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
:disabled="pending"
|
||||
@click="notice.action.onClick"
|
||||
>
|
||||
{{ notice.action.label }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,7 +87,7 @@ const snippetTabs = computed(() => [
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="
|
||||
endpoint.access === 'Owner only' || endpoint.access === 'Owner or admin'
|
||||
endpoint.access === 'Owner only' || endpoint.access === 'Needs permission'
|
||||
? CHIP_CLASS.strong
|
||||
: CHIP_CLASS.neutral
|
||||
"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
@@ -13,7 +14,7 @@ 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 INVALID_EMAIL_MESSAGE = 'Enter a valid email address.'
|
||||
const DEFAULT_ROLE_NAME = 'Writer'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
@@ -30,6 +31,7 @@ const touched = ref(false)
|
||||
const focused = ref(false)
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
const roleError = ref('')
|
||||
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
|
||||
const refusal = ref<DirectoryNotice | null>(null)
|
||||
const roles = ref<RoleItem[]>([])
|
||||
@@ -47,18 +49,27 @@ const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||
const emailError = computed(
|
||||
() => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
|
||||
)
|
||||
const hasFormErrors = computed(
|
||||
() => !!refusal.value || !!formAlert.value || !!emailError.value || !!roleError.value,
|
||||
)
|
||||
const inviteTop = computed(() => (hasFormErrors.value ? 'sm:top-20' : 'sm:top-30'))
|
||||
|
||||
const alertMessage = computed(() => {
|
||||
const alertCopy = computed(() => {
|
||||
if (formAlert.value === 'invalid') {
|
||||
return t("Something in the form isn't valid. Check the highlighted fields.")
|
||||
return {
|
||||
description: t("Something in the form isn't valid. Check the highlighted fields."),
|
||||
}
|
||||
}
|
||||
if (formAlert.value === 'rateLimited') {
|
||||
return t('Too many invites. Wait a moment, then try again.')
|
||||
return { description: t('Too many invites. Wait a moment, then try again.') }
|
||||
}
|
||||
if (formAlert.value === 'failed') {
|
||||
return t("Couldn't send the invite. Try again in a moment.")
|
||||
return {
|
||||
title: t("Couldn't send the invite"),
|
||||
description: t('Try again in a moment.'),
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function defaultRoleId() {
|
||||
@@ -88,6 +99,7 @@ function resetForm() {
|
||||
focused.value = false
|
||||
submitting.value = false
|
||||
serverError.value = ''
|
||||
roleError.value = ''
|
||||
formAlert.value = ''
|
||||
}
|
||||
|
||||
@@ -119,12 +131,20 @@ watch(email, () => {
|
||||
serverError.value = ''
|
||||
})
|
||||
|
||||
watch(roleId, () => {
|
||||
roleError.value = ''
|
||||
})
|
||||
|
||||
function onBlur() {
|
||||
touched.value = true
|
||||
focused.value = false
|
||||
}
|
||||
|
||||
function showFailure(error: unknown) {
|
||||
if (showRolePicker.value && isCode(error, 403, 'escalation')) {
|
||||
roleError.value = t("Your role can't assign a role with more power than yours.")
|
||||
return
|
||||
}
|
||||
const refused = inlineRefusal(error)
|
||||
if (refused !== undefined) {
|
||||
refusal.value = refused
|
||||
@@ -167,6 +187,7 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
serverError.value = ''
|
||||
roleError.value = ''
|
||||
formAlert.value = ''
|
||||
refusal.value = null
|
||||
submitting.value = true
|
||||
@@ -209,14 +230,13 @@ function onCancel() {
|
||||
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
|
||||
@update:open="onOpenChange"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
content: `${inviteTop} ${SHEET_CONTENT_CLASS} sm:max-w-115`,
|
||||
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
|
||||
wrapper: 'pe-10',
|
||||
close: 'top-5 end-6 size-7 justify-center p-0',
|
||||
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',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -233,14 +253,15 @@ function onCancel() {
|
||||
:description="refusal.description"
|
||||
/>
|
||||
<UAlert
|
||||
v-if="alertMessage"
|
||||
v-if="alertCopy"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
:description="alertMessage"
|
||||
:title="alertCopy.title"
|
||||
:description="alertCopy.description"
|
||||
/>
|
||||
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
|
||||
<UFormField
|
||||
@@ -273,19 +294,34 @@ function onCancel() {
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField v-if="showRolePicker" :label="t('Role')" name="role">
|
||||
<UFormField
|
||||
v-if="showRolePicker"
|
||||
:label="t('Role')"
|
||||
name="role"
|
||||
:error="roleError || undefined"
|
||||
:ui="{ error: 'mt-1.5 text-xs/5' }"
|
||||
>
|
||||
<template v-if="roleError" #error>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ roleError }}
|
||||
</span>
|
||||
</template>
|
||||
<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 v-if="selectedRole" class="mt-1.5 text-xs/5 text-muted">
|
||||
{{ selectedRole.description }}
|
||||
</div>
|
||||
</UFormField>
|
||||
<span v-if="showRolePicker && defaultRoleSelected" class="text-muted">{{
|
||||
t('Writer is the default. You can change a role later.')
|
||||
}}</span>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
action?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-10 sm:px-6 sm:py-10 lg:px-6 lg:py-10',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md/7 font-semibold',
|
||||
description: 'max-w-90 text-muted text-pretty',
|
||||
}"
|
||||
>
|
||||
<template v-if="action" #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-8 px-4 max-md:h-11" @click="emit('action')">
|
||||
{{ action }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -131,7 +131,7 @@ function typeOf(typeKey?: string) {
|
||||
<template v-if="!mobile">
|
||||
<div
|
||||
role="row"
|
||||
class="grid grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,1fr))] items-center gap-3 border-b border-default bg-elevated px-3 py-2 text-sm font-medium text-muted"
|
||||
class="grid grid-cols-[minmax(7.5rem,1fr)_7.375rem_5.25rem_7.375rem_7.375rem_7.375rem] items-center gap-2 border-b border-default bg-elevated px-3 py-2 text-sm font-medium text-muted"
|
||||
>
|
||||
<span role="columnheader">{{ t('Content type') }}</span>
|
||||
<span v-for="column in columns" :key="column.action" role="columnheader">{{
|
||||
@@ -143,7 +143,7 @@ function typeOf(typeKey?: string) {
|
||||
:key="row.typeKey ?? 'all'"
|
||||
role="row"
|
||||
:data-testid="row.typeKey === undefined ? 'row-all' : `row-${row.typeKey}`"
|
||||
class="grid grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,1fr))] items-center gap-3 border-b border-default px-3 py-2"
|
||||
class="grid grid-cols-[minmax(7.5rem,1fr)_7.375rem_5.25rem_7.375rem_7.375rem_7.375rem] items-center gap-2 border-b border-default px-3 py-2"
|
||||
>
|
||||
<span role="rowheader" class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-highlighted">{{ row.name }}</span>
|
||||
|
||||
@@ -3,8 +3,9 @@ withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
}>(),
|
||||
{ description: undefined },
|
||||
{ description: undefined, icon: 'i-lucide-lock' },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -13,7 +14,7 @@ withDefaults(
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-lock"
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:close="false"
|
||||
|
||||
@@ -33,6 +33,7 @@ interface HighlightPayload {
|
||||
}
|
||||
|
||||
const SKELETON_ROWS = 3
|
||||
const MORE_SKELETON_WIDTHS = ['55%', '70%', '45%']
|
||||
const LISTBOX = '[role="listbox"]'
|
||||
const OWNER_ATTRIBUTE = 'data-relation-owner'
|
||||
const VOWEL = /^[aeiou]/
|
||||
@@ -76,6 +77,7 @@ const searchTerm = ref('')
|
||||
const announcement = ref('')
|
||||
const activeId = ref<string | undefined>(undefined)
|
||||
const listboxId = ref<string | undefined>(undefined)
|
||||
const loadMoreButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const ids = computed(() => relationIdsOf(props.value))
|
||||
const chips = computed<ChipItem[]>(() =>
|
||||
@@ -158,7 +160,8 @@ const menuUi = computed(() => ({
|
||||
tagsInput: 'min-w-20 flex-1 placeholder:text-muted',
|
||||
trailing: 'pe-2.5',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
|
||||
content:
|
||||
'max-md:max-h-88 rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
|
||||
empty: 'p-2 text-left text-sm text-muted',
|
||||
group: 'p-0',
|
||||
item: 'min-h-10 flex-col justify-center gap-0 rounded-none px-2 py-1 before:inset-0 before:rounded-none data-highlighted:not-data-disabled:before:bg-elevated max-md:min-h-12',
|
||||
@@ -266,6 +269,35 @@ async function trackListbox() {
|
||||
undefined
|
||||
}
|
||||
|
||||
function focusLoadMore() {
|
||||
loadMoreButton.value?.$el.focus()
|
||||
}
|
||||
|
||||
function focusRow(rowId: number) {
|
||||
const label = rows.value.find((row) => row.value === rowId)?.label
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
`[${OWNER_ATTRIBUTE}="${id}"] [role="option"], [${OWNER_ATTRIBUTE}="${id}"][role="option"]`,
|
||||
),
|
||||
].find((candidate) => candidate.textContent?.includes(label ?? '\u0000'))
|
||||
option?.focus()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (search.loadingMore.value) {
|
||||
return
|
||||
}
|
||||
const loaded = search.entries.value.length
|
||||
await search.more()
|
||||
await nextTick()
|
||||
const firstNew = search.entries.value[loaded]
|
||||
if (firstNew) {
|
||||
focusRow(firstNew.id)
|
||||
} else {
|
||||
focusLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
function announceResults() {
|
||||
const total = search.total.value
|
||||
announce(total > 0 ? t('{0} results', [total]) : t('No results'))
|
||||
@@ -431,16 +463,55 @@ onMounted(() => store.load())
|
||||
</div>
|
||||
</template>
|
||||
<template #content-bottom>
|
||||
<div v-if="search.loadingMore.value" data-testid="relation-more">
|
||||
<span class="sr-only" role="status" aria-live="polite">{{
|
||||
t('Loading…')
|
||||
}}</span>
|
||||
<div
|
||||
v-for="width in MORE_SKELETON_WIDTHS"
|
||||
:key="width"
|
||||
data-testid="relation-more-skeleton"
|
||||
class="flex min-h-10 flex-col justify-center gap-1.5 px-2 py-1 max-md:min-h-12"
|
||||
>
|
||||
<USkeleton
|
||||
class="h-3 rounded-none bg-accented motion-reduce:animate-none"
|
||||
:style="{ width }"
|
||||
/>
|
||||
<USkeleton
|
||||
class="h-2.5 w-2/5 rounded-none bg-accented motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="search.total.value > 0 && search.entries.value.length > 0"
|
||||
data-testid="relation-footer"
|
||||
class="flex items-center justify-between gap-2 border-t border-default px-2 py-1.5"
|
||||
>
|
||||
<span class="text-xs text-muted">{{
|
||||
t('Showing {0} of {1}', [
|
||||
search.entries.value.length,
|
||||
search.total.value,
|
||||
])
|
||||
}}</span>
|
||||
<UButton
|
||||
v-if="search.hasMore.value && !atCap"
|
||||
ref="loadMoreButton"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:loading="search.loadingMore.value"
|
||||
class="h-8 w-full justify-center text-xs text-highlighted max-md:h-11"
|
||||
@click="search.more()"
|
||||
variant="outline"
|
||||
:icon="
|
||||
search.loadingMore.value ? 'i-lucide-loader-circle' : undefined
|
||||
"
|
||||
:aria-disabled="search.loadingMore.value ? 'true' : undefined"
|
||||
class="h-7 justify-center border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0 max-md:h-11"
|
||||
:class="search.loadingMore.value ? 'opacity-40' : ''"
|
||||
:ui="{
|
||||
leadingIcon: 'size-3.5 animate-spin motion-reduce:animate-none',
|
||||
}"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ t('Load more') }}
|
||||
{{ search.loadingMore.value ? t('Loading…') : t('Load more') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UInputMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import { useUsers, type AcceptedUser } from '@/composables/useUsers'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import {
|
||||
inlineRefusal,
|
||||
isCode,
|
||||
isMissingPermission,
|
||||
refusalToast,
|
||||
userToast,
|
||||
} from '@/lib/userRefusal'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const SUCCESS_TOAST_MS = 6000
|
||||
|
||||
const props = defineProps<{ user: AcceptedUser }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
removed: [user: AcceptedUser]
|
||||
roleChanged: [user: AcceptedUser]
|
||||
stale: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { removeUser } = useUsers()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
const name = computed(() => props.user.display_name || props.user.username)
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
if (isCode(error, 409, 'user_role_changed')) {
|
||||
emit('roleChanged', props.user)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isCode(error, 404, 'user_not_found')) {
|
||||
emit('stale')
|
||||
toast.add(
|
||||
userToast(
|
||||
`user-remove-stale-${props.user.id}`,
|
||||
'error',
|
||||
() => t('That user no longer exists'),
|
||||
() => t('The list was refreshed.'),
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
if (isMissingPermission(error)) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
const refusal = inlineRefusal(error)
|
||||
if (refusal !== undefined) {
|
||||
toast.add(refusalToast(`user-remove-refused-${props.user.id}`, refusal))
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
notice.value = {
|
||||
title: t("Couldn't remove the user"),
|
||||
description: t('Nothing was changed. Try again in a moment.'),
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
const expectedRoleId = props.user.role?.id
|
||||
if (pending.value || expectedRoleId === undefined) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
try {
|
||||
await removeUser(props.user.id, expectedRoleId)
|
||||
emit('removed', props.user)
|
||||
toast.add(
|
||||
userToast(
|
||||
`user-removed-${props.user.id}`,
|
||||
'success',
|
||||
() => t('User removed'),
|
||||
() => t('{0} no longer has access.', [name.value]),
|
||||
SUCCESS_TOAST_MS,
|
||||
),
|
||||
)
|
||||
open.value = false
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
v-model:open="open"
|
||||
:title="t('Remove {0}?', [name])"
|
||||
:description="
|
||||
t(
|
||||
'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.',
|
||||
)
|
||||
"
|
||||
:confirm-label="t('Remove user')"
|
||||
:pending-label="t('Removing…')"
|
||||
tone="danger"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="notice ? 25 : undefined"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { RoleDiffRow } from '@/lib/roleDiff'
|
||||
|
||||
defineProps<{
|
||||
description: string
|
||||
rows: RoleDiffRow[]
|
||||
pending?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ useSaved: []; keepEdits: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
v-model:open="open"
|
||||
:title="t('How the saved role differs from your edits')"
|
||||
:description="description"
|
||||
:confirm-label="t('Keep my edits')"
|
||||
:pending-label="t('Keep my edits')"
|
||||
:cancel-label="t('Use the saved role')"
|
||||
tone="neutral"
|
||||
:pending="pending"
|
||||
wide
|
||||
review-width
|
||||
:top="25"
|
||||
closable
|
||||
@confirm="emit('keepEdits')"
|
||||
@cancel="emit('useSaved')"
|
||||
>
|
||||
<div
|
||||
role="table"
|
||||
:aria-label="t('How the saved role differs from your edits')"
|
||||
class="overflow-x-auto border border-default"
|
||||
>
|
||||
<div
|
||||
role="row"
|
||||
class="grid grid-cols-[minmax(0,1fr)_72px_72px] gap-2 bg-elevated px-3 py-2 font-medium text-muted"
|
||||
>
|
||||
<span role="columnheader">{{ t('Setting') }}</span>
|
||||
<span role="columnheader">{{ t('Saved') }}</span>
|
||||
<span role="columnheader">{{ t('Yours') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
role="row"
|
||||
class="grid grid-cols-[minmax(0,1fr)_72px_72px] gap-2 border-t border-default px-3 py-2.5 text-highlighted"
|
||||
>
|
||||
<span role="cell" class="font-medium break-words">{{ row.setting }}</span>
|
||||
<span role="cell" class="break-words">{{ row.saved }}</span>
|
||||
<span role="cell" class="font-semibold break-words">{{ row.yours }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -1,20 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import type { ConflictState, ReadOnlyReason } from '@/composables/useRoleEditor'
|
||||
import type { RefusalCopy } from '@/lib/permissionCopy'
|
||||
import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
role: RoleItem | null
|
||||
reason: ReadOnlyReason | null
|
||||
conflict: ConflictState | null
|
||||
refusal: RefusalCopy | null
|
||||
saveFailed: boolean
|
||||
canDuplicate: boolean
|
||||
warnMembers: boolean
|
||||
reviewing: boolean
|
||||
differences: { key: string; name: string; kind: 'added' | 'removed' | 'changed' }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,19 +22,28 @@ const emit = defineEmits<{
|
||||
reloadRole: []
|
||||
reloadPage: []
|
||||
review: []
|
||||
retrySave: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const retryButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.saveFailed,
|
||||
async (failed) => {
|
||||
if (!failed) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
retryButton.value?.$el.focus()
|
||||
},
|
||||
)
|
||||
|
||||
const ALERT_UI = {
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}
|
||||
|
||||
function kindLabel(kind: 'added' | 'removed' | 'changed') {
|
||||
return kind === 'added' ? t('Added') : kind === 'removed' ? t('Removed') : t('Changed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -158,25 +167,50 @@ function kindLabel(kind: 'added' | 'removed' | 'changed') {
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('reloadRole')">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 px-2.5 text-xs md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('reloadRole')"
|
||||
>
|
||||
{{ t('Reload role') }}
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="outline" class="h-8" @click="emit('review')">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 px-2.5 text-xs md:h-7"
|
||||
@click="emit('review')"
|
||||
>
|
||||
{{ t('Review differences') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<ul
|
||||
v-if="conflict && reviewing"
|
||||
data-testid="role-differences"
|
||||
class="flex flex-col gap-1 border border-default bg-default p-3"
|
||||
<UAlert
|
||||
v-if="saveFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:close="false"
|
||||
:title="t('Couldn\'t save the role')"
|
||||
:description="t('Your changes are still here. Try again in a moment.')"
|
||||
:ui="ALERT_UI"
|
||||
>
|
||||
<li v-for="entry in differences" :key="entry.key" class="flex gap-2 text-sm">
|
||||
<span class="font-medium text-highlighted">{{ kindLabel(entry.kind) }}</span>
|
||||
<span class="text-muted">{{ entry.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<template #actions>
|
||||
<UButton
|
||||
ref="retryButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8"
|
||||
@click="emit('retrySave')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-if="refusal"
|
||||
|
||||
@@ -51,18 +51,16 @@ const items = computed(() =>
|
||||
variant="card"
|
||||
color="primary"
|
||||
:aria-label="label"
|
||||
:ui="{ item: 'rounded-none', fieldset: 'gap-2' }"
|
||||
:ui="{
|
||||
item: 'rounded-none has-data-[state=checked]:border-primary has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand)',
|
||||
fieldset: 'gap-2',
|
||||
description: 'text-xs',
|
||||
}"
|
||||
>
|
||||
<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 v-if="item.current" class="text-xs font-medium">· {{ t('Current') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
@@ -77,7 +75,7 @@ const items = computed(() =>
|
||||
:content="{ position: 'inline' }"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none md:h-10',
|
||||
base: 'h-11 rounded-none md:h-8',
|
||||
content: 'rounded-none border border-default',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 md:min-h-9',
|
||||
itemDescription: 'text-xs text-muted',
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, 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 {
|
||||
isCode,
|
||||
isMissingPermission,
|
||||
isSessionRequired,
|
||||
staleToast,
|
||||
userToast,
|
||||
} from '@/lib/userRefusal'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
@@ -16,7 +23,7 @@ const emit = defineEmits<{ transferred: []; stale: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const { transferOwnership } = useUsers()
|
||||
const { transferOwnership, listUsers } = useUsers()
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
@@ -26,6 +33,8 @@ const typed = ref('')
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const fieldError = ref(false)
|
||||
const awaitingReload = ref(false)
|
||||
let disposed = false
|
||||
|
||||
function displayName(user: AcceptedUser) {
|
||||
return user.display_name || user.username
|
||||
@@ -45,6 +54,12 @@ const options = computed(() =>
|
||||
})),
|
||||
)
|
||||
const picking = computed(() => step.value !== 'confirm')
|
||||
const transferTop = computed(() => {
|
||||
if (picking.value) {
|
||||
return 25
|
||||
}
|
||||
return notice.value ? 10 : 20
|
||||
})
|
||||
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 ?? '')
|
||||
@@ -60,7 +75,9 @@ const description = computed(() =>
|
||||
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,
|
||||
picking.value
|
||||
? chosen.value === undefined
|
||||
: typed.value !== email.value || awaitingReload.value,
|
||||
)
|
||||
const consequences = computed(() => [
|
||||
t('{0} becomes the owner.', [name.value]),
|
||||
@@ -77,15 +94,104 @@ function onOpenChange(value: boolean) {
|
||||
open.value = value
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
if (step.value === 'confirm') {
|
||||
step.value = 'back'
|
||||
typed.value = ''
|
||||
awaitingReload.value = false
|
||||
notice.value = null
|
||||
fieldError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function announceStaleAfterClose() {
|
||||
const id = 'ownership-stale'
|
||||
toast.add(
|
||||
staleToast(
|
||||
id,
|
||||
() => t('Ownership not transferred'),
|
||||
() =>
|
||||
elideDe(
|
||||
t(
|
||||
"{0}'s role changed in the meantime, so nothing was transferred. Check it and try again.",
|
||||
[name.value],
|
||||
),
|
||||
name.value,
|
||||
),
|
||||
() => {
|
||||
void listUsers()
|
||||
toast.remove(id)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function findTarget(users: UserListItem[]) {
|
||||
return users.find(
|
||||
(item): item is AcceptedUser => item.kind === 'user' && item.id === chosenId.value,
|
||||
)
|
||||
}
|
||||
|
||||
function staleNotice(role: string): DirectoryNotice {
|
||||
return {
|
||||
title: t('Role changed in the meantime'),
|
||||
tone: 'warning',
|
||||
description: t(
|
||||
'{0} is now {1}. What you typed is kept. Check the change, then try again.',
|
||||
[name.value, role],
|
||||
),
|
||||
action: { label: t('Reload member'), onClick: reloadMember },
|
||||
}
|
||||
}
|
||||
|
||||
function backToPick() {
|
||||
step.value = 'pick'
|
||||
chosenId.value = undefined
|
||||
typed.value = ''
|
||||
awaitingReload.value = false
|
||||
emit('stale')
|
||||
notice.value = {
|
||||
description: t("That person can't become the owner. Choose another member."),
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadMember() {
|
||||
try {
|
||||
const target = findTarget(await listUsers())
|
||||
if (target === undefined || target.role?.locked) {
|
||||
backToPick()
|
||||
return
|
||||
}
|
||||
awaitingReload.value = false
|
||||
notice.value = null
|
||||
emit('stale')
|
||||
} catch {
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleChangedFirst() {
|
||||
if (disposed || !open.value) {
|
||||
announceStaleAfterClose()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const target = findTarget(await listUsers())
|
||||
if (target === undefined) {
|
||||
backToPick()
|
||||
return
|
||||
}
|
||||
awaitingReload.value = true
|
||||
notice.value = staleNotice(target.role?.name ?? '')
|
||||
} catch {
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailure(error: unknown) {
|
||||
if (isMissingPermission(error)) {
|
||||
open.value = false
|
||||
@@ -109,17 +215,7 @@ async function handleFailure(error: unknown) {
|
||||
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
|
||||
await handleRoleChangedFirst()
|
||||
return
|
||||
}
|
||||
notice.value = { description: t("Couldn't transfer ownership. Try again in a moment.") }
|
||||
@@ -167,6 +263,7 @@ function onConfirm() {
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
closable
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
@@ -176,6 +273,7 @@ function onConfirm() {
|
||||
:tone="picking ? 'neutral' : 'danger'"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:top="transferTop"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@update:open="onOpenChange"
|
||||
@cancel="onCancel"
|
||||
|
||||
@@ -19,9 +19,18 @@ const model = computed(() => buildHeldRolePreview(permissions.grants))
|
||||
<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>
|
||||
<UBadge
|
||||
v-if="permissions.role?.locked"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-lock"
|
||||
:label="t('Locked')"
|
||||
/>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
:label="permissions.role?.built_in ? t('Built-in') : t('Custom')"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { AcceptedUser } from '@/composables/useUsers'
|
||||
@@ -12,13 +13,15 @@ import ChangeRoleDialog from '../ChangeRoleDialog.vue'
|
||||
|
||||
const listRoles = vi.fn()
|
||||
const changeUserRole = vi.fn()
|
||||
const listUsers = vi.fn()
|
||||
const toastRemove = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({ useRoles: () => ({ listRoles }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ changeUserRole }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ changeUserRole, listUsers }) }))
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
||||
})
|
||||
|
||||
const INES: AcceptedUser = {
|
||||
@@ -82,6 +85,8 @@ function emitted(name: string) {
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
listRoles.mockResolvedValue(WORLD)
|
||||
listUsers.mockResolvedValue([INES])
|
||||
toastRemove.mockReset()
|
||||
changeUserRole.mockResolvedValue({
|
||||
...INES,
|
||||
role: { id: CONTRIBUTOR.id, name: 'Contributor', built_in: false, locked: false },
|
||||
@@ -94,6 +99,7 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
listRoles.mockReset()
|
||||
changeUserRole.mockReset()
|
||||
listUsers.mockReset()
|
||||
})
|
||||
|
||||
describe('The dialog', () => {
|
||||
@@ -209,19 +215,6 @@ describe('Changing the role', () => {
|
||||
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' }),
|
||||
@@ -268,3 +261,217 @@ describe('Roles are read through useRoles', () => {
|
||||
expect(roleRefFor('admin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: final copy', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function toastContent() {
|
||||
const argument = toastAdd.mock.calls[0]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
it('toasts the role change with the member and role names for 6 s', async () => {
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
const toast = toastContent()
|
||||
expect(toast.color).toBe('success')
|
||||
expect(toast.title).toBe('Role changed')
|
||||
expect(toast.description).toBe(
|
||||
'Ines Duarte is now Contributor, and their API keys follow the new role.',
|
||||
)
|
||||
expect(toast.duration).toBe(6000)
|
||||
})
|
||||
|
||||
it('toasts the role change in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button('Changer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const toast = toastContent()
|
||||
expect(toast.title).toBe('Rôle modifié')
|
||||
expect(toast.description).toBe(
|
||||
'Ines Duarte a maintenant le rôle Contributor, et ses clés API suivent le nouveau rôle.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])('shows the alert on %s and keeps the choice', async (_failure, error) => {
|
||||
changeUserRole.mockRejectedValue(error)
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
const alert = dialog().querySelector('[role="alert"]')!
|
||||
expect(alert.textContent).toContain("Couldn't change the role")
|
||||
expect(alert.textContent).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(optionNamed('Contributor').getAttribute('aria-checked')).toBe('true')
|
||||
expect(button('Change role')!.disabled).toBe(false)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the alert in French', async () => {
|
||||
setLocale('fr')
|
||||
changeUserRole.mockRejectedValue(new ApiError(500))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button('Changer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const alert = dialog().querySelector('[role="alert"]')!.textContent!
|
||||
expect(alert).toContain('Impossible de modifier le rôle')
|
||||
expect(alert.replace(/\u2019/g, "'")).toContain(
|
||||
"Rien n'a été modifié. Réessayez dans un instant.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the member role changed first (409)', () => {
|
||||
const STALE = new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' })
|
||||
|
||||
function withRole(name: string, id: number): AcceptedUser {
|
||||
return { ...INES, role: { id, name, built_in: name !== 'Contributor', locked: false } }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function firstToast() {
|
||||
const argument = toastAdd.mock.calls[0]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
function alertText() {
|
||||
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function staleWithAdmin() {
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([withRole('Admin', 2)])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
}
|
||||
|
||||
it('keeps the dialog open with the alert, the choice, the moved Current mark and no toast', async () => {
|
||||
await staleWithAdmin()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(alertText()).toContain('Role changed in the meantime')
|
||||
expect(alertText()).toContain(
|
||||
'Ines Duarte is now Admin. Your choice is kept. Check it and try again.',
|
||||
)
|
||||
expect(optionNamed('Contributor').getAttribute('aria-checked')).toBe('true')
|
||||
expect(labelNamed('Admin').textContent).toContain('Current')
|
||||
expect(labelNamed('Writer').textContent).not.toContain('Current')
|
||||
expect(button('Change role')!.disabled).toBe(false)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(emitted('refreshed')?.[0]?.[0]).toMatchObject({ role: { name: 'Admin' } })
|
||||
})
|
||||
|
||||
it('sends the same role again when Change role is pressed once more', async () => {
|
||||
await staleWithAdmin()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(changeUserRole).toHaveBeenNthCalledWith(2, 7, CONTRIBUTOR.id)
|
||||
})
|
||||
|
||||
it('disables Change role when the chosen role became the current one', async () => {
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([withRole('Contributor', CONTRIBUTOR.id)])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(alertText()).toContain('Ines Duarte is now Contributor.')
|
||||
expect(button('Change role')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes, asks for a re-read and toasts when the member is gone', async () => {
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
await confirm()
|
||||
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
const toast = firstToast()
|
||||
expect(toast.title).toBe('That user no longer exists')
|
||||
})
|
||||
|
||||
it('reads the alert in French', async () => {
|
||||
setLocale('fr')
|
||||
changeUserRole.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue([withRole('Admin', 2)])
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button('Changer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Rôle modifié entre-temps')
|
||||
expect(alertText()).toContain(
|
||||
'Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.',
|
||||
)
|
||||
})
|
||||
|
||||
describe('when the dialog is gone before the answer arrives', () => {
|
||||
async function unmountedBefore409(confirmLabel = 'Change role') {
|
||||
let reject!: (reason: unknown) => void
|
||||
changeUserRole.mockReturnValue(new Promise((_, fail) => (reject = fail)))
|
||||
await mountDialog()
|
||||
await pick('Contributor')
|
||||
button(confirmLabel)!.click()
|
||||
await flushPromises()
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
reject(STALE)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('raises a warning toast that stays, with Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
|
||||
const toast = firstToast()
|
||||
expect(toast.color).toBe('warning')
|
||||
expect(toast.duration).toBe(0)
|
||||
expect(toast.title).toBe('Role not changed')
|
||||
expect(toast.description).toBe(
|
||||
"Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again.",
|
||||
)
|
||||
expect(toast.actions[0].label).toBe('Reload')
|
||||
expect(listUsers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refetches the users and dismisses the toast on Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
const toast = firstToast()
|
||||
|
||||
toast.actions[0].onClick()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
||||
})
|
||||
|
||||
it('words the toast in French', async () => {
|
||||
setLocale('fr')
|
||||
await unmountedBefore409('Changer le rôle')
|
||||
|
||||
const toast = firstToast()
|
||||
expect(toast.title).toBe('Rôle non modifié')
|
||||
expect(toast.actions[0].label).toBe('Recharger')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import DeleteRoleDialog from '../DeleteRoleDialog.vue'
|
||||
import { renderInApp } from './roleKit'
|
||||
import { canCover } from '@/lib/grantLocks'
|
||||
@@ -10,9 +11,10 @@ import type { RoleItem } from '@/lib/roles'
|
||||
|
||||
const reassignRole = vi.fn()
|
||||
const deleteRole = vi.fn()
|
||||
const listRoles = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useRoles', () => ({
|
||||
useRoles: () => ({ reassignRole, deleteRole }),
|
||||
useRoles: () => ({ reassignRole, deleteRole, listRoles }),
|
||||
}))
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
@@ -118,11 +120,15 @@ beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
reassignRole.mockReset()
|
||||
deleteRole.mockReset()
|
||||
listRoles.mockReset()
|
||||
listRoles.mockResolvedValue(ROLES)
|
||||
reassignRole.mockResolvedValue({ moved: 4 })
|
||||
deleteRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
@@ -291,3 +297,169 @@ describe('Built-in role dialog', () => {
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: inline errors', () => {
|
||||
function alertText() {
|
||||
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
function pickerText() {
|
||||
return dialog().querySelector('[aria-haspopup="listbox"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function moveContributorTo(name: string) {
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
await choose(name)
|
||||
button('Move 4 members and delete role')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('says the target no longer exists on 409 target_role_not_found and clears the picker', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'target_role_not_found', {
|
||||
code: 'target_role_not_found',
|
||||
}),
|
||||
)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(alertText()).toContain('Writer no longer exists')
|
||||
expect(alertText()).toContain(
|
||||
'It was deleted somewhere else. Choose another role to move the members to.',
|
||||
)
|
||||
expect(pickerText()).toContain('Choose a role')
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(true)
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
await openPicker()
|
||||
expect(optionNamed('Writer')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hands the page the gone role when the move answers 404 role_not_found', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(404, undefined, 'role_not_found', { code: 'role_not_found' }),
|
||||
)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(wrapper!.findComponent(DeleteRoleDialog).emitted('gone')).toHaveLength(1)
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads GET /v1/role on 409 target_role_not_found and hands the fresh list to the page', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'target_role_not_found', {
|
||||
code: 'target_role_not_found',
|
||||
}),
|
||||
)
|
||||
const afterDeletion = ROLES.filter((item) => item.name !== 'Writer')
|
||||
listRoles.mockResolvedValue(afterDeletion)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(listRoles).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper!.findComponent(DeleteRoleDialog).emitted('refreshed')?.[0]).toEqual([
|
||||
afterDeletion,
|
||||
])
|
||||
})
|
||||
|
||||
it('turns into the blocked state with the live count on 409 role_has_members', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
listRoles.mockResolvedValue(
|
||||
ROLES.map((item) => (item.id === 6 ? { ...item, members: 1 } : item)),
|
||||
)
|
||||
await mountDialog(INTERN)
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Role still has members')
|
||||
expect(alertText()).toContain('Intern now has 1 member. Move them to another role first.')
|
||||
expect(button('Move 1 member and delete role')!.disabled).toBe(true)
|
||||
expect(pickerText()).toContain('Choose a role')
|
||||
})
|
||||
|
||||
it('uses the plural for several new members', async () => {
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
listRoles.mockResolvedValue(
|
||||
ROLES.map((item) => (item.id === 6 ? { ...item, members: 3 } : item)),
|
||||
)
|
||||
await mountDialog(INTERN)
|
||||
|
||||
button('Delete role')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Intern now has 3 members. Move them to another role first.')
|
||||
expect(button('Move 3 members and delete role')).toBeDefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])('keeps the choice and sends no DELETE when the move answers %s', async (_failure, error) => {
|
||||
reassignRole.mockRejectedValue(error)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(alertText()).toContain("Couldn't move the members")
|
||||
expect(alertText()).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(pickerText()).toContain('Writer')
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(false)
|
||||
expect(deleteRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the picker on 400 invalid_reassign and keeps the existing refusal', async () => {
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(400, undefined, 'invalid_reassign', { code: 'invalid_reassign' }),
|
||||
)
|
||||
await moveContributorTo('Writer')
|
||||
|
||||
expect(alertText()).toContain("Couldn't delete the role. Try again in a moment.")
|
||||
expect(pickerText()).toContain('Choose a role')
|
||||
expect(button('Move 4 members and delete role')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the three alerts in French', async () => {
|
||||
setLocale('fr')
|
||||
const plain = () => alertText().replace(/\u2019/g, "'")
|
||||
reassignRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'target_role_not_found', {
|
||||
code: 'target_role_not_found',
|
||||
}),
|
||||
)
|
||||
await mountDialog(CONTRIBUTOR)
|
||||
await choose('Writer')
|
||||
button('Déplacer 4 membres et supprimer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(plain()).toContain("Writer n'existe plus")
|
||||
expect(plain()).toContain(
|
||||
'Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.',
|
||||
)
|
||||
|
||||
reassignRole.mockReset()
|
||||
reassignRole.mockRejectedValue(new ApiError(500))
|
||||
await choose('Admin')
|
||||
button('Déplacer 4 membres et supprimer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(plain()).toContain('Impossible de déplacer les membres')
|
||||
expect(plain()).toContain("Rien n'a été modifié. Réessayez dans un instant.")
|
||||
|
||||
wrapper?.unmount()
|
||||
document.body.innerHTML = ''
|
||||
deleteRole.mockRejectedValue(
|
||||
new ApiError(409, undefined, 'role_has_members', { code: 'role_has_members' }),
|
||||
)
|
||||
listRoles.mockResolvedValue(
|
||||
ROLES.map((item) => (item.id === 6 ? { ...item, members: 1 } : item)),
|
||||
)
|
||||
await mountDialog(INTERN)
|
||||
button('Supprimer le rôle')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(plain()).toContain('Le rôle a encore des membres')
|
||||
expect(plain()).toContain(
|
||||
"Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,14 @@ 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 { initializeLocale, setLocale } from '@/i18n'
|
||||
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 INVALID_EMAIL = 'Enter a valid email address.'
|
||||
const ESCALATION_REASON = "Your role can't assign a role with more power than yours."
|
||||
|
||||
const inviteUser = vi.fn()
|
||||
@@ -95,6 +96,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
@@ -237,7 +240,6 @@ describe('Invite answers that concern roles', () => {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['escalation', "You can't grant that", 'You can only grant what your role has.'],
|
||||
[
|
||||
'role_locked',
|
||||
'This role is locked',
|
||||
@@ -255,6 +257,22 @@ describe('Invite answers that concern roles', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('403 escalation reads under Role, not in an alert, and keeps the email and the role', async () => {
|
||||
await submitRefused(new ApiError(403, undefined, 'escalation', { code: 'escalation' }))
|
||||
|
||||
const field = [...document.body.querySelectorAll('[data-slot="root"]')].find(
|
||||
(candidate) =>
|
||||
candidate.textContent?.includes('Choose a role') ||
|
||||
candidate.querySelector('[aria-haspopup="listbox"]'),
|
||||
)
|
||||
expect(field?.textContent).toContain(ESCALATION_REASON)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(dialogText()).not.toContain("You can't grant that")
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(sendButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('429 shows the rate limit alert and keeps the dialog and the address', async () => {
|
||||
await submitRefused(new ApiError(429, undefined, undefined, undefined))
|
||||
|
||||
@@ -273,3 +291,70 @@ describe('Invite answers that concern roles', () => {
|
||||
expect(LEAD.name).toBe('Content lead')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: invite answers', () => {
|
||||
async function submitAs(error: unknown) {
|
||||
inviteUser.mockRejectedValue(error)
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin@renew.example')
|
||||
await clickSend()
|
||||
}
|
||||
|
||||
function emailField() {
|
||||
let node: HTMLElement | null = emailInput()
|
||||
while (node && !node.querySelector('label')) {
|
||||
node = node.parentElement
|
||||
}
|
||||
return node!
|
||||
}
|
||||
|
||||
it('409 email_has_account reads under Email and keeps the role', async () => {
|
||||
await submitAs(
|
||||
new ApiError(409, undefined, 'email_has_account', { code: 'email_has_account' }),
|
||||
)
|
||||
|
||||
expect(emailField().textContent).toContain('This address already has an account.')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
})
|
||||
|
||||
it('a client check failure reads Enter a valid email address. under Email', async () => {
|
||||
inviteUser.mockResolvedValue({ id: 1 })
|
||||
await mountOpenModal()
|
||||
await typeEmail('lea.martin')
|
||||
await clickSend()
|
||||
|
||||
expect(emailField().textContent).toContain('Enter a valid email address.')
|
||||
expect(inviteUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 reads Enter a valid email address. under Email', async () => {
|
||||
await submitAs(new ApiError(400))
|
||||
|
||||
expect(emailField().textContent).toContain('Enter a valid email address.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])('%s shows the alert and keeps the email and the role', async (_failure, error) => {
|
||||
await submitAs(error)
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(alert.textContent).toContain("Couldn't send the invite")
|
||||
expect(alert.textContent).toContain('Try again in a moment.')
|
||||
expect(emailInput().value).toBe('lea.martin@renew.example')
|
||||
expect(picker()?.textContent).toContain('Writer')
|
||||
expect(sendButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the send failed alert in French', async () => {
|
||||
await submitAs(new ApiError(500))
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!.textContent!
|
||||
expect(alert.replace(/\u2019/g, "'")).toContain("Impossible d'envoyer l'invitation")
|
||||
expect(alert).toContain('Réessayez dans un instant.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { renderInApp } from './roleKit'
|
||||
import OwnScopeEmpty from '../OwnScopeEmpty.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
describe('OwnScopeEmpty', () => {
|
||||
it('shows the title and description without an action button', async () => {
|
||||
wrapper = renderInApp(OwnScopeEmpty, {
|
||||
icon: 'i-lucide-file',
|
||||
title: 'Nothing here',
|
||||
description: 'You have no entries yet.',
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Nothing here')
|
||||
expect(wrapper.text()).toContain('You have no entries yet.')
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders the action button and emits action on click', async () => {
|
||||
wrapper = renderInApp(OwnScopeEmpty, {
|
||||
icon: 'i-lucide-file',
|
||||
title: 'Nothing here',
|
||||
description: 'You have no entries yet.',
|
||||
action: 'New entry',
|
||||
})
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.text()).toBe('New entry')
|
||||
await buttons[0]!.trigger('click')
|
||||
expect(wrapper.findComponent(OwnScopeEmpty).emitted('action')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { AcceptedUser } from '@/composables/useUsers'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
import { renderInApp } from './roleKit'
|
||||
import RemoveUserDialog from '../RemoveUserDialog.vue'
|
||||
|
||||
const removeUser = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ removeUser }) }))
|
||||
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: roleRefFor('editor'),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountDialog() {
|
||||
setActivePinia(createPinia())
|
||||
wrapper = renderInApp(RemoveUserDialog, { user: INES, 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
|
||||
}
|
||||
|
||||
async function confirm(label = 'Remove user') {
|
||||
button(label)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function emitted(name: string) {
|
||||
return wrapper!.findComponent(RemoveUserDialog).emitted(name)
|
||||
}
|
||||
|
||||
function toastContent(index = 0) {
|
||||
const argument = toastAdd.mock.calls[index]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
function refuse(status: number, code: string) {
|
||||
return new ApiError(status, undefined, code, { code: code as never })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
removeUser.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
removeUser.mockReset()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Remove user: the confirmation dialog', () => {
|
||||
it('names the member and offers Cancel and a destructive confirm action', async () => {
|
||||
await mountDialog()
|
||||
|
||||
expect(dialog().textContent).toContain('Remove Ines Duarte?')
|
||||
expect(button('Cancel')).toBeDefined()
|
||||
expect(button('Remove user')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the role of the list item as expected_role_id and locks the dialog while in flight', async () => {
|
||||
let release!: (value?: unknown) => void
|
||||
removeUser.mockReturnValue(new Promise((resolve) => (release = resolve)))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(removeUser).toHaveBeenCalledWith(7, INES.role!.id)
|
||||
expect(button('Removing…')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
|
||||
release()
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('closes, tells the page and toasts for 6 s on success', async () => {
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('removed')?.[0]?.[0]).toMatchObject({ id: 7 })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastContent()).toMatchObject({ color: 'success', duration: 6000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove user: answers', () => {
|
||||
it('409 user_role_changed closes the dialog and hands the member to the page', async () => {
|
||||
removeUser.mockRejectedValue(refuse(409, 'user_role_changed'))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('roleChanged')?.[0]?.[0]).toMatchObject({ id: 7 })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(emitted('removed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('404 user_not_found asks for a re-read, toasts and closes', async () => {
|
||||
removeUser.mockRejectedValue(refuse(404, 'user_not_found'))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
expect(toastContent().title).toBe('That user no longer exists')
|
||||
expect(toastContent().description).toBe('The list was refreshed.')
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
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 toasts the shared refusal and closes', async (code, title, description) => {
|
||||
removeUser.mockRejectedValue(refuse(403, code))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(toastContent()).toMatchObject({ color: 'error', title, description })
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('403 missing_permission closes and leaves the toast to the API client', async () => {
|
||||
removeUser.mockRejectedValue(refuse(403, 'missing_permission'))
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(emitted('update:open')?.at(-1)).toEqual([false])
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', new ApiError(500)],
|
||||
['a network failure', new TypeError('Failed to fetch')],
|
||||
])(
|
||||
'%s stays open with an inline error and keeps the confirm action on',
|
||||
async (_name, error) => {
|
||||
removeUser.mockRejectedValue(error)
|
||||
await mountDialog()
|
||||
|
||||
await confirm()
|
||||
|
||||
expect(dialog().querySelector('[role="alert"]')).not.toBeNull()
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(button('Remove user')!.disabled).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Remove user: French', () => {
|
||||
it('reads the dialog in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountDialog()
|
||||
|
||||
expect(dialog().textContent).toContain('Retirer Ines Duarte ?')
|
||||
expect(button('Retirer l’utilisateur')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { renderInApp } from './roleKit'
|
||||
import RoleDifferencesDialog from '../RoleDifferencesDialog.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
const ROWS = [
|
||||
{ id: 'a', setting: 'Articles: delete', saved: 'No', yours: 'Yes' },
|
||||
{ id: 'b', setting: 'Name', saved: 'Editor', yours: 'Writer' },
|
||||
]
|
||||
|
||||
async function mountDialog() {
|
||||
wrapper = renderInApp(RoleDifferencesDialog, {
|
||||
open: true,
|
||||
description: 'Two differences.',
|
||||
rows: ROWS,
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return [...document.body.querySelectorAll('[role="row"]')].map((row) =>
|
||||
[...row.querySelectorAll('[role="cell"],[role="columnheader"]')].map((cell) =>
|
||||
cell.textContent?.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
) as HTMLButtonElement
|
||||
}
|
||||
|
||||
describe('RoleDifferencesDialog', () => {
|
||||
it('lists a header row then one row per difference', async () => {
|
||||
await mountDialog()
|
||||
expect(document.body.textContent).toContain('Two differences.')
|
||||
expect(rows()).toEqual([
|
||||
['Setting', 'Saved', 'Yours'],
|
||||
['Articles: delete', 'No', 'Yes'],
|
||||
['Name', 'Editor', 'Writer'],
|
||||
])
|
||||
})
|
||||
|
||||
it('emits keepEdits and useSaved from the two buttons', async () => {
|
||||
await mountDialog()
|
||||
button('Keep my edits').click()
|
||||
await flushPromises()
|
||||
const component = wrapper!.findComponent(RoleDifferencesDialog)
|
||||
expect(component.emitted('keepEdits')).toHaveLength(1)
|
||||
button('Use the saved role').click()
|
||||
await flushPromises()
|
||||
expect(component.emitted('useSaved')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -9,13 +10,15 @@ import { renderInApp } from './roleKit'
|
||||
import TransferOwnershipDialog from '../TransferOwnershipDialog.vue'
|
||||
|
||||
const transferOwnership = vi.fn()
|
||||
const listUsers = vi.fn()
|
||||
const toastRemove = vi.fn()
|
||||
const refresh = vi.fn()
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership }) }))
|
||||
vi.mock('@/composables/useUsers', () => ({ useUsers: () => ({ transferOwnership, listUsers }) }))
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: toastRemove }) }
|
||||
})
|
||||
|
||||
const JONAS_EMAIL = 'jonas@renewbuild.example'
|
||||
@@ -104,6 +107,8 @@ function emitted(name: string) {
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
refresh.mockResolvedValue(undefined)
|
||||
listUsers.mockResolvedValue(USERS)
|
||||
toastRemove.mockReset()
|
||||
transferOwnership.mockResolvedValue({
|
||||
owner: { id: 2, name: 'Jonas Weber' },
|
||||
former_owner: { id: 1, name: 'Marta Alves', role: roleRefFor('admin') },
|
||||
@@ -116,6 +121,7 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
transferOwnership.mockReset()
|
||||
refresh.mockReset()
|
||||
listUsers.mockReset()
|
||||
})
|
||||
|
||||
describe('Pick a member', () => {
|
||||
@@ -241,19 +247,6 @@ describe('Transferring', () => {
|
||||
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' }),
|
||||
@@ -288,3 +281,135 @@ describe('Transferring', () => {
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the target role changed first (409)', () => {
|
||||
const STALE = new ApiError(409, undefined, 'user_role_changed', { code: 'user_role_changed' })
|
||||
const JONAS_NOW_WRITER = accepted(2, 'Jonas Weber', JONAS_EMAIL, 'editor')
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function alertText() {
|
||||
return dialog().querySelector('[role="alert"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
function firstToast() {
|
||||
const argument = toastAdd.mock.calls[0]![0]
|
||||
return typeof argument === 'function' ? argument() : argument
|
||||
}
|
||||
|
||||
async function staleAfterSubmit() {
|
||||
transferOwnership.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue(USERS.map((user) => (user.id === 2 ? JONAS_NOW_WRITER : user)))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
}
|
||||
|
||||
it('shows the alert with Reload member, keeps the typed email and switches the primary action off', async () => {
|
||||
await staleAfterSubmit()
|
||||
|
||||
expect(alertText()).toContain('Role changed in the meantime')
|
||||
expect(alertText()).toContain(
|
||||
'Jonas Weber is now Writer. What you typed is kept. Check the change, then try again.',
|
||||
)
|
||||
expect(button('Reload member')).toBeDefined()
|
||||
expect(field().value).toBe(JONAS_EMAIL)
|
||||
expect(button('Transfer ownership')!.disabled).toBe(true)
|
||||
expect(emitted('update:open')).toBeUndefined()
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads the member on Reload member and turns the primary action back on', async () => {
|
||||
await staleAfterSubmit()
|
||||
listUsers.mockClear()
|
||||
|
||||
button('Reload member')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(dialog().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(field().value).toBe(JONAS_EMAIL)
|
||||
expect(button('Transfer ownership')!.disabled).toBe(false)
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('transfers once reloaded', async () => {
|
||||
await staleAfterSubmit()
|
||||
button('Reload member')!.click()
|
||||
await flushPromises()
|
||||
|
||||
button('Transfer ownership')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(transferOwnership).toHaveBeenLastCalledWith(2, JONAS_EMAIL)
|
||||
expect(emitted('transferred')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('goes back to the member list when the target is gone', async () => {
|
||||
transferOwnership.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue(USERS.filter((user) => user.id !== 2))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
|
||||
expect(dialog().textContent).toContain('Choose who becomes the owner')
|
||||
expect(emitted('stale')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads the alert in French', async () => {
|
||||
setLocale('fr')
|
||||
transferOwnership.mockRejectedValueOnce(STALE)
|
||||
listUsers.mockResolvedValue(USERS.map((user) => (user.id === 2 ? JONAS_NOW_WRITER : user)))
|
||||
await mountDialog()
|
||||
radio('Jonas Weber')!.click()
|
||||
await flushPromises()
|
||||
button('Continuer')!.click()
|
||||
await flushPromises()
|
||||
await type(JONAS_EMAIL)
|
||||
button('Transférer la propriété')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(alertText()).toContain('Rôle modifié entre-temps')
|
||||
expect(alertText()).toContain(
|
||||
'Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
||||
)
|
||||
expect(button('Recharger le membre')).toBeDefined()
|
||||
})
|
||||
|
||||
describe('when the dialog is gone before the answer arrives', () => {
|
||||
async function unmountedBefore409() {
|
||||
let reject!: (reason: unknown) => void
|
||||
transferOwnership.mockReturnValue(new Promise((_, fail) => (reject = fail)))
|
||||
await mountDialog()
|
||||
await submit()
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
reject(STALE)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('raises a warning toast that stays, with Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
|
||||
const toast = firstToast()
|
||||
expect(toast.color).toBe('warning')
|
||||
expect(toast.duration).toBe(0)
|
||||
expect(toast.title).toBe('Ownership not transferred')
|
||||
expect(toast.description).toBe(
|
||||
"Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again.",
|
||||
)
|
||||
expect(toast.actions[0].label).toBe('Reload')
|
||||
})
|
||||
|
||||
it('refetches the users and dismisses the toast on Reload', async () => {
|
||||
await unmountedBefore409()
|
||||
const toast = firstToast()
|
||||
|
||||
toast.actions[0].onClick()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(toastRemove).toHaveBeenCalledWith(toast.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -230,3 +230,71 @@ describe('useRelationSearch load more', () => {
|
||||
expect(searchEntries.mock.calls.map((call) => call[1].page)).toEqual([1, 1, 1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRelationSearch loading more', () => {
|
||||
it('flags loadingMore for a page above 1 only and keeps hasMore while it loads', async () => {
|
||||
const pending = deferred()
|
||||
const search = setup()
|
||||
|
||||
search.search('rue')
|
||||
await debounce()
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
pending[0]!.resolve(answer([1, 2], 5))
|
||||
await flushPromises()
|
||||
|
||||
search.more()
|
||||
expect(search.loadingMore.value).toBe(true)
|
||||
expect(search.hasMore.value).toBe(true)
|
||||
pending[1]!.resolve(answer([3, 4], 5, 2))
|
||||
await flushPromises()
|
||||
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
expect(search.entries.value.map((item) => item.id)).toEqual([1, 2, 3, 4])
|
||||
expect(search.total.value).toBe(5)
|
||||
expect(search.hasMore.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sends no second request while the next page is loading', async () => {
|
||||
const pending = deferred()
|
||||
const search = setup()
|
||||
|
||||
search.reload()
|
||||
pending[0]!.resolve(answer([1], 3))
|
||||
await flushPromises()
|
||||
search.more()
|
||||
search.more()
|
||||
|
||||
expect(searchEntries).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a pending page after a new search', async () => {
|
||||
const pending = deferred()
|
||||
const search = setup()
|
||||
|
||||
search.reload()
|
||||
pending[0]!.resolve(answer([1], 3))
|
||||
await flushPromises()
|
||||
search.more()
|
||||
search.search('port')
|
||||
await debounce()
|
||||
pending[1]!.resolve(answer([9], 3, 2))
|
||||
pending[2]!.resolve(answer([5], 1))
|
||||
await flushPromises()
|
||||
|
||||
expect(search.entries.value.map((item) => item.id)).toEqual([5])
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
expect(search.total.value).toBe(1)
|
||||
})
|
||||
|
||||
it('offers Load more again after a failed page and keeps the results', async () => {
|
||||
searchEntries.mockResolvedValueOnce(answer([1], 5)).mockRejectedValueOnce(new ApiError(500))
|
||||
const search = setup()
|
||||
|
||||
await search.reload()
|
||||
await search.more()
|
||||
|
||||
expect(search.loadingMore.value).toBe(false)
|
||||
expect(search.entries.value.map((item) => item.id)).toEqual([1])
|
||||
expect(search.hasMore.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@ export function useContentAccess(typeKey: string, noun: ContentNoun) {
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const canCreate = computed(() => permissions.can('content.create', { typeKey }))
|
||||
const readScope = computed(() => permissions.scopeOf('content.read', { typeKey }))
|
||||
const readsOwnOnly = computed(() => readScope.value === 'own')
|
||||
const updateScope = computed(() => permissions.scopeOf('content.update', { typeKey }))
|
||||
const deleteScope = computed(() => permissions.scopeOf('content.delete', { typeKey }))
|
||||
const publishScope = computed(() => permissions.scopeOf('content.publish', { typeKey }))
|
||||
@@ -58,6 +60,8 @@ export function useContentAccess(typeKey: string, noun: ContentNoun) {
|
||||
|
||||
return {
|
||||
canCreate,
|
||||
readScope,
|
||||
readsOwnOnly,
|
||||
updateScope,
|
||||
deleteScope,
|
||||
publishScope,
|
||||
|
||||
@@ -23,11 +23,16 @@ export function useRelationSearch(target: () => string) {
|
||||
const page = ref(0)
|
||||
const status = ref<RelationSearchStatus>('idle')
|
||||
const loadingMore = ref(false)
|
||||
const moreFailed = ref(false)
|
||||
const query = ref('')
|
||||
let token = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const hasMore = computed(() => status.value === 'ready' && entries.value.length < total.value)
|
||||
const hasMore = computed(
|
||||
() =>
|
||||
entries.value.length < total.value &&
|
||||
(status.value === 'ready' || loadingMore.value || moreFailed.value),
|
||||
)
|
||||
const searching = computed(() => status.value === 'loading' && !loadingMore.value)
|
||||
const showSkeleton = computed(() => searching.value && skeleton.visible.value)
|
||||
|
||||
@@ -52,6 +57,7 @@ export function useRelationSearch(target: () => string) {
|
||||
async function load(pageNumber: number) {
|
||||
const current = ++token
|
||||
loadingMore.value = pageNumber > 1
|
||||
moreFailed.value = false
|
||||
status.value = 'loading'
|
||||
skeleton.start()
|
||||
try {
|
||||
@@ -73,6 +79,8 @@ export function useRelationSearch(target: () => string) {
|
||||
page.value = 0
|
||||
}
|
||||
fail(error)
|
||||
moreFailed.value =
|
||||
pageNumber > 1 && (status.value as RelationSearchStatus) === 'failed'
|
||||
}
|
||||
} finally {
|
||||
if (current === token) {
|
||||
@@ -85,6 +93,7 @@ export function useRelationSearch(target: () => string) {
|
||||
function search(text: string) {
|
||||
clearTimer()
|
||||
query.value = text.trim()
|
||||
moreFailed.value = false
|
||||
token++
|
||||
skeleton.stop()
|
||||
loadingMore.value = false
|
||||
@@ -100,7 +109,7 @@ export function useRelationSearch(target: () => string) {
|
||||
}
|
||||
|
||||
function more() {
|
||||
if (hasMore.value) {
|
||||
if (hasMore.value && !loadingMore.value) {
|
||||
return load(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { RoleConflictError, useRoles } from '@/composables/useRoles'
|
||||
import { refusalCopy, type RefusalCopy } from '@/lib/permissionCopy'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import {
|
||||
applyPreset,
|
||||
cloneForm,
|
||||
differences,
|
||||
emptyForm,
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
reapplyEdits,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
unsavedCount,
|
||||
activePreset,
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
type RoleForm,
|
||||
} from '@/lib/roleForm'
|
||||
import { buildRolePreview } from '@/lib/rolePreview'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import { roleDiffRows, type RoleDiffContext } from '@/lib/roleDiff'
|
||||
import {
|
||||
OWNER_ONLY_FALLBACK,
|
||||
formatClock,
|
||||
@@ -46,6 +48,7 @@ export interface ConflictState {
|
||||
}
|
||||
|
||||
const NO_CONFLICT = null
|
||||
const INFO_TOAST_MS = 6000
|
||||
|
||||
export function useRoleEditor() {
|
||||
const route = useRoute()
|
||||
@@ -68,7 +71,9 @@ export function useRoleEditor() {
|
||||
const serverLocked = ref(false)
|
||||
const serverOwnRole = ref(false)
|
||||
const refusal = ref<RefusalCopy | null>(null)
|
||||
const saveFailed = ref(false)
|
||||
const reviewing = ref(false)
|
||||
const keeping = ref(false)
|
||||
let loadedKey = ''
|
||||
let loadToken = 0
|
||||
|
||||
@@ -152,6 +157,12 @@ export function useRoleEditor() {
|
||||
return [...named, ...typed]
|
||||
})
|
||||
|
||||
const readsOwn = computed(() =>
|
||||
gridTypes.value.some(
|
||||
(type) => scopeAt(form.value.grants, contentKey('read'), type.key) === 'own',
|
||||
),
|
||||
)
|
||||
|
||||
const preview = computed(() =>
|
||||
buildRolePreview({
|
||||
grants: form.value.grants,
|
||||
@@ -161,21 +172,32 @@ export function useRoleEditor() {
|
||||
}),
|
||||
)
|
||||
|
||||
const diffContext = computed<RoleDiffContext>(() => {
|
||||
const items = catalogue.value.flatMap((group) => group.items)
|
||||
return {
|
||||
types: gridTypes.value.map((type) => ({ key: type.key, name: type.name })),
|
||||
toggles: new Set(
|
||||
items.filter((item) => item.scope_kind === 'toggle').map((item) => item.key),
|
||||
),
|
||||
order: items.map((item) => item.key),
|
||||
}
|
||||
})
|
||||
|
||||
const differenceRows = computed(() => {
|
||||
const latest = conflict.value?.latest
|
||||
if (!latest) {
|
||||
return []
|
||||
}
|
||||
return differences(form.value, formFromRole(latest)).map((entry) => ({
|
||||
name: permissionName(entry.key) ?? entry.key,
|
||||
key: entry.key,
|
||||
kind:
|
||||
entry.before === undefined || entry.before === 'none'
|
||||
? ('added' as const)
|
||||
: entry.after === undefined || entry.after === 'none'
|
||||
? ('removed' as const)
|
||||
: ('changed' as const),
|
||||
}))
|
||||
return roleDiffRows(formFromRole(latest), form.value, diffContext.value)
|
||||
})
|
||||
|
||||
const conflictSummary = computed(() => {
|
||||
const latest = conflict.value?.latest
|
||||
return t('{0} saved changes to {1} at {2}.', [
|
||||
latest?.updated_by?.name ?? t('Someone'),
|
||||
latest?.name ?? form.value.name,
|
||||
formatClock(latest?.updated_at, locale.value),
|
||||
])
|
||||
})
|
||||
|
||||
function adopt(next: RoleItem) {
|
||||
@@ -186,6 +208,7 @@ export function useRoleEditor() {
|
||||
conflict.value = NO_CONFLICT
|
||||
reviewing.value = false
|
||||
refusal.value = null
|
||||
saveFailed.value = false
|
||||
}
|
||||
|
||||
function routeKey() {
|
||||
@@ -357,13 +380,7 @@ export function useRoleEditor() {
|
||||
}
|
||||
|
||||
function toastFailure() {
|
||||
toast.add(() => ({
|
||||
id: 'role-save-failed',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: t("Couldn't save the role"),
|
||||
description: t('Try again in a moment.'),
|
||||
}))
|
||||
saveFailed.value = true
|
||||
}
|
||||
|
||||
function memberCountCopy(saved: RoleItem) {
|
||||
@@ -431,6 +448,7 @@ export function useRoleEditor() {
|
||||
}
|
||||
nameError.value = ''
|
||||
refusal.value = null
|
||||
saveFailed.value = false
|
||||
if (nameMissing.value) {
|
||||
nameError.value = t('Give the role a name.')
|
||||
return
|
||||
@@ -501,8 +519,53 @@ export function useRoleEditor() {
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleReview() {
|
||||
reviewing.value = !reviewing.value
|
||||
function openReview() {
|
||||
reviewing.value = true
|
||||
}
|
||||
|
||||
function closeReview() {
|
||||
reviewing.value = false
|
||||
}
|
||||
|
||||
function keptEditsCopy(count: number) {
|
||||
return count === 1
|
||||
? t('Your 1 change is still here. Review it, then save.')
|
||||
: t('Your {0} changes are still here. Review them, then save.', [count])
|
||||
}
|
||||
|
||||
async function keepEdits() {
|
||||
const target = role.value
|
||||
if (!target || keeping.value) {
|
||||
return
|
||||
}
|
||||
keeping.value = true
|
||||
try {
|
||||
const latest = await api.getRole(target.id)
|
||||
const merged = reapplyEdits(formFromRole(latest), initial.value, form.value)
|
||||
role.value = latest
|
||||
initial.value = formFromRole(latest)
|
||||
form.value = merged
|
||||
conflict.value = NO_CONFLICT
|
||||
reviewing.value = false
|
||||
const count = unsavedCount(form.value, initial.value)
|
||||
if (count > 0) {
|
||||
toast.add(() => ({
|
||||
id: `role-reloaded-${latest.id}`,
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
duration: INFO_TOAST_MS,
|
||||
title: t('Role reloaded'),
|
||||
description: keptEditsCopy(count),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'role_not_found') {
|
||||
reviewing.value = false
|
||||
loadState.value = 'missing'
|
||||
}
|
||||
} finally {
|
||||
keeping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
@@ -526,7 +589,9 @@ export function useRoleEditor() {
|
||||
conflict,
|
||||
lowered,
|
||||
refusal,
|
||||
saveFailed,
|
||||
reviewing,
|
||||
keeping,
|
||||
readOnlyReason,
|
||||
readOnly,
|
||||
unsaved,
|
||||
@@ -538,7 +603,9 @@ export function useRoleEditor() {
|
||||
canReset,
|
||||
gridTypes,
|
||||
preview,
|
||||
readsOwn,
|
||||
differenceRows,
|
||||
conflictSummary,
|
||||
load,
|
||||
changeCell,
|
||||
changeGeneral,
|
||||
@@ -551,7 +618,9 @@ export function useRoleEditor() {
|
||||
reloadRole,
|
||||
reloadPage,
|
||||
resetRole,
|
||||
toggleReview,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepEdits,
|
||||
duplicate,
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,12 @@ export function useUsers() {
|
||||
})
|
||||
}
|
||||
|
||||
function removeUser(id: number, expectedRoleId: number): Promise<void> {
|
||||
return request<void>(`/v1/user/${id}?expected_role_id=${expectedRoleId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
function cancelInvite(id: number): Promise<void> {
|
||||
return request<void>(`/v1/user/invite/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -85,6 +91,7 @@ export function useUsers() {
|
||||
inviteUser,
|
||||
changeUserRole,
|
||||
transferOwnership,
|
||||
removeUser,
|
||||
cancelInvite,
|
||||
renewInvite,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ERROR_ROWS,
|
||||
LOGIN_FAMILY_CAPTION,
|
||||
NO_CONTENT_RESPONSE,
|
||||
OWNER_OR_ADMIN_MESSAGE,
|
||||
OWNER_REQUIRED_MESSAGE,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
RATE_LIMIT_SENTENCE,
|
||||
@@ -316,7 +315,7 @@ describe('Catalogue completeness', () => {
|
||||
const examples = endpointExamples(endpoint, ORIGIN)
|
||||
expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).toContain(endpoint.method)
|
||||
expect(endpoint.path.startsWith('/v1/')).toBe(true)
|
||||
expect(['Public', 'Verified user', 'Owner or admin', 'Owner only']).toContain(
|
||||
expect(['Public', 'Verified user', 'Needs permission', 'Owner only']).toContain(
|
||||
endpoint.access,
|
||||
)
|
||||
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
|
||||
@@ -407,32 +406,90 @@ describe('SEO output routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Owner or admin purge routes', () => {
|
||||
it('gives Owner or admin to the content type writes, the built-in types and the four purge routes', () => {
|
||||
const GATED_ROUTES: [string, string][] = [
|
||||
['POST /v1/content-type/', 'Create and edit content types'],
|
||||
['PUT /v1/content-type/{key}', 'Create and edit content types'],
|
||||
['DELETE /v1/content-type/{key}', 'Delete content types'],
|
||||
['GET /v1/builtin-type/', 'View content types'],
|
||||
['GET /v1/builtin-type/{key}', 'View content types'],
|
||||
['GET /v1/media/purge-settings', 'View settings'],
|
||||
['PATCH /v1/media/purge-settings', 'Edit general settings'],
|
||||
['GET /v1/media/purge-preview', 'Delete files'],
|
||||
['POST /v1/media/purge', 'Delete files'],
|
||||
]
|
||||
|
||||
describe('Needs permission routes', () => {
|
||||
it('gives Needs permission to the nine gated routes and no route keeps Owner or admin', () => {
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Owner or admin')
|
||||
.filter((endpoint) => endpoint.access === 'Needs permission')
|
||||
.map(label),
|
||||
).toEqual([
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
])
|
||||
).toEqual(GATED_ROUTES.map(([route]) => route))
|
||||
expect(allEndpoints().map((endpoint) => endpoint.access as string)).not.toContain(
|
||||
'Owner or admin',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists the owner or admin 403 on each purge route', () => {
|
||||
for (const endpoint of allEndpoints().filter((item) => item.access === 'Owner or admin')) {
|
||||
expect(endpoint.errors).toContainEqual({
|
||||
it.each(GATED_ROUTES)(
|
||||
'%s ends its description with the permission sentence',
|
||||
(route, permission) => {
|
||||
const endpoint = find(route)
|
||||
|
||||
expect(endpoint.description.endsWith(` Needs the permission "${permission}".`)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(endpoint.access).not.toBe('Owner only')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(GATED_ROUTES)('%s lists the missing_permission 403 in code style', (route) => {
|
||||
expect(find(route).errors).toContainEqual({
|
||||
status: 403,
|
||||
caption: 'missing_permission',
|
||||
literal: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Owner only on the owner-only routes', () => {
|
||||
expect(
|
||||
allEndpoints().filter((endpoint) => endpoint.access === 'Owner only').length,
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('never says only owners and admins can do this', () => {
|
||||
const everything = JSON.stringify([API_GROUPS, ERROR_ROWS])
|
||||
|
||||
expect(everything).not.toContain('Only owners and admins')
|
||||
expect(everything).not.toContain('Only the owner can do this')
|
||||
expect(everything).not.toContain('Owner or admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: section copy', () => {
|
||||
function groupNamed(title: string) {
|
||||
return API_GROUPS.find((group) => group.title === title)!
|
||||
}
|
||||
|
||||
it('words the Content types and Entries intros around permissions', () => {
|
||||
expect(groupNamed('Content types').description).toBe(
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
)
|
||||
expect(groupNamed('Entries').description).toBe(
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
|
||||
)
|
||||
})
|
||||
|
||||
it('lists missing_permission as a code on the 403 row with the literal-body note', () => {
|
||||
const row = ERROR_ROWS.find((item) => item.status === 403)!
|
||||
|
||||
expect(row.messages).toEqual([
|
||||
SESSION_REQUIRED_MESSAGE,
|
||||
'You must verify your account to access this resource.',
|
||||
])
|
||||
expect(row.codes).toEqual(['missing_permission'])
|
||||
expect(row.note).toBe(
|
||||
'`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -852,12 +909,8 @@ describe('Errors table', () => {
|
||||
[401, ['You must be logged in to access this resource.'], []],
|
||||
[
|
||||
403,
|
||||
[
|
||||
SESSION_REQUIRED_MESSAGE,
|
||||
OWNER_OR_ADMIN_MESSAGE,
|
||||
'You must verify your account to access this resource.',
|
||||
],
|
||||
[],
|
||||
[SESSION_REQUIRED_MESSAGE, 'You must verify your account to access this resource.'],
|
||||
['missing_permission'],
|
||||
],
|
||||
[
|
||||
404,
|
||||
@@ -993,12 +1046,17 @@ describe('Custom content types round', () => {
|
||||
find(route)
|
||||
.errors.filter((error) => error.literal)
|
||||
.map((error) => `${error.status} ${error.caption}`)
|
||||
expect(literalErrors('POST /v1/content-type/')).toEqual(['409 content_type_key_taken'])
|
||||
expect(literalErrors('POST /v1/content-type/')).toEqual([
|
||||
'403 missing_permission',
|
||||
'409 content_type_key_taken',
|
||||
])
|
||||
expect(literalErrors('PUT /v1/content-type/{key}')).toEqual([
|
||||
'403 missing_permission',
|
||||
'404 content_type_not_found',
|
||||
'409 schema_incompatible',
|
||||
])
|
||||
expect(literalErrors('DELETE /v1/content-type/{key}')).toEqual([
|
||||
'403 missing_permission',
|
||||
'404 content_type_not_found',
|
||||
'409 content_type_has_entries',
|
||||
'409 content_type_referenced',
|
||||
@@ -1008,7 +1066,10 @@ describe('Custom content types round', () => {
|
||||
'409 invalid_entry: title must be unique',
|
||||
'422 invalid_entry: capacity must be at most 5000',
|
||||
])
|
||||
expect(literalErrors('GET /v1/builtin-type/{key}')).toEqual(['404 builtin_type_not_found'])
|
||||
expect(literalErrors('GET /v1/builtin-type/{key}')).toEqual([
|
||||
'403 missing_permission',
|
||||
'404 builtin_type_not_found',
|
||||
])
|
||||
})
|
||||
|
||||
it('takes the entry data and indexes the events keys as body fields', () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import { allEndpoints } from '../apiCatalogue'
|
||||
|
||||
const GATING: [string, string, string][] = [
|
||||
['POST /v1/content-type/', 'schema.edit', 'Create and edit content types'],
|
||||
['PUT /v1/content-type/{key}', 'schema.edit', 'Create and edit content types'],
|
||||
['DELETE /v1/content-type/{key}', 'schema.delete', 'Delete content types'],
|
||||
['GET /v1/builtin-type/', 'schema.view', 'View content types'],
|
||||
['GET /v1/builtin-type/{key}', 'schema.view', 'View content types'],
|
||||
['GET /v1/media/purge-settings', 'settings.view', 'View settings'],
|
||||
['PATCH /v1/media/purge-settings', 'settings.edit_general', 'Edit general settings'],
|
||||
['GET /v1/media/purge-preview', 'media.delete', 'Delete files'],
|
||||
['POST /v1/media/purge', 'media.delete', 'Delete files'],
|
||||
]
|
||||
|
||||
function endpointFor(route: string) {
|
||||
return allEndpoints().find((endpoint) => `${endpoint.method} ${endpoint.path}` === route)!
|
||||
}
|
||||
|
||||
describe('API gating of the permission-gated routes', () => {
|
||||
it.each(GATING)('%s needs %s, read as "%s"', (route, key, label) => {
|
||||
expect(permissionName(key)).toBe(label)
|
||||
expect(endpointFor(route).description.endsWith(`Needs the permission "${label}".`)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(endpointFor(route).access).toBe('Needs permission')
|
||||
})
|
||||
|
||||
it('covers exactly the routes the catalogue marks as Needs permission', () => {
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Needs permission')
|
||||
.map((endpoint) => `${endpoint.method} ${endpoint.path}`),
|
||||
).toEqual(GATING.map(([route]) => route))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only'
|
||||
export type AccessLevel = 'Public' | 'Verified user' | 'Needs permission' | 'Owner only'
|
||||
|
||||
export interface ApiField {
|
||||
name: string
|
||||
@@ -94,7 +94,7 @@ export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
|
||||
export const NO_CONTENT_RESPONSE = '204 No Content'
|
||||
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
|
||||
export const OWNER_REQUIRED_MESSAGE = 'missing_permission'
|
||||
export const OWNER_OR_ADMIN_MESSAGE = 'missing_permission'
|
||||
export const MISSING_PERMISSION_BODY = 'missing_permission'
|
||||
export const IMAGES_AFTER_GROUP = 'sites'
|
||||
export const FIELD_TYPES_ANCHOR = 'ct-fields'
|
||||
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
|
||||
@@ -649,7 +649,7 @@ const literal = (status: number, caption: string, tail?: string): ApiError => ({
|
||||
})
|
||||
|
||||
const e401 = e(401, UNAUTHORIZED)
|
||||
const eOwnerOrAdmin = e(403, OWNER_OR_ADMIN_MESSAGE)
|
||||
const eMissingPermission = literal(403, MISSING_PERMISSION_BODY)
|
||||
const contentTypeNotFound = literal(404, 'content_type_not_found')
|
||||
const entryNotFound = literal(404, 'entry_not_found')
|
||||
const invalidEntryUnique = literal(409, 'invalid_entry: title must be unique')
|
||||
@@ -662,11 +662,7 @@ const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
|
||||
const verifiedAfterLogin = (errors: ApiError[]) =>
|
||||
errors.flatMap((error) => (error.status === 401 ? [error, e(403, VERIFY_REQUIRED)] : [error]))
|
||||
|
||||
const ownerOrAdminErrors = (...own: ApiError[]) => [
|
||||
...own,
|
||||
e(401, UNAUTHORIZED),
|
||||
e(403, OWNER_OR_ADMIN_MESSAGE),
|
||||
]
|
||||
const permissionErrors = (...own: ApiError[]) => [...own, e(401, UNAUTHORIZED), eMissingPermission]
|
||||
|
||||
const articlePage = () => ({
|
||||
items: [
|
||||
@@ -1032,7 +1028,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
key: 'ctypes',
|
||||
title: 'Content types',
|
||||
description:
|
||||
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
||||
note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.',
|
||||
fieldTypes: FIELD_TYPE_ROWS,
|
||||
snippet: { key: 'field', label: 'Field object', code: json(eventFields[2]) },
|
||||
@@ -1059,12 +1055,12 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'ct-create',
|
||||
'POST',
|
||||
'/v1/content-type/',
|
||||
'Create a content type.',
|
||||
'Owner or admin',
|
||||
'Create a content type. Needs the permission "Create and edit content types".',
|
||||
'Needs permission',
|
||||
[
|
||||
e(400, BODY_MISMATCH),
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
eMissingPermission,
|
||||
literal(409, 'content_type_key_taken'),
|
||||
],
|
||||
json(eventType),
|
||||
@@ -1095,12 +1091,12 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'ct-update',
|
||||
'PUT',
|
||||
'/v1/content-type/{key}',
|
||||
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.",
|
||||
'Owner or admin',
|
||||
'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".',
|
||||
'Needs permission',
|
||||
[
|
||||
e(400, BODY_MISMATCH),
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
eMissingPermission,
|
||||
contentTypeNotFound,
|
||||
literal(
|
||||
409,
|
||||
@@ -1128,11 +1124,11 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'ct-delete',
|
||||
'DELETE',
|
||||
'/v1/content-type/{key}',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it.',
|
||||
'Owner or admin',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".',
|
||||
'Needs permission',
|
||||
[
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
eMissingPermission,
|
||||
contentTypeNotFound,
|
||||
literal(409, 'content_type_has_entries'),
|
||||
literal(409, 'content_type_referenced'),
|
||||
@@ -1145,7 +1141,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
key: 'entries',
|
||||
title: 'Entries',
|
||||
description:
|
||||
'Read and write the content of a custom type. Any verified user can write entries.',
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
|
||||
note: "An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).",
|
||||
snippet: { key: 'entry', label: 'Entry', code: json(eventEntry()) },
|
||||
endpoints: [
|
||||
@@ -1258,9 +1254,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'bt-list',
|
||||
'GET',
|
||||
'/v1/builtin-type/',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.',
|
||||
'Owner or admin',
|
||||
[e401, eOwnerOrAdmin],
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".',
|
||||
'Needs permission',
|
||||
[e401, eMissingPermission],
|
||||
json([
|
||||
{ key: 'article', name: 'Articles', fields: articleBuiltinFields },
|
||||
{ key: 'author', name: 'Authors', fields: authorBuiltinFields },
|
||||
@@ -1270,9 +1266,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'bt-get',
|
||||
'GET',
|
||||
'/v1/builtin-type/{key}',
|
||||
'Get one built-in type. Keys are article, author, site, media and user.',
|
||||
'Owner or admin',
|
||||
[e401, eOwnerOrAdmin, literal(404, 'builtin_type_not_found')],
|
||||
'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".',
|
||||
'Needs permission',
|
||||
[e401, eMissingPermission, literal(404, 'builtin_type_not_found')],
|
||||
json({ key: 'author', name: 'Authors', fields: authorBuiltinFields }),
|
||||
),
|
||||
],
|
||||
@@ -1431,18 +1427,18 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-get',
|
||||
'GET',
|
||||
'/v1/media/purge-settings',
|
||||
'Get the automatic duplicate purge settings.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(),
|
||||
'Get the automatic duplicate purge settings. Needs the permission "View settings".',
|
||||
'Needs permission',
|
||||
permissionErrors(),
|
||||
json(PURGE_SETTINGS),
|
||||
),
|
||||
endpoint(
|
||||
'med-purge-set',
|
||||
'PATCH',
|
||||
'/v1/media/purge-settings',
|
||||
'Turn the automatic purge on or off, or change its interval.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)),
|
||||
'Turn the automatic purge on or off, or change its interval. Needs the permission "Edit general settings".',
|
||||
'Needs permission',
|
||||
permissionErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)),
|
||||
json(PURGE_SETTINGS),
|
||||
{
|
||||
bodyKind: 'json',
|
||||
@@ -1470,9 +1466,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-prev',
|
||||
'GET',
|
||||
'/v1/media/purge-preview',
|
||||
'Preview what a purge would merge. Nothing changes.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(),
|
||||
'Preview what a purge would merge. Nothing changes. Needs the permission "Delete files".',
|
||||
'Needs permission',
|
||||
permissionErrors(),
|
||||
json({
|
||||
groups: 1,
|
||||
copies: 2,
|
||||
@@ -1494,13 +1490,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-run',
|
||||
'POST',
|
||||
'/v1/media/purge',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(
|
||||
e(400, BODY_MISMATCH),
|
||||
e(409, PURGE_CONFLICT),
|
||||
e(409, PURGE_BUSY),
|
||||
),
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".',
|
||||
'Needs permission',
|
||||
permissionErrors(e(400, BODY_MISMATCH), e(409, PURGE_CONFLICT), e(409, PURGE_BUSY)),
|
||||
json({
|
||||
groups_total: 1,
|
||||
groups_purged: 1,
|
||||
@@ -1885,8 +1877,9 @@ export const ERROR_ROWS: ErrorRow[] = [
|
||||
{
|
||||
status: 403,
|
||||
meaning: 'Forbidden',
|
||||
messages: [SESSION_REQUIRED_MESSAGE, OWNER_OR_ADMIN_MESSAGE, VERIFY_REQUIRED],
|
||||
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
|
||||
messages: [SESSION_REQUIRED_MESSAGE, VERIFY_REQUIRED],
|
||||
codes: [MISSING_PERMISSION_BODY],
|
||||
note: '`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
|
||||
+122
-36
@@ -590,8 +590,6 @@ export const messages = {
|
||||
"There's already a pending invite for this address.":
|
||||
'Une invitation est déjà en attente pour cette adresse.',
|
||||
'This address already has an account.': 'Cette adresse a déjà un compte.',
|
||||
"Couldn't send the invite. Try again in a moment.":
|
||||
'Impossible d’envoyer l’invitation. Réessayez dans un instant.',
|
||||
'Loading users': 'Chargement des utilisateurs',
|
||||
"Couldn't load users": 'Impossible de charger les utilisateurs',
|
||||
Name: 'Nom',
|
||||
@@ -928,7 +926,6 @@ export const messages = {
|
||||
Public: 'Public',
|
||||
'Verified user': 'Utilisateur vérifié',
|
||||
'Owner only': 'Propriétaire uniquement',
|
||||
'Owner or admin': 'Propriétaire ou administrateur',
|
||||
'Settings · owner only': 'Paramètres · propriétaire uniquement',
|
||||
'Activity log': 'Journal d’activité',
|
||||
Authentication: 'Authentification',
|
||||
@@ -958,8 +955,6 @@ export const messages = {
|
||||
'Aucune clé nécessaire. Deux routes en lecture seule, et seuls les articles publiés sont renvoyés.',
|
||||
'API key': 'Clé d’API',
|
||||
'Full access': 'Accès complet',
|
||||
"Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.":
|
||||
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.',
|
||||
'Keys start with `rcms_` and are shown in full once, when you create them.':
|
||||
'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.',
|
||||
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.':
|
||||
@@ -1657,12 +1652,6 @@ export const messages = {
|
||||
'Delete an image. Articles that use it show a broken image.':
|
||||
'Supprime une image. Les articles qui l’utilisent affichent une image cassée.',
|
||||
'Merge identical copies into one image.': 'Fusionne les copies identiques en une seule image.',
|
||||
'Get the automatic duplicate purge settings.':
|
||||
'Récupère les paramètres de la purge automatique des doublons.',
|
||||
'Turn the automatic purge on or off, or change its interval.':
|
||||
'Active ou désactive la purge automatique, ou modifie son intervalle.',
|
||||
'Preview what a purge would merge. Nothing changes.':
|
||||
'Prévisualise ce qu’une purge fusionnerait. Rien n’est modifié.',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une médiathèque modifiée.',
|
||||
'List users and invites.': 'Liste les utilisateurs et les invitations.',
|
||||
@@ -2244,8 +2233,6 @@ export const messages = {
|
||||
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex }. Le total est dans l’en-tête X-Total-Count.',
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
|
||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.':
|
||||
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
|
||||
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).':
|
||||
'Le serveur attend un identifiant au format d’un entier non signé de 32 bits (uint32).',
|
||||
'Article not found': 'Article introuvable',
|
||||
@@ -2742,8 +2729,6 @@ export const messages = {
|
||||
'Status codes and messages': 'Codes d’état et messages',
|
||||
'Images, CORS and ?format=': 'Images, CORS et ?format=',
|
||||
'{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API',
|
||||
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.':
|
||||
'Définissez votre propre type de contenu, comme des événements ou des recettes. Un propriétaire ou un administrateur crée un type composé de 1 à 50 champs ordonnés ; le type a alors ses propres routes d’entrées.',
|
||||
'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.':
|
||||
'Un type de contenu a une `key` (2 à 40 lettres minuscules, chiffres ou tirets, choisie à la création et jamais modifiée) et des champs. Chaque champ a une `key` (lettres, chiffres et tirets bas, commençant par une lettre), un `label`, un `type` et des règles.',
|
||||
'Field object': 'Objet champ',
|
||||
@@ -2767,21 +2752,14 @@ export const messages = {
|
||||
'identifiants d’entrées d’un autre type de contenu ; `relation: { content_type, kind }`, kind valant one_to_one | one_to_many | many_to_many',
|
||||
'List all custom content types.': 'Liste tous les types de contenu personnalisés.',
|
||||
'Get one content type with its fields.': 'Renvoie un type de contenu avec ses champs.',
|
||||
'Create a content type.': 'Crée un type de contenu.',
|
||||
"2 to 40 characters. Lowercase letters, digits and hyphens. Can't be changed later.":
|
||||
'2 à 40 caractères. Lettres minuscules, chiffres et tirets. Ne peut pas être modifiée ensuite.',
|
||||
'Up to 100 characters.': 'Jusqu’à 100 caractères.',
|
||||
'Optional.': 'Facultatif.',
|
||||
'1 to 50 fields, in order. See Field types.':
|
||||
'1 à 50 champs, dans l’ordre. Voir Types de champ.',
|
||||
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.":
|
||||
'Remplace le nom, l’icône et les champs d’un type. La clé ne change jamais. Les clés et types des champs enregistrés ne peuvent pas changer.',
|
||||
"Some existing entries don't fit the new fields. Nothing was changed.":
|
||||
'Certaines entrées existantes ne conviennent pas aux nouveaux champs. Rien n’a été modifié.',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it.':
|
||||
'Supprime un type de contenu. Possible seulement s’il n’a aucune entrée et si aucun champ de relation ne pointe vers lui.',
|
||||
'Read and write the content of a custom type. Any verified user can write entries.':
|
||||
'Lisez et écrivez le contenu d’un type personnalisé. Tout utilisateur vérifié peut écrire des entrées.',
|
||||
"An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).":
|
||||
'Les `data` d’une entrée forment un objet indexé par clé de champ, validé selon le type. Les réponses ajoutent `media` (les images résolues des champs média, indexées par clé de champ : id, filename, width, height, size et thumbnail_url, ou { id, missing: true } si l’image a été supprimée) et `relations` (le premier champ résolu des entrées liées).',
|
||||
'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.':
|
||||
@@ -2799,12 +2777,6 @@ export const messages = {
|
||||
'Les champs des articles, auteurs, sites, médias et utilisateurs, dans la même forme que les champs d’un type de contenu.',
|
||||
'Built-in types are read-only: they cannot be changed through the API.':
|
||||
'Les types intégrés sont en lecture seule : ils ne peuvent pas être modifiés par l’API.',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.':
|
||||
'Liste les types intégrés (articles, auteurs, sites, médias, utilisateurs) et leurs champs. Les clés sont article, author, site, media et user.',
|
||||
'Get one built-in type. Keys are article, author, site, media and user.':
|
||||
'Renvoie un type intégré. Les clés sont article, author, site, media et user.',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged.':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une bibliothèque modifiée. failed_groups compte les groupes qui n’ont pas pu être purgés ; les autres l’ont été.',
|
||||
'Sites and authors': 'Sites et auteurs',
|
||||
'Read the sites and authors your content belongs to. Creating and changing them needs a session.':
|
||||
'Lisez les sites et les auteurs auxquels votre contenu appartient. Les créer et les modifier demande une session.',
|
||||
@@ -2998,7 +2970,6 @@ export const messages = {
|
||||
'{0} saved changes to {1} at {2}. Reload to see them, or review how they differ from your edits.':
|
||||
'{0} a enregistré des modifications sur {1} à {2}. Rechargez pour les voir, ou comparez-les à vos modifications.',
|
||||
Someone: 'Quelqu’un',
|
||||
"Couldn't save the role": 'Impossible d’enregistrer le rôle',
|
||||
'{0} is updated for 1 member.': '{0} est mis à jour pour 1 membre.',
|
||||
'{0} is updated for {1} members.': '{0} est mis à jour pour {1} membres.',
|
||||
'Give the role a name.': 'Donnez un nom au rôle.',
|
||||
@@ -3058,7 +3029,9 @@ export const messages = {
|
||||
'edit activity retention': 'modifier la conservation de l’activité',
|
||||
'transfer ownership': 'transférer la propriété',
|
||||
'read any entry': 'lire n’importe quelle entrée',
|
||||
'read only their own entries': 'lire uniquement leurs propres entrées',
|
||||
'read online articles and their own drafts':
|
||||
'lire les articles en ligne et leurs propres brouillons',
|
||||
'read only entries they created': 'lire uniquement les entrées créées par la personne',
|
||||
'create entries': 'créer des entrées',
|
||||
'edit any entry': 'modifier n’importe quelle entrée',
|
||||
'edit only their own': 'modifier uniquement les leurs',
|
||||
@@ -3142,7 +3115,6 @@ export const messages = {
|
||||
"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}',
|
||||
@@ -3150,8 +3122,6 @@ export const messages = {
|
||||
'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}',
|
||||
@@ -3165,7 +3135,6 @@ export const messages = {
|
||||
'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é',
|
||||
@@ -3185,7 +3154,6 @@ export const messages = {
|
||||
"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.':
|
||||
@@ -3211,9 +3179,127 @@ export const messages = {
|
||||
'{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}',
|
||||
'{0} is now {1}, and their API keys follow the new role.':
|
||||
'{0} a maintenant le rôle {1}, et ses clés API suivent le nouveau rôle.',
|
||||
"Couldn't save the role": "Impossible d'enregistrer le rôle",
|
||||
'Your changes are still here. Try again in a moment.':
|
||||
'Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
"Couldn't change the role": 'Impossible de modifier le rôle',
|
||||
'{0} no longer exists': "{0} n'existe plus",
|
||||
'It was deleted somewhere else. Choose another role to move the members to.':
|
||||
'Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.',
|
||||
'Role still has members': 'Le rôle a encore des membres',
|
||||
'{0} now has 1 member. Move them to another role first.':
|
||||
"{0} a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle.",
|
||||
'{0} now has {1} members. Move them to another role first.':
|
||||
"{0} a maintenant {1} membres. Déplacez-les d'abord vers un autre rôle.",
|
||||
"Couldn't move the members": 'Impossible de déplacer les membres',
|
||||
'Enter a valid email address.': 'Saisissez une adresse e-mail valide.',
|
||||
"Couldn't send the invite": "Impossible d'envoyer l'invitation",
|
||||
'Moved {0} members from {1} to {2}': '{0} membres déplacés de {1} vers {2}',
|
||||
'Moved 1 member from {0} to {1}': '1 membre déplacé de {0} vers {1}',
|
||||
'Moved {0} members from {1} to another role': '{0} membres déplacés de {1} vers un autre rôle',
|
||||
'Moved 1 member from {0} to another role': '1 membre déplacé de {0} vers un autre rôle',
|
||||
'Failed to move {0} members from {1} to {2}':
|
||||
'Échec du déplacement de {0} membres de {1} vers {2}',
|
||||
'Failed to move 1 member from {0} to {1}': 'Échec du déplacement de 1 membre de {0} vers {1}',
|
||||
'Failed to move {0} members from {1} to another role':
|
||||
'Échec du déplacement de {0} membres de {1} vers un autre rôle',
|
||||
'Failed to move 1 member from {0} to another role':
|
||||
'Échec du déplacement de 1 membre de {0} vers un autre rôle',
|
||||
'Removed {0}': '{0} retiré',
|
||||
'Failed to remove {0}': 'Échec du retrait de {0}',
|
||||
'Move 1 member and delete role': 'Déplacer 1 membre et supprimer le rôle',
|
||||
'Showing {0} of {1}': 'Affichage de {0} sur {1}',
|
||||
Reload: 'Recharger',
|
||||
'Role changed elsewhere': 'Rôle modifié ailleurs',
|
||||
'{0} edited {1}, so nothing was saved. Reload and try again.':
|
||||
"{0} a modifié {1}, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
'That role no longer exists': "Ce rôle n'existe plus",
|
||||
'Role not changed': 'Rôle non modifié',
|
||||
"{0}'s role changed in the meantime, so nothing was saved. Reload and try again.":
|
||||
"Le rôle de {0} a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
'Ownership not transferred': 'Propriété non transférée',
|
||||
"{0}'s role changed in the meantime, so nothing was transferred. Check it and try again.":
|
||||
"Le rôle de {0} a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez.",
|
||||
'User not removed': 'Utilisateur non retiré',
|
||||
"{0}'s role changed in the meantime, so nothing was removed. Check it and try again.":
|
||||
"Le rôle de {0} a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez.",
|
||||
'How the saved role differs from your edits':
|
||||
'Comment le rôle enregistré diffère de vos modifications',
|
||||
Setting: 'Réglage',
|
||||
Yours: 'Vous',
|
||||
'Use the saved role': 'Utiliser le rôle enregistré',
|
||||
'Keep my edits': 'Garder mes modifications',
|
||||
'Role reloaded': 'Rôle rechargé',
|
||||
'Your {0} changes are still here. Review them, then save.':
|
||||
'Vos {0} modifications sont toujours là. Relisez-les, puis enregistrez.',
|
||||
'Read: Own': 'Lecture : Les siennes',
|
||||
"Articles show online articles plus the person's own drafts. Custom content types show only entries the person created. Status counts follow the same rule.":
|
||||
"Les articles affichent les articles en ligne et les brouillons de la personne. Les types de contenu personnalisés n'affichent que les entrées créées par la personne. Les compteurs de statut suivent la même règle.",
|
||||
'Role changed in the meantime': 'Rôle modifié entre-temps',
|
||||
'{0} is now {1}. Your choice is kept. Check it and try again.':
|
||||
'{0} a maintenant le rôle {1}. Votre choix est conservé. Vérifiez-le et réessayez.',
|
||||
'{0} is now {1}. What you typed is kept. Check the change, then try again.':
|
||||
'{0} a maintenant le rôle {1}. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
||||
'Reload member': 'Recharger le membre',
|
||||
'Create a content type. Needs the permission "Create and edit content types".':
|
||||
'Crée un type de contenu. Nécessite la permission « Créer et modifier des types de contenu ».',
|
||||
'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".':
|
||||
'Remplace le nom, l’icône et les champs d’un type. La clé ne change jamais. Les clés et types des champs enregistrés ne peuvent pas changer. Nécessite la permission « Créer et modifier des types de contenu ».',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".':
|
||||
'Supprime un type de contenu. Possible seulement s’il n’a aucune entrée et si aucun champ de relation ne pointe vers lui. Nécessite la permission « Supprimer des types de contenu ».',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".':
|
||||
'Liste les types intégrés (articles, auteurs, sites, médias, utilisateurs) et leurs champs. Les clés sont article, author, site, media et user. Nécessite la permission « Voir les types de contenu ».',
|
||||
'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".':
|
||||
'Renvoie un type intégré. Les clés sont article, author, site, media et user. Nécessite la permission « Voir les types de contenu ».',
|
||||
'Get the automatic duplicate purge settings. Needs the permission "View settings".':
|
||||
'Récupère les paramètres de la purge automatique des doublons. Nécessite la permission « Voir les paramètres ».',
|
||||
'Turn the automatic purge on or off, or change its interval. Needs the permission "Edit general settings".':
|
||||
'Active ou désactive la purge automatique, ou modifie son intervalle. Nécessite la permission « Modifier les paramètres généraux ».',
|
||||
'Preview what a purge would merge. Nothing changes. Needs the permission "Delete files".':
|
||||
'Prévisualise ce qu’une purge fusionnerait. Rien n’est modifié. Nécessite la permission « Supprimer des fichiers ».',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une bibliothèque modifiée. failed_groups compte les groupes qui n’ont pas pu être purgés ; les autres l’ont été. Nécessite la permission « Supprimer des fichiers ».',
|
||||
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.':
|
||||
'Définissez votre propre type de contenu, comme des événements ou des recettes. Une personne dont le rôle peut créer et modifier des types de contenu définit un type composé de 1 à 50 champs ordonnés ; le type a alors ses propres routes d’entrées.',
|
||||
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.":
|
||||
'Lisez et écrivez le contenu d’un type personnalisé. Chaque route exige l’autorisation de contenu correspondante du rôle du créateur de la clé.',
|
||||
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.':
|
||||
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle tel qu’il est actuellement. Une clé n’a jamais plus de droits que son créateur. Une route qui exige une permission répond 403 « missing_permission » à une clé dont le rôle du créateur ne la possède pas.',
|
||||
'`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. Article SEO fields need only the right to edit the article.':
|
||||
'`missing_permission` est le corps littéral de la réponse, pas une phrase. Les valeurs SEO par défaut d’un site demandent la permission de gérer les sites. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
|
||||
'Needs permission': 'Autorisation requise',
|
||||
'{0} saved changes to {1} at {2}.': '{0} a enregistré des modifications sur {1} à {2}.',
|
||||
'Your 1 change is still here. Review it, then save.':
|
||||
'Votre 1 modification est toujours là. Relisez-la, puis enregistrez.',
|
||||
'Remove user': 'Retirer l’utilisateur',
|
||||
"You can't remove yourself. Ask another owner or admin.":
|
||||
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
|
||||
'Remove {0}?': 'Retirer {0} ?',
|
||||
'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.':
|
||||
'Cette personne perd l’accès immédiatement, et les clés API qu’elle a créées cessent de fonctionner. Ses articles, entrées et médias restent.',
|
||||
"Couldn't remove the user": 'Impossible de retirer l’utilisateur',
|
||||
'User removed': 'Utilisateur retiré',
|
||||
'{0} no longer has access.': '{0} n’a plus accès.',
|
||||
'Online articles and the drafts you created.':
|
||||
'Les articles en ligne et les brouillons que vous avez créés.',
|
||||
'Drafts by others are hidden': 'Les brouillons des autres sont masqués',
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.':
|
||||
'Votre rôle lit les articles en ligne et les brouillons que vous avez créés. Les compteurs suivent la même règle.',
|
||||
'You have no drafts': 'Vous n’avez aucun brouillon',
|
||||
"Drafts you create appear here. Your role can't see drafts by other people.":
|
||||
'Les brouillons que vous créez apparaissent ici. Votre rôle ne peut pas voir les brouillons des autres personnes.',
|
||||
'Your role shows only entries you created.':
|
||||
'Votre rôle n’affiche que les entrées que vous avez créées.',
|
||||
"Entries by others aren't shown": 'Les entrées des autres ne sont pas affichées',
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.':
|
||||
'Votre rôle ne lit que les entrées que vous avez créées. Demandez à un propriétaire ou administrateur si vous avez besoin d’en voir plus.',
|
||||
"You haven't created {0} yet": 'Vous n’avez pas encore créé d’entrée dans {0}',
|
||||
"Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.":
|
||||
'Votre rôle n’affiche que les entrées que vous avez créées, donc les entrées des autres personnes dans {0} ne sont pas listées ici. Utilisez Nouvelle entrée pour en ajouter une.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { messages } from './messages'
|
||||
|
||||
const GENDERED_PRONOUN = /\b(he|she|his|her|him|hers)\b/i
|
||||
|
||||
describe('Neutral wording', () => {
|
||||
it('has no gendered pronoun in any English string', () => {
|
||||
const offenders = Object.keys(messages).filter((english) => GENDERED_PRONOUN.test(english))
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,6 @@ describe('Roles screens copy', () => {
|
||||
})
|
||||
|
||||
const USERS_ROLES_KEYS = [
|
||||
'Assigned by {0}',
|
||||
'Your role',
|
||||
'See what I can do',
|
||||
'What you can do',
|
||||
@@ -90,10 +89,140 @@ 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')
|
||||
const FINAL_COPY: [string, (string | number)[], string][] = [
|
||||
[
|
||||
'{0} is now {1}, and their API keys follow the new role.',
|
||||
['Ines Duarte', 'Content lead'],
|
||||
'Ines Duarte a maintenant le rôle Content lead, et ses clés API suivent le nouveau rôle.',
|
||||
],
|
||||
["Couldn't save the role", [], "Impossible d'enregistrer le rôle"],
|
||||
[
|
||||
'Your changes are still here. Try again in a moment.',
|
||||
[],
|
||||
'Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
],
|
||||
['Try again', [], 'Réessayer'],
|
||||
["Couldn't change the role", [], 'Impossible de modifier le rôle'],
|
||||
[
|
||||
'Nothing was changed. Try again in a moment.',
|
||||
[],
|
||||
"Rien n'a été modifié. Réessayez dans un instant.",
|
||||
],
|
||||
[
|
||||
"Your role can't assign a role with more power than yours.",
|
||||
[],
|
||||
'Votre rôle ne peut pas attribuer un rôle plus puissant que le vôtre.',
|
||||
],
|
||||
['{0} no longer exists', ['Writer'], "Writer n'existe plus"],
|
||||
[
|
||||
'It was deleted somewhere else. Choose another role to move the members to.',
|
||||
[],
|
||||
'Il a été supprimé ailleurs. Choisissez un autre rôle pour y déplacer les membres.',
|
||||
],
|
||||
['Role still has members', [], 'Le rôle a encore des membres'],
|
||||
[
|
||||
'{0} now has 1 member. Move them to another role first.',
|
||||
['Intern'],
|
||||
"Intern a maintenant 1 membre. Déplacez-le d'abord vers un autre rôle.",
|
||||
],
|
||||
[
|
||||
'{0} now has {1} members. Move them to another role first.',
|
||||
['Intern', 3],
|
||||
"Intern a maintenant 3 membres. Déplacez-les d'abord vers un autre rôle.",
|
||||
],
|
||||
["Couldn't move the members", [], 'Impossible de déplacer les membres'],
|
||||
['Enter a valid email address.', [], 'Saisissez une adresse e-mail valide.'],
|
||||
['This address already has an account.', [], 'Cette adresse a déjà un compte.'],
|
||||
["Couldn't send the invite", [], "Impossible d'envoyer l'invitation"],
|
||||
['Try again in a moment.', [], 'Réessayez dans un instant.'],
|
||||
['Role changed elsewhere', [], 'Rôle modifié ailleurs'],
|
||||
[
|
||||
'{0} edited {1}, so nothing was saved. Reload and try again.',
|
||||
['Jonas Weber', 'Contributor'],
|
||||
"Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
],
|
||||
['Reload', [], 'Recharger'],
|
||||
['That role no longer exists', [], "Ce rôle n'existe plus"],
|
||||
['Role not changed', [], 'Rôle non modifié'],
|
||||
[
|
||||
"{0}'s role changed in the meantime, so nothing was saved. Reload and try again.",
|
||||
['Ines Duarte'],
|
||||
"Le rôle de Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez.",
|
||||
],
|
||||
['Ownership not transferred', [], 'Propriété non transférée'],
|
||||
[
|
||||
"{0}'s role changed in the meantime, so nothing was transferred. Check it and try again.",
|
||||
['Jonas Weber'],
|
||||
"Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez.",
|
||||
],
|
||||
['User not removed', [], 'Utilisateur non retiré'],
|
||||
[
|
||||
"{0}'s role changed in the meantime, so nothing was removed. Check it and try again.",
|
||||
['Ines Duarte'],
|
||||
"Le rôle de Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez.",
|
||||
],
|
||||
['Role changed in the meantime', [], 'Rôle modifié entre-temps'],
|
||||
[
|
||||
'{0} is now {1}. Your choice is kept. Check it and try again.',
|
||||
['Ines Duarte', 'Admin'],
|
||||
'Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez.',
|
||||
],
|
||||
[
|
||||
'{0} is now {1}. What you typed is kept. Check the change, then try again.',
|
||||
['Jonas Weber', 'Writer'],
|
||||
'Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez.',
|
||||
],
|
||||
['Reload member', [], 'Recharger le membre'],
|
||||
['Role reloaded', [], 'Rôle rechargé'],
|
||||
[
|
||||
'Your {0} changes are still here. Review them, then save.',
|
||||
[2],
|
||||
'Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez.',
|
||||
],
|
||||
['Use the saved role', [], 'Utiliser le rôle enregistré'],
|
||||
['Keep my edits', [], 'Garder mes modifications'],
|
||||
['Loading…', [], 'Chargement…'],
|
||||
['Locked', [], 'Verrouillé'],
|
||||
]
|
||||
|
||||
describe('Final copy: toasts and errors, EN and FR', () => {
|
||||
it.each(FINAL_COPY)('"%s" reads in English and in French', (key, values, french) => {
|
||||
expect(t(key as keyof typeof messages, values)).toContain(
|
||||
key.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)])),
|
||||
)
|
||||
setLocale('fr')
|
||||
expect(t('Assigned by {0}', ['Marta Alves'])).toBe('Attribué par Marta Alves')
|
||||
expect(t(key as keyof typeof messages, values).replace(/\u2019/g, "'")).toBe(french)
|
||||
})
|
||||
})
|
||||
|
||||
const PROPOSED_COPY = [
|
||||
'Remove user',
|
||||
'Remove {0}?',
|
||||
'They lose access straight away, and the API keys they created stop working. Their articles, entries and media stay.',
|
||||
"Couldn't remove the user",
|
||||
'User removed',
|
||||
'{0} no longer has access.',
|
||||
"You can't remove yourself. Ask another owner or admin.",
|
||||
'Online articles and the drafts you created.',
|
||||
'Drafts by others are hidden',
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
|
||||
'You have no drafts',
|
||||
"Drafts you create appear here. Your role can't see drafts by other people.",
|
||||
'Your role shows only entries you created.',
|
||||
"Entries by others aren't shown",
|
||||
'Your role reads only entries you created. Ask an owner or admin if you need to see more.',
|
||||
"You haven't created {0} yet",
|
||||
"Your role shows only entries you created, so other people's {0} aren't listed here. Use New entry to add one.",
|
||||
'{0} saved changes to {1} at {2}.',
|
||||
'Your 1 change is still here. Review it, then save.',
|
||||
'read online articles and their own drafts',
|
||||
'read only entries they created',
|
||||
] as const
|
||||
|
||||
describe('Proposed copy that the design does not give', () => {
|
||||
it.each(PROPOSED_COPY)('"%s" has a French entry', (key) => {
|
||||
expect((messages as Record<string, string>)[key]).toMatch(/\S/)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { ACTIVITY_CATEGORIES, categoryIcon, categoryLabel } from '@/lib/activity'
|
||||
|
||||
describe('activity categories', () => {
|
||||
@@ -9,3 +10,49 @@ describe('activity categories', () => {
|
||||
expect(categoryIcon('roles')).toMatch(/^i-lucide-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity category names', () => {
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
it('lists the categories in the design order', () => {
|
||||
expect([...ACTIVITY_CATEGORIES]).toEqual([
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in English', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Roles',
|
||||
'API keys',
|
||||
'Authentication',
|
||||
'System',
|
||||
])
|
||||
})
|
||||
|
||||
it('names them in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel)).toEqual([
|
||||
'Articles',
|
||||
'Médias',
|
||||
'Utilisateurs et invitations',
|
||||
'Rôles',
|
||||
'Clés d’API',
|
||||
'Authentification',
|
||||
'Système',
|
||||
])
|
||||
})
|
||||
|
||||
it('never writes "Users & invites"', () => {
|
||||
expect(ACTIVITY_CATEGORIES.map(categoryLabel).join()).not.toContain('&')
|
||||
})
|
||||
})
|
||||
@@ -291,6 +291,7 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'role.reassign',
|
||||
'user.change_role',
|
||||
'user.transfer_ownership',
|
||||
'user.remove',
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
@@ -380,3 +381,188 @@ describe('role and ownership sentences', () => {
|
||||
expect(sentenceText(item({ action: 'role.unknown' }))).toBe('role.unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles sync: the six role events in EN and FR', () => {
|
||||
const roleTarget = (name: string) => ({ type: 'role', id: '4', name, exists: true })
|
||||
const userTarget = (name: string) => ({ type: 'user', id: '9', name, exists: true })
|
||||
const roleChange = {
|
||||
field: 'role',
|
||||
kind: 'text' as const,
|
||||
before: 'Writer',
|
||||
after: 'Content lead',
|
||||
}
|
||||
|
||||
function event(overrides: Partial<ActivityItem>) {
|
||||
return item({ category: 'roles', target: roleTarget('Intern'), ...overrides })
|
||||
}
|
||||
|
||||
function both(source: ActivityItem) {
|
||||
setLocale('en')
|
||||
const english = sentenceText(source)
|
||||
setLocale('fr')
|
||||
const french = sentenceText(source)
|
||||
return [english, french]
|
||||
}
|
||||
|
||||
it.each([
|
||||
['role.create', 'Contributor', 'Role Contributor created', 'Rôle Contributor créé'],
|
||||
['role.edit', 'Contributor', 'Role Contributor edited', 'Rôle Contributor modifié'],
|
||||
['role.delete', 'Intern', 'Role Intern deleted', 'Rôle Intern supprimé'],
|
||||
])('success %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, target: roleTarget(name) }))).toEqual([english, french])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 'Moved 3 members from Intern to Writer', '3 membres déplacés de Intern vers Writer'],
|
||||
[1, 'Moved 1 member from Intern to Writer', '1 membre déplacé de Intern vers Writer'],
|
||||
])('success role.reassign with moved %s', (moved, english, french) => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
params: { from: 4, to: 3, moved, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([english, french])
|
||||
setLocale('en')
|
||||
expect(composeSentence(source).find((segment) => segment.strong)?.text).toBe('Intern')
|
||||
})
|
||||
|
||||
it('success user.change_role', () => {
|
||||
const source = event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
target: userTarget('Ines Duarte'),
|
||||
changes: [roleChange],
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
"Ines Duarte's role changed from Writer to Content lead",
|
||||
"Le rôle d'Ines Duarte est passé de Writer à Content lead",
|
||||
])
|
||||
})
|
||||
|
||||
it('success user.transfer_ownership', () => {
|
||||
const source = event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
target: userTarget('Jonas Weber'),
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Ownership transferred from Marta Alves to Jonas Weber',
|
||||
'Propriété transférée de Marta Alves à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'role.create',
|
||||
'Contributor',
|
||||
'Failed to create the role Contributor',
|
||||
'Échec de la création du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.edit',
|
||||
'Contributor',
|
||||
'Failed to edit the role Contributor',
|
||||
'Échec de la modification du rôle Contributor',
|
||||
],
|
||||
[
|
||||
'role.delete',
|
||||
'Intern',
|
||||
'Failed to delete the role Intern',
|
||||
'Échec de la suppression du rôle Intern',
|
||||
],
|
||||
])('failed %s', (action, name, english, french) => {
|
||||
expect(both(event({ action, outcome: 'failed', target: roleTarget(name) }))).toEqual([
|
||||
english,
|
||||
french,
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign with the count and the destination', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 3, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)).toEqual([
|
||||
'Failed to move 3 members from Intern to Writer',
|
||||
'Échec du déplacement de 3 membres de Intern vers Writer',
|
||||
])
|
||||
})
|
||||
|
||||
it('failed role.reassign for one member', () => {
|
||||
const source = event({
|
||||
action: 'role.reassign',
|
||||
outcome: 'failed',
|
||||
params: { moved: 1, to_name: 'Writer' },
|
||||
})
|
||||
|
||||
expect(both(source)[0]).toBe('Failed to move 1 member from Intern to Writer')
|
||||
})
|
||||
|
||||
it('failed user.change_role and user.transfer_ownership', () => {
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.change_role',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Ines Duarte'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to change the role of Ines Duarte',
|
||||
"Échec du changement de rôle d'Ines Duarte",
|
||||
])
|
||||
expect(
|
||||
both(
|
||||
event({
|
||||
action: 'user.transfer_ownership',
|
||||
category: 'users',
|
||||
outcome: 'failed',
|
||||
target: userTarget('Jonas Weber'),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'Failed to transfer ownership to Jonas Weber',
|
||||
'Échec du transfert de la propriété à Jonas Weber',
|
||||
])
|
||||
})
|
||||
|
||||
it('writes user.remove with the success and the failed sentence', () => {
|
||||
const target = userTarget('Ines Duarte')
|
||||
const removed = event({ action: 'user.remove', category: 'users', target })
|
||||
|
||||
expect(both(removed)).toEqual(['Removed Ines Duarte', 'Ines Duarte retiré'])
|
||||
expect(both({ ...removed, outcome: 'failed' })).toEqual([
|
||||
'Failed to remove Ines Duarte',
|
||||
"Échec du retrait d'Ines Duarte",
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', { moved: 3 }, 'Moved 3 members from Intern to another role'],
|
||||
['success', { moved: 1 }, 'Moved 1 member from Intern to another role'],
|
||||
['success', { to_name: 'Writer' }, 'Moved the members of the role Intern'],
|
||||
['failed', {}, 'Failed to move the members of the role Intern'],
|
||||
] as const)('role.reassign %s without all params: %j', (outcome, params, sentence) => {
|
||||
const text = sentenceText(event({ action: 'role.reassign', outcome, params }))
|
||||
|
||||
expect(text).toBe(sentence)
|
||||
expect(text).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('ignores an empty destination name', () => {
|
||||
const text = sentenceText(
|
||||
event({ action: 'role.reassign', params: { moved: 2, to_name: ' ' } }),
|
||||
)
|
||||
|
||||
expect(text).toBe('Moved 2 members from Intern to another role')
|
||||
})
|
||||
|
||||
it('renders an unknown action as its name', () => {
|
||||
expect(sentenceText(event({ action: 'role.shuffle' }))).toBe('role.shuffle')
|
||||
})
|
||||
})
|
||||
@@ -251,6 +251,45 @@ describe('custom content types', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('roles sync: permission-gated routes', () => {
|
||||
const GATED = [
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
]
|
||||
|
||||
it('matches "permission" on the nine routes through the access chip and the description', () => {
|
||||
const found = routes(search('permission').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).toContain(route)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches "missing_permission" on the error code of the gated routes', () => {
|
||||
const found = search('missing_permission').endpoints
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(routes(found)).toContain(route)
|
||||
}
|
||||
expect(found.find((hit) => routes([hit])[0] === GATED[0])!.via!.kind).toBe('error')
|
||||
})
|
||||
|
||||
it('no longer matches "owner or admin" on the nine routes', () => {
|
||||
const found = routes(search('owner or admin').endpoints)
|
||||
|
||||
for (const route of GATED) {
|
||||
expect(found).not.toContain(route)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { elideDe } from '@/lib/frenchElision'
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('elideDe', () => {
|
||||
it('elides before a vowel in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Ines Duarte a changé', 'Ines Duarte')).toBe(
|
||||
"Le rôle d'Ines Duarte a changé",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps de before a consonant', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(elideDe('Le rôle de Jonas Weber a changé', 'Jonas Weber')).toBe(
|
||||
'Le rôle de Jonas Weber a changé',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves English untouched', () => {
|
||||
expect(elideDe("Ines Duarte's role", 'Ines Duarte')).toBe("Ines Duarte's role")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entryNounOf,
|
||||
inverseNoun,
|
||||
inverseSide,
|
||||
kindFromInverseSide,
|
||||
@@ -87,3 +88,14 @@ describe('the preview', () => {
|
||||
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the entry noun of a type name', () => {
|
||||
it.each([
|
||||
['Events', 'an event', 'events'],
|
||||
['Recipes', 'a recipe', 'recipes'],
|
||||
['Press mentions', 'a press mention', 'press mentions'],
|
||||
['Categories', 'a category', 'categories'],
|
||||
])('turns %s into %s', (name, singular, plural) => {
|
||||
expect(entryNounOf(name)).toEqual({ singular, plural })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { roleDiffRows } from '@/lib/roleDiff'
|
||||
import type { RoleForm } from '@/lib/roleForm'
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'article', name: 'Articles' },
|
||||
{ key: 'events', name: 'Events' },
|
||||
]
|
||||
|
||||
const CONTEXT = {
|
||||
types: TYPES,
|
||||
toggles: new Set(['activity.export']),
|
||||
order: ['activity.view', 'activity.export'],
|
||||
}
|
||||
|
||||
function form(grants: RoleForm['grants'], name = 'Contributor', description = ''): RoleForm {
|
||||
return { name, description, grants }
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('roleDiffRows', () => {
|
||||
it('lists only the settings where the saved role and the edits disagree', () => {
|
||||
const saved = form({ 'content.read': 'all', 'content.update:events': 'own' })
|
||||
const yours = form({
|
||||
'content.read': 'all',
|
||||
'content.update:events': 'all',
|
||||
'content.delete:events': 'own',
|
||||
})
|
||||
|
||||
expect(
|
||||
roleDiffRows(saved, yours, CONTEXT).map(({ setting, saved, yours }) => [
|
||||
setting,
|
||||
saved,
|
||||
yours,
|
||||
]),
|
||||
).toEqual([
|
||||
['Events · Update', 'Own', 'All'],
|
||||
['Events · Delete', 'None', 'Own'],
|
||||
])
|
||||
})
|
||||
|
||||
it('shows nothing when an override equals the inherited value', () => {
|
||||
const saved = form({ 'content.read': 'all' })
|
||||
const yours = form({ 'content.read': 'all', 'content.read:events': 'all' })
|
||||
|
||||
expect(roleDiffRows(saved, yours, CONTEXT)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the Create switch as On and Off', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.create:events': 'all' }), CONTEXT)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ id: 'content.create:events', setting: 'Events · Create', saved: 'Off', yours: 'On' },
|
||||
])
|
||||
})
|
||||
|
||||
it('names general permissions and keeps the catalogue order', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({ 'activity.export': 'all' }),
|
||||
form({ 'activity.view': 'own', 'activity.export': 'none' }),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['See activity', 'None', 'Own'],
|
||||
['Export activity', 'On', 'Off'],
|
||||
])
|
||||
})
|
||||
|
||||
it('lists a changed name or description first', () => {
|
||||
const rows = roleDiffRows(
|
||||
form({}, 'Contributor', 'Old'),
|
||||
form({}, 'Writer', 'New'),
|
||||
CONTEXT,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => [row.setting, row.saved, row.yours])).toEqual([
|
||||
['Name', 'Contributor', 'Writer'],
|
||||
['Description', 'Old', 'New'],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to one row per action when there are no types', () => {
|
||||
const rows = roleDiffRows(form({}), form({ 'content.read': 'all' }), {
|
||||
...CONTEXT,
|
||||
types: [],
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.setting)).toEqual(['All content types · Read'])
|
||||
})
|
||||
|
||||
it('translates the scopes', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const rows = roleDiffRows(form({}), form({ 'content.update:events': 'own' }), CONTEXT)
|
||||
|
||||
expect(rows[0]).toMatchObject({ saved: 'Aucune', yours: 'Les siennes' })
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formFromRole,
|
||||
grantsToPayload,
|
||||
isCustomRow,
|
||||
reapplyEdits,
|
||||
resetRow,
|
||||
scopeAt,
|
||||
setScope,
|
||||
@@ -191,3 +192,46 @@ describe('differences', () => {
|
||||
).toEqual(['content.create', 'content.update', 'content.update:case-studies'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reapplyEdits', () => {
|
||||
const initial = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all' } as const,
|
||||
}
|
||||
|
||||
it('puts the edited cells back on top of the new saved role', () => {
|
||||
const base = {
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own' } as const,
|
||||
}
|
||||
const edited = {
|
||||
name: 'Contributor',
|
||||
description: '',
|
||||
grants: { 'content.read': 'all', 'content.delete:events': 'own' } as const,
|
||||
}
|
||||
|
||||
const merged = reapplyEdits(base, initial, edited)
|
||||
|
||||
expect(merged).toEqual({
|
||||
name: 'Contributor',
|
||||
description: 'Saved',
|
||||
grants: { 'content.read': 'own', 'content.delete:events': 'own' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an edited name and removes a cell the person reset', () => {
|
||||
const withOverride = {
|
||||
...initial,
|
||||
grants: { ...initial.grants, 'content.update:events': 'own' } as const,
|
||||
}
|
||||
const base = { name: 'Contributor', description: '', grants: { ...withOverride.grants } }
|
||||
const edited = { ...initial, name: 'Writer', grants: { 'content.read': 'all' } as const }
|
||||
|
||||
const merged = reapplyEdits(base, withOverride, edited)
|
||||
|
||||
expect(merged.name).toBe('Writer')
|
||||
expect(merged.grants).toEqual({ 'content.read': 'all' })
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('buildRolePreview content', () => {
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => line.label)).toEqual([
|
||||
'Articles, Case studies',
|
||||
'Articles and Case studies',
|
||||
'Events',
|
||||
])
|
||||
expect(preview.content[1]!.sentence).toContain('edit any entry')
|
||||
@@ -132,3 +132,67 @@ describe('buildRolePreview Changed tagging and members', () => {
|
||||
expect(buildRolePreview({ grants: {}, types: TYPES, members: 0 }).members).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRolePreview Read = Own', () => {
|
||||
const readOwn = {
|
||||
'content.read': 'own',
|
||||
'content.create': 'all',
|
||||
'content.update': 'own',
|
||||
} as const
|
||||
|
||||
it('writes one line per kind of type when Read is Own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content.map((line) => [line.label, line.sentence])).toEqual([
|
||||
[
|
||||
'Articles',
|
||||
"Can read online articles and their own drafts, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
[
|
||||
'Events and Case studies',
|
||||
"Can read only entries they created, create entries and edit only their own. Can't delete and publish.",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the custom type sentence for a custom type override', () => {
|
||||
const grants = setScope({ 'content.read': 'all' }, 'content.read', 'own', 'events')
|
||||
|
||||
const preview = buildRolePreview({ grants, types: TYPES })
|
||||
|
||||
const events = preview.content.find((line) => line.label === 'Events')
|
||||
expect(events?.sentence).toContain('read only entries they created')
|
||||
})
|
||||
|
||||
it('uses the articles sentence alone when only Articles exists', () => {
|
||||
const preview = buildRolePreview({
|
||||
grants: { ...readOwn },
|
||||
types: [{ key: 'article', name: 'Articles' }],
|
||||
})
|
||||
|
||||
expect(preview.content).toHaveLength(1)
|
||||
expect(preview.content[0]).toMatchObject({ label: 'All content types' })
|
||||
expect(preview.content[0]!.sentence).toContain('read online articles and their own drafts')
|
||||
})
|
||||
|
||||
it('never says read only their own', () => {
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(JSON.stringify(preview)).not.toContain('read only their own')
|
||||
})
|
||||
|
||||
it('keeps the Read All wording', () => {
|
||||
const preview = buildRolePreview({ grants: applyPreset({}, 'write_own'), types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('Can read any entry')
|
||||
})
|
||||
|
||||
it('translates both sentences', () => {
|
||||
setLocale('fr')
|
||||
|
||||
const preview = buildRolePreview({ grants: { ...readOwn }, types: TYPES })
|
||||
|
||||
expect(preview.content[0]!.sentence).toContain('lire les articles en ligne')
|
||||
expect(preview.content[1]!.sentence).toContain('créées par la personne')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { userMenuItems, type UserMenuContext } from '@/lib/userMenu'
|
||||
import type { AcceptedUser } from '@/lib/users'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
|
||||
function accepted(id: number, role: string): AcceptedUser {
|
||||
return {
|
||||
kind: 'user',
|
||||
id,
|
||||
username: `user${id}`,
|
||||
display_name: `User ${id}`,
|
||||
email: `user${id}@renew.example`,
|
||||
role: roleRefFor(role),
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<UserMenuContext> = {}): UserMenuContext {
|
||||
return {
|
||||
sessionUserId: 1,
|
||||
canChangeRole: true,
|
||||
canTransfer: false,
|
||||
canViewRoles: true,
|
||||
canRemove: true,
|
||||
canCover: () => true,
|
||||
changeRole: vi.fn(),
|
||||
transfer: vi.fn(),
|
||||
removeUser: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
function remove(user: AcceptedUser, overrides: Partial<UserMenuContext> = {}) {
|
||||
return userMenuItems(user, context(overrides)).find((item) => item.key === 'remove-user')
|
||||
}
|
||||
|
||||
describe('Remove user in the row menu', () => {
|
||||
it('is enabled below Change role for an accepted user the person can cover', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context())
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['change-role', 'remove-user'])
|
||||
expect(items[1]).toMatchObject({
|
||||
label: 'Remove user',
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('is the only item for a person who can remove but not change roles', () => {
|
||||
const items = userMenuItems(accepted(2, 'editor'), context({ canChangeRole: false }))
|
||||
|
||||
expect(items.map((item) => item.key)).toEqual(['remove-user'])
|
||||
})
|
||||
|
||||
it('is absent without the permission', () => {
|
||||
expect(remove(accepted(2, 'editor'), { canRemove: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is disabled on the owner row with the owner reason', () => {
|
||||
expect(remove(accepted(3, 'owner'))).toMatchObject({
|
||||
unavailable: true,
|
||||
hint: "The owner can't be changed or removed. Transfer ownership first.",
|
||||
})
|
||||
})
|
||||
|
||||
it('is disabled on the row marked you', () => {
|
||||
const item = remove(accepted(1, 'admin'))
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("You can't remove yourself. Ask another owner or admin.")
|
||||
})
|
||||
|
||||
it('is disabled with the escalation reason when the role cannot be covered', () => {
|
||||
const item = remove(accepted(2, 'admin'), { canCover: () => false })
|
||||
|
||||
expect(item?.unavailable).toBe(true)
|
||||
expect(item?.hint).toBe("Your role can't assign a role with more power than yours.")
|
||||
})
|
||||
|
||||
it('selects through the context', () => {
|
||||
const removeUser = vi.fn()
|
||||
const user = accepted(2, 'editor')
|
||||
|
||||
remove(user, { removeUser })!.onSelect(null)
|
||||
|
||||
expect(removeUser).toHaveBeenCalledWith(user, null)
|
||||
})
|
||||
|
||||
it('is absent for a user without a role', () => {
|
||||
const user = { ...accepted(2, 'editor'), role: null }
|
||||
|
||||
expect(remove(user)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(remove(accepted(2, 'editor'))?.label).toBe('Retirer l’utilisateur')
|
||||
expect(remove(accepted(1, 'admin'))?.hint).toBe(
|
||||
'Vous ne pouvez pas vous retirer vous-même. Demandez à un autre propriétaire ou administrateur.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,10 @@ export const ACTIVITY_CATEGORIES = [
|
||||
'articles',
|
||||
'media',
|
||||
'users',
|
||||
'roles',
|
||||
'api_keys',
|
||||
'auth',
|
||||
'system',
|
||||
'roles',
|
||||
] as const
|
||||
|
||||
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import { formatNumber, locale, t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { sizeLabel } from '@/lib/activityRetention'
|
||||
@@ -19,6 +19,8 @@ interface Template {
|
||||
const TARGET_SLOT = '\u0000'
|
||||
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
|
||||
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
|
||||
const FRENCH_ELIDING_ACTIONS = ['user.change_role', 'user.remove']
|
||||
const FRENCH_VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
const CATALOGUE: Record<string, Template> = {
|
||||
'article.create': {
|
||||
@@ -123,8 +125,12 @@ const CATALOGUE: Record<string, Template> = {
|
||||
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}',
|
||||
success: 'Moved {0} members from {1} to {2}',
|
||||
failed: 'Failed to move {0} members from {1} to {2}',
|
||||
},
|
||||
'user.remove': {
|
||||
success: 'Removed {0}',
|
||||
failed: 'Failed to remove {0}',
|
||||
},
|
||||
'user.change_role': {
|
||||
success: 'Changed the role of {0}',
|
||||
@@ -226,9 +232,70 @@ function roleChangeOf(item: ActivityItem) {
|
||||
return item.changes.find((change) => change.field === 'role' && change.kind === 'text')
|
||||
}
|
||||
|
||||
interface SentenceParts {
|
||||
template: MessageKey
|
||||
values: string[]
|
||||
}
|
||||
|
||||
function destinationName(params: Record<string, unknown>) {
|
||||
const name = params.to_name
|
||||
return typeof name === 'string' && name.trim() !== '' ? name : null
|
||||
}
|
||||
|
||||
function movedCount(params: Record<string, unknown>) {
|
||||
const moved = params.moved
|
||||
return typeof moved === 'number' && Number.isFinite(moved) && moved >= 1 ? moved : null
|
||||
}
|
||||
|
||||
function reassignParts(item: ActivityItem): SentenceParts {
|
||||
const failed = item.outcome === 'failed'
|
||||
const moved = movedCount(item.params)
|
||||
const destination = destinationName(item.params)
|
||||
if (moved === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move the members of the role {0}'
|
||||
: 'Moved the members of the role {0}',
|
||||
values: [TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
const count = formatNumber(moved)
|
||||
if (moved === 1) {
|
||||
if (destination === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move 1 member from {0} to another role'
|
||||
: 'Moved 1 member from {0} to another role',
|
||||
values: [TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move 1 member from {0} to {1}'
|
||||
: 'Moved 1 member from {0} to {1}',
|
||||
values: [TARGET_SLOT, destination],
|
||||
}
|
||||
}
|
||||
if (destination === null) {
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move {0} members from {1} to another role'
|
||||
: 'Moved {0} members from {1} to another role',
|
||||
values: [count, TARGET_SLOT],
|
||||
}
|
||||
}
|
||||
return {
|
||||
template: failed
|
||||
? 'Failed to move {0} members from {1} to {2}'
|
||||
: 'Moved {0} members from {1} to {2}',
|
||||
values: [count, TARGET_SLOT, destination],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
const template = CATALOGUE[item.action]
|
||||
if (!template) return null
|
||||
if (item.action === 'role.reassign') return reassignParts(item).template
|
||||
if (item.action === 'user.change_role' && item.outcome === 'success' && roleChangeOf(item)) {
|
||||
return "{0}'s role changed from {1} to {2}"
|
||||
}
|
||||
@@ -248,6 +315,9 @@ function resolveTemplate(item: ActivityItem): MessageKey | null {
|
||||
}
|
||||
|
||||
function slotValues(item: ActivityItem, template: MessageKey) {
|
||||
if (item.action === 'role.reassign') {
|
||||
return reassignParts(item).values
|
||||
}
|
||||
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
|
||||
return [purgeSummary(item.params)]
|
||||
}
|
||||
@@ -276,6 +346,13 @@ function usesTargetSlot(values: string[]) {
|
||||
return values.includes(TARGET_SLOT)
|
||||
}
|
||||
|
||||
function elideFrench(item: ActivityItem, name: string, rendered: string) {
|
||||
if (locale.value !== 'fr' || !FRENCH_ELIDING_ACTIONS.includes(item.action)) return rendered
|
||||
return FRENCH_VOWEL_START.test(name)
|
||||
? rendered.replace(`de ${TARGET_SLOT}`, `d'${TARGET_SLOT}`)
|
||||
: rendered
|
||||
}
|
||||
|
||||
export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
const template = resolveTemplate(item)
|
||||
if (template === null) return [{ text: item.action, strong: false }]
|
||||
@@ -291,7 +368,7 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
|
||||
const values = slotValues(item, template)
|
||||
const name = targetName(item)
|
||||
const rendered = t(template, values)
|
||||
const rendered = elideFrench(item, name, t(template, values))
|
||||
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
|
||||
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const KNOWN_ERROR_CODES = [
|
||||
'invalid_role_name',
|
||||
'invalid_reassign',
|
||||
'role_not_found',
|
||||
'target_role_not_found',
|
||||
'user_role_changed',
|
||||
'session_required',
|
||||
'invalid_role',
|
||||
|
||||
@@ -6,5 +6,7 @@ export interface LinkableUser {
|
||||
|
||||
export interface DirectoryNotice {
|
||||
title?: string
|
||||
tone?: 'error' | 'warning'
|
||||
description: string
|
||||
action?: { label: string; onClick: () => void }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { locale } from '@/i18n'
|
||||
|
||||
const VOWEL_START = /^[aeiouyàâäéèêëîïôöùûü]/i
|
||||
|
||||
export function elideDe(text: string, name: string) {
|
||||
return locale.value === 'fr' && VOWEL_START.test(name)
|
||||
? text.replace(`de ${name}`, `d'${name}`)
|
||||
: text
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCop
|
||||
case 'invalid_role_name':
|
||||
case 'invalid_reassign':
|
||||
case 'role_not_found':
|
||||
case 'target_role_not_found':
|
||||
case 'session_required':
|
||||
case 'invalid_role':
|
||||
case 'invalid_transfer':
|
||||
|
||||
@@ -27,6 +27,15 @@ export function singularOf(name: string) {
|
||||
return name.length > 1 ? name.replace(FINAL_PLURAL, '') : name
|
||||
}
|
||||
|
||||
const PLURAL_IES = /ies$/i
|
||||
const VOWEL_START = /^[aeiou]/
|
||||
|
||||
export function entryNounOf(name: string) {
|
||||
const plural = name.trim().toLowerCase()
|
||||
const singular = PLURAL_IES.test(plural) ? plural.replace(PLURAL_IES, 'y') : singularOf(plural)
|
||||
return { singular: `${VOWEL_START.test(singular) ? 'an' : 'a'} ${singular}`, plural }
|
||||
}
|
||||
|
||||
export function relationNames(
|
||||
ownerName: string,
|
||||
relatedName: string,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { t } from '@/i18n'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import {
|
||||
CONTENT_ACTIONS,
|
||||
contentKey,
|
||||
scopeAt,
|
||||
type ContentAction,
|
||||
type RoleForm,
|
||||
} from '@/lib/roleForm'
|
||||
import { parseGrantKey, type Scope } from '@/lib/permissions'
|
||||
|
||||
export interface RoleDiffRow {
|
||||
id: string
|
||||
setting: string
|
||||
saved: string
|
||||
yours: string
|
||||
}
|
||||
|
||||
export interface RoleDiffContext {
|
||||
types: { key: string; name: string }[]
|
||||
toggles: ReadonlySet<string>
|
||||
order: readonly string[]
|
||||
}
|
||||
|
||||
const NAME_ROW = 'name'
|
||||
const DESCRIPTION_ROW = 'description'
|
||||
|
||||
function actionLabel(action: ContentAction) {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return t('Read')
|
||||
case 'create':
|
||||
return t('Create')
|
||||
case 'update':
|
||||
return t('Update')
|
||||
case 'delete':
|
||||
return t('Delete')
|
||||
default:
|
||||
return t('Publish')
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(scope: Scope, toggle: boolean) {
|
||||
if (toggle) {
|
||||
return scope === 'none' ? t('Off') : t('On')
|
||||
}
|
||||
return scope === 'none' ? t('None') : scope === 'own' ? t('Own') : t('All')
|
||||
}
|
||||
|
||||
function contentRows(saved: RoleForm, yours: RoleForm, context: RoleDiffContext): RoleDiffRow[] {
|
||||
const rows: RoleDiffRow[] = []
|
||||
const types = context.types.length > 0 ? context.types : [{ key: undefined, name: '' }]
|
||||
for (const type of types) {
|
||||
for (const action of CONTENT_ACTIONS) {
|
||||
const key = contentKey(action)
|
||||
const before = scopeAt(saved.grants, key, type.key)
|
||||
const after = scopeAt(yours.grants, key, type.key)
|
||||
if (before === after) {
|
||||
continue
|
||||
}
|
||||
const toggle = action === 'create'
|
||||
const scope = type.key === undefined ? t('All content types') : type.name
|
||||
rows.push({
|
||||
id: `${key}:${type.key ?? ''}`,
|
||||
setting: `${scope} · ${actionLabel(action)}`,
|
||||
saved: scopeLabel(before, toggle),
|
||||
yours: scopeLabel(after, toggle),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function generalKeys(saved: RoleForm, yours: RoleForm, order: readonly string[]) {
|
||||
const keys = new Set(
|
||||
[...Object.keys(saved.grants), ...Object.keys(yours.grants)]
|
||||
.map((key) => parseGrantKey(key).base)
|
||||
.filter((base) => !base.startsWith('content.')),
|
||||
)
|
||||
const rank = (key: string) => {
|
||||
const index = order.indexOf(key)
|
||||
return index === -1 ? order.length : index
|
||||
}
|
||||
return [...keys].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
}
|
||||
|
||||
function generalRows(saved: RoleForm, yours: RoleForm, context: RoleDiffContext): RoleDiffRow[] {
|
||||
return generalKeys(saved, yours, context.order).flatMap((key) => {
|
||||
const before = scopeAt(saved.grants, key)
|
||||
const after = scopeAt(yours.grants, key)
|
||||
if (before === after) {
|
||||
return []
|
||||
}
|
||||
const toggle = context.toggles.has(key)
|
||||
return [
|
||||
{
|
||||
id: key,
|
||||
setting: permissionName(key) ?? key,
|
||||
saved: scopeLabel(before, toggle),
|
||||
yours: scopeLabel(after, toggle),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function textRows(saved: RoleForm, yours: RoleForm): RoleDiffRow[] {
|
||||
const rows: RoleDiffRow[] = []
|
||||
if (saved.name !== yours.name) {
|
||||
rows.push({ id: NAME_ROW, setting: t('Name'), saved: saved.name, yours: yours.name })
|
||||
}
|
||||
if (saved.description !== yours.description) {
|
||||
rows.push({
|
||||
id: DESCRIPTION_ROW,
|
||||
setting: t('Description'),
|
||||
saved: saved.description,
|
||||
yours: yours.description,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function roleDiffRows(
|
||||
saved: RoleForm,
|
||||
yours: RoleForm,
|
||||
context: RoleDiffContext,
|
||||
): RoleDiffRow[] {
|
||||
return [
|
||||
...textRows(saved, yours),
|
||||
...contentRows(saved, yours, context),
|
||||
...generalRows(saved, yours, context),
|
||||
]
|
||||
}
|
||||
@@ -156,6 +156,28 @@ export function unsavedCount(current: RoleForm, initial: RoleForm): number {
|
||||
return count
|
||||
}
|
||||
|
||||
export function reapplyEdits(base: RoleForm, initial: RoleForm, edited: RoleForm): RoleForm {
|
||||
const grants = { ...base.grants }
|
||||
const keys = new Set([...Object.keys(initial.grants), ...Object.keys(edited.grants)])
|
||||
for (const key of keys) {
|
||||
if (rawEntry(initial.grants, key) === rawEntry(edited.grants, key)) {
|
||||
continue
|
||||
}
|
||||
const value = edited.grants[key]
|
||||
if (value === undefined) {
|
||||
delete grants[key]
|
||||
} else {
|
||||
grants[key] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: edited.name !== initial.name ? edited.name : base.name,
|
||||
description:
|
||||
edited.description !== initial.description ? edited.description : base.description,
|
||||
grants,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCellChanged(
|
||||
current: GrantMap,
|
||||
initial: GrantMap,
|
||||
|
||||
+38
-10
@@ -145,8 +145,10 @@ const AREAS: Area[] = [
|
||||
|
||||
const OWNER_ONLY_AREA = 'system'
|
||||
|
||||
type TypeKind = 'article' | 'custom'
|
||||
|
||||
const GRANTED_CONTENT: Record<string, Record<'all' | 'own', () => string>> = {
|
||||
read: { all: () => t('read any entry'), own: () => t('read only their own entries') },
|
||||
read: { all: () => t('read any entry'), own: () => t('read only entries they created') },
|
||||
create: { all: () => t('create entries'), own: () => t('create entries') },
|
||||
update: { all: () => t('edit any entry'), own: () => t('edit only their own') },
|
||||
delete: { all: () => t('delete any entry'), own: () => t('delete only their own entries') },
|
||||
@@ -169,7 +171,7 @@ export function joinList(items: string[]): string {
|
||||
return t('{0} and {1}', [head, items[items.length - 1]])
|
||||
}
|
||||
|
||||
function contentSentence(scopes: Scope[]): string {
|
||||
function contentSentence(scopes: Scope[], kind: TypeKind): string {
|
||||
const granted: string[] = []
|
||||
const refused: string[] = []
|
||||
CONTENT_ACTIONS.forEach((action, index) => {
|
||||
@@ -178,7 +180,11 @@ function contentSentence(scopes: Scope[]): string {
|
||||
refused.push(REFUSED_CONTENT[action]!())
|
||||
return
|
||||
}
|
||||
granted.push(GRANTED_CONTENT[action]![scope]())
|
||||
granted.push(
|
||||
action === 'read' && scope === 'own' && kind === 'article'
|
||||
? t('read online articles and their own drafts')
|
||||
: GRANTED_CONTENT[action]![scope](),
|
||||
)
|
||||
})
|
||||
if (granted.length === 0) {
|
||||
return t('No access to entries.')
|
||||
@@ -187,6 +193,10 @@ function contentSentence(scopes: Scope[]): string {
|
||||
return refused.length === 0 ? can : `${can} ${t("Can't {0}.", [joinList(refused)])}`
|
||||
}
|
||||
|
||||
function kindOf(type: PreviewType): TypeKind {
|
||||
return type.key === ARTICLE_TYPE_KEY ? 'article' : 'custom'
|
||||
}
|
||||
|
||||
function contentLines(
|
||||
grants: GrantMap,
|
||||
types: PreviewType[],
|
||||
@@ -194,24 +204,42 @@ function contentLines(
|
||||
const hasOverrides = Object.keys(grants).some(
|
||||
(key) => key.startsWith('content.') && parseGrantKey(key).typeKey !== undefined,
|
||||
)
|
||||
if (!hasOverrides || types.length === 0) {
|
||||
const readsOwn = types.some((type) => scopeAt(grants, contentKey('read'), type.key) === 'own')
|
||||
if ((!hasOverrides && !readsOwn) || types.length === 0) {
|
||||
const scopes = CONTENT_ACTIONS.map((action) => scopeAt(grants, contentKey(action)))
|
||||
return [{ id: 'all', label: t('All content types'), sentence: contentSentence(scopes) }]
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All content types'),
|
||||
sentence: contentSentence(scopes, 'custom'),
|
||||
},
|
||||
]
|
||||
}
|
||||
const groups = new Map<string, { names: string[]; scopes: Scope[] }>()
|
||||
const groups = new Map<string, { names: string[]; scopes: Scope[]; kind: TypeKind }>()
|
||||
for (const type of types) {
|
||||
const scopes = CONTENT_ACTIONS.map((action) =>
|
||||
scopeAt(grants, contentKey(action), type.key),
|
||||
)
|
||||
const signature = scopes.join('|')
|
||||
const group = groups.get(signature) ?? { names: [], scopes }
|
||||
const kind = kindOf(type)
|
||||
const signature = `${scopes.join('|')}${scopes[0] === 'own' ? `|${kind}` : ''}`
|
||||
const group = groups.get(signature) ?? { names: [], scopes, kind }
|
||||
group.names.push(type.name)
|
||||
groups.set(signature, group)
|
||||
}
|
||||
const [only] = [...groups.values()]
|
||||
if (!hasOverrides && groups.size === 1) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All content types'),
|
||||
sentence: contentSentence(only!.scopes, only!.kind),
|
||||
},
|
||||
]
|
||||
}
|
||||
return [...groups.entries()].map(([signature, group]) => ({
|
||||
id: signature,
|
||||
label: group.names.join(', '),
|
||||
sentence: contentSentence(group.scopes),
|
||||
label: joinList(group.names),
|
||||
sentence: contentSentence(group.scopes, group.kind),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,24 @@ export interface UserMenuContext {
|
||||
canChangeRole: boolean
|
||||
canTransfer: boolean
|
||||
canViewRoles: boolean
|
||||
canRemove: boolean
|
||||
canCover: (user: AcceptedUser) => boolean
|
||||
changeRole: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
transfer: (trigger: HTMLElement | null) => void
|
||||
removeUser: (user: AcceptedUser, trigger: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
function removeHint(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 remove yourself. Ask another owner or admin.")
|
||||
}
|
||||
if (!context.canCover(user)) {
|
||||
return t("Your role can't assign a role with more power than yours.")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function changeRoleHint(user: AcceptedUser, context: UserMenuContext): string | undefined {
|
||||
@@ -45,6 +61,18 @@ export function userMenuItems(user: AcceptedUser, context: UserMenuContext): Dir
|
||||
onSelect: (trigger) => context.transfer(trigger),
|
||||
})
|
||||
}
|
||||
if (context.canRemove && user.role) {
|
||||
const hint = removeHint(user, context)
|
||||
items.push({
|
||||
key: 'remove-user',
|
||||
label: t('Remove user'),
|
||||
icon: 'i-lucide-user-x',
|
||||
destructive: true,
|
||||
unavailable: hint !== undefined,
|
||||
hint,
|
||||
onSelect: (trigger) => context.removeUser(user, trigger),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError, type ApiErrorCode } from '@/lib/apiError'
|
||||
import { refusalCopy } from '@/lib/permissionCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
@@ -31,16 +32,29 @@ function resolveText(text: ToastText) {
|
||||
return typeof text === 'function' ? text() : text
|
||||
}
|
||||
|
||||
type ToastColor = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
const TOAST_ICONS: Record<ToastColor, string> = {
|
||||
success: 'i-lucide-circle-check',
|
||||
error: 'i-lucide-circle-x',
|
||||
info: 'i-lucide-info',
|
||||
warning: 'i-lucide-triangle-alert',
|
||||
}
|
||||
|
||||
const STICKY_TOAST_MS = 0
|
||||
|
||||
export function userToast(
|
||||
id: string,
|
||||
color: 'success' | 'error',
|
||||
color: ToastColor,
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
duration?: number,
|
||||
) {
|
||||
return () => ({
|
||||
id,
|
||||
color,
|
||||
icon: color === 'success' ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
|
||||
duration,
|
||||
icon: TOAST_ICONS[color],
|
||||
get title() {
|
||||
return resolveText(title)
|
||||
},
|
||||
@@ -50,6 +64,27 @@ export function userToast(
|
||||
})
|
||||
}
|
||||
|
||||
export function staleToast(
|
||||
id: string,
|
||||
title: ToastText,
|
||||
description: ToastText,
|
||||
reload: () => void,
|
||||
actionLabel: ToastText = () => t('Reload'),
|
||||
) {
|
||||
const base = userToast(id, 'warning', title, description, STICKY_TOAST_MS)
|
||||
return () => ({
|
||||
...base(),
|
||||
actions: [
|
||||
{
|
||||
label: resolveText(actionLabel),
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: reload,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export function refusalToast(id: string, refusal: DirectoryNotice) {
|
||||
return userToast(id, 'error', refusal.title ?? '', refusal.description)
|
||||
}
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user