From 907949aa1239d259808f356fed8d99a492065708 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 27 Oct 2026 22:21:12 +0100 Subject: [PATCH] feat: roles SPA foundations --- api/handlers/article/articleHandler.go | 38 +- docs/api.md | 2 +- docs/api/articles.md | 3 + .../roles_permissions_spa_foundation.feature | 331 ++++++++++++++++++ .../roles_permissions_spa_foundation.qa.md | 71 ++++ test/article_created_by_http_test.go | 232 ++++++++++++ web/src/components/ArticleCardList.vue | 59 +--- web/src/components/ArticleTable.vue | 59 +--- web/src/components/AuthorProfileCard.vue | 4 +- web/src/components/ContentEntries.vue | 39 ++- web/src/components/ContentNavGroup.vue | 9 +- web/src/components/ContentRowMenu.vue | 43 +++ web/src/components/DashboardShell.vue | 61 ++-- web/src/components/DirectoryRowMenu.vue | 44 ++- web/src/components/DuplicateGroups.vue | 5 + web/src/components/EditorHeader.vue | 32 +- web/src/components/EditorMobileBar.vue | 21 +- web/src/components/EntryActions.vue | 4 +- web/src/components/EntryCardList.vue | 4 + web/src/components/EntryEditor.vue | 7 +- web/src/components/EntryTable.vue | 53 ++- web/src/components/MediaCheckbox.vue | 5 + web/src/components/MediaCleanupCard.vue | 20 +- web/src/components/MediaDetailPanel.vue | 6 + web/src/components/MediaEmptyState.vue | 5 +- web/src/components/MetaSection.vue | 4 +- web/src/components/NoAccessState.vue | 46 +++ web/src/components/OwnerOnlyTag.vue | 14 + web/src/components/ReadOnlyBanner.vue | 27 ++ web/src/components/RecentActivityCard.vue | 6 +- web/src/components/RetentionCard.vue | 21 +- web/src/components/RoleBadge.vue | 21 +- web/src/components/RouteAccessGate.vue | 38 ++ web/src/components/SchemaBuilderPage.vue | 11 +- web/src/components/SchemaForbidden.vue | 32 -- web/src/components/SitesForbidden.vue | 33 -- web/src/components/UpdatesCard.vue | 54 ++- web/src/components/ViewOnlyMark.vue | 17 + .../__tests__/AccountSheetFocus.spec.ts | 2 + .../__tests__/ArticleEditor.spec.ts | 3 +- .../__tests__/ArticleEditorLink.spec.ts | 3 +- .../components/__tests__/ContentNav.spec.ts | 4 +- .../__tests__/DashboardShell.spec.ts | 96 ++++- .../__tests__/NoAccessState.spec.ts | 82 +++++ .../components/__tests__/RoleMarks.spec.ts | 83 +++++ web/src/components/__tests__/UserMenu.spec.ts | 3 +- .../__tests__/languageSwitcher.spec.ts | 3 +- web/src/components/__tests__/pickerKit.ts | 5 +- .../components/variants/VariantsForbidden.vue | 34 -- web/src/composables/__tests__/useApi.spec.ts | 6 +- .../__tests__/useApiPermissions.spec.ts | 323 +++++++++++++++++ .../__tests__/useSchemaGate.spec.ts | 10 +- .../composables/__tests__/useUsers.spec.ts | 15 +- web/src/composables/useActivityPage.ts | 7 +- web/src/composables/useApi.ts | 68 +++- web/src/composables/useArticleMenu.ts | 80 +++++ web/src/composables/useArticles.ts | 1 + web/src/composables/useCanManageVariants.ts | 6 +- web/src/composables/useContentAccess.ts | 68 ++++ web/src/composables/useDirectoryToasts.ts | 12 +- web/src/composables/useEntryEditorPage.ts | 15 + web/src/composables/useEntryRowDelete.ts | 66 ++++ web/src/composables/useSchemaGate.ts | 4 +- web/src/composables/useUsers.ts | 9 +- web/src/data/__tests__/apiCatalogue.spec.ts | 39 ++- web/src/data/apiCatalogue.ts | 36 +- web/src/i18n/directoryMessages.spec.ts | 1 - web/src/i18n/messages.ts | 86 +++++ web/src/i18n/metaMessages.spec.ts | 30 +- web/src/i18n/permissionMessages.spec.ts | 36 ++ web/src/i18n/spa.spec.ts | 6 +- web/src/lib/__tests__/directoryErrors.spec.ts | 2 +- web/src/lib/__tests__/permissionNames.spec.ts | 34 ++ web/src/lib/apiError.ts | 37 +- web/src/lib/directoryErrors.ts | 10 +- web/src/lib/directoryMenu.ts | 1 + web/src/lib/permissionCopy.ts | 55 +++ web/src/lib/permissionNames.ts | 50 +++ web/src/lib/permissions.ts | 70 ++++ web/src/lib/routeAccess.ts | 34 ++ web/src/main.ts | 3 + web/src/router/__tests__/routeAccess.spec.ts | 147 ++++++++ web/src/router/__tests__/routerGuard.spec.ts | 15 +- web/src/router/index.ts | 144 ++++++-- web/src/stores/__tests__/permissions.spec.ts | 86 +++++ web/src/stores/__tests__/session.spec.ts | 11 +- web/src/stores/__tests__/sessionRoles.spec.ts | 36 -- web/src/stores/__tests__/update.spec.ts | 3 +- web/src/stores/permissions.ts | 35 ++ web/src/stores/session.ts | 47 ++- web/src/stores/update.ts | 16 +- web/src/test/access.ts | 152 ++++++++ web/src/views/ActivityView.vue | 3 +- web/src/views/ApiKeysView.vue | 10 +- web/src/views/ArticleView.vue | 16 +- web/src/views/AuthorsView.vue | 17 +- web/src/views/CachePage.vue | 31 +- web/src/views/ContentListPage.vue | 4 +- web/src/views/EditorView.vue | 16 +- web/src/views/HomeView.vue | 10 +- web/src/views/MediaView.vue | 18 +- web/src/views/SchemaBuilderView.vue | 12 +- web/src/views/SchemaTypeView.vue | 13 +- web/src/views/SchemaView.vue | 15 +- web/src/views/SettingsView.vue | 14 +- web/src/views/SitesView.vue | 36 +- web/src/views/UsersView.vue | 5 +- web/src/views/VariantsPage.vue | 25 +- .../views/__tests__/AcceptInviteView.spec.ts | 4 +- .../views/__tests__/ActivityFilters.spec.ts | 5 +- web/src/views/__tests__/ActivityView.spec.ts | 1 + web/src/views/__tests__/ApiKeysView.spec.ts | 21 +- .../__tests__/ArticlePermissions.spec.ts | 150 ++++++++ web/src/views/__tests__/ArticleView.spec.ts | 3 +- .../__tests__/ArticlesPageSizeMemory.spec.ts | 3 +- .../__tests__/ArticlesPagination.spec.ts | 3 +- .../__tests__/ArticlesSitesAuthors.spec.ts | 3 +- web/src/views/__tests__/CachePage.spec.ts | 31 +- web/src/views/__tests__/ContentList.spec.ts | 4 +- .../__tests__/ContentListColumns.spec.ts | 2 +- .../views/__tests__/ContentListMedia.spec.ts | 2 +- .../__tests__/ContentListNavigation.spec.ts | 4 +- .../views/__tests__/ContentOwnScope.spec.ts | 190 ++++++++++ .../views/__tests__/DirectoryFrench.spec.ts | 6 +- .../views/__tests__/DirectorySessions.spec.ts | 3 +- web/src/views/__tests__/DocumentTitle.spec.ts | 13 +- .../views/__tests__/EditorAddAuthor.spec.ts | 15 + .../views/__tests__/EditorImageUpload.spec.ts | 3 +- .../views/__tests__/EditorMediaPicker.spec.ts | 3 +- .../views/__tests__/EditorMetaSection.spec.ts | 3 +- .../views/__tests__/EditorSeoSession.spec.ts | 3 +- .../views/__tests__/EditorVariants.spec.ts | 5 +- web/src/views/__tests__/EditorView.spec.ts | 5 +- .../__tests__/EntryEditorViewOnly.spec.ts | 49 +++ .../views/__tests__/HomeActivityCard.spec.ts | 17 +- .../views/__tests__/HomeUpdateBanner.spec.ts | 3 +- .../views/__tests__/HomeUpdateReady.spec.ts | 3 +- web/src/views/__tests__/HomeView.spec.ts | 9 +- .../views/__tests__/IntegrationImages.spec.ts | 4 +- .../views/__tests__/IntegrationSearch.spec.ts | 3 +- web/src/views/__tests__/LoginView.spec.ts | 2 + .../views/__tests__/MediaPermissions.spec.ts | 56 +++ web/src/views/__tests__/MediaSessions.spec.ts | 3 +- web/src/views/__tests__/MediaUpload.spec.ts | 3 +- web/src/views/__tests__/MediaView.spec.ts | 3 +- web/src/views/__tests__/PendingView.spec.ts | 5 +- web/src/views/__tests__/ProfileView.spec.ts | 2 + .../__tests__/SchemaBuilderDelete.spec.ts | 4 +- .../__tests__/SchemaBuilderEditLoad.spec.ts | 4 +- .../__tests__/SchemaBuilderEditSave.spec.ts | 2 +- .../__tests__/SchemaBuilderRelation.spec.ts | 2 +- .../views/__tests__/SchemaBuilderSave.spec.ts | 2 +- .../views/__tests__/SchemaBuilderView.spec.ts | 2 +- .../views/__tests__/SchemaTypeView.spec.ts | 2 +- web/src/views/__tests__/SchemaView.spec.ts | 4 +- web/src/views/__tests__/SeoDismiss.spec.ts | 3 +- web/src/views/__tests__/SeoSitesForm.spec.ts | 13 +- .../SettingsContainerUpdates.spec.ts | 8 +- .../__tests__/SettingsMediaCleanup.spec.ts | 7 +- .../views/__tests__/SettingsRestart.spec.ts | 11 +- .../views/__tests__/SettingsRetention.spec.ts | 51 ++- .../__tests__/SettingsUpdateDownload.spec.ts | 15 +- .../views/__tests__/SettingsUpdates.spec.ts | 44 ++- web/src/views/__tests__/SettingsView.spec.ts | 36 +- .../__tests__/SitesAuthorsIntegration.spec.ts | 3 +- .../__tests__/SitesAuthorsSessions.spec.ts | 2 + web/src/views/__tests__/SitesView.spec.ts | 63 +++- .../views/__tests__/UsersRowActions.spec.ts | 14 +- web/src/views/__tests__/UsersView.spec.ts | 30 +- web/src/views/__tests__/VariantsPage.spec.ts | 13 +- .../__tests__/VariantsPermissions.spec.ts | 42 +++ web/src/views/__tests__/VerifyView.spec.ts | 5 +- web/src/views/__tests__/activityHarness.ts | 5 +- web/src/views/__tests__/directoryTestKit.ts | 11 +- web/src/views/__tests__/mediaTestKit.ts | 7 +- web/src/views/__tests__/metaTestKit.ts | 5 +- web/src/views/__tests__/purgeTestKit.ts | 9 +- web/src/views/__tests__/schemaTestKit.ts | 11 +- web/src/views/__tests__/variantsTestKit.ts | 7 +- web/visual/states.ts | 103 +++--- 180 files changed, 4308 insertions(+), 835 deletions(-) create mode 100644 features/roles_permissions_spa_foundation.feature create mode 100644 features/roles_permissions_spa_foundation.qa.md create mode 100644 test/article_created_by_http_test.go create mode 100644 web/src/components/ContentRowMenu.vue create mode 100644 web/src/components/NoAccessState.vue create mode 100644 web/src/components/OwnerOnlyTag.vue create mode 100644 web/src/components/ReadOnlyBanner.vue create mode 100644 web/src/components/RouteAccessGate.vue delete mode 100644 web/src/components/SchemaForbidden.vue delete mode 100644 web/src/components/SitesForbidden.vue create mode 100644 web/src/components/ViewOnlyMark.vue create mode 100644 web/src/components/__tests__/NoAccessState.spec.ts create mode 100644 web/src/components/__tests__/RoleMarks.spec.ts delete mode 100644 web/src/components/variants/VariantsForbidden.vue create mode 100644 web/src/composables/__tests__/useApiPermissions.spec.ts create mode 100644 web/src/composables/useArticleMenu.ts create mode 100644 web/src/composables/useContentAccess.ts create mode 100644 web/src/composables/useEntryRowDelete.ts create mode 100644 web/src/i18n/permissionMessages.spec.ts create mode 100644 web/src/lib/__tests__/permissionNames.spec.ts create mode 100644 web/src/lib/permissionCopy.ts create mode 100644 web/src/lib/permissionNames.ts create mode 100644 web/src/lib/permissions.ts create mode 100644 web/src/lib/routeAccess.ts create mode 100644 web/src/router/__tests__/routeAccess.spec.ts create mode 100644 web/src/stores/__tests__/permissions.spec.ts delete mode 100644 web/src/stores/__tests__/sessionRoles.spec.ts create mode 100644 web/src/stores/permissions.ts create mode 100644 web/src/test/access.ts create mode 100644 web/src/views/__tests__/ArticlePermissions.spec.ts create mode 100644 web/src/views/__tests__/ContentOwnScope.spec.ts create mode 100644 web/src/views/__tests__/EntryEditorViewOnly.spec.ts create mode 100644 web/src/views/__tests__/MediaPermissions.spec.ts create mode 100644 web/src/views/__tests__/VariantsPermissions.spec.ts diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index bd066c1..78c8d56 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -55,6 +55,7 @@ type baseArticleResponse struct { BodyFormat domain.BodyFormat `json:"body_format"` IsOnline bool `json:"is_online"` SiteID uint32 `json:"site_id"` + CreatedBy *uint32 `json:"created_by,omitempty"` Authors []author.Byline `json:"authors"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -84,7 +85,15 @@ type listItemResponse struct { SEOSuggestions int `json:"seo_suggestions"` } -func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArticleResponse, error) { +func creatorFor(article domain.Article, viewer access.Caller) *uint32 { + if viewer.UserID == 0 { + return nil + } + createdBy := article.CreatedBy + return &createdBy +} + +func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (baseArticleResponse, error) { body := json.RawMessage(article.Body) if format != domain.BodyFormatJSON { encoded, err := json.Marshal(article.Body) @@ -104,14 +113,15 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat) (baseArtic BodyFormat: format, IsOnline: article.IsOnline, SiteID: article.SiteID, + CreatedBy: creatorFor(article, viewer), Authors: authors, CreatedAt: article.CreatedAt, UpdatedAt: article.UpdatedAt, }, nil } -func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) { - base, err := toBaseResponse(article, format) +func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) { + base, err := toBaseResponse(article, format, viewer) if err != nil { return articleResponse{}, err } @@ -136,16 +146,16 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl }, nil } -func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat) (listItemResponse, error) { - base, err := toBaseResponse(article, format) +func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat, viewer access.Caller) (listItemResponse, error) { + base, err := toBaseResponse(article, format, viewer) if err != nil { return listItemResponse{}, err } return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil } -func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) { - response, err := toArticleResponse(article, format) +func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) { + response, err := toArticleResponse(article, format, viewer) if err != nil { return nil, err } @@ -188,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { return } - articleJson, err := encodeArticle(article, format) + articleJson, err := encodeArticle(article, format, access.CallerOf(r)) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -233,7 +243,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { return } - writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format) + writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format) } func writeArticleErrorResponse(w http.ResponseWriter, err error) { @@ -298,8 +308,8 @@ func writeValidationError(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusBadRequest) } -func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) { - articleJson, err := encodeArticle(article, format) +func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) { + articleJson, err := encodeArticle(article, format, access.CallerOf(r)) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -331,7 +341,7 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b return } - writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) + writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format) } type optionalImageID struct { @@ -475,7 +485,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) { return } - writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) + writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format) } func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { @@ -585,7 +595,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { responses := make([]listItemResponse, 0, len(page.Items)) for _, article := range page.Items { - response, err := toListItemResponse(article, page.SEO[article.ID], format) + response, err := toListItemResponse(article, page.SEO[article.ID], format, access.CallerOf(r)) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return diff --git a/docs/api.md b/docs/api.md index 9e15e89..b8b8fc9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -488,7 +488,7 @@ Each action reads its own permission: publish does not imply update, update does means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the -wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. +wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. Article responses expose `created_by` to signed-in callers and API keys only (never to anonymous callers), so a client can tell which rows are its own; see `docs/api/articles.md`. A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`, never `content.update:blog`). The permission is checked before the id and the body are read, then the scope diff --git a/docs/api/articles.md b/docs/api/articles.md index e51187b..1dc12b4 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -72,6 +72,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send ` "body_format": "markdown", "is_online": true, "site_id": 1, + "created_by": 1, "authors": [], "slug": "backyard-renovation-update", "excerpt": "", @@ -90,6 +91,8 @@ Breaking change: the default used to be HTML. Clients that need HTML must send ` } ``` +`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish. + Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten. ## SEO fields diff --git a/features/roles_permissions_spa_foundation.feature b/features/roles_permissions_spa_foundation.feature new file mode 100644 index 0000000..f579e72 --- /dev/null +++ b/features/roles_permissions_spa_foundation.feature @@ -0,0 +1,331 @@ +Feature: SPA foundation for roles and permissions (slice 5) + + As a signed-in person with a custom or built-in role + I want the app to know exactly what my role allows, from the server's answer + So that every screen, route, control and error speaks in permissions, never in fixed role names + + Background: + Given the backend contract of features/roles_permissions_{foundation,enforcement,content_scope,roles_api,users_api}.feature + And GET /v1/auth/me answers access {role: {id, name, built_in, locked}, permissions: [{key, scope}]} and no top-level role string + And the 34-key catalogue, scopes none|own|all, typed entries ":" (article for articles) that replace the wildcard for that type, a "none" entry denying + And toggle keys arrive with scope "all"; owner-only keys arrive only for the Owner role + And a refusal is a plain-text 403 body: missing_permission with X-Missing-Permission (base key), escalation with X-Escalation-Key, or role_locked, own_role, role_built_in, built_in_name, role_conflict (409), session_required ("This action needs a real session.") + And own means created_by equals the caller's user id; created_by 0 is owned by nobody + And the ownership data the SPA can see today is: entries carry created_by (GET /v1/content-type/{key}/entry and /{id}); articles did not (Article.CreatedBy was json:"-"); this slice exposes it to signed-in callers, see the backend Rule; media items have no owner and the media delete, merge and purge routes require scope all + And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR copy in i18n/messages.ts, no comments in code + And the test runner is web/vitest.config.ts (jsdom) run by `task test:web`; it already exists, this slice runs a baseline first + + Rule: The session carries access, and can() decides + + Scenario: getMe maps the access object + Given GET /v1/auth/me answers 200 with access.role {id: 3, name: "Writer", built_in: true, locked: false} and 9 permission entries + When useApi().getMe() resolves + Then the session holds role {id 3, name "Writer", built_in true, locked false} and the 9 entries in order + And the session has no string role field and no isOwner / canManageContent getter exists + + Scenario Outline: can(key, options) resolves the effective scope + Given the session grants + When can("", ) is evaluated + Then the result is + + Examples: + | grants | key | options | result | + | content.update=all | content.update | {} | true | + | content.update=own | content.update | {} | true | + | content.update=own | content.update | {scope: "all"} | false | + | content.update=own, session id 7 | content.update | {ownerId: 7} | true | + | content.update=own, session id 7 | content.update | {ownerId: 8} | false | + | content.update=own, session id 7 | content.update | {ownerId: 0} | false | + | content.update=all | content.update | {ownerId: 8} | true | + | content.update=none | content.update | {} | false | + | key absent | content.update | {} | false | + | content.update=own, content.update:events=all | content.update | {typeKey: "events", scope: "all"} | true | + | content.update=all, content.update:events=none | content.update | {typeKey: "events"} | false | + | content.update=all, content.update:events=none | content.update | {typeKey: "venues"} | true | + | content.read=all | content.read | {typeKey: "article"} | true | + | settings.view=all | content.explode | {} | false | + | no session | media.view | {} | false | + + Scenario: scopeOf exposes none, own or all + Given the session grants content.read=all and content.read:events=own + Then scopeOf("content.read") is "all" + And scopeOf("content.read", {typeKey: "events"}) is "own" + And scopeOf("schema.view") is "none" + + Scenario: Live refresh after a refused action + Given the session grants content.delete=all + And the server now answers 403 missing_permission with X-Missing-Permission "content.delete" and GET /v1/auth/me then lacks content.delete + When the SPA receives that 403 + Then GET /v1/auth/me is requested once more (not more than once every 5 seconds) + And can("content.delete") becomes false without a reload + + Scenario: Live refresh when the tab comes back + Given the last /v1/auth/me answer is 31 seconds old + When the document becomes visible + Then the session is refreshed + And when the last answer is 10 seconds old, it is not + + Scenario: A failed refresh keeps the previous grants + Given GET /v1/auth/me fails with a network error + Then the session keeps its last grants and shows no error + + Rule: Every fixed-role check is replaced by a permission + + Scenario Outline: Permission replaces the role string + Given the session grants only + When is rendered + Then + + Examples: + | grants | place | effect | + | users.invite | Users, Invite action | the Invite button is shown | + | users.view | Users, Invite action | the Invite button is hidden | + | apikeys.view=all | API keys list | the Created by column is shown | + | apikeys.view=own | API keys list | the Created by column is hidden | + | media.delete=all | Media, Duplicates | the purge control is available | + | media.delete=own | Media, Duplicates | the purge control is hidden (purge routes need scope all) | + | settings.edit_general | Cache, Variants, Media cleanup | controls are editable | + | settings.view | Cache, Variants, Media cleanup | read-only with the ReadOnlyBanner | + | activity.view=all | Home recent activity, Activity page | full view | + | activity.view=own | Home recent activity, Activity page | own events only | + | activity.view=none | Home recent activity | the card is not shown | + | authors.create | Authors, Add author button, MetaSection | shown / inline create enabled | + | authors.view | Authors | read-only list and detail | + | system.apply_updates | Settings, Software card, Home banner | Apply and Restart are actionable, cue shown | + | settings.view | Settings, Software card | the Apply action is disabled with the "Owner only" tag | + | system.edit_retention | Settings, Retention card | editable | + | settings.view | Settings, Retention card | visible, locked, "Owner only" tag | + | settings.manage_sites | Sidebar, Settings children | the Sites child is shown | + | settings.manage_sites | Sites, Delete | Delete is disabled with the "Owner only" tag | + | settings.manage_sites, settings.delete_site | Sites, Delete | Delete is enabled | + + Scenario: Sidebar lists items per permission + Given the session grants only content.read, media.view, authors.view, activity.view=own + Then the sidebar shows Home, Articles, Media, Authors, Activity and Integration + And it shows no Users, no Schema, no Settings + And the mobile drawer shows the same items + And the Access / Roles entry is not added in this slice + + Scenario: The Writer built-in sees the Writer menu + Given the session is the built-in Writer (9 grants) + Then the sidebar shows Home, Articles, Media, Authors, Schema, Activity, Integration and no Users and no Settings + + Scenario: The Admin built-in loses only the owner-only actions + Given the session is the built-in Admin (29 keys) + Then Sites is reachable and Delete site is disabled with the "Owner only" tag + And Apply update is disabled with "Only the owner can apply updates." + And the Retention card reads "Admins can read the log. Only the owner can change how long it is kept." + + Rule: Route guard by permission + + Scenario Outline: A direct link to a page the role lacks shows the page-level 403 state + Given the session grants + When the person opens + Then the URL stays and no redirect happens + And the page shows the icon-lock state titled "You don't have access to this page" + And the description is "Your role, , doesn't include it. Ask an owner or admin if you need it." + And the single action is "" + + Examples: + | grants | role | route | action | + | content.read=all, no schema | Contributor | /schema | Go to Articles | + | content.read=all | Contributor | /schema/new | Go to Articles | + | content.read=all | Contributor | /users | Go to Articles | + | content.read=all | Contributor | /settings/sites | Go to Articles | + | content.read=all | Contributor | /integration/api-keys | Go to Articles | + | content.read:events=none | Contributor | /content/events | Go to Articles | + | media.view only | Media | /schema | Go to Home | + + Scenario Outline: A page the role can use renders normally + Given the session grants + When the person opens + Then the page renders and no 403 state shows + + Examples: + | grants | route | + | schema.view | /schema | + | schema.edit | /schema/new | + | users.view | /users | + | settings.manage_sites | /settings/sites | + | content.read:events=own | /content/events | + | content.create:events=all | /content/events/new | + | apikeys.view=own | /integration/api-keys | + | settings.view | /settings/media/cache | + + Scenario: The guard waits for the session + Given the session status is "checking" + When the person opens /schema + Then neither the page nor the 403 state flashes + And once the status is "ready" the matching state shows + + Scenario: Unverified and signed-out rules are unchanged + Given an unverified session + Then the person is sent to /register/pending + And a missing session goes to /login with redirect and reason=expired + + Scenario: Grants lowered while a page is open + Given the person is on /schema with schema.view + And a refresh then returns no schema.view + Then the page switches to the page-level 403 state without reload + + Rule: Error handling in useApi + + Scenario Outline: A 403 with a code + When the server answers 403 with body "" and header
+ Then the call rejects with ApiError status 403, code ""
+ And + + Examples: + | body | header | details | toast | + | missing_permission | X-Missing-Permission: content.delete | permission "content.delete" | an error toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." | + | missing_permission | X-Missing-Permission: users.invite | permission "users.invite" | the toast names "Invite users" | + | missing_permission | (none) | permission undefined | the toast reads "Your role doesn't include this action. Ask an owner or admin." | + | escalation | X-Escalation-Key: content.publish:events| escalationKey kept | no global toast; the screen shows "You can't grant that" / "You can only grant what your role has." | + | role_locked | (none) | code kept | no global toast; copy "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." is available to screens | + | own_role | (none) | code kept | no global toast; copy "You can't change your own role" / "Ask another owner or admin." | + | role_built_in | (none) | code kept | no global toast; copy "Built-in roles can't be deleted" / "Duplicate it to make a custom role." | + | built_in_name | (none) | code kept | no global toast; copy "Built-in roles keep their name." | + | session_required | (none) | code kept | no global toast | + + Scenario: A caller can opt out of the toast + Given a request sent with onMissingPermission "passthrough" + When the server answers 403 missing_permission + Then the ApiError carries the code and permission and no toast shows + And the session is still refreshed + + Scenario: The unverified redirect still wins + Given the session is unverified + When any call answers 403 + Then the person is sent to /register/pending, as before + + Scenario: Unchanged statuses + Then 401 still redirects to /login, 429 still carries Retry-After, network failures still set isOffline + + Scenario: 409 role_conflict and user_role_changed are mapped, not toasted + When the server answers 409 "role_conflict" or "user_role_changed" + Then ApiError.code carries it and useApi shows no toast + And a screen without its own handling shows "This changed somewhere else. Reload and try again." + + Scenario: The old role-specific toasts are retired + Given the Sites or Authors write answers 403 missing_permission + Then the generic missing_permission toast shows + And "Only the owner can manage sites" no longer shows anywhere + + Rule: Copy follows the new Admin rights + + Scenario Outline: Owner-only copy + Then "" no longer says "" + And it says "" + + Examples: + | screen | old | new | + | Sites 403 page | Only the owner can manage sites… | the page-level state | + | Schema 403 page | Only owners and admins can change content types… | the page-level state | + | Settings, Software | Ask the owner of this site to update RenewCMS. | Only the owner can apply updates. | + | Settings, Retention | Only owners can change it. (hidden for admin) | Admins can read the log. Only the owner can change how long it is kept. | + | Settings, Media cleanup | Only owners and admins can change this. | You can read these settings but not change them / Ask the owner to change them, or ask for a role that can edit settings. | + + Rule: Own-scope and role-aware controls on content (design: roles.md section 6) + + Scenario Outline: Entry list rows under each scope for content.update and content.delete + Given the session is user 21 with content.update:events=, content.delete:events= + And entry 1 has created_by 21, entry 2 has created_by 9, entry 3 has created_by 0 + When /content/events lists the three entries and the row menu opens on + Then the "View only" mark with a lock icon is + And Edit is , with the reason "" + And Delete is , with the reason "" + + Examples: + | update | delete | row | mark | edit | editReason | del | delReason | + | own | own | entry 1 | absent | enabled | | enabled | | + | own | own | entry 2 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. | + | own | own | entry 3 | shown | disabled | You can only edit content you created. | disabled | You can only edit content you created. | + | all | all | entry 2 | absent | enabled | | enabled | | + | all | none | entry 1 | absent | enabled | | disabled | Your role can't delete entries. | + | none | none | entry 1 | shown | disabled | Your role can't edit entries. | disabled | Your role can't delete entries. | + + Scenario: The entry list explains the own scope + Given the session has content.create:events=all and content.update:events=own + Then the Events list reads "You can create entries and edit your own." + And a note reads "Entries by others are view only" / "Your role lets you edit entries you created." + And the New entry button is shown + And with content.create:events=none the New entry button is hidden + + Scenario: Direct link to an entry the role can only view + Given the session has content.update:events=own and entry 2 has created_by 9 + When the person opens /content/events/2 + Then the entry is shown with the "View only" mark and no Save action + And the exact layout beyond the mark is a design gap (not specified in roles.md) + + Scenario Outline: Article controls under all and none (ownership not needed) + Given the session has content.publish:article=, content.delete:article=, content.update:article= + When the Articles list and the editor of an article render + Then Publish is + And the status reads + And Delete in the row menu is , with the reason "" + And Edit is + + Examples: + | publish | delete | update | publishState | status | del | delReason | edit | + | all | all | all | available | Draft or Published as today | enabled | | enabled | + | none | none | all | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | enabled | + | none | none | none | hidden | Draft with "Ask an editor to publish." | disabled | Your role can't delete articles. | disabled | + + Scenario: The Writer built-in Articles list + Given the session is the built-in Writer + Then the Articles header reads "Articles" with "You can write and edit all articles. Publishing is done by an editor." + And the row menu shows Edit enabled and Delete disabled with "Your role can't delete articles." + And the editor shows Save draft, Status "Draft" and "Ask an editor to publish." and no Publish + + Scenario Outline: Article controls under own scope + Given the session is user 21 with content.update:article=own, content.delete:article=own, content.publish:article=own + And article 1 has created_by 21 and article 2 has created_by 9, both exposed by GET /v1/article/ + When the Articles list renders + Then on article 1 Edit, Delete and Publish are available + And on article 2 Edit and Delete are disabled with "You can only edit content you created." and Publish is hidden with "Ask an editor to publish." + + Scenario Outline: Media controls + Given the session grants + When Media renders + Then + + Examples: + | grants | effect | + | media.view | the library is read-only: no upload control, no Rename, no Delete, no Merge, no purge | + | media.view, media.upload | upload and Rename available; Delete, Merge and purge hidden | + | media.view, media.delete=all | Delete, Merge and purge available | + | media.view, media.delete=own | Delete, Merge and purge unavailable: media has no owner and the server requires all | + | no media.view | the page-level 403 state | + + Scenario: A stale menu still ends well + Given the person sees Delete on an article and the role was just lowered + When Delete answers 403 missing_permission + Then the toast "You don't have permission to do that" / "Your role doesn't include Delete entries. Ask an owner or admin." shows + And the session refreshes and the control becomes disabled with its reason + + Rule: Shapes of the changed API are documented in the catalogue + + Scenario: The catalogue samples match the backend + Then the /auth/me sample shows access.role and access.permissions and no role string + And the /user list sample shows role {id, name, built_in, locked} + And the invite create request documents the optional role_id (absent means Writer) and the response carries role + And docs/llms output is regenerated by `task build:llms` from the catalogue, never edited by hand + + Rule: The backend exposes created_by on articles to signed-in callers (Go, Go test suite) + + Scenario Outline: created_by on article responses + When calls + Then the article objects + + Examples: + | caller | route | result | + | a signed-in session | GET /v1/article/ | carry created_by (the user id) | + | a signed-in session | GET /v1/article/{id} | carry created_by | + | a signed-in session | PATCH /v1/article/{id} (response) | carry created_by | + | a signed-in session | POST /v1/article (response) | carry created_by, the caller's id | + | an API key | GET /v1/article/ | carry created_by (a key acts as its creator, authenticated) | + | an anonymous caller | GET /v1/article/ and GET /v1/article/{id} | carry no created_by field at all | + | an unverified or stale session | GET /v1/article/ | carry no created_by field (treated as anonymous, as in the public read rule) | + And created_by 0 is returned as 0 for a signed-in caller + And the same article encoded for an anonymous caller and a signed-in caller differ only by that field + And docs/api.md and docs/api/articles.md document the field, the audience and the SPA use; the Go suite covers every row with the existing optionalAccess middleware deciding authenticated versus anonymous diff --git a/features/roles_permissions_spa_foundation.qa.md b/features/roles_permissions_spa_foundation.qa.md new file mode 100644 index 0000000..83ec0b2 --- /dev/null +++ b/features/roles_permissions_spa_foundation.qa.md @@ -0,0 +1,71 @@ +# QA procedure: SPA foundation for roles and permissions (slice 5) + +Spec: features/roles_permissions_spa_foundation.feature. Everything below is Vitest + Vue Test Utils under `task test:web` with `/v1` mocked through the existing kits. No live server, no real SMTP. + +## Honesty rules +0. The runner already exists (web/vitest.config.ts, `test:web` in Taskfile.yml, vitest and @vue/test-utils in web/package.json). First run `task test:web` on the unmodified tree and record exit code and failing specs. That is the baseline. +1. If the runner cannot execute (bun missing, install failing), every SPA item is reported NOT RUN, never passing. A green Go suite never counts for the SPA. +2. Report each edited spec, and which ones already failed in the baseline. +3. The backend change (created_by on article responses for signed-in callers) is part of this slice and is covered by Go tests in test/; own-scope article specs are normal specs, not todo. +4. The sentence in CLAUDE.md saying the runner is added by the first SPA slice is stale; the coordinator updates it. + +## Shared helper +`web/src/test/access.ts` (new) builds the Owner (34, all), Admin (29, no owner-only key), Writer (9 grants of roles_permissions_foundation.feature), custom and none presets. Kits keep their `role` option and call it; no spec writes a raw role string. Legacy mapping: owner, admin, member and user become Owner, Admin, Writer and no-grants; undefined becomes no `access`. + +## Automated (Vitest) + +| Spec | Cases | +|---|---| +| stores/__tests__/permissions.spec.ts (new, replaces sessionRoles.spec.ts) | every row of the can() table; scopeOf; no session; typed none; wildcard for a type without row; unknown key false | +| composables/__tests__/useApi.spec.ts (extend or new) | getMe maps access and drops role; 403 matrix (missing_permission with and without header, escalation, role_locked, own_role, role_built_in, built_in_name, session_required); passthrough option; refresh debounce 5 s and visibility refresh 31 s vs 10 s with fake timers (no real dates); failed refresh keeps grants; unverified redirect wins; 401, 429, network unchanged; 409 codes mapped without toast | +| lib/__tests__/permissionNames.spec.ts (new) | all 34 keys have an EN and FR human name; content.delete is "Delete entries"; a typed key shows the base-key name | +| router/__tests__/routerGuard.spec.ts (extend) | each route of the guard table denied and allowed; status checking shows nothing; unverified and signed-out unchanged; typed param route /content/events; lowered-while-open switch; URL unchanged | +| components/__tests__/NoAccessState.spec.ts (new) | title, description with role name, single action Articles vs Home, no "See what I can do" | +| components/__tests__/OwnerOnlyTag.spec.ts, ReadOnlyBanner.spec.ts, ViewOnlyMark.spec.ts (new) | lock icon, texts EN and FR, tooltip reason | +| components/__tests__/DashboardShell.spec.ts (update) | Writer, Admin, Owner, custom menus; drawer equals sidebar; no Roles entry | +| views/__tests__/UsersView.spec.ts, UsersRowActions.spec.ts | users.invite shows Invite; role object rendered through RoleBadge (Owner locked, Admin built-in, custom, null) | +| views/__tests__/ApiKeysView.spec.ts | apikeys.view all vs own for Created by | +| views/__tests__/Settings{Updates,UpdateDownload,Restart,ContainerUpdates,Retention,MediaCleanup}.spec.ts, HomeUpdate*.spec.ts, stores/__tests__/update.spec.ts | system.apply_updates, system.edit_retention, settings.edit_general gating; Owner only tag; Admin Retention state | +| views/__tests__/SitesView.spec.ts, Schema*.spec.ts, composables/__tests__/useSchemaGate.spec.ts | page-level state replaces the old forbidden copy; Delete site disabled for Admin | +| views/__tests__/Authors*.spec.ts, EditorAddAuthor.spec.ts, CachePage.spec.ts, VariantsPage.spec.ts, Activity*.spec.ts, Home*.spec.ts | kits emit the new access body; owner and admin behaviour unchanged; permission-driven cases | +| views/__tests__/ContentList*.spec.ts, EntryEditor*.spec.ts (extend) | the entry table of the feature: View only mark, Edit and Delete disabled with their exact reasons, own note, New entry hidden without create, direct link to a viewed entry shows View only and no Save | +| views/__tests__/Articles*.spec.ts, Editor*.spec.ts, composables PublishControl / useArticleRowActions specs (extend) | all/none table: Publish hidden with "Ask an editor to publish.", Delete disabled with "Your role can't delete articles.", Writer header copy, stale-menu 403 toast then refresh and disabled control | +| same | own-scope article rows (created_by from the API) | +| views/__tests__/MediaView.spec.ts, MediaUpload.spec.ts, mediaTestKit | the media table: upload, rename, delete, merge, purge by grants; own behaves as none; no media.view shows the page state | +| data/__tests__/apiCatalogue.spec.ts | sample shapes; no role string in me or user list; role_id documented | +| i18n/spa.spec.ts (update) | new keys exist in EN and FR | + +## Visual (`task visual:check -- `, hard gate 10 %) +Step 1. After migrating the `/auth/me` mocks in web/visual/states.ts (about 48) to access/helper bodies, run every screen of `visualStates`. Everything not listed below must still PASS; clock-sensitive states stay `skip: 'clock-sensitive'`. + +Step 2. States whose rendering changes by design and whose board frames still carry the old or different copy. The user approved per-state exceptions in the slice 5 acceptance. Each is recorded in web/visual/states.ts as `exception` with `approvedBy: 'user (slice 5 acceptance)'` ONLY if the run prints FAIL for it (do not add an exception for a state that passes). Candidates, from a static read of states.ts: + +| Screen | State | Why it may fail | +|---|---|---| +| sites | Forbidden (403) | old "You don't have access to Sites" copy replaced by the page-level state | +| schema | Forbidden (403) | old "Only owners and admins can change content types" copy replaced | +| variants | Forbidden | the permission guard shows the page-level state instead of the local one | +| activity | Forbidden (403) | same, only if the mock session now lacks activity.view | +| content | Forbidden (403) | same, only if the mock now lacks content.read for the type | +| settings | Retention for an admin (board shows it hidden for non-owners), Software for an admin (Owner only tag) | new Owner-only treatment | +| settings / cache / variants | Media cleanup read-only | new read-only copy and ReadOnlyBanner | +| users | Invite action gating, role badge from a role object | badge rendering from RoleRef | + +Step 3. New or changed rendering with no board frame: NoAccessState (reference frame "Page: no access" in roles.md section 7), ReadOnlyBanner and OwnerOnlyTag ("Admin: read-only settings", "Admin: owner-only settings"), the View only mark and disabled row menus ("Own scope: entry list", "Own scope: row menu", "Writer: Articles list", "Writer: row menu", "Writer: entry editor"). If the board file does not expose them as states of an existing screen, record "no board frame, manual" and list it in the report as a design-doc gap. Do not invent a screen entry. + +## Manual checks +1. Keyboard: Tab order and visible focus on NoAccessState action; disabled row-menu items are reachable and announce their reason; View only mark has an accessible name and the tooltip opens on focus. +2. Two real browsers, two roles: change a role through PUT /v1/user/{id}/role; the other browser's next refused action shows the toast and the page updates within 30 s of returning to the tab. +3. As a Writer open /schema, /users, /settings/sites by link: the page-level state, URL unchanged. +4. As a Contributor with content.update:events=own: another person's entry shows View only, Edit and Delete disabled with reasons; own entry editable. +5. Media as a role with only media.view: no write control anywhere. +6. French: all new copy translated. Dark mode and mobile for NoAccessState, OwnerOnlyTag, ReadOnlyBanner, ViewOnlyMark. +7. `task lint:web` (eslint and vue-tsc) green; `task test:web` exit 0 on the final tree. +8. The TestRouterRoutesAreInTheCatalogue Go failure is pre-existing and reported, not fixed. + +## Backend items blocking or limiting this slice (for the coordinator) +- B1 (done in this slice, signed-in callers only): `Article.CreatedBy` is `json:"-"`. GET /v1/article/, GET /v1/article/{id} and write responses must expose `created_by`. Privacy decision: anonymous callers should probably not get it. +- B2 ("Created by" column of the Writer Articles frame): needs names for created_by ids; GET /v1/user/lookup?ids= exists and answers for roles with no grants, so it can serve, but the column is not part of this draft unless asked. +- B3 (info): media has no owner; delete, merge and purge need scope all, so `own` on media.delete cannot be honoured in the UI. +- B4 (info): entries already expose created_by; no backend change. +- B5 (design gap): the design shows Publish and Status for entries, but the backend has no entry publish route. diff --git a/test/article_created_by_http_test.go b/test/article_created_by_http_test.go new file mode 100644 index 0000000..81f571d --- /dev/null +++ b/test/article_created_by_http_test.go @@ -0,0 +1,232 @@ +package test + +import ( + "RenewCMS/api" + "RenewCMS/api/pkg/authtoken" + domainArticle "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +const ( + createdByWriterID = 5 + createdByStaleID = 6 + createdByIdleID = 7 +) + +func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) { + t.Helper() + e := newActivityHTTPEnv(t, 1000) + e.users.users[createdByWriterID] = user.User{ID: createdByWriterID, Username: "wren", Email: "wren@example.com", IsVerified: true, RoleID: writerRoleID} + e.users.users[createdByStaleID] = user.User{ID: createdByStaleID, Username: "stella", Email: "stella@example.com", IsVerified: true, RoleID: writerRoleID, TokenVersion: 3} + e.users.users[createdByIdleID] = user.User{ID: createdByIdleID, Username: "ida", Email: "ida@example.com", IsVerified: false, RoleID: writerRoleID} + e.articles.articles[1] = domainArticle.Article{ID: 1, Title: "Winter checklist for timber windows", IsOnline: true, CreatedBy: createdByWriterID} + e.articles.articles[2] = domainArticle.Article{ID: 2, Title: "Orphaned draft with no owner", IsOnline: true, CreatedBy: 0} + + container := &api.UseCases{ + Token: e.token, + GetUserUseCase: useCases.NewGetUserUseCase(e.users), + CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles), + AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}), + ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()), + DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder), + UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), + } + router := chi.NewRouter() + router.Use(authtoken.Verifier(e.token)) + router.Mount("/v1/article", api.NewArticleRouter(container, nil)) + return e, router +} + +func sendCreatedBy(router http.Handler, method string, path string, body string, options ...activityRequest) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = activityPeer + for _, option := range options { + option(req) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func asStaleUser(e *activityHTTPEnv, id uint32) activityRequest { + return func(r *http.Request) { + tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0}) + r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) + } +} + +func decodeArticleFields(t *testing.T, body []byte) map[string]json.RawMessage { + t.Helper() + var object map[string]json.RawMessage + if err := json.Unmarshal(body, &object); err != nil { + t.Fatalf("invalid json %s: %v", body, err) + } + return object +} + +func decodeArticleItems(t *testing.T, body []byte) []map[string]json.RawMessage { + t.Helper() + var list struct { + Items []map[string]json.RawMessage `json:"items"` + } + if err := json.Unmarshal(body, &list); err != nil { + t.Fatalf("invalid json %s: %v", body, err) + } + return list.Items +} + +func TestArticleCreatedByHttp(t *testing.T) { + t.Run("a signed-in session carries created_by on GET /v1/article/", func(t *testing.T) { + e, router := newCreatedByEnv(t) + rec := sendCreatedBy(router, http.MethodGet, "/v1/article/", "", asUser(e, activityOwnerID)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + seen := map[string]string{} + for _, item := range decodeArticleItems(t, rec.Body.Bytes()) { + seen[string(item["id"])] = string(item["created_by"]) + } + if seen["1"] != "5" || seen["2"] != "0" { + t.Errorf("expected created_by 5 and 0, got %v", seen) + } + }) + + t.Run("a signed-in session carries created_by on GET /v1/article/{id}", func(t *testing.T) { + e, router := newCreatedByEnv(t) + rec := sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID)) + if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" { + t.Errorf("expected 5, got %q", got) + } + }) + + t.Run("a signed-in session carries created_by on the PATCH response", func(t *testing.T) { + e, router := newCreatedByEnv(t) + rec := sendCreatedBy(router, http.MethodPatch, "/v1/article/1", `{"excerpt":"Short"}`, asUser(e, activityOwnerID)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" { + t.Errorf("expected 5, got %q", got) + } + }) + + t.Run("a signed-in session carries the caller id on the POST response", func(t *testing.T) { + e, router := newCreatedByEnv(t) + rec := sendCreatedBy(router, http.MethodPost, "/v1/article/", `{"title":"Fresh draft title","body":""}`, asUser(e, createdByWriterID)) + if rec.Code != http.StatusCreated { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" { + t.Errorf("expected 5, got %q", got) + } + }) + + t.Run("an API key carries created_by on GET /v1/article/", func(t *testing.T) { + e, router := newCreatedByEnv(t) + raw, _ := e.issueKey(t, activityOwnerID, "Deploy bot") + rec := sendCreatedBy(router, http.MethodGet, "/v1/article/", "", withBearer(raw)) + items := decodeArticleItems(t, rec.Body.Bytes()) + if len(items) == 0 { + t.Fatalf("no items: %s", rec.Body) + } + for _, item := range items { + if _, present := item["created_by"]; !present { + t.Errorf("created_by missing from %v", item) + } + } + }) + + t.Run("an API key carries created_by on GET /v1/article/{id}", func(t *testing.T) { + e, router := newCreatedByEnv(t) + raw, _ := e.issueKey(t, activityOwnerID, "Deploy bot") + rec := sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", withBearer(raw)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" { + t.Errorf("expected 5, got %q", got) + } + }) + + for _, action := range []string{"publish", "unpublish"} { + t.Run("a signed-in session carries created_by on the "+action+" response", func(t *testing.T) { + e, router := newCreatedByEnv(t) + rec := sendCreatedBy(router, http.MethodPatch, "/v1/article/1/"+action, "", asUser(e, activityOwnerID)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" { + t.Errorf("expected 5, got %q", got) + } + }) + + t.Run("an anonymous caller gets no article body on "+action, func(t *testing.T) { + _, router := newCreatedByEnv(t) + rec := sendCreatedBy(router, http.MethodPatch, "/v1/article/1/"+action, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if strings.Contains(rec.Body.String(), "created_by") { + t.Errorf("created_by leaked: %s", rec.Body) + } + }) + } + + t.Run("an anonymous caller gets no created_by field", func(t *testing.T) { + _, router := newCreatedByEnv(t) + list := sendCreatedBy(router, http.MethodGet, "/v1/article/", "") + for _, item := range decodeArticleItems(t, list.Body.Bytes()) { + if _, present := item["created_by"]; present { + t.Errorf("created_by leaked in list: %v", item) + } + } + one := sendCreatedBy(router, http.MethodGet, "/v1/article/1", "") + if _, present := decodeArticleFields(t, one.Body.Bytes())["created_by"]; present { + t.Errorf("created_by leaked: %s", one.Body) + } + }) + + t.Run("an unverified or stale session is treated as anonymous", func(t *testing.T) { + e, router := newCreatedByEnv(t) + for name, option := range map[string]activityRequest{ + "unverified": asUser(e, createdByIdleID), + "stale": asStaleUser(e, createdByStaleID), + } { + rec := sendCreatedBy(router, http.MethodGet, "/v1/article/", "", option) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d", name, rec.Code) + } + for _, item := range decodeArticleItems(t, rec.Body.Bytes()) { + if _, present := item["created_by"]; present { + t.Errorf("%s: created_by leaked: %v", name, item) + } + } + } + }) + + t.Run("anonymous and signed-in encodings differ only by created_by", func(t *testing.T) { + e, router := newCreatedByEnv(t) + anonymous := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "").Body.Bytes()) + signedIn := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID)).Body.Bytes()) + delete(signedIn, "created_by") + if len(anonymous) != len(signedIn) { + t.Fatalf("field sets differ: %d vs %d", len(anonymous), len(signedIn)) + } + for key, value := range anonymous { + if string(signedIn[key]) != string(value) { + t.Errorf("%s differs: %s vs %s", key, value, signedIn[key]) + } + } + }) +} diff --git a/web/src/components/ArticleCardList.vue b/web/src/components/ArticleCardList.vue index be7c5d4..b2da9ac 100644 --- a/web/src/components/ArticleCardList.vue +++ b/web/src/components/ArticleCardList.vue @@ -8,6 +8,7 @@ import AuthorStack from '@/components/AuthorStack.vue' import { authorsSummary } from '@/lib/articleMeta' import { formatRelativeDate } from '@/lib/relativeDate' import type { Article } from '@/composables/useArticles' +import { useArticleMenu } from '@/composables/useArticleMenu' import { seoChipOf } from '@/lib/articleSeoStatus' const props = withDefaults( @@ -45,50 +46,14 @@ function pendingFor(article: Article) { return article.is_online ? ('unpublish' as const) : ('publish' as const) } -function actionsFor(article: Article) { - return [ - [ - { - get label() { - return t('Edit') - }, - icon: 'i-lucide-pencil', - onSelect: () => router.push(editPath(article)), - }, - { - get label() { - return article.is_online ? t('Unpublish') : t('Publish') - }, - icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe', - disabled: isBusy(article), - onSelect: () => { - if (article.is_online) { - emit('unpublish', article.id) - } else { - emit('publish', article.id) - } - }, - }, - { - get label() { - return t('Copy API URL') - }, - icon: 'i-lucide-link', - onSelect: () => emit('copy-url', article.id), - }, - ], - [ - { - get label() { - return t('Delete…') - }, - icon: 'i-lucide-trash-2', - color: 'error' as const, - onSelect: () => emit('delete', article), - }, - ], - ] -} +const { actionsFor, publishHint } = useArticleMenu({ + isBusy, + edit: (article) => router.push(editPath(article)), + publish: (id) => emit('publish', id), + unpublish: (id) => emit('unpublish', id), + copyUrl: (id) => emit('copy-url', id), + remove: (article) => emit('delete', article), +}) function onRowClick(article: Article, event: MouseEvent) { if ((event.target as HTMLElement).closest('[data-row-menu]')) { @@ -139,6 +104,12 @@ function onRowClick(article: Article, event: MouseEvent) { size="sm" :pending="pendingFor(article)" /> + {{ publishHint(article) }} router.push(editPath(article)), - }, - { - get label() { - return article.is_online ? t('Unpublish') : t('Publish') - }, - icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe', - disabled: isBusy(article), - onSelect: () => { - if (article.is_online) { - emit('unpublish', article.id) - } else { - emit('publish', article.id) - } - }, - }, - { - get label() { - return t('Copy API URL') - }, - icon: 'i-lucide-link', - onSelect: () => emit('copy-url', article.id), - }, - ], - [ - { - get label() { - return t('Delete…') - }, - icon: 'i-lucide-trash-2', - color: 'error' as const, - onSelect: () => emit('delete', article), - }, - ], - ] -} +const { actionsFor, publishHint } = useArticleMenu({ + isBusy, + edit: (article) => router.push(editPath(article)), + publish: (id) => emit('publish', id), + unpublish: (id) => emit('unpublish', id), + copyUrl: (id) => emit('copy-url', id), + remove: (article) => emit('delete', article), +}) function onSelect(_event: Event, row: { original: Article }) { if (props.loading) { @@ -364,6 +329,12 @@ function onSelect(_event: Event, row: { original: Article }) { :pending="pendingFor(row.original)" :aria-busy="isBusy(row.original) ? 'true' : undefined" /> + {{ publishHint(row.original) }} (null) const loadingEdit = ref(false) const author = computed(() => session.session?.author) -const canManage = computed(() => session.canManageContent) +const canManage = computed(() => permissions.scopeOf('authors.edit') === 'all') const nameLine = computed(() => { const displayName = session.session?.displayName return displayName diff --git a/web/src/components/ContentEntries.vue b/web/src/components/ContentEntries.vue index a5513e8..020979d 100644 --- a/web/src/components/ContentEntries.vue +++ b/web/src/components/ContentEntries.vue @@ -7,11 +7,15 @@ import ArticleListNotice from '@/components/ArticleListNotice.vue' import ContentEmpty from '@/components/ContentEmpty.vue' import ContentState from '@/components/ContentState.vue' import ContentToolbar from '@/components/ContentToolbar.vue' +import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue' import DirectoryAddButton from '@/components/DirectoryAddButton.vue' import DirectoryLoadError from '@/components/DirectoryLoadError.vue' import EntryCardList from '@/components/EntryCardList.vue' import EntrySkeleton from '@/components/EntrySkeleton.vue' +import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue' import EntryTable from '@/components/EntryTable.vue' +import { useContentAccess } from '@/composables/useContentAccess' +import { useEntryRowDelete } from '@/composables/useEntryRowDelete' import type { ContentListPage } from '@/composables/useContentListPage' import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages' @@ -40,6 +44,18 @@ const { retry, } = props.page +const access = useContentAccess(typeKey, 'entries') +const rowDelete = useEntryRowDelete( + typeKey, + () => name.value, + () => list.start(), +) +const createNote = computed(() => + access.canCreate.value && access.updatesOwnOnly.value + ? t('You can create entries and edit your own.') + : '', +) + const singlePageLabel = computed(() => list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]), ) @@ -66,11 +82,11 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))

