From b39a41be889850e121b30f74424f77aec826d5d4 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Thu, 29 Oct 2026 16:28:25 +0100 Subject: [PATCH] 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 --- api/dependecyInjection.go | 2 + api/handlers/role/roleHandler.go | 1 + api/handlers/user/removeHandler.go | 62 + api/pkg/apperr/messages.go | 1 + api/router.go | 12 + docs/api.md | 10 +- docs/design/README.md | 6 +- docs/design/change-log-roles-sync.md | 118 ++ docs/design/routes.md | 2 +- docs/design/screens/activity.md | 17 + docs/design/screens/integration.md | 6 +- docs/design/screens/roles.md | 308 ++++- docs/design/screens/schema-later.md | 7 +- docs/design/source/IntegrationPage.dc.html | 28 +- docs/design/source/RolesFrame.dc.html | 85 +- docs/design/source/SchemaFrame.dc.html | 13 +- docs/design/source/roles-spec.js | 122 +- docs/design/source/schema-spec.js | 3 +- docs/design/states.md | 34 +- features/relation_picker_loading_more.feature | 57 + features/relation_picker_loading_more.qa.md | 30 + features/roles_sync_conflicts.feature | 157 +++ features/roles_sync_conflicts.qa.md | 55 + features/roles_sync_copy_activity.feature | 169 +++ features/roles_sync_copy_activity.qa.md | 56 + .../roles_sync_integration_catalogue.feature | 87 ++ .../roles_sync_integration_catalogue.qa.md | 29 + features/roles_sync_profile_role_card.feature | 55 + features/roles_sync_profile_role_card.qa.md | 28 + features/roles_sync_read_own.feature | 101 ++ features/roles_sync_read_own.qa.md | 34 + features/roles_sync_remove_user.feature | 147 +++ features/roles_sync_remove_user.qa.md | 62 + internal/domain/audit/auditDomain.go | 2 + internal/domain/role/roleDomain.go | 25 +- .../domain/user/userRepositoryInterface.go | 1 + .../repositories/userRepository.go | 20 + .../useCases/ReassignRoleMembers.go | 19 +- .../infrastructure/useCases/RemoveUser.go | 79 ++ test/api_catalogue_drift_test.go | 1 + test/roles_api_http_write_test.go | 2 +- test/roles_api_usecases_test.go | 4 +- test/roles_sync_remove_user_http_test.go | 288 +++++ test/user_test.go | 23 + test/users_api_world_test.go | 1 + web/src/components/ChangeRoleDialog.vue | 104 +- web/src/components/ContentEntries.vue | 44 +- web/src/components/DeleteRoleDialog.vue | 130 +- web/src/components/DirectoryDialog.vue | 48 +- web/src/components/EndpointCard.vue | 2 +- web/src/components/InviteUserModal.vue | 70 +- web/src/components/OwnScopeEmpty.vue | 31 + web/src/components/PermissionGrid.vue | 4 +- web/src/components/ReadOnlyBanner.vue | 5 +- web/src/components/RelationField.vue | 91 +- web/src/components/RemoveUserDialog.vue | 117 ++ web/src/components/RoleDifferencesDialog.vue | 59 + web/src/components/RoleNotices.vue | 70 +- web/src/components/RolePicker.vue | 16 +- .../components/TransferOwnershipDialog.vue | 128 +- web/src/components/YourRoleCard.vue | 15 +- .../__tests__/ChangeRoleDialog.spec.ts | 237 +++- .../__tests__/DeleteRoleDialog.spec.ts | 174 ++- .../__tests__/InviteUserModal.spec.ts | 89 +- .../__tests__/OwnScopeEmpty.spec.ts | 40 + .../__tests__/RemoveUserDialog.spec.ts | 194 +++ .../__tests__/RoleDifferencesDialog.spec.ts | 62 + .../__tests__/TransferOwnershipDialog.spec.ts | 155 ++- .../__tests__/useRelationSearch.spec.ts | 68 ++ web/src/composables/useContentAccess.ts | 4 + web/src/composables/useRelationSearch.ts | 13 +- web/src/composables/useRoleEditor.ts | 113 +- web/src/composables/useUsers.ts | 7 + web/src/data/__tests__/apiCatalogue.spec.ts | 123 +- web/src/data/__tests__/apiGating.spec.ts | 37 + web/src/data/apiCatalogue.ts | 79 +- web/src/i18n/messages.ts | 158 ++- web/src/i18n/neutralWording.spec.ts | 12 + web/src/i18n/roleMessages.spec.ts | 137 ++- .../lib/__tests__/activityCategories.spec.ts | 49 +- .../lib/__tests__/activitySentence.spec.ts | 186 +++ web/src/lib/__tests__/apiSearch.spec.ts | 39 + web/src/lib/__tests__/frenchElision.spec.ts | 27 + .../lib/__tests__/relationSentences.spec.ts | 12 + web/src/lib/__tests__/roleDiff.spec.ts | 101 ++ web/src/lib/__tests__/roleForm.spec.ts | 44 + web/src/lib/__tests__/rolePreview.spec.ts | 66 +- web/src/lib/__tests__/userMenu.spec.ts | 108 ++ web/src/lib/activity.ts | 2 +- web/src/lib/activitySentence.ts | 85 +- web/src/lib/apiError.ts | 1 + web/src/lib/directoryTypes.ts | 2 + web/src/lib/frenchElision.ts | 9 + web/src/lib/permissionCopy.ts | 1 + web/src/lib/relationSentences.ts | 9 + web/src/lib/roleDiff.ts | 132 ++ web/src/lib/roleForm.ts | 22 + web/src/lib/rolePreview.ts | 48 +- web/src/lib/userMenu.ts | 28 + web/src/lib/userRefusal.ts | 39 +- web/src/views/ApiKeysView.vue | 1 + web/src/views/ArticleView.vue | 39 +- web/src/views/IntegrationView.vue | 13 +- web/src/views/ProfileView.vue | 4 +- web/src/views/RoleEditorView.vue | 54 +- web/src/views/RolesListView.vue | 49 +- web/src/views/UsersView.vue | 92 +- .../views/__tests__/ActivityFilters.spec.ts | 12 + web/src/views/__tests__/ActivityRoles.spec.ts | 95 ++ .../views/__tests__/ArticlesReadOwn.spec.ts | 150 +++ .../views/__tests__/ContentReadOwn.spec.ts | 158 +++ .../EntryEditorRelationStates.spec.ts | 164 +++ .../views/__tests__/IntegrationView.spec.ts | 34 +- web/src/views/__tests__/ProfileView.spec.ts | 79 +- .../views/__tests__/RoleEditorView.spec.ts | 283 ++++- web/src/views/__tests__/RolesListView.spec.ts | 118 +- .../views/__tests__/SeoIntegration.spec.ts | 11 +- web/src/views/__tests__/UsersRemove.spec.ts | 272 +++++ web/src/views/__tests__/UsersRoles.spec.ts | 23 +- web/src/views/__tests__/UsersView.spec.ts | 12 +- web/visual/approvedExceptions.ts | 191 +++ web/visual/check.ts | 176 ++- web/visual/rolesStates.ts | 1057 +++++++++++++++++ web/visual/states.ts | 237 +++- 124 files changed, 8816 insertions(+), 523 deletions(-) create mode 100644 api/handlers/user/removeHandler.go create mode 100644 docs/design/change-log-roles-sync.md create mode 100644 features/relation_picker_loading_more.feature create mode 100644 features/relation_picker_loading_more.qa.md create mode 100644 features/roles_sync_conflicts.feature create mode 100644 features/roles_sync_conflicts.qa.md create mode 100644 features/roles_sync_copy_activity.feature create mode 100644 features/roles_sync_copy_activity.qa.md create mode 100644 features/roles_sync_integration_catalogue.feature create mode 100644 features/roles_sync_integration_catalogue.qa.md create mode 100644 features/roles_sync_profile_role_card.feature create mode 100644 features/roles_sync_profile_role_card.qa.md create mode 100644 features/roles_sync_read_own.feature create mode 100644 features/roles_sync_read_own.qa.md create mode 100644 features/roles_sync_remove_user.feature create mode 100644 features/roles_sync_remove_user.qa.md create mode 100644 internal/infrastructure/useCases/RemoveUser.go create mode 100644 test/roles_sync_remove_user_http_test.go create mode 100644 web/src/components/OwnScopeEmpty.vue create mode 100644 web/src/components/RemoveUserDialog.vue create mode 100644 web/src/components/RoleDifferencesDialog.vue create mode 100644 web/src/components/__tests__/OwnScopeEmpty.spec.ts create mode 100644 web/src/components/__tests__/RemoveUserDialog.spec.ts create mode 100644 web/src/components/__tests__/RoleDifferencesDialog.spec.ts create mode 100644 web/src/data/__tests__/apiGating.spec.ts create mode 100644 web/src/i18n/neutralWording.spec.ts create mode 100644 web/src/lib/__tests__/frenchElision.spec.ts create mode 100644 web/src/lib/__tests__/roleDiff.spec.ts create mode 100644 web/src/lib/__tests__/userMenu.spec.ts create mode 100644 web/src/lib/frenchElision.ts create mode 100644 web/src/lib/roleDiff.ts create mode 100644 web/src/views/__tests__/ArticlesReadOwn.spec.ts create mode 100644 web/src/views/__tests__/ContentReadOwn.spec.ts create mode 100644 web/src/views/__tests__/UsersRemove.spec.ts create mode 100644 web/visual/approvedExceptions.ts create mode 100644 web/visual/rolesStates.ts diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 9fdd0f9..f291fc7 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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), diff --git a/api/handlers/role/roleHandler.go b/api/handlers/role/roleHandler.go index 0e25caa..6d31f73 100644 --- a/api/handlers/role/roleHandler.go +++ b/api/handlers/role/roleHandler.go @@ -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}, diff --git a/api/handlers/user/removeHandler.go b/api/handlers/user/removeHandler.go new file mode 100644 index 0000000..c07e52c --- /dev/null +++ b/api/handlers/user/removeHandler.go @@ -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) + } +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 4cd1364..cd66146 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -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" diff --git a/api/router.go b/api/router.go index 5a5b5a7..8b30297 100644 --- a/api/router.go +++ b/api/router.go @@ -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 } diff --git a/docs/api.md b/docs/api.md index f9bbe29..da7153f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/design/README.md b/docs/design/README.md index 5c70435..75e7281 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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`). diff --git a/docs/design/change-log-roles-sync.md b/docs/design/change-log-roles-sync.md new file mode 100644 index 0000000..cea2562 --- /dev/null +++ b/docs/design/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. diff --git a/docs/design/routes.md b/docs/design/routes.md index 2f4969b..b080ce7 100644 --- a/docs/design/routes.md +++ b/docs/design/routes.md @@ -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. | diff --git a/docs/design/screens/activity.md b/docs/design/screens/activity.md index eff9595..8146aa3 100644 --- a/docs/design/screens/activity.md +++ b/docs/design/screens/activity.md @@ -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". diff --git a/docs/design/screens/integration.md b/docs/design/screens/integration.md index 0bba967..d96bd1e 100644 --- a/docs/design/screens/integration.md +++ b/docs/design/screens/integration.md @@ -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" diff --git a/docs/design/screens/roles.md b/docs/design/screens/roles.md index 959f716..d43ac08 100644 --- a/docs/design/screens/roles.md +++ b/docs/design/screens/roles.md @@ -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. diff --git a/docs/design/screens/schema-later.md b/docs/design/screens/schema-later.md index 976aa99..f5f9fc2 100644 --- a/docs/design/screens/schema-later.md +++ b/docs/design/screens/schema-later.md @@ -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) diff --git a/docs/design/source/IntegrationPage.dc.html b/docs/design/source/IntegrationPage.dc.html index 5c65253..edb3ac1 100644 --- a/docs/design/source/IntegrationPage.dc.html +++ b/docs/design/source/IntegrationPage.dc.html @@ -45,7 +45,7 @@
API keyFull access
-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. +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. Keys start with rcms_ and are shown in full once, when you create them. Manage API keys @@ -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: \`, 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`.' }, diff --git a/docs/design/source/RolesFrame.dc.html b/docs/design/source/RolesFrame.dc.html index c953646..9a9b1a9 100644 --- a/docs/design/source/RolesFrame.dc.html +++ b/docs/design/source/RolesFrame.dc.html @@ -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' diff --git a/docs/design/source/SchemaFrame.dc.html b/docs/design/source/SchemaFrame.dc.html index 9e28529..b127942 100644 --- a/docs/design/source/SchemaFrame.dc.html +++ b/docs/design/source/SchemaFrame.dc.html @@ -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' diff --git a/docs/design/source/roles-spec.js b/docs/design/source/roles-spec.js index c2973e7..9ed4534 100644 --- a/docs/design/source/roles-spec.js +++ b/docs/design/source/roles-spec.js @@ -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.'], diff --git a/docs/design/source/schema-spec.js b/docs/design/source/schema-spec.js index 58072ac..b0fe66c 100644 --- a/docs/design/source/schema-spec.js +++ b/docs/design/source/schema-spec.js @@ -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: [ diff --git a/docs/design/states.md b/docs/design/states.md index 4dfbf54..c7ec245 100644 --- a/docs/design/states.md +++ b/docs/design/states.md @@ -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. diff --git a/features/relation_picker_loading_more.feature b/features/relation_picker_loading_more.feature new file mode 100644 index 0000000..19540c6 --- /dev/null +++ b/features/relation_picker_loading_more.feature @@ -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 diff --git a/features/relation_picker_loading_more.qa.md b/features/relation_picker_loading_more.qa.md new file mode 100644 index 0000000..064e344 --- /dev/null +++ b/features/relation_picker_loading_more.qa.md @@ -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. diff --git a/features/roles_sync_conflicts.feature b/features/roles_sync_conflicts.feature new file mode 100644 index 0000000..11313a7 --- /dev/null +++ b/features/roles_sync_conflicts.feature @@ -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//reassign {"target_role_id": } + Then the answer is + 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 + Then + 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": } + 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 + 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 diff --git a/features/roles_sync_conflicts.qa.md b/features/roles_sync_conflicts.qa.md new file mode 100644 index 0000000..ad442ee --- /dev/null +++ b/features/roles_sync_conflicts.qa.md @@ -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. diff --git a/features/roles_sync_copy_activity.feature b/features/roles_sync_copy_activity.feature new file mode 100644 index 0000000..f432609 --- /dev/null +++ b/features/roles_sync_copy_activity.feature @@ -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 + 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 is open + When + Then + 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 + Then + And the dialog stays open and keeps the email and the role + And in FR it reads + 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 with outcome success + Then the sentence reads and in FR + 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 has the outcome failed + Then the sentence reads followed by the Failed badge, and in FR + 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 whose params lack + Then the sentence reads + 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 diff --git a/features/roles_sync_copy_activity.qa.md b/features/roles_sync_copy_activity.qa.md new file mode 100644 index 0000000..b1adcdc --- /dev/null +++ b/features/roles_sync_copy_activity.qa.md @@ -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. diff --git a/features/roles_sync_integration_catalogue.feature b/features/roles_sync_integration_catalogue.feature new file mode 100644 index 0000000..d933a67 --- /dev/null +++ b/features/roles_sync_integration_catalogue.feature @@ -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 + Then its access chip reads "Needs permission" + And its description ends with the sentence Needs the 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 diff --git a/features/roles_sync_integration_catalogue.qa.md b/features/roles_sync_integration_catalogue.qa.md new file mode 100644 index 0000000..54951ed --- /dev/null +++ b/features/roles_sync_integration_catalogue.qa.md @@ -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 "