{{ name }}

-

{{ subtitle }}

+

{{ createNote || subtitle }}

entriesLoadErrorLine(typeKey)) + + entriesLoadErrorLine(typeKey)) :sort="list.shown.value.sort" :names="list.names.value" :caption="caption" + @delete="rowDelete.open" /> entriesLoadErrorLine(typeKey)) {{ t('New entry') }} + + diff --git a/web/src/components/ContentNavGroup.vue b/web/src/components/ContentNavGroup.vue index dd66bb2..aca01d7 100644 --- a/web/src/components/ContentNavGroup.vue +++ b/web/src/components/ContentNavGroup.vue @@ -5,6 +5,7 @@ import { computed, ref } from 'vue' import { useRoute } from 'vue-router' import { useMediaQuery } from '@vueuse/core' import { useContentTypesStore } from '@/stores/contentTypes' +import { usePermissionsStore } from '@/stores/permissions' import { contentLink, selectedContentKey, visibleContentTypes } from '@/lib/contentNav' import { contentTypeIcon } from '@/lib/contentTypeIcon' @@ -18,15 +19,19 @@ const SIDEBAR_QUERY = '(min-width: 64rem)' const route = useRoute() const store = useContentTypesStore() +const permissions = usePermissionsStore() const isSidebar = useMediaQuery(SIDEBAR_QUERY) const expanded = ref(false) const selectedKey = computed(() => selectedContentKey(route.path)) -const total = computed(() => store.items.length) +const readable = computed(() => + store.items.filter((type) => permissions.can('content.read', { typeKey: type.key })), +) +const total = computed(() => readable.value.length) const showGroup = computed(() => store.status === 'ready' && total.value > 0) const rows = computed(() => isSidebar.value || expanded.value - ? visibleContentTypes(store.items, selectedKey.value, expanded.value) + ? visibleContentTypes(readable.value, selectedKey.value, expanded.value) : [], ) const hiddenCount = computed(() => total.value - rows.value.length) diff --git a/web/src/components/ContentRowMenu.vue b/web/src/components/ContentRowMenu.vue new file mode 100644 index 0000000..40406d2 --- /dev/null +++ b/web/src/components/ContentRowMenu.vue @@ -0,0 +1,43 @@ + + + diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 61f3ead..a186437 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -4,6 +4,7 @@ import { t, translate } from '@/i18n' import { computed, nextTick, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { useSessionStore } from '@/stores/session' +import { usePermissionsStore } from '@/stores/permissions' import { useUpdateStore } from '@/stores/update' import { useContentTypesPreload, useShellLogout } from '@/composables/useShellSession' import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed' @@ -68,11 +69,12 @@ const bodyUi = computed(() => { const route = useRoute() const session = useSessionStore() +const permissions = usePermissionsStore() const update = useUpdateStore() const { loggingOut, logout } = useShellLogout() const wantsBuiltin = computed( - () => session.canManageContent && route.meta.needsBuiltinTypes === true, + () => permissions.can('schema.view') && route.meta.needsBuiltinTypes === true, ) useContentTypesPreload(() => wantsBuiltin.value) @@ -87,7 +89,7 @@ const publishingChildren = computed(() => [ }, ]) -const settingsChildren = computed(() => [ +const mediaSettingsChildren = computed(() => [ { label: t('Media'), type: 'label' as const }, { label: t('Variants'), @@ -101,11 +103,15 @@ const settingsChildren = computed(() => [ icon: 'i-lucide-database', active: !props.noCurrentNav && route.path === '/settings/media/cache', }, - ...(session.isOwner ? publishingChildren.value : []), +]) + +const settingsChildren = computed(() => [ + ...(permissions.can('settings.view') ? mediaSettingsChildren.value : []), + ...(permissions.can('settings.manage_sites') ? publishingChildren.value : []), ]) const schemaItem = computed(() => - session.canManageContent + permissions.can('schema.view') ? [ { get label() { @@ -113,6 +119,7 @@ const schemaItem = computed(() => }, to: SCHEMA_PATH, icon: 'i-lucide-shapes', + show: true, }, ] : [], @@ -126,6 +133,7 @@ const navItems = computed(() => }, to: HOME_PATH, icon: 'i-lucide-home', + show: true, }, { get label() { @@ -133,6 +141,7 @@ const navItems = computed(() => }, to: '/article', icon: 'i-lucide-file-text', + show: permissions.can('content.read', { typeKey: 'article' }), }, { get label() { @@ -140,6 +149,7 @@ const navItems = computed(() => }, to: '/media', icon: 'i-lucide-images', + show: permissions.can('media.view'), }, { get label() { @@ -147,6 +157,7 @@ const navItems = computed(() => }, to: '/authors', icon: 'i-lucide-feather', + show: permissions.can('authors.view'), }, { get label() { @@ -154,6 +165,7 @@ const navItems = computed(() => }, to: '/users', icon: 'i-lucide-users', + show: permissions.can('users.view'), }, ...schemaItem.value, { @@ -162,6 +174,7 @@ const navItems = computed(() => }, to: '/activity', icon: 'i-lucide-history', + show: permissions.can('activity.view'), }, { get label() { @@ -169,6 +182,7 @@ const navItems = computed(() => }, to: '/integration', icon: 'i-lucide-plug', + show: true, }, { get label() { @@ -177,24 +191,29 @@ const navItems = computed(() => to: '/settings', icon: 'i-lucide-settings', slot: 'settings', + show: permissions.can('settings.view') || permissions.can('settings.manage_sites'), }, - ].map((item) => { - const isSettings = item.to === '/settings' - const parentActive = isSettings - ? route.path === item.to - : route.path === item.to || route.path.startsWith(`${item.to}/`) - return { - ...item, - active: !props.noCurrentNav && parentActive, - ...(isSettings && session.canManageContent - ? { - children: settingsChildren.value, - defaultOpen: route.path.startsWith(item.to), - trailingIcon: 'i-lucide-chevron-right', - } - : {}), - } - }), + ] + .filter((item) => item.show) + .map((entry) => { + const { show, ...item } = entry + void show + const isSettings = item.to === '/settings' + const parentActive = isSettings + ? route.path === item.to + : route.path === item.to || route.path.startsWith(`${item.to}/`) + return { + ...item, + active: !props.noCurrentNav && parentActive, + ...(isSettings && settingsChildren.value.length > 0 + ? { + children: settingsChildren.value, + defaultOpen: route.path.startsWith(item.to), + trailingIcon: 'i-lucide-chevron-right', + } + : {}), + } + }), ) const routeTitle = computed(() => translate((route.meta.title as string) ?? '')) diff --git a/web/src/components/DirectoryRowMenu.vue b/web/src/components/DirectoryRowMenu.vue index 367720d..123e724 100644 --- a/web/src/components/DirectoryRowMenu.vue +++ b/web/src/components/DirectoryRowMenu.vue @@ -3,6 +3,7 @@ import { t } from '@/i18n' import { computed, ref, useId } from 'vue' import type { DirectoryMenuItem } from '@/lib/directoryMenu' +import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue' const props = defineProps<{ items: DirectoryMenuItem[] @@ -15,7 +16,11 @@ const open = defineModel('open', { default: false }) const hintId = useId() const trigger = ref<{ $el: HTMLElement } | null>(null) const label = computed(() => t('Actions for {0}', [props.title])) -const hint = computed(() => props.items.find((item) => item.unavailable)?.hint ?? '') +const hinted = computed(() => props.items.filter((item) => item.unavailable && item.hint)) + +function hintIdOf(item: DirectoryMenuItem) { + return `${hintId}-${item.key}` +} const groups = computed(() => { const result: DirectoryMenuItem[][] = [] @@ -40,12 +45,16 @@ function select(item: DirectoryMenuItem, event?: Event) { const menuItems = computed(() => groups.value.map((group) => group.map((item) => ({ + key: item.key, + hint: item.hint ?? '', + destructive: item.destructive, label: item.label, icon: item.icon, color: item.destructive ? ('error' as const) : undefined, + ownerOnly: item.ownerOnly, slot: item.unavailable ? ('unavailable' as const) : undefined, 'aria-disabled': item.unavailable ? 'true' : undefined, - 'aria-describedby': item.unavailable ? hintId : undefined, + 'aria-describedby': item.unavailable && item.hint ? hintIdOf(item) : undefined, class: item.unavailable ? 'opacity-40' : undefined, onSelect: (event: Event) => select(item, event), })), @@ -80,11 +89,20 @@ const TRIGGER_CLASS = @click.stop /> @@ -127,7 +145,9 @@ const TRIGGER_CLASS = block :icon="item.icon" :aria-disabled="item.unavailable ? 'true' : undefined" - :aria-describedby="item.unavailable ? hintId : undefined" + :aria-describedby=" + item.unavailable && item.hint ? hintIdOf(item) : undefined + " class="min-h-12 justify-start gap-3 px-4 text-start text-md" :class="[ item.destructive ? 'text-error' : 'text-highlighted', @@ -137,10 +157,16 @@ const TRIGGER_CLASS = @click="select(item, $event)" > {{ item.label }} + -

- {{ hint }} +

+ {{ item.hint }}

diff --git a/web/src/components/DuplicateGroups.vue b/web/src/components/DuplicateGroups.vue index 9259138..3d77d60 100644 --- a/web/src/components/DuplicateGroups.vue +++ b/web/src/components/DuplicateGroups.vue @@ -1,7 +1,9 @@ + + diff --git a/web/src/components/OwnerOnlyTag.vue b/web/src/components/OwnerOnlyTag.vue new file mode 100644 index 0000000..21bbcde --- /dev/null +++ b/web/src/components/OwnerOnlyTag.vue @@ -0,0 +1,14 @@ + + + diff --git a/web/src/components/ReadOnlyBanner.vue b/web/src/components/ReadOnlyBanner.vue new file mode 100644 index 0000000..d45c001 --- /dev/null +++ b/web/src/components/ReadOnlyBanner.vue @@ -0,0 +1,27 @@ + + + diff --git a/web/src/components/RecentActivityCard.vue b/web/src/components/RecentActivityCard.vue index 9833d2b..ecba597 100644 --- a/web/src/components/RecentActivityCard.vue +++ b/web/src/components/RecentActivityCard.vue @@ -3,12 +3,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import ActivityFeedCard from '@/components/ActivityFeedCard.vue' import { useActivity } from '@/composables/useActivity' import type { ActivityItem } from '@/lib/activity' -import { useSessionStore } from '@/stores/session' +import { usePermissionsStore } from '@/stores/permissions' const SKELETON_DELAY_MS = 150 const RECENT_SIZE = 5 -const session = useSessionStore() +const permissions = usePermissionsStore() const { listRecentActivity } = useActivity() const items = ref([]) @@ -17,7 +17,7 @@ const error = ref(false) const showSkeleton = ref(false) let skeletonTimer: ReturnType | null = null -const own = computed(() => session.session?.role !== 'owner' && session.session?.role !== 'admin') +const own = computed(() => permissions.scopeOf('activity.view') === 'own') function clearSkeletonTimer() { if (skeletonTimer !== null) { diff --git a/web/src/components/RetentionCard.vue b/web/src/components/RetentionCard.vue index 5b2c432..3383de5 100644 --- a/web/src/components/RetentionCard.vue +++ b/web/src/components/RetentionCard.vue @@ -18,6 +18,8 @@ import { sizeOptions, } from '@/lib/activityRetention' import { useSessionStore } from '@/stores/session' +import { usePermissionsStore } from '@/stores/permissions' +import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue' const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again." const SKELETON_BAR = 'rounded-none bg-accented' @@ -46,6 +48,8 @@ const emit = defineEmits<{ }>() const session = useSessionStore() +const permissions = usePermissionsStore() +const canEdit = computed(() => permissions.can('system.edit_retention')) const { updateRetention } = useSettings() const online = useOnline() @@ -68,7 +72,9 @@ const controls = computed(() => [ key: 'days' as const, labelId: 'retention-label', label: t('Keep activity for'), - helper: helperCopy(selectedDays.value, selectedGb.value), + helper: canEdit.value + ? helperCopy(selectedDays.value, selectedGb.value) + : t('Admins can read the log. Only the owner can change how long it is kept.'), items: retentionOptions(), value: selectedDays.value, }, @@ -246,12 +252,17 @@ onBeforeUnmount(clearSavedTimer)

{{ - t( - 'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.', - ) + canEdit + ? t( + 'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.', + ) + : t( + 'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events.', + ) }}

+ import { t } from '@/i18n' import { computed } from 'vue' +import type { RoleRef } from '@/lib/permissions' const props = defineProps<{ - role: 'owner' | 'admin' + role: RoleRef | null | undefined }>() +const BUILT_IN_LABELS: Record string> = { + Owner: () => t('owner'), + Admin: () => t('admin'), + Writer: () => t('writer'), +} + +const label = computed(() => { + if (!props.role) { + return '—' + } + const builtIn = props.role.built_in ? BUILT_IN_LABELS[props.role.name] : undefined + return builtIn ? builtIn() : props.role.name +}) + const roleClass = computed(() => - props.role === 'owner' + props.role?.locked ? 'border border-primary bg-primary text-(--ui-bg)' : 'border border-accented bg-transparent text-toned', ) @@ -22,6 +37,6 @@ const roleClass = computed(() => base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium', }" > - {{ t(role) }} + {{ label }} diff --git a/web/src/components/RouteAccessGate.vue b/web/src/components/RouteAccessGate.vue new file mode 100644 index 0000000..40f3841 --- /dev/null +++ b/web/src/components/RouteAccessGate.vue @@ -0,0 +1,38 @@ + + + diff --git a/web/src/components/SchemaBuilderPage.vue b/web/src/components/SchemaBuilderPage.vue index b26064c..f89c144 100644 --- a/web/src/components/SchemaBuilderPage.vue +++ b/web/src/components/SchemaBuilderPage.vue @@ -2,7 +2,6 @@ import { t } from '@/i18n' import { computed, nextTick, ref } from 'vue' -import { useRouter } from 'vue-router' import { useMediaQuery } from '@vueuse/core' import ApiPreview from '@/components/ApiPreview.vue' import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue' @@ -13,14 +12,13 @@ import BuilderTypeBar from '@/components/BuilderTypeBar.vue' import FieldList from '@/components/FieldList.vue' import FieldSheet from '@/components/FieldSheet.vue' import RetryAlert from '@/components/RetryAlert.vue' -import SchemaForbidden from '@/components/SchemaForbidden.vue' +import NoAccessState from '@/components/NoAccessState.vue' import SchemaDeleteControls from '@/components/SchemaDeleteControls.vue' import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue' import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue' import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder' import { useSchemaDelete } from '@/composables/useSchemaDelete' import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard' -import { HOME_PATH } from '@/lib/routePaths' import { sampleJson } from '@/lib/schemaBuilderSample' import { entriesNotice } from '@/lib/schemaEntries' import { formatClock } from '@/lib/updateFormat' @@ -29,7 +27,6 @@ const DESKTOP_QUERY = '(min-width: 48rem)' const props = defineProps<{ edit?: EditSource }>() -const router = useRouter() const isDesktop = useMediaQuery(DESKTOP_QUERY) const builder = useSchemaBuilder(props.edit) const { form } = builder @@ -70,10 +67,6 @@ const selectedName = computed(() => builder.selected.value?.label.trim() || t('U const sheetTitle = computed(() => t('Field: {0}', [selectedName.value])) const reviewName = computed(() => builder.baseline.value?.name ?? '') -function goHome() { - router.push(HOME_PATH) -} - function onSelect(uid: number) { builder.selectField(uid) if (!isDesktop.value) { @@ -113,7 +106,7 @@ async function restoreRowFocus() {