feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4263 -790

No files matched your search

+24 -14
View File
@@ -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
+1 -1
View File
@@ -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
+3
View File
@@ -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
@@ -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 "<key>:<type_key>" (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 <grants>
When can("<key>", <options>) is evaluated
Then the result is <result>
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 <grants>
When <place> is rendered
Then <effect>
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 <grants>
When the person opens <route>
Then the URL stays <route> 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, <role>, doesn't include it. Ask an owner or admin if you need it."
And the single action is "<action>"
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 <grants>
When the person opens <route>
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 "<body>" and header <header>
Then the call rejects with ApiError status 403, code "<body>"<details>
And <toast>
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 "<screen>" no longer says "<old>"
And it says "<new>"
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=<update>, content.delete:events=<delete>
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 <row>
Then the "View only" mark with a lock icon is <mark>
And Edit is <edit>, with the reason "<editReason>"
And Delete is <del>, with the reason "<delReason>"
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=<publish>, content.delete:article=<delete>, content.update:article=<update>
When the Articles list and the editor of an article render
Then Publish is <publishState>
And the status reads <status>
And Delete in the row menu is <del>, with the reason "<delReason>"
And Edit is <edit>
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 <grants>
When Media renders
Then <effect>
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 <caller> calls <route>
Then the article objects <result>
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
@@ -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 -- <screen>`, 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.
+232
View File
@@ -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])
}
}
})
}
+15 -44
View File
@@ -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)"
/>
<span
v-if="publishHint(article)"
class="sr-only"
data-testid="publish-hint"
>{{ publishHint(article) }}</span
>
<SeoStatusChip
v-if="seoChipOf(article)"
:chip="seoChipOf(article)!"
+15 -44
View File
@@ -9,6 +9,7 @@ import AuthorStack from '@/components/AuthorStack.vue'
import type { Site } from '@/composables/useSites'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { useArticleMenu } from '@/composables/useArticleMenu'
import { seoChipOf } from '@/lib/articleSeoStatus'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
@@ -177,50 +178,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 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"
/>
<span
v-if="publishHint(row.original)"
class="sr-only"
data-testid="publish-hint"
>{{ publishHint(row.original) }}</span
>
<SeoStatusChip
v-if="seoChipOf(row.original)"
:chip="seoChipOf(row.original)!"
+3 -1
View File
@@ -11,10 +11,12 @@ import { useLocalizedToast } from '@/i18n/presentation'
import { baseSlugOf } from '@/lib/authorProfile'
import { copy } from '@/lib/directoryCopy'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const SLUG_LOOKUP_SIZE = 25
const session = useSessionStore()
const permissions = usePermissionsStore()
const profile = useMyAuthorProfile()
const { listAuthors } = useAuthors()
const toast = useLocalizedToast()
@@ -25,7 +27,7 @@ const editing = ref<Author | null>(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
+36 -3
View File
@@ -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))
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
{{ name }}
</h1>
<p v-if="type" class="text-muted">{{ subtitle }}</p>
<p v-if="type" class="text-muted">{{ createNote || subtitle }}</p>
</template>
</div>
<UButton
v-if="isDesktop"
v-if="isDesktop && access.canCreate.value"
icon="i-lucide-plus"
class="h-8 px-3.5"
:disabled="view === 'loading'"
@@ -80,6 +96,12 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
</UButton>
</div>
<ReadOnlyBanner
v-if="access.updatesOwnOnly.value && !failed"
:title="t('Entries by others are view only')"
:description="t('Your role lets you edit entries you created.')"
/>
<ContentToolbar
v-if="!failed"
:sort="list.desired.value.sort"
@@ -126,6 +148,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
:sort="list.shown.value.sort"
:names="list.names.value"
:caption="caption"
@delete="rowDelete.open"
/>
<EntryCardList
v-else
@@ -153,10 +176,20 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
</div>
<DirectoryAddButton
v-if="!isDesktop && !centered && !failed && view !== 'loading'"
v-if="!isDesktop && !centered && !failed && view !== 'loading' && access.canCreate.value"
pinned
@click="createEntry"
>
{{ t('New entry') }}
</DirectoryAddButton>
<EntryDeleteDialog
v-if="rowDelete.target.value"
:title="rowDelete.titleOf(rowDelete.target.value)"
:type-name="name"
:pending="rowDelete.pending.value"
:failed="rowDelete.failed.value"
@confirm="rowDelete.confirm"
@close="rowDelete.close"
/>
</template>
+7 -2
View File
@@ -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)
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import type { RowAccess } from '@/composables/useContentAccess'
const props = defineProps<{
title: string
desktop: boolean
access: RowAccess
}>()
const emit = defineEmits<{ edit: []; delete: [] }>()
const open = defineModel<boolean>('open', { default: false })
const items = computed<DirectoryMenuItem[]>(() => [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
unavailable: !props.access.canEdit,
hint: props.access.canEdit ? undefined : props.access.editReason,
onSelect: () => emit('edit'),
},
{
key: 'delete',
label: t('Delete'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
unavailable: !props.access.canDelete,
hint: props.access.canDelete ? undefined : props.access.deleteReason,
onSelect: () => emit('delete'),
},
])
</script>
<template>
<DirectoryRowMenu v-model:open="open" :items="items" :title="title" :desktop="desktop" />
</template>
+25 -6
View File
@@ -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,8 +191,13 @@ const navItems = computed(() =>
to: '/settings',
icon: 'i-lucide-settings',
slot: 'settings',
show: permissions.can('settings.view') || permissions.can('settings.manage_sites'),
},
].map((item) => {
]
.filter((item) => item.show)
.map((entry) => {
const { show, ...item } = entry
void show
const isSettings = item.to === '/settings'
const parentActive = isSettings
? route.path === item.to
@@ -186,7 +205,7 @@ const navItems = computed(() =>
return {
...item,
active: !props.noCurrentNav && parentActive,
...(isSettings && session.canManageContent
...(isSettings && settingsChildren.value.length > 0
? {
children: settingsChildren.value,
defaultOpen: route.path.startsWith(item.to),
+35 -9
View File
@@ -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<boolean>('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
/>
<template #unavailable="{ item }">
<UTooltip :text="hint" :content="{ side: 'left' }">
<UTooltip :text="item.hint" :content="{ side: 'left' }">
<span class="flex items-center gap-2">
<UIcon :name="item.icon" class="size-4 shrink-0 text-error" />
<span class="truncate text-error">{{ item.label }}</span>
<span :id="hintId" aria-hidden="true" class="sr-only">{{ hint }}</span>
<UIcon
:name="item.icon"
class="size-4 shrink-0"
:class="item.destructive ? 'text-error' : 'text-muted'"
/>
<span class="truncate" :class="item.destructive ? 'text-error' : ''">{{
item.label
}}</span>
<OwnerOnlyTag v-if="item.ownerOnly" />
<span :id="`${hintId}-${item.key}`" aria-hidden="true" class="sr-only">{{
item.hint
}}</span>
</span>
</UTooltip>
</template>
@@ -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 }}
<OwnerOnlyTag v-if="item.ownerOnly" />
</UButton>
</template>
<p v-if="hint" :id="hintId" class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted">
{{ hint }}
<p
v-for="item in hinted"
:id="hintIdOf(item)"
:key="item.key"
class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted"
>
{{ item.hint }}
</p>
</div>
</template>
+5
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
import { usePermissionsStore } from '@/stores/permissions'
import type { MediaItem } from '@/composables/useMedia'
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
@@ -9,6 +11,8 @@ import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
const FOOTER =
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.'
const canMerge = computed(() => usePermissionsStore().scopeOf('media.delete') === 'all')
defineProps<{
groups: DuplicateGroup[]
offline: boolean
@@ -98,6 +102,7 @@ function uploaded(copy: MediaItem) {
</div>
</div>
<UButton
v-if="canMerge"
icon="i-lucide-merge"
:color="copy.id === group.suggested.id ? 'primary' : 'neutral'"
:variant="copy.id === group.suggested.id ? 'solid' : 'outline'"
+14 -2
View File
@@ -8,6 +8,7 @@ import SaveStatus from '@/components/SaveStatus.vue'
import type { Article } from '@/composables/useArticles'
import type { ArticleDraftState } from '@/composables/useArticleDraft'
withDefaults(
defineProps<{
article: Article
state: ArticleDraftState
@@ -16,7 +17,12 @@ defineProps<{
saveEnabled: boolean
publishPending: string | undefined
publishDisabled: boolean
}>()
canPublish?: boolean
canDelete?: boolean
deleteReason?: string
}>(),
{ canPublish: true, canDelete: true, deleteReason: '' },
)
const emit = defineEmits<{
back: []
@@ -52,6 +58,9 @@ const previewOn = defineModel<boolean>('preview', { required: true })
{{ article.title }}
</h1>
<ArticleStatusBadge :online="article.is_online" size="lg" />
<span v-if="!canPublish && !article.is_online" class="text-xs text-muted">{{
t('Ask an editor to publish.')
}}</span>
</div>
<SaveStatus :state="state" :saved-at="savedAt" @retry="emit('retry')" />
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
@@ -74,9 +83,10 @@ const previewOn = defineModel<boolean>('preview', { required: true })
:disabled="!saveEnabled"
@click="emit('save')"
>
{{ t('Save') }}
{{ canPublish ? t('Save') : t('Save draft') }}
</UButton>
<PublishControl
v-if="canPublish"
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="state === 'dirty'"
@@ -90,6 +100,8 @@ const previewOn = defineModel<boolean>('preview', { required: true })
color="error"
variant="ghost"
class="h-8 gap-1.5 px-3"
:disabled="!canDelete"
:title="canDelete ? undefined : deleteReason"
@click="emit('delete')"
>
{{ t('Delete') }}
+7 -2
View File
@@ -4,13 +4,17 @@ import { t } from '@/i18n'
import PublishControl from '@/components/PublishControl.vue'
import type { ArticleDraftState } from '@/composables/useArticleDraft'
withDefaults(
defineProps<{
online: boolean
state: ArticleDraftState
saveEnabled: boolean
publishPending: string | undefined
publishDisabled: boolean
}>()
canPublish?: boolean
}>(),
{ canPublish: true },
)
const emit = defineEmits<{
details: []
@@ -38,9 +42,10 @@ const emit = defineEmits<{
:disabled="!saveEnabled"
@click="emit('save')"
>
{{ t('Save') }}
{{ canPublish ? t('Save') : t('Save draft') }}
</UButton>
<PublishControl
v-if="canPublish"
class="h-11 flex-1 justify-center"
hide-icon
:online="online"
+3 -1
View File
@@ -10,6 +10,7 @@ defineProps<{
creating: boolean
saving: boolean
canSave: boolean
readOnly?: boolean
bar?: boolean
}>()
@@ -24,7 +25,7 @@ defineEmits<{ remove: []; cancel: [] }>()
>
<BuilderStatusChip v-if="!bar" :status="chip" :saved-at="savedClock" />
<UButton
v-if="!bar && !creating"
v-if="!bar && !creating && !readOnly"
type="button"
color="error"
variant="outline"
@@ -47,6 +48,7 @@ defineEmits<{ remove: []; cancel: [] }>()
{{ t('Cancel') }}
</UButton>
<UButton
v-if="!readOnly"
type="submit"
form="entry-form"
:disabled="!canSave"
+4
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import FieldCell from '@/components/FieldCell.vue'
import ViewOnlyMark from '@/components/ViewOnlyMark.vue'
import { useContentAccess } from '@/composables/useContentAccess'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import {
EMPTY_TEXT,
@@ -26,6 +28,7 @@ const props = defineProps<{
caption: string
}>()
const access = useContentAccess(props.typeKey, 'entries')
const shown = computed(() => cardFields(props.fields))
const lead = computed(() => thumbnailColumn(props.columns))
const relationColumns = computed(() =>
@@ -117,6 +120,7 @@ function nameOf(entry: ContentEntry) {
class="shrink-0"
/>
<span class="truncate">· {{ nameOf(entry) ?? EMPTY_TEXT }}</span>
<ViewOnlyMark v-if="!access.rowAccess(entry.created_by).canEdit" />
</span>
</span>
<span class="flex h-11 w-9 shrink-0 items-center justify-center text-muted">
+6 -1
View File
@@ -10,6 +10,7 @@ import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue'
import EntryEditorSkeleton from '@/components/EntryEditorSkeleton.vue'
import EntryForm from '@/components/EntryForm.vue'
import EntryNotices from '@/components/EntryNotices.vue'
import ViewOnlyMark from '@/components/ViewOnlyMark.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { typeEndpoint } from '@/lib/contentEndpoints'
import { entryLoadErrorLine } from '@/lib/entryMessages'
@@ -35,6 +36,7 @@ const actionProps = computed(() => ({
creating: props.page.creating.value,
saving: props.page.saving.value,
canSave: props.page.canSave.value,
readOnly: props.page.readOnly.value,
}))
function goHome() {
@@ -71,7 +73,10 @@ function backToList() {
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
{{ page.title.value }}
</h1>
<p class="text-muted">{{ page.subtitle.value }}</p>
<p class="flex items-center gap-2 text-muted">
{{ page.subtitle.value }}
<ViewOnlyMark v-if="page.readOnly.value" />
</p>
</template>
</div>
<EntryActions
+24 -3
View File
@@ -2,8 +2,11 @@
import { t } from '@/i18n'
import { computed, ref, watchPostEffect } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import ContentRowMenu from '@/components/ContentRowMenu.vue'
import FieldCell from '@/components/FieldCell.vue'
import ViewOnlyMark from '@/components/ViewOnlyMark.vue'
import { useContentAccess } from '@/composables/useContentAccess'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
@@ -19,6 +22,7 @@ const WIDE_WIDTH = 'w-50'
const CREATED_WIDTH = 'w-30.5'
const UPDATED_WIDTH = 'w-40.5'
const UPDATED_BY_WIDTH = 'w-37'
const ACTIONS_WIDTH = 'w-12'
const FIELD_PREFIX = 'field:'
const props = defineProps<{
@@ -30,6 +34,10 @@ const props = defineProps<{
caption: string
}>()
const emit = defineEmits<{ delete: [entry: ContentEntry] }>()
const router = useRouter()
const access = useContentAccess(props.typeKey, 'entries')
const root = ref<HTMLElement | null>(null)
const sorted = computed(() => sortParts(props.sort))
@@ -61,7 +69,8 @@ const columns = computed(() => [
),
column('created_at', t('Created'), CREATED_WIDTH),
column('updated_at', t('Updated'), UPDATED_WIDTH),
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH, 'pr-3 pl-1.5'),
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH),
column('actions', '', ACTIONS_WIDTH, 'pr-3 pl-1.5'),
])
function fieldOf(columnId: string) {
@@ -148,8 +157,8 @@ watchPostEffect(() => {
:key="field.key"
#[`${FIELD_PREFIX}${field.key}-cell`]="{ row }"
>
<div v-if="index === 0" class="flex min-w-0 items-center gap-2">
<RouterLink
v-if="index === 0"
:to="entryPath(typeKey, row.original.id)"
data-testid="entry-link"
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@@ -162,6 +171,8 @@ watchPostEffect(() => {
class="min-w-0"
/>
</RouterLink>
<ViewOnlyMark v-if="!access.rowAccess(row.original.created_by).canEdit" />
</div>
<FieldCell
v-else
:field="field"
@@ -179,6 +190,16 @@ watchPostEffect(() => {
<span class="text-muted">{{ formatEntryDate(row.original.updated_at) }}</span>
</template>
<template #actions-cell="{ row }">
<ContentRowMenu
:title="`#${row.original.id}`"
:desktop="true"
:access="access.rowAccess(row.original.created_by)"
@edit="router.push(entryPath(typeKey, row.original.id))"
@delete="emit('delete', row.original)"
/>
</template>
<template #updated_by-cell="{ row }">
<span v-if="nameOf(row.original)" class="block truncate">{{
nameOf(row.original)
+5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePermissionsStore } from '@/stores/permissions'
const props = withDefaults(
defineProps<{
@@ -13,6 +14,9 @@ const props = withDefaults(
const emit = defineEmits<{ toggle: [] }>()
const permissions = usePermissionsStore()
const selectable = computed(() => permissions.scopeOf('media.delete') === 'all')
const modelValue = computed(() => {
if (props.state === 'partial') {
return 'indeterminate' as const
@@ -26,6 +30,7 @@ const boxClass = computed(() =>
<template>
<UCheckbox
v-if="selectable"
:model-value="modelValue"
:aria-label="label"
:tabindex="tabbable ? undefined : -1"
+9 -11
View File
@@ -18,23 +18,23 @@ import {
purgedToast,
} from '@/lib/mediaPurge'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
const READ_ONLY_COPY = 'Only owners and admins can change this.'
const SKELETON_BAR = 'rounded-none bg-accented'
const OUTLINE_BUTTON =
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
const session = useSessionStore()
const permissions = usePermissionsStore()
const toast = useLocalizedToast()
const router = useRouter()
const online = useOnline()
const isAdmin = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const isAdmin = computed(() => permissions.can('settings.edit_general'))
const readOnly = computed(() => !isAdmin.value)
const offline = computed(() => !online.value)
@@ -256,14 +256,12 @@ function reviewDuplicates() {
</UAlert>
<template v-else-if="showBody">
<UAlert
<ReadOnlyBanner
v-if="readOnly"
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-info"
:description="t(READ_ONLY_COPY)"
:title="t('You can read these settings but not change them')"
:description="
t('Ask the owner to change them, or ask for a role that can edit settings.')
"
/>
<div
+6
View File
@@ -18,6 +18,7 @@ import {
import { usageSplitOf } from '@/lib/mediaUsage'
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 48rem)'
const ACTION_BUTTON =
@@ -42,6 +43,9 @@ const emit = defineEmits<{
const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const canManageVariants = useCanManageVariants()
const permissions = usePermissionsStore()
const canRename = computed(() => permissions.can('media.upload'))
const canDelete = computed(() => permissions.scopeOf('media.delete') === 'all')
const panelTab = ref<'details' | 'variants'>('details')
const variantsVisited = ref(false)
@@ -276,6 +280,7 @@ const duplicateAlert = computed(() => {
{{ t('Copy URL') }}
</UButton>
<UButton
v-if="canRename"
color="neutral"
variant="outline"
icon="i-lucide-pencil"
@@ -286,6 +291,7 @@ const duplicateAlert = computed(() => {
{{ t('Rename') }}
</UButton>
<UButton
v-if="canDelete"
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
+3 -2
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
filterLabel?: string
offline?: boolean
limited?: boolean
readOnly?: boolean
}>()
const emit = defineEmits<{ action: [] }>()
@@ -89,7 +90,7 @@ const current = computed(() => {
>
<template #actions>
<UButton
v-if="variant === 'first-run'"
v-if="variant === 'first-run' && !readOnly"
icon="i-lucide-upload"
:disabled="offline || limited"
class="h-11 px-4 md:h-8"
@@ -98,7 +99,7 @@ const current = computed(() => {
{{ current.action }}
</UButton>
<UButton
v-else
v-else-if="variant !== 'first-run'"
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
+3 -1
View File
@@ -14,6 +14,7 @@ import type { Site } from '@/composables/useSites'
import { metaSummary } from '@/lib/articleMeta'
import { readHintDismissed, storeHintDismissed } from '@/lib/metaSectionMemory'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const props = withDefaults(
defineProps<{
@@ -33,6 +34,7 @@ const emit = defineEmits<{
}>()
const session = useSessionStore()
const permissions = usePermissionsStore()
const profile = useMyAuthorProfile()
const bodyId = useId()
@@ -48,7 +50,7 @@ const siteName = computed(() => props.sites.find((site) => site.id === props.sit
const summary = computed(() =>
metaSummary(props.authors, severalSites.value ? siteName.value : null),
)
const canCreate = computed(() => session.canManageContent)
const canCreate = computed(() => permissions.can('authors.create'))
const showHint = computed(
() =>
session.session !== null &&
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { usePermissionsStore } from '@/stores/permissions'
const router = useRouter()
const permissions = usePermissionsStore()
const canReadArticles = computed(() => permissions.can('content.read', { typeKey: 'article' }))
const actionLabel = computed(() => (canReadArticles.value ? t('Go to Articles') : t('Go to Home')))
function goToSafePage() {
router.push(canReadArticles.value ? '/article' : '/home')
}
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access to this page')"
:description="
t('Your role, {0}, doesn\'t include it. Ask an owner or admin if you need it.', [
permissions.roleName,
])
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 sm:h-8"
@click="goToSafePage"
>
{{ actionLabel }}
</UButton>
</template>
</UEmpty>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<UBadge
color="neutral"
variant="subtle"
icon="i-lucide-lock"
:label="t('Owner only')"
data-testid="owner-only-tag"
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 text-xs font-medium', leadingIcon: 'size-3' }"
/>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
description?: string
}>(),
{ description: undefined },
)
</script>
<template>
<UAlert
role="status"
color="info"
variant="soft"
icon="i-lucide-lock"
:title="title"
:description="description"
:close="false"
data-testid="read-only-banner"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
/>
</template>
+3 -3
View File
@@ -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<ActivityItem[]>([])
@@ -17,7 +17,7 @@ const error = ref(false)
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | 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) {
+14 -3
View File
@@ -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)
</h2>
<p class="text-pretty text-muted">
{{
t(
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.',
)
}}
</p>
</div>
<OwnerOnlyTag v-if="!canEdit" />
<span
aria-live="polite"
data-testid="retention-save-status"
@@ -352,7 +363,7 @@ onBeforeUnmount(clearSavedTimer)
:items="control.items"
value-key="value"
:search-input="false"
:disabled="offline"
:disabled="offline || !canEdit"
:aria-labelledby="control.labelId"
:data-testid="`retention-select-${control.key}`"
color="neutral"
+18 -3
View File
@@ -1,13 +1,28 @@
<script setup lang="ts">
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, () => 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 }}
</UBadge>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import { isRouteAllowed, type RouteAccess } from '@/lib/routeAccess'
import AppBoot from '@/components/AppBoot.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import NoAccessState from '@/components/NoAccessState.vue'
const route = useRoute()
const session = useSessionStore()
const permissions = usePermissionsStore()
type GateState = 'open' | 'waiting' | 'denied'
const state = computed<GateState>(() => {
const access = route.meta.access as RouteAccess | undefined
if (access === undefined) {
return 'open'
}
if (session.status === 'idle' || session.status === 'checking') {
return 'waiting'
}
if (!session.session?.verified) {
return 'open'
}
return isRouteAllowed(access, route.params, permissions.can) ? 'open' : 'denied'
})
</script>
<template>
<AppBoot v-if="state === 'waiting'" />
<DashboardShell v-else-if="state === 'denied'">
<NoAccessState />
</DashboardShell>
<slot v-else />
</template>
+2 -9
View File
@@ -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() {
</script>
<template>
<SchemaForbidden v-if="forbidden" @back="goHome" />
<NoAccessState v-if="forbidden" />
<template v-else>
<div
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<div class="flex min-h-0 flex-1 items-center justify-center px-4 py-6">
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access to Schema')"
:description="
t(
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
)
"
:ui="{
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-title font-semibold text-wrap',
description: 'mt-1 w-full max-w-none px-0 text-base/5 text-wrap text-muted',
}"
>
<template #actions>
<UButton class="h-8 px-4 max-md:h-11" @click="emit('back')">
{{ t('Go to Home') }}
</UButton>
</template>
</UEmpty>
</div>
</template>
-33
View File
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access to Sites')"
:description="
t('Only the owner can manage sites. Ask the owner of this site if you need a change.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 sm:h-8"
@click="emit('back')"
>
{{ t('Back to Settings') }}
</UButton>
</template>
</UEmpty>
</template>
+37 -17
View File
@@ -7,7 +7,7 @@ import ServerCommand from '@/components/ServerCommand.vue'
import UpdateProgress from '@/components/UpdateProgress.vue'
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue'
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
import {
CONTAINER_UPDATE_COMMAND,
@@ -77,7 +77,7 @@ const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
const props = defineProps<{
status: UpdateStatus
role: UserRole | undefined
canApply: boolean
phase: UpdateCheckPhase
applyPhase: UpdateApplyPhase
retryLabel: string
@@ -103,13 +103,13 @@ function focusRestart() {
defineExpose({ focusHeading, focusRestart })
const isOwner = computed(() => props.role === 'owner')
const canApplyUpdates = computed(() => props.canApply)
const newVersion = computed(() => displayVersion(props.status.latest))
const lastChecked = computed(() =>
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
)
const canApplyView = computed(() => isOwner.value && props.status.can_apply)
const canApplyView = computed(() => canApplyUpdates.value && props.status.can_apply)
const applyView = computed<ApplyView | null>(() => {
if (!canApplyView.value || props.phase !== 'idle') {
@@ -339,7 +339,7 @@ function applyButton(view: ApplyView): ActionButton {
}
const block = computed<Block>(() => {
if (isOwner.value && props.phase === 'checking') {
if (canApplyUpdates.value && props.phase === 'checking') {
return {
tone: 'neutral',
icon: 'i-lucide-loader-circle',
@@ -351,10 +351,10 @@ const block = computed<Block>(() => {
},
}
}
if (isOwner.value && props.phase === 'limited') {
if (canApplyUpdates.value && props.phase === 'limited') {
return LIMITED_BLOCK
}
if (isOwner.value && props.phase === 'failed') {
if (canApplyUpdates.value && props.phase === 'failed') {
return {
tone: 'warning',
icon: 'i-lucide-wifi-off',
@@ -400,7 +400,7 @@ const block = computed<Block>(() => {
},
}
}
if (props.status.available && isOwner.value) {
if (props.status.available && canApplyUpdates.value) {
return {
tone: 'info',
icon: 'i-lucide-info',
@@ -423,10 +423,7 @@ const block = computed<Block>(() => {
return t('Version {0} is available', [newVersion.value])
},
get text() {
return t(
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.',
[props.status.current],
)
return t('Only the owner can apply updates.')
},
meta: lastChecked.value,
}
@@ -438,11 +435,11 @@ const block = computed<Block>(() => {
return t("You're up to date")
},
get text() {
return isOwner.value
return canApplyUpdates.value
? t('You have the latest version of RenewCMS.')
: t('Only the owner can update or restart the site.')
},
meta: isOwner.value ? lastChecked.value : undefined,
meta: canApplyUpdates.value ? lastChecked.value : undefined,
}
})
@@ -456,12 +453,15 @@ const restartAvailable = computed(
const showContainerGuidance = computed(
() =>
isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
canApplyUpdates.value &&
props.status.container &&
props.status.available &&
props.phase === 'idle',
)
const showServerGuidance = computed(
() =>
isOwner.value &&
canApplyUpdates.value &&
!props.status.container &&
!props.status.can_apply &&
props.status.available &&
@@ -480,7 +480,7 @@ const plainRestartButton: ActionButton = {
}
const actionButtons = computed<ActionButton[]>(() => {
if (!isOwner.value || isRestarting.value) {
if (!canApplyUpdates.value || isRestarting.value) {
return []
}
if (props.phase === 'checking') {
@@ -533,6 +533,8 @@ const inBlockButton = computed(() =>
const rowButtons = computed(() => (inBlockButton.value ? [] : actionButtons.value))
const showOwnerOnlyApply = computed(() => !props.canApply && props.status.available)
const showRestartHint = computed(() => actionButtons.value.includes(plainRestartButton))
function withPlainRestart(button: ActionButton) {
@@ -681,6 +683,24 @@ const notes = computed(() => {
</UButton>
</div>
<div
v-if="showOwnerOnlyApply"
data-testid="owner-only-apply"
class="flex flex-col gap-2 md:flex-row md:items-center"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
disabled
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
:ui="{ leadingIcon: 'size-4' }"
>
{{ t('Apply update') }}
</UButton>
<OwnerOnlyTag />
</div>
<span v-if="hint" class="text-xs/4.5 text-pretty text-muted">{{ hint }}</span>
<a
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<UTooltip :text="t('You can only edit content you created.')">
<span
tabindex="0"
data-testid="view-only-mark"
:aria-label="t('View only')"
class="inline-flex shrink-0 items-center gap-1 text-xs text-muted"
>
<UIcon name="i-lucide-lock" class="size-3" />
{{ t('View only') }}
</span>
</UTooltip>
</template>
@@ -6,6 +6,7 @@ import router from '@/router'
import { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
import { MOBILE_WIDTH, setViewportWidth } from './viewport'
import { ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -24,6 +25,7 @@ async function mountMobileShell() {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
verified: true,
access: ownerAccess(),
username: 'marta',
email: 'marta@renew.example',
displayName: '',
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import { ownerAccess } from '@/test/access'
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({
@@ -33,7 +34,7 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticleEditor(body = '<p>Hello</p>') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(() =>
Promise.resolve(
@@ -13,6 +13,7 @@ import RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import { messages } from '@/i18n/messages'
import { setLocale } from '@/i18n'
import { ownerAccess } from '@/test/access'
class TestPointerEvent extends MouseEvent {
pointerType: string
@@ -91,7 +92,7 @@ async function mountEditor(body = SENTENCE, media: Media = DESKTOP) {
mockMedia(media)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
fetchMock = vi.fn((_url: string, init?: RequestInit) => {
const sent = init?.method === 'PATCH' ? JSON.parse(String(init.body)).body : body
@@ -54,7 +54,7 @@ describe('The Schema entry', () => {
it.each([
['owner', true],
['admin', true],
['unknown', false],
['noschema', false],
])('role %s: shown %s, after Users, linking to /schema', async (role, shown) => {
await mountAt('/home', { role }, true)
@@ -88,7 +88,7 @@ describe('The Schema entry', () => {
describe('The Content group', () => {
it('lists one link per type in API order and marks the current route', async () => {
await mountAt('/content/recipes', { role: 'unknown' }, true)
await mountAt('/content/recipes', { role: 'noschema' }, true)
expect(contentLinks().map((link) => link.getAttribute('href'))).toEqual([
'/content/events',
@@ -9,6 +9,8 @@ import DashboardShell from '../DashboardShell.vue'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
import type { Access } from '@/lib/permissions'
import { accessFor, customAccess, writerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -23,6 +25,7 @@ interface ShellOptions {
displayName?: string
email?: string
role?: string
access?: Access
width?: number
}
@@ -33,7 +36,8 @@ async function mountShell(path: string, options: ShellOptions = {}) {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
role = undefined,
role = 'noschema',
access,
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
@@ -45,7 +49,7 @@ async function mountShell(path: string, options: ShellOptions = {}) {
username,
email,
displayName,
role,
access: access ?? accessFor(role),
}),
request: vi.fn().mockResolvedValue(undefined),
})
@@ -106,6 +110,10 @@ describe('DashboardShell renders the eight navigation destinations', () => {
const links = Array.from(
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
).filter(
(link) =>
link.getAttribute('href') === '/settings' ||
!link.getAttribute('href')?.startsWith('/settings/'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
@@ -784,3 +792,87 @@ describe('DashboardShell mobile drawer rows', () => {
expectUnfilled(navLink(drawer, '/settings/media/cache'))
})
})
function navHrefs(wrapper: VueWrapper) {
return wrapper.findAll('nav[aria-label="Main"] a').map((link) => link.attributes('href'))
}
describe('DashboardShell lists items per permission', () => {
it('shows Home, Articles, Media, Authors, Activity and Integration for a role with those grants only', async () => {
const wrapper = await mountShell('/home', {
access: customAccess(
'content.read, media.view, authors.view, activity.view=own',
'Contributor',
),
})
expect(navHrefs(wrapper)).toEqual([
'/home',
'/article',
'/media',
'/authors',
'/activity',
'/integration',
])
expect(wrapper.find('nav[aria-label="Main"]').text()).not.toContain('Roles')
})
it('shows the same items in the mobile drawer', async () => {
await mountShell('/home', {
width: MOBILE_WIDTH,
access: customAccess('content.read, media.view, authors.view, activity.view=own'),
})
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const links = Array.from(
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/authors',
'/activity',
'/integration',
])
})
it('shows the Writer built-in menu with Schema and no Users or Settings', async () => {
const wrapper = await mountShell('/home', { access: writerAccess() })
expect(navHrefs(wrapper)).toEqual([
'/home',
'/article',
'/media',
'/authors',
'/schema',
'/activity',
'/integration',
])
})
it('shows the Sites child under Settings with settings.manage_sites', async () => {
const wrapper = await mountShell('/settings', {
access: customAccess('settings.view, settings.manage_sites'),
})
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
})
it('hides the Sites child for the built-in Admin without settings.manage_sites', async () => {
const wrapper = await mountShell('/settings', {
access: customAccess('settings.view'),
})
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(false)
})
it('lists Sites for the built-in Admin', async () => {
const wrapper = await mountShell('/settings', { role: 'admin' })
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
})
})
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UIcon from '@nuxt/ui/components/Icon.vue'
import router from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import { customAccess } from '@/test/access'
import NoAccessState from '../NoAccessState.vue'
let wrapper: VueWrapper | null = null
async function mountState(grants: string, roleName: string) {
setActivePinia(createPinia())
useSessionStore().session = {
id: 4,
verified: true,
displayName: '',
access: customAccess(grants, roleName),
}
useSessionStore().status = 'ready'
await router.push('/schema')
wrapper = mount(NoAccessState, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
setLocale('en')
document.body.innerHTML = ''
})
describe('NoAccessState', () => {
it('names the role and offers one action to Articles when articles can be read', async () => {
const state = await mountState('content.read=all', 'Contributor')
expect(state.text()).toContain("You don't have access to this page")
expect(state.text()).toContain(
"Your role, Contributor, doesn't include it. Ask an owner or admin if you need it.",
)
const buttons = state.findAll('button')
expect(buttons.map((button) => button.text())).toEqual(['Go to Articles'])
expect(state.text()).not.toContain('See what I can do')
})
it('sends a role that cannot read articles to Home', async () => {
const state = await mountState('media.view', 'Media')
expect(state.findAll('button').map((button) => button.text())).toEqual(['Go to Home'])
await state.find('button').trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('goes to /article from the Articles action', async () => {
const state = await mountState('content.read=all', 'Contributor')
await state.find('button').trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
})
it('speaks French', async () => {
setLocale('fr')
const state = await mountState('content.read=all', 'Contributor')
expect(state.text()).toContain('Vous n’avez pas accès à cette page')
expect(state.text()).toContain('Aller aux articles')
})
it('renders the lock icon', async () => {
const state = await mountState('content.read=all', 'Contributor')
const icons = state.findAllComponents(UIcon).map((icon) => icon.props('name'))
expect(icons).toContain('i-lucide-lock')
})
})
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import UIcon from '@nuxt/ui/components/Icon.vue'
import { setLocale } from '@/i18n'
import OwnerOnlyTag from '../OwnerOnlyTag.vue'
import ReadOnlyBanner from '../ReadOnlyBanner.vue'
import ViewOnlyMark from '../ViewOnlyMark.vue'
let wrapper: VueWrapper | null = null
function render(component: object, props: Record<string, unknown> = {}) {
wrapper = mount(() => h(UApp, null, () => h(component, props)), {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return wrapper
}
function iconsOf(target: VueWrapper) {
return target.findAllComponents(UIcon).map((icon) => icon.props('name'))
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
setLocale('en')
document.body.innerHTML = ''
})
describe('OwnerOnlyTag', () => {
it('reads Owner only with a lock icon', () => {
const tag = render(OwnerOnlyTag)
expect(tag.text()).toBe('Owner only')
expect(iconsOf(tag)).toContain('i-lucide-lock')
})
it('reads in French', () => {
setLocale('fr')
expect(render(OwnerOnlyTag).text()).toBe('Propriétaire uniquement')
})
})
describe('ReadOnlyBanner', () => {
it('shows the title and the description with a lock icon', () => {
const banner = render(ReadOnlyBanner, {
title: 'You can read these settings but not change them',
description: 'Ask the owner to change them, or ask for a role that can edit settings.',
})
expect(banner.text()).toContain('You can read these settings but not change them')
expect(banner.text()).toContain('Ask the owner to change them')
expect(iconsOf(banner)).toContain('i-lucide-lock')
})
})
describe('ViewOnlyMark', () => {
it('shows View only with a lock icon and an accessible name', () => {
const mark = render(ViewOnlyMark)
expect(mark.text()).toBe('View only')
expect(mark.find('[data-testid="view-only-mark"]').attributes('aria-label')).toBe(
'View only',
)
expect(iconsOf(mark)).toContain('i-lucide-lock')
})
it('is focusable so the tooltip opens on focus', () => {
expect(
render(ViewOnlyMark).find('[data-testid="view-only-mark"]').attributes('tabindex'),
).toBe('0')
})
it('reads Lecture seule in French', () => {
setLocale('fr')
expect(render(ViewOnlyMark).text()).toBe('Lecture seule')
})
})
@@ -7,6 +7,7 @@ import router from '@/router'
import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
import { ownerAccess } from '@/test/access'
interface MenuProps {
username: string
@@ -26,7 +27,7 @@ const baseProps: MenuProps = {
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
await router.push(path)
return mount(UserMenu, {
@@ -14,6 +14,7 @@ import { initializeLocale, locale, setLocale, t } from '@/i18n'
import { messages } from '@/i18n/messages'
import router from '@/router'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
@@ -39,7 +40,7 @@ const SIGNED_IN = {
username: 'marta',
displayName: '',
email: 'marta@renew.example',
role: 'owner',
access: accessFor('owner'),
}
let wrapper: VueWrapper | undefined
+4 -1
View File
@@ -8,6 +8,7 @@ import { useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
import { ownerAccess } from '@/test/access'
export { textOf }
@@ -50,7 +51,9 @@ export function installApi(routes: PickerRoutes = {}) {
return (routes.list ?? (() => pageBody()))(url.searchParams)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
getMe: vi
.fn()
.mockResolvedValue({ verified: true, access: ownerAccess(), username: 'marta', id: 1 }),
request: apiKit.request,
})
}
@@ -1,34 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ home: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access')"
:description="
t('Only admins can manage image variants. Ask an admin if you need one changed.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-18',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-arrow-left"
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="emit('home')"
>
{{ t('Back to Home') }}
</UButton>
</template>
</UEmpty>
</template>
+5 -1
View File
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, MissingTotalError, NetworkError, isOffline, useApi } from '@/composables/useApi'
import { ownerAccess } from '@/test/access'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
@@ -58,7 +59,7 @@ describe('useApi global 401/403/429/offline handling', () => {
it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => {
const session = useSessionStore()
session.session = { verified: true }
session.session = { verified: true, access: ownerAccess() }
session.status = 'ready'
await router.push('/article/12/edit')
fetchMock.mockResolvedValue(mockResponse(401))
@@ -207,6 +208,7 @@ describe('getMe maps the account fields', () => {
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
access: ownerAccess(),
display_name: 'Marta Alves',
}),
)
@@ -214,6 +216,7 @@ describe('getMe maps the account fields', () => {
await expect(useApi().getMe()).resolves.toEqual({
id: 1,
verified: true,
access: ownerAccess(),
username: 'marta',
email: 'marta@renew.example',
displayName: 'Marta Alves',
@@ -230,6 +233,7 @@ describe('getMe maps the account fields', () => {
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
access: ownerAccess(),
display_name: value,
}),
)
@@ -0,0 +1,323 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import router from '@/router'
import { useSessionStore, refreshSessionWhenVisible } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import { ApiError, useApi } from '@/composables/useApi'
import { ownerAccess, writerAccess, customAccess } from '@/test/access'
const toastAdd = vi.fn()
vi.mock('@/i18n/presentation', () => ({
useLocalizedToast: () => ({
add: (content: unknown) => toastAdd(typeof content === 'function' ? content() : content),
}),
}))
function reply(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
text?: string,
) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
text: async () => text ?? '',
} as unknown as Response
}
const fetchMock = vi.fn()
function answer(refusal: Response) {
fetchMock.mockImplementation(async (url: string) =>
url === '/v1/auth/me'
? reply(200, { id: 4, is_verified: true, access: ownerAccess() })
: refusal,
)
}
function signIn(access = ownerAccess()) {
const session = useSessionStore()
session.session = { id: 4, verified: true, displayName: '', access }
session.status = 'ready'
return session
}
beforeEach(() => {
setActivePinia(createPinia())
fetchMock.mockReset()
toastAdd.mockReset()
global.fetch = fetchMock as unknown as typeof fetch
})
afterEach(async () => {
vi.useRealTimers()
await router.push('/login')
})
describe('getMe maps the access object', () => {
it('keeps the role and the permission entries in order and drops the role string', async () => {
const writer = writerAccess()
fetchMock.mockResolvedValue(
reply(200, {
id: 4,
is_verified: true,
username: 'wren',
email: 'wren@example.com',
display_name: 'Wren',
access: writer,
}),
)
const me = await useApi().getMe()
expect(me.access?.role).toEqual({ id: 3, name: 'Writer', built_in: true, locked: false })
expect(me.access?.permissions).toHaveLength(9)
expect(me.access?.permissions).toEqual(writer.permissions)
expect('role' in me).toBe(false)
})
})
describe('403 with a code', () => {
it('rejects with the code and the missing permission and shows the toast naming it', async () => {
signIn()
answer(reply(403, null, { 'X-Missing-Permission': 'content.delete' }, 'missing_permission'))
const call = useApi().request('/v1/article/1', { method: 'DELETE' })
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject({
status: 403,
code: 'missing_permission',
permission: 'content.delete',
})
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(toastAdd.mock.calls[0]![0]).toMatchObject({
color: 'error',
title: "You don't have permission to do that",
description: "Your role doesn't include Delete entries. Ask an owner or admin.",
})
})
it('names Invite users for users.invite', async () => {
signIn()
answer(reply(403, null, { 'X-Missing-Permission': 'users.invite' }, 'missing_permission'))
await expect(
useApi().request('/v1/user/invite', { method: 'POST' }),
).rejects.toBeInstanceOf(ApiError)
expect(toastAdd.mock.calls[0]![0].description).toContain('Invite users')
})
it('uses the generic sentence without the header', async () => {
signIn()
answer(reply(403, null, {}, 'missing_permission'))
await expect(useApi().request('/v1/x')).rejects.toMatchObject({
code: 'missing_permission',
permission: undefined,
})
expect(toastAdd.mock.calls[0]![0].description).toBe(
"Your role doesn't include this action. Ask an owner or admin.",
)
})
it('keeps the escalation key and shows no global toast', async () => {
signIn()
fetchMock.mockResolvedValue(
reply(403, null, { 'X-Escalation-Key': 'content.publish:events' }, 'escalation'),
)
await expect(useApi().request('/v1/role/3', { method: 'PUT' })).rejects.toMatchObject({
code: 'escalation',
escalationKey: 'content.publish:events',
})
expect(toastAdd).not.toHaveBeenCalled()
})
it.each([
['role_locked', 'role_locked'],
['own_role', 'own_role'],
['role_built_in', 'role_built_in'],
['built_in_name', 'built_in_name'],
['This action needs a real session.', 'session_required'],
])('keeps %s as the code without a global toast', async (body, code) => {
signIn()
fetchMock.mockResolvedValue(reply(403, null, {}, body))
await expect(useApi().request('/v1/role/1', { method: 'PUT' })).rejects.toMatchObject({
code,
})
expect(toastAdd).not.toHaveBeenCalled()
})
it.each(['role_conflict', 'user_role_changed'])(
'maps the 409 %s without a toast',
async (body) => {
signIn()
fetchMock.mockResolvedValue(reply(409, null, {}, body))
await expect(useApi().request('/v1/role/3', { method: 'PUT' })).rejects.toMatchObject({
status: 409,
code: body,
})
expect(toastAdd).not.toHaveBeenCalled()
},
)
it('lets a caller opt out of the toast and still refreshes the session', async () => {
signIn()
fetchMock
.mockResolvedValueOnce(
reply(
403,
null,
{ 'X-Missing-Permission': 'content.delete' },
'missing_permission',
),
)
.mockResolvedValueOnce(
reply(200, { id: 4, is_verified: true, access: customAccess('content.read=all') }),
)
await expect(
useApi().request('/v1/article/1', {
method: 'DELETE',
onMissingPermission: 'passthrough',
}),
).rejects.toMatchObject({ code: 'missing_permission', permission: 'content.delete' })
await flushPromises()
expect(toastAdd).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1]![0]).toBe('/v1/auth/me')
})
it('still sends an unverified session to the pending page', async () => {
const session = useSessionStore()
session.session = { verified: false, displayName: '' }
session.status = 'ready'
fetchMock.mockResolvedValue(reply(403))
await expect(useApi().request('/v1/article/')).rejects.toMatchObject({ status: 403 })
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
})
describe('live refresh', () => {
it('refreshes the session once after a refusal and drops the lost grant', async () => {
const session = signIn(customAccess('content.delete=all, content.read=all'))
fetchMock
.mockResolvedValueOnce(
reply(
403,
null,
{ 'X-Missing-Permission': 'content.delete' },
'missing_permission',
),
)
.mockResolvedValueOnce(
reply(200, { id: 4, is_verified: true, access: customAccess('content.read=all') }),
)
expect(usePermissionsStore().can('content.delete')).toBe(true)
await expect(
useApi().request('/v1/article/1', { method: 'DELETE' }),
).rejects.toBeInstanceOf(ApiError)
await flushPromises()
expect(fetchMock.mock.calls.filter(([url]) => url === '/v1/auth/me')).toHaveLength(1)
expect(usePermissionsStore().can('content.delete')).toBe(false)
expect(session.session?.verified).toBe(true)
})
it('refreshes at most once every 5 seconds', async () => {
vi.useFakeTimers()
signIn()
fetchMock.mockImplementation(async (url: string) =>
url === '/v1/auth/me'
? reply(200, { id: 4, is_verified: true, access: ownerAccess() })
: reply(
403,
null,
{ 'X-Missing-Permission': 'content.delete' },
'missing_permission',
),
)
const refuse = () =>
useApi()
.request('/v1/article/1', { method: 'DELETE' })
.catch(() => undefined)
const refreshes = () => fetchMock.mock.calls.filter(([url]) => url === '/v1/auth/me').length
await refuse()
await refuse()
await vi.advanceTimersByTimeAsync(0)
expect(refreshes()).toBe(1)
await vi.advanceTimersByTimeAsync(5001)
await refuse()
await vi.advanceTimersByTimeAsync(0)
expect(refreshes()).toBe(2)
})
it('keeps the previous grants and shows no error when the refresh fails', async () => {
signIn(customAccess('content.read=all'))
fetchMock
.mockResolvedValueOnce(reply(403, null, {}, 'missing_permission'))
.mockRejectedValueOnce(new TypeError('offline'))
await expect(useApi().request('/v1/x')).rejects.toBeInstanceOf(ApiError)
await flushPromises()
expect(usePermissionsStore().can('content.read')).toBe(true)
expect(useSessionStore().status).toBe('ready')
})
})
describe('refresh when the tab comes back', () => {
function showTab() {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
document.dispatchEvent(new Event('visibilitychange'))
}
beforeEach(() => {
vi.useFakeTimers()
refreshSessionWhenVisible()
fetchMock.mockResolvedValue(reply(200, { id: 4, is_verified: true, access: ownerAccess() }))
})
it('refreshes when the last answer is 31 seconds old', async () => {
const session = signIn()
await session.refresh()
fetchMock.mockClear()
await vi.advanceTimersByTimeAsync(31000)
showTab()
await vi.advanceTimersByTimeAsync(0)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('does not refresh when the last answer is 10 seconds old', async () => {
const session = signIn()
await session.refresh()
fetchMock.mockClear()
await vi.advanceTimersByTimeAsync(10000)
showTab()
await vi.advanceTimersByTimeAsync(0)
expect(fetchMock).not.toHaveBeenCalled()
})
})
@@ -4,6 +4,7 @@ import { ApiError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
import { useSchemaGate, type SchemaGateScope } from '@/composables/useSchemaGate'
import { useContentTypesStore } from '@/stores/contentTypes'
import { noAccess, ownerAccess } from '@/test/access'
import { useSessionStore } from '@/stores/session'
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: vi.fn() }))
@@ -17,7 +18,10 @@ beforeEach(() => {
listBuiltinTypes.mockReset()
vi.mocked(useContentTypes).mockReturnValue({ listContentTypes, listBuiltinTypes })
const session = useSessionStore()
session.$patch({ status: 'ready', session: { role: 'owner' } })
session.$patch({
status: 'ready',
session: { verified: true, displayName: '', access: ownerAccess() },
})
})
async function gate(scope: SchemaGateScope) {
@@ -52,7 +56,9 @@ describe('schema gate forbidden', () => {
it('is forbidden for a session that cannot manage content, whatever the scope', async () => {
listContentTypes.mockResolvedValue([])
listBuiltinTypes.mockResolvedValue([])
useSessionStore().$patch({ session: { role: 'user' } })
useSessionStore().$patch({
session: { verified: true, displayName: '', access: noAccess() },
})
expect((await gate('custom')).forbidden.value).toBe(true)
})
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import { roleRefFor } from '@/test/access'
import { userRole, type UserListItem } from '@/composables/useUsers'
describe('userRole', () => {
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (role) => {
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (name) => {
const role = roleRefFor(name)
const item: UserListItem = {
kind: 'user',
id: 1,
@@ -14,21 +16,22 @@ describe('userRole', () => {
created_at: '2026-03-12T00:00:00Z',
}
expect(userRole(item)).toBe(role)
expect(userRole(item)).toEqual(role)
})
it.each([['pending' as const], ['expired' as const]])(
'returns admin for a %s invite',
'reads the role of a %s invite and falls back to null without one',
(status) => {
const item: UserListItem = {
kind: 'invite',
const base = {
kind: 'invite' as const,
id: 2,
email: 'i@renew.example',
status,
created_at: '2026-03-12T00:00:00Z',
}
expect(userRole(item)).toBe('admin')
expect(userRole({ ...base, role: roleRefFor('member') })).toEqual(roleRefFor('member'))
expect(userRole(base)).toBeNull()
},
)
})
+5 -2
View File
@@ -3,6 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ApiError } from '@/composables/useApi'
import { useActivity, type PersonOptions } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import { ACTIVITY_PAGE_SIZE_KEY, resolvePageSize, storePageSize } from '@/lib/pageSizeMemory'
import {
activeChips,
@@ -48,6 +49,7 @@ function delayedFlag() {
export function useActivityPage() {
const session = useSessionStore()
const permissions = usePermissionsStore()
const route = useRoute()
const router = useRouter()
const { listActivity, getSummary, listPeople } = useActivity()
@@ -77,8 +79,8 @@ export function useActivityPage() {
let peopleRequested = false
let requestedPage = 1
const role = computed(() => session.session?.role as string | undefined)
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
const isFullView = computed(() => permissions.scopeOf('activity.view') === 'all')
const canExport = computed(() => permissions.can('activity.export'))
const chips = computed(() => activeChips(filters.value))
const showListSkeleton = computed(
() => listState.value === 'loading' && listSkeleton.flag.value,
@@ -280,6 +282,7 @@ export function useActivityPage() {
people,
chips,
isFullView,
canExport,
isEmpty,
exportDisabled,
showListSkeleton,
+58 -10
View File
@@ -1,7 +1,10 @@
import { ref } from 'vue'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError } from '@/lib/apiError'
import { ApiError, errorCodeOf } from '@/lib/apiError'
import { missingPermissionCopy } from '@/lib/permissionCopy'
import { useLocalizedToast } from '@/i18n/presentation'
import type { Access, Grant, RoleRef } from '@/lib/permissions'
export { ApiError }
@@ -22,8 +25,6 @@ export interface PagedResponse<T> {
total: number
}
export type UserRole = 'owner' | 'admin'
export interface MeAuthor {
id: number
name: string
@@ -36,16 +37,18 @@ export interface MeResult {
username?: string
email?: string
displayName: string
role?: UserRole
access?: Access
author?: MeAuthor
}
export type OnUnauthorized = 'redirect' | 'passthrough'
export type OnForbidden = 'redirect' | 'passthrough'
export type OnMissingPermission = 'toast' | 'passthrough'
export interface RequestOptions extends RequestInit {
onUnauthorized?: OnUnauthorized
onForbidden?: OnForbidden
onMissingPermission?: OnMissingPermission
}
export function isAbort(error: unknown) {
@@ -115,9 +118,52 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
}
}
const MISSING_PERMISSION_TOAST_ID = 'missing-permission'
function showMissingPermissionToast(permission: string | undefined) {
useLocalizedToast().add(() => {
const copy = missingPermissionCopy(permission)
return {
id: MISSING_PERMISSION_TOAST_ID,
color: 'error',
icon: 'i-lucide-circle-x',
title: copy.title,
description: copy.description,
}
})
}
function headerOrUndefined(response: Response, name: string) {
return response.headers.get(name) ?? undefined
}
async function failForbidden(
response: Response,
onForbidden: OnForbidden,
onMissingPermission: OnMissingPermission,
): Promise<never> {
redirectUnverified(onForbidden)
const body = await readErrorBody(response)
const code = errorCodeOf(body)
const permission = headerOrUndefined(response, 'X-Missing-Permission')
const escalationKey = headerOrUndefined(response, 'X-Escalation-Key')
if (code === 'missing_permission') {
void useSessionStore().refreshAfterRefusal()
if (onMissingPermission !== 'passthrough') {
showMissingPermissionToast(permission)
}
}
throw new ApiError(403, undefined, body, { code, permission, escalationKey })
}
export function useApi() {
async function send(path: string, options: RequestOptions = {}): Promise<Response> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
const {
onUnauthorized = 'redirect',
onForbidden = 'redirect',
onMissingPermission = 'toast',
...init
} = options
let response: Response
try {
@@ -139,15 +185,15 @@ export function useApi() {
}
if (response.status === 403) {
redirectUnverified(onForbidden)
throw new ApiError(403, undefined, await readErrorBody(response))
return failForbidden(response, onForbidden, onMissingPermission)
}
if (response.status === 429) {
throw new ApiError(429, parseRetryAfterSeconds(response))
}
throw new ApiError(response.status, undefined, await readErrorBody(response))
const body = await readErrorBody(response)
throw new ApiError(response.status, undefined, body, { code: errorCodeOf(body) })
}
return response
@@ -187,7 +233,7 @@ export function useApi() {
username?: string
email?: string
display_name?: string | null
role?: string
access?: { role: RoleRef; permissions: Grant[] }
author?: MeAuthor | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
@@ -196,7 +242,9 @@ export function useApi() {
username: body.username,
email: body.email,
displayName: body.display_name ?? '',
role: body.role as UserRole | undefined,
...(body.access
? { access: { role: body.access.role, permissions: body.access.permissions } }
: {}),
...(body.author ? { author: body.author } : {}),
}
}
+80
View File
@@ -0,0 +1,80 @@
import { t } from '@/i18n'
import { useContentAccess } from '@/composables/useContentAccess'
import type { Article } from '@/composables/useArticles'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
interface ArticleMenuHandlers {
isBusy: (article: Article) => boolean
edit: (article: Article) => void
publish: (id: number) => void
unpublish: (id: number) => void
copyUrl: (id: number) => void
remove: (article: Article) => void
}
export function useArticleMenu(handlers: ArticleMenuHandlers) {
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
function publishHint(article: Article) {
return !article.is_online && !access.canPublish(article.created_by)
? t('Ask an editor to publish.')
: ''
}
function actionsFor(article: Article) {
const row = access.rowAccess(article.created_by)
const toggle = access.canPublish(article.created_by)
? [
{
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: handlers.isBusy(article),
onSelect: () => {
if (article.is_online) {
handlers.unpublish(article.id)
} else {
handlers.publish(article.id)
}
},
},
]
: []
return [
[
{
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
disabled: !row.canEdit,
description: row.canEdit ? undefined : row.editReason,
onSelect: () => handlers.edit(article),
},
...toggle,
{
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => handlers.copyUrl(article.id),
},
],
[
{
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
disabled: !row.canDelete,
description: row.canDelete ? undefined : row.deleteReason,
onSelect: () => handlers.remove(article),
},
],
]
}
return { actionsFor, publishHint, access }
}
+1
View File
@@ -12,6 +12,7 @@ export interface Article extends Partial<ArticleSeo> {
body: string
is_online: boolean
site_id?: number
created_by?: number
authors?: Byline[]
created_at: string
updated_at: string
+3 -3
View File
@@ -1,7 +1,7 @@
import { computed } from 'vue'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
export function useCanManageVariants() {
const session = useSessionStore()
return computed(() => session.session?.role === 'owner' || session.session?.role === 'admin')
const permissions = usePermissionsStore()
return computed(() => permissions.can('settings.edit_general'))
}
+68
View File
@@ -0,0 +1,68 @@
import { computed } from 'vue'
import { t } from '@/i18n'
import { usePermissionsStore } from '@/stores/permissions'
export type ContentNoun = 'entries' | 'articles'
export interface RowAccess {
canEdit: boolean
canDelete: boolean
editReason: string
deleteReason: string
}
const NO_REASON = ''
export function useContentAccess(typeKey: string, noun: ContentNoun) {
const permissions = usePermissionsStore()
const canCreate = computed(() => permissions.can('content.create', { typeKey }))
const updateScope = computed(() => permissions.scopeOf('content.update', { typeKey }))
const deleteScope = computed(() => permissions.scopeOf('content.delete', { typeKey }))
const publishScope = computed(() => permissions.scopeOf('content.publish', { typeKey }))
const updatesOwnOnly = computed(() => updateScope.value === 'own')
function noneReason(action: 'edit' | 'delete') {
if (noun === 'articles') {
return action === 'edit'
? t("Your role can't edit articles.")
: t("Your role can't delete articles.")
}
return action === 'edit'
? t("Your role can't edit entries.")
: t("Your role can't delete entries.")
}
function reasonFor(action: 'edit' | 'delete', allowed: boolean, scope: string) {
if (allowed) {
return NO_REASON
}
return scope === 'none' ? noneReason(action) : t('You can only edit content you created.')
}
function rowAccess(createdBy: number | undefined): RowAccess {
const ownerId = createdBy ?? 0
const canEdit = permissions.can('content.update', { typeKey, ownerId })
const canDelete = permissions.can('content.delete', { typeKey, ownerId })
return {
canEdit,
canDelete,
editReason: reasonFor('edit', canEdit, updateScope.value),
deleteReason: reasonFor('delete', canDelete, deleteScope.value),
}
}
function canPublish(createdBy: number | undefined) {
return permissions.can('content.publish', { typeKey, ownerId: createdBy ?? 0 })
}
return {
canCreate,
updateScope,
deleteScope,
publishScope,
updatesOwnOnly,
rowAccess,
canPublish,
}
}
+1 -11
View File
@@ -46,15 +46,6 @@ export function useDirectoryToasts() {
)
}
function ownerOnly() {
show(
'site-owner-only',
'error',
() => t('Only the owner can manage sites'),
() => t('Ask the owner of this site.'),
)
}
function defaultSiteBlocked() {
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
}
@@ -122,7 +113,7 @@ export function useDirectoryToasts() {
function report(kind: FailureKind, context: FailureContext) {
const handlers: Partial<Record<FailureKind, () => void>> = {
forbidden: ownerOnly,
permission: () => undefined,
defaultSite: defaultSiteBlocked,
rights: authorRights,
gone: context === 'site' ? siteGone : authorGone,
@@ -136,7 +127,6 @@ export function useDirectoryToasts() {
report,
siteGone,
authorGone,
ownerOnly,
defaultSiteBlocked,
authorRights,
siteDefaultChanged,
+15
View File
@@ -6,6 +6,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
import { useEntryDraft } from '@/composables/useEntryDraft'
import { useEntryNames } from '@/composables/useEntryNames'
import { useEntryRemoval } from '@/composables/useEntryRemoval'
import { usePermissionsStore } from '@/stores/permissions'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { entryTitle } from '@/lib/entryForm'
import type { EntryTarget } from '@/lib/entryEditorState'
@@ -29,6 +30,17 @@ export function useEntryEditorPage(
const editor = useEntryDraft(typeKey, target)
const { detail, load, form } = editor
const names = useEntryNames(() => load.entry.value)
const permissions = usePermissionsStore()
const readOnly = computed(() => {
const entry = load.entry.value
return (
!editor.creating.value &&
entry != null &&
!permissions.can('content.update', { typeKey, ownerId: entry.created_by })
)
})
const locked = computed(() => editor.locked.value || readOnly.value)
const canSave = computed(() => editor.canSave.value && !readOnly.value)
const typeName = computed(() => detail.type.value?.name ?? typeKey)
const title = computed(() => entryTitle(editor.fields.value, load.entry.value))
@@ -131,6 +143,9 @@ export function useEntryEditorPage(
return {
...editor,
readOnly,
locked,
canSave,
isDesktop,
names,
typeName,
+66
View File
@@ -0,0 +1,66 @@
import { ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useEntries } from '@/composables/useEntries'
import type { ContentEntry } from '@/lib/contentTypes'
const UNAUTHORIZED = 401
const TOAST_ID = 'entry-deleted'
export function useEntryRowDelete(
typeKey: string,
typeName: () => string,
reload: () => Promise<unknown>,
) {
const { deleteEntry } = useEntries()
const toast = useLocalizedToast()
const target = ref<ContentEntry | null>(null)
const pending = ref(false)
const failed = ref(false)
function open(entry: ContentEntry) {
target.value = entry
failed.value = false
}
function close() {
if (!pending.value) {
target.value = null
}
}
function titleOf(entry: ContentEntry) {
return t('Entry {0}', [entry.id])
}
async function confirm() {
const entry = target.value
if (!entry || pending.value) {
return
}
pending.value = true
try {
await deleteEntry(typeKey, entry.id)
} catch (error) {
pending.value = false
if (!(error instanceof ApiError && error.status === UNAUTHORIZED)) {
failed.value = true
}
return
}
pending.value = false
target.value = null
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Entry deleted'),
description: t('"{0}" was deleted from {1}.', [titleOf(entry), typeName()]),
}))
await reload()
}
return { target, pending, failed, open, close, confirm, titleOf }
}
+3 -1
View File
@@ -2,11 +2,13 @@ import { computed, watch } from 'vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
export type SchemaGateScope = 'all' | 'builtin' | 'custom'
export function useSchemaGate(scope: SchemaGateScope) {
const session = useSessionStore()
const permissions = usePermissionsStore()
const store = useContentTypesStore()
const skeleton = useDelayedSkeleton()
const withCustom = scope !== 'builtin'
@@ -15,7 +17,7 @@ export function useSchemaGate(scope: SchemaGateScope) {
const sessionReady = computed(() => session.status === 'ready')
const forbidden = computed(
() =>
(sessionReady.value && !session.canManageContent) ||
(sessionReady.value && !permissions.can('schema.view')) ||
(withCustom && store.status === 'forbidden') ||
(withBuiltin && store.builtinStatus === 'forbidden'),
)
+6 -3
View File
@@ -1,5 +1,6 @@
import { useApi } from '@/composables/useApi'
import type { LinkableUser } from '@/lib/directoryTypes'
import type { RoleRef } from '@/lib/permissions'
export type UserListItem =
| {
@@ -8,7 +9,7 @@ export type UserListItem =
username: string
display_name: string
email: string
role: 'owner' | 'admin'
role: RoleRef | null
status: 'accepted'
created_at: string
}
@@ -17,6 +18,7 @@ export type UserListItem =
id: number
email: string
display_name?: string
role?: RoleRef | null
status: 'pending' | 'expired'
created_at: string
}
@@ -31,14 +33,15 @@ export function userDisplayName(item: UserListItem) {
return item.display_name ?? ''
}
export function userRole(item: UserListItem): 'owner' | 'admin' {
return item.kind === 'user' ? item.role : 'admin'
export function userRole(item: UserListItem): RoleRef | null {
return item.role ?? null
}
export interface CreatedInvite {
id: number
kind: 'invite'
email: string
role?: RoleRef | null
status: 'pending'
created_at: string
expires_at: string
+35 -4
View File
@@ -200,7 +200,7 @@ const USER_LIST_KEYS = [
'status',
'created_at',
]
const INVITE_KEYS = ['id', 'kind', 'email', 'status', 'created_at', 'expires_at']
const INVITE_KEYS = ['id', 'kind', 'email', 'role', 'status', 'created_at', 'expires_at']
const ME_KEYS = [
'id',
'username',
@@ -209,8 +209,8 @@ const ME_KEYS = [
'created_at',
'updated_at',
'display_name',
'role',
]
const AUTH_ME_KEYS = [...ME_KEYS, 'author', 'access']
function sampleKey(path: string) {
if (path.startsWith('/v1/content-type')) {
@@ -538,7 +538,7 @@ describe('Response samples use the real domain JSON keys', () => {
})
it('uses the public user keys for the profile routes', () => {
expectOnlyKeys(parsed(find('GET /v1/auth/me')), ME_KEYS)
expectOnlyKeys(parsed(find('GET /v1/auth/me')), AUTH_ME_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/user/me')), ME_KEYS)
})
@@ -608,7 +608,10 @@ describe('Request bodies and query parameters', () => {
])
expect(bodyOf('PATCH /v1/user/me')).toEqual([['display_name', 'string', true]])
expect(find('PATCH /v1/user/me').body[0]!.rule).toContain('60')
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
expect(bodyOf('POST /v1/user/invite')).toEqual([
['email', 'string', true],
['role_id', 'number', false],
])
})
it('limits the SEO request fields like the handler validate tags', () => {
@@ -1042,3 +1045,31 @@ describe('Custom content types round', () => {
expect(created.curl).toContain('-d \'{\n "key": "events"')
})
})
describe('Role and permission shapes', () => {
it('shows access.role and access.permissions and no role string in the /auth/me sample', () => {
const me = parsed(find('GET /v1/auth/me'))
expect(me.role).toBeUndefined()
expect(me.access.role).toEqual({ id: 1, name: 'Owner', built_in: true, locked: true })
expect(me.access.permissions[0]).toEqual({ key: 'content.read', scope: 'all' })
expect(
me.access.permissions.some((grant: { key: string }) => grant.key.includes(':')),
).toBe(true)
})
it('shows role {id, name, built_in, locked} on the user and invite samples', () => {
const list = parsed(find('GET /v1/user/'))
expect(list[0].role).toEqual({ id: 1, name: 'Owner', built_in: true, locked: true })
expect(list[1].role).toEqual({ id: 3, name: 'Writer', built_in: true, locked: false })
expect(parsed(find('POST /v1/user/invite')).role.name).toBe('Writer')
})
it('documents the optional role_id of the invite request', () => {
const roleId = find('POST /v1/user/invite').body.find((field) => field.name === 'role_id')
expect(roleId?.required).toBe(false)
expect(roleId?.rule).toContain('Writer')
})
})
+32 -4
View File
@@ -605,13 +605,34 @@ const me = {
created_at: '2026-01-05T08:30:00Z',
updated_at: '2026-09-10T07:00:00Z',
display_name: 'Marta Alves',
role: 'owner',
}
const ownerRole = { id: 1, name: 'Owner', built_in: true, locked: true }
const writerRole = { id: 3, name: 'Writer', built_in: true, locked: false }
const meWithAccess = {
...me,
author: null,
access: {
role: ownerRole,
permissions: [
{ key: 'content.read', scope: 'all' },
{ key: 'content.create', scope: 'all' },
{ key: 'content.update', scope: 'all' },
{ key: 'content.delete', scope: 'all' },
{ key: 'content.publish', scope: 'all' },
{ key: 'settings.view', scope: 'all' },
{ key: 'users.view', scope: 'all' },
{ key: 'content.update:article', scope: 'own' },
],
},
}
const invite = (overrides: Record<string, unknown> = {}) => ({
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
role: writerRole,
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
expires_at: '2026-09-13T07:00:00Z',
@@ -1606,7 +1627,7 @@ export const API_GROUPS: ApiGroup[] = [
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
role: ownerRole,
status: 'accepted',
created_at: '2026-01-05T08:30:00Z',
},
@@ -1614,6 +1635,7 @@ export const API_GROUPS: ApiGroup[] = [
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
role: writerRole,
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
},
@@ -1666,8 +1688,14 @@ export const API_GROUPS: ApiGroup[] = [
required: true,
rule: 'A valid email address.',
},
{
name: 'role_id',
type: 'number',
required: false,
rule: 'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.',
},
],
bodySample: { email: 'lea.martin@renew.example' },
bodySample: { email: 'lea.martin@renew.example', role_id: 3 },
},
),
ownerOnly(
@@ -1698,7 +1726,7 @@ export const API_GROUPS: ApiGroup[] = [
"Get the current user. With a key, the key's owner.",
'Verified user',
[e(401, UNAUTHORIZED)],
json(me),
json(meWithAccess),
),
],
},
-1
View File
@@ -23,7 +23,6 @@ const SOURCES = [
'components/PublishingLinksCard.vue',
'components/ReassignDialog.vue',
'components/SiteFormPanel.vue',
'components/SitesForbidden.vue',
'components/SlugField.vue',
'components/SocialLinksRepeater.vue',
'components/UserSelect.vue',
+86
View File
@@ -2831,6 +2831,92 @@ export const messages = {
'Value in an entry': 'Valeur dans une entrée',
'1 or higher. Default 1.': '1 ou plus. 1 par défaut.',
'One of 5, 25, 50 or 100. Default 25.': '5, 25, 50 ou 100. 25 par défaut.',
'Read entries': 'Lire les entrées',
'Create entries': 'Créer des entrées',
'Update entries': 'Modifier des entrées',
'Delete entries': 'Supprimer des entrées',
'Publish entries': 'Publier des entrées',
'View content types': 'Voir les types de contenu',
'Create and edit content types': 'Créer et modifier des types de contenu',
'Delete content types': 'Supprimer des types de contenu',
'View the media library': 'Voir la médiathèque',
'Upload files': 'Téléverser des fichiers',
'Delete files': 'Supprimer des fichiers',
'View authors': 'Voir les auteurs',
'Create authors': 'Créer des auteurs',
'Edit authors': 'Modifier des auteurs',
'Delete authors': 'Supprimer des auteurs',
'See activity': 'Voir l’activité',
'Export activity': 'Exporter l’activité',
'View users': 'Voir les utilisateurs',
'Invite users': 'Inviter des utilisateurs',
"Change a user's role": 'Changer le rôle d’un utilisateur',
'Remove users': 'Retirer des utilisateurs',
'View settings': 'Voir les paramètres',
'Edit general settings': 'Modifier les paramètres généraux',
'Delete a site': 'Supprimer un site',
'See API keys': 'Voir les clés d’API',
'Create API keys': 'Créer des clés d’API',
'Revoke API keys': 'Révoquer des clés d’API',
'View roles': 'Voir les rôles',
'Manage roles': 'Gérer les rôles',
'Edit roles with owner-only power': 'Modifier les rôles à pouvoir réservé au propriétaire',
'Apply software updates': 'Appliquer les mises à jour du logiciel',
'Edit activity retention': 'Modifier la durée de conservation de l’activité',
'Transfer ownership': 'Transférer la propriété',
"You don't have permission to do that": 'Vous n’avez pas la permission de faire cela',
"Your role doesn't include {0}. Ask an owner or admin.":
'Votre rôle n’inclut pas « {0} ». Demandez à un propriétaire ou à un administrateur.',
"Your role doesn't include this action. Ask an owner or admin.":
'Votre rôle n’inclut pas cette action. Demandez à un propriétaire ou à un administrateur.',
"You can't grant that": 'Vous ne pouvez pas accorder cela',
'You can only grant what your role has.':
'Vous ne pouvez accorder que ce que votre rôle possède.',
'This role is locked': 'Ce rôle est verrouillé',
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.":
'Le rôle Propriétaire ne peut être ni modifié ni supprimé. Transférez plutôt la propriété depuis Utilisateurs.',
"You can't change your own role": 'Vous ne pouvez pas changer votre propre rôle',
'Ask another owner or admin.': 'Demandez à un autre propriétaire ou administrateur.',
"Built-in roles can't be deleted": 'Les rôles intégrés ne peuvent pas être supprimés',
'Duplicate it to make a custom role.': 'Dupliquez-le pour créer un rôle personnalisé.',
'Built-in roles keep their name.': 'Les rôles intégrés gardent leur nom.',
'This changed somewhere else. Reload and try again.':
'Cela a changé ailleurs. Rechargez la page et réessayez.',
'View only': 'Lecture seule',
'You can only edit content you created.':
'Vous ne pouvez modifier que le contenu que vous avez créé.',
"You don't have access to this page": 'Vous n’avez pas accès à cette page',
"Your role, {0}, doesn't include it. Ask an owner or admin if you need it.":
'Votre rôle, {0}, ne l’inclut pas. Demandez à un propriétaire ou à un administrateur si vous en avez besoin.',
'Go to Articles': 'Aller aux articles',
writer: 'rédacteur',
'Only the owner can delete a site.': 'Seul le propriétaire peut supprimer un site.',
'Only the owner can apply updates.': 'Seul le propriétaire peut appliquer les mises à jour.',
'Apply update': 'Appliquer la mise à jour',
'Admins can read the log. Only the owner can change how long it is kept.':
'Les administrateurs peuvent lire le journal. Seul le propriétaire peut changer sa durée de conservation.',
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events.':
'Durée de conservation de l’activité et volume de journal à garder. La première limite atteinte supprime les événements les plus anciens.',
'You can read these settings but not change them':
'Vous pouvez lire ces paramètres mais pas les modifier',
'Ask the owner to change them, or ask for a role that can edit settings.':
'Demandez au propriétaire de les modifier, ou demandez un rôle qui peut modifier les paramètres.',
'You can create entries and edit your own.':
'Vous pouvez créer des entrées et modifier les vôtres.',
'Entries by others are view only': 'Les entrées des autres sont en lecture seule',
'Your role lets you edit entries you created.':
'Votre rôle vous permet de modifier les entrées que vous avez créées.',
"Your role can't edit entries.": 'Votre rôle ne permet pas de modifier des entrées.',
"Your role can't delete entries.": 'Votre rôle ne permet pas de supprimer des entrées.',
"Your role can't edit articles.": 'Votre rôle ne permet pas de modifier des articles.',
"Your role can't delete articles.": 'Votre rôle ne permet pas de supprimer des articles.',
'Ask an editor to publish.': 'Demandez à un éditeur de publier.',
'You can write and edit all articles. Publishing is done by an editor.':
'Vous pouvez rédiger et modifier tous les articles. La publication est faite par un éditeur.',
"Your role can't delete files.": 'Votre rôle ne permet pas de supprimer des fichiers.',
'Save draft': 'Enregistrer le brouillon',
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
} as const
export type MessageKey = keyof typeof messages
+7 -23
View File
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { API_GROUPS, SESSION_GROUPS } from '@/data/apiCatalogue'
@@ -108,32 +108,16 @@ describe('Catalogue groups of Sites and Authors in French', () => {
})
})
describe('The owner_only copy of the design board', () => {
it.each([
"You don't have access to Sites",
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
'Only the owner can manage sites',
'Ask the owner of this site.',
])('"%s" has a French entry', (text) => {
expect(frenchOf(text)).toMatch(/\S/)
expect(frenchOf(text)).not.toBe(text)
})
it('is used by the 403 page and by the action toast', () => {
const page = readFileSync(
join(import.meta.dirname, '..', 'components/SitesForbidden.vue'),
'utf8',
)
const toast = readFileSync(
describe('The retired owner-only sites copy', () => {
it('shows nowhere any more: the page-level state and the generic toast replace it', () => {
const toasts = readFileSync(
join(import.meta.dirname, '..', 'composables/useDirectoryToasts.ts'),
'utf8',
)
expect(page).toContain("You don\\'t have access to Sites")
expect(page).toContain(
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
expect(toasts).not.toContain('Only the owner can manage sites')
expect(existsSync(join(import.meta.dirname, '..', 'components/SitesForbidden.vue'))).toBe(
false,
)
expect(toast).toContain('Only the owner can manage sites')
expect(toast).toContain('Ask the owner of this site.')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const KEYS = [
"You don't have permission to do that",
"Your role doesn't include {0}. Ask an owner or admin.",
"Your role doesn't include this action. Ask an owner or admin.",
"You can't grant that",
'This role is locked',
"You can't change your own role",
"Built-in roles can't be deleted",
'Built-in roles keep their name.',
'This changed somewhere else. Reload and try again.',
"You don't have access to this page",
"Your role, {0}, doesn't include it. Ask an owner or admin if you need it.",
'Go to Articles',
'View only',
'Owner only',
'Only the owner can apply updates.',
'Admins can read the log. Only the owner can change how long it is kept.',
'You can read these settings but not change them',
'Ask the owner to change them, or ask for a role that can edit settings.',
'You can create entries and edit your own.',
'Entries by others are view only',
"Your role can't delete articles.",
'Ask an editor to publish.',
'You can write and edit all articles. Publishing is done by an editor.',
'Save draft',
] as const
describe('Permission copy', () => {
it.each(KEYS)('"%s" has a French entry', (key) => {
const french = (messages as Record<string, string>)[key]
expect(french).toMatch(/\S/)
})
})
+4 -2
View File
@@ -14,6 +14,7 @@ import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
import { accessFor, ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
@@ -70,7 +71,7 @@ async function mountRoute(path: string) {
username: 'marta',
displayName: 'Marta',
email: 'marta@example.com',
role: 'owner',
access: accessFor('owner'),
}
session.status = 'ready'
await router.push(path)
@@ -315,7 +316,7 @@ describe('Hardener localization outcomes', () => {
async (authenticated) => {
const { default: NotFoundView } = await import('@/views/NotFoundView.vue')
useSessionStore().session = authenticated
? { verified: true, username: 'marta', displayName: '' }
? { verified: true, access: ownerAccess(), username: 'marta', displayName: '' }
: null
wrapper = mount(NotFoundView, {
attachTo: document.body,
@@ -389,6 +390,7 @@ describe('Hardener localization outcomes', () => {
setLocale('fr')
useSessionStore().session = {
verified: true,
access: ownerAccess(),
username: 'marta',
displayName: '',
}
@@ -5,7 +5,7 @@ import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/di
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
['site', 401, undefined, 'session'],
['site', 403, 'default_site', 'defaultSite'],
['site', 403, 'Only the owner can do this.', 'forbidden'],
['site', 403, 'Only the owner can do this.', 'permission'],
['site', 404, 'site_not_found', 'gone'],
['site', 409, 'site_key_taken', 'keyTaken'],
['site', 409, 'site_has_articles', 'hasArticles'],
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import { PERMISSION_KEYS, permissionName } from '@/lib/permissionNames'
import { CATALOGUE_KEYS } from '@/test/access'
afterEach(() => setLocale('en'))
describe('permissionName', () => {
it('knows every key of the catalogue', () => {
expect([...PERMISSION_KEYS].sort()).toEqual([...CATALOGUE_KEYS].sort())
expect(PERMISSION_KEYS).toHaveLength(34)
})
it.each(CATALOGUE_KEYS)('%s has an English and a French name', (key) => {
const english = permissionName(key)
expect(english).toBeTruthy()
expect(messages[english as keyof typeof messages]).toBeTruthy()
setLocale('fr')
expect(permissionName(key)).not.toBe(english)
})
it('names content.delete Delete entries', () => {
expect(permissionName('content.delete')).toBe('Delete entries')
})
it('shows the base-key name for a typed key', () => {
expect(permissionName('content.publish:events')).toBe('Publish entries')
})
it('has no name for an unknown key', () => {
expect(permissionName('content.explode')).toBeUndefined()
})
})
+36 -1
View File
@@ -1,12 +1,47 @@
export const KNOWN_ERROR_CODES = [
'missing_permission',
'escalation',
'role_locked',
'own_role',
'role_built_in',
'built_in_name',
'role_conflict',
'user_role_changed',
'session_required',
] as const
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
export interface ApiErrorDetails {
code?: ApiErrorCode
permission?: string
escalationKey?: string
}
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export function errorCodeOf(body: string | undefined): ApiErrorCode | undefined {
if (body === SESSION_REQUIRED_MESSAGE) {
return 'session_required'
}
return KNOWN_ERROR_CODES.find((code) => code === body)
}
export class ApiError extends Error {
status: number
retryAfter?: number
body?: string
code?: ApiErrorCode
permission?: string
escalationKey?: string
constructor(status: number, retryAfter?: number, body?: string) {
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
this.body = body
this.code = details.code
this.permission = details.permission
this.escalationKey = details.escalationKey
}
}
+8 -2
View File
@@ -39,7 +39,7 @@ export type FailureContext = 'site' | 'author'
export type FailureKind =
| 'session'
| 'forbidden'
| 'permission'
| 'defaultSite'
| 'rights'
| 'gone'
@@ -64,10 +64,16 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
return 'gone'
}
if (status === 403) {
if (
code === 'missing_permission' ||
(error instanceof ApiError && error.code === 'missing_permission')
) {
return 'permission'
}
if (context === 'author') {
return 'rights'
}
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'permission'
}
const table: Record<FailureContext, Record<string, FailureKind>> = {
site: {
+1
View File
@@ -4,6 +4,7 @@ export interface DirectoryMenuItem {
icon: string
destructive?: boolean
unavailable?: boolean
ownerOnly?: boolean
hint?: string
separated?: boolean
onSelect: (trigger: HTMLElement | null) => void
+55
View File
@@ -0,0 +1,55 @@
import { t } from '@/i18n'
import { permissionName } from '@/lib/permissionNames'
import type { ApiErrorCode } from '@/lib/apiError'
export interface RefusalCopy {
title: string
description?: string
}
export function missingPermissionCopy(permission?: string): RefusalCopy {
const name = permission === undefined ? undefined : permissionName(permission)
return {
title: t("You don't have permission to do that"),
description:
name === undefined
? t("Your role doesn't include this action. Ask an owner or admin.")
: t("Your role doesn't include {0}. Ask an owner or admin.", [name]),
}
}
export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCopy | undefined {
switch (code) {
case 'missing_permission':
return missingPermissionCopy(permission)
case 'escalation':
return {
title: t("You can't grant that"),
description: t('You can only grant what your role has.'),
}
case 'role_locked':
return {
title: t('This role is locked'),
description: t(
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
),
}
case 'own_role':
return {
title: t("You can't change your own role"),
description: t('Ask another owner or admin.'),
}
case 'role_built_in':
return {
title: t("Built-in roles can't be deleted"),
description: t('Duplicate it to make a custom role.'),
}
case 'built_in_name':
return { title: t('Built-in roles keep their name.') }
case 'role_conflict':
case 'user_role_changed':
return { title: t('This changed somewhere else. Reload and try again.') }
case 'session_required':
return undefined
}
}
+50
View File
@@ -0,0 +1,50 @@
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
const NAMES = {
'content.read': 'Read entries',
'content.create': 'Create entries',
'content.update': 'Update entries',
'content.delete': 'Delete entries',
'content.publish': 'Publish entries',
'schema.view': 'View content types',
'schema.edit': 'Create and edit content types',
'schema.delete': 'Delete content types',
'media.view': 'View the media library',
'media.upload': 'Upload files',
'media.delete': 'Delete files',
'authors.view': 'View authors',
'authors.create': 'Create authors',
'authors.edit': 'Edit authors',
'authors.delete': 'Delete authors',
'activity.view': 'See activity',
'activity.export': 'Export activity',
'users.view': 'View users',
'users.invite': 'Invite users',
'users.change_role': "Change a user's role",
'users.remove': 'Remove users',
'settings.view': 'View settings',
'settings.edit_general': 'Edit general settings',
'settings.manage_sites': 'Manage sites',
'settings.delete_site': 'Delete a site',
'apikeys.view': 'See API keys',
'apikeys.create': 'Create API keys',
'apikeys.revoke': 'Revoke API keys',
'roles.view': 'View roles',
'roles.manage': 'Manage roles',
'roles.edit_owner_only': 'Edit roles with owner-only power',
'system.apply_updates': 'Apply software updates',
'system.edit_retention': 'Edit activity retention',
'system.transfer_ownership': 'Transfer ownership',
} as const satisfies Record<string, MessageKey>
export const PERMISSION_KEYS = Object.keys(NAMES)
export function baseKey(key: string) {
return key.split(':')[0]!
}
export function permissionName(key: string): string | undefined {
const name = NAMES[baseKey(key) as keyof typeof NAMES]
return name === undefined ? undefined : t(name)
}
+70
View File
@@ -0,0 +1,70 @@
export type Scope = 'none' | 'own' | 'all'
export interface Grant {
key: string
scope: Scope
}
export interface RoleRef {
id: number
name: string
built_in: boolean
locked: boolean
}
export interface Access {
role: RoleRef
permissions: Grant[]
}
export interface CanOptions {
ownerId?: number
typeKey?: string
scope?: 'own' | 'all'
}
export interface ScopeOptions {
typeKey?: string
}
const TYPE_SEPARATOR = ':'
function scopeOfGrant(grants: readonly Grant[], key: string): Scope | undefined {
return grants.find((grant) => grant.key === key)?.scope
}
export function effectiveScope(
grants: readonly Grant[],
key: string,
options: ScopeOptions = {},
): Scope {
if (options.typeKey) {
const typed = scopeOfGrant(grants, `${key}${TYPE_SEPARATOR}${options.typeKey}`)
if (typed !== undefined) {
return typed
}
}
return scopeOfGrant(grants, key) ?? 'none'
}
export function allows(
grants: readonly Grant[],
userId: number | undefined,
key: string,
options: CanOptions = {},
): boolean {
const scope = effectiveScope(grants, key, options)
if (scope === 'none') {
return false
}
if (scope === 'all') {
return true
}
if (options.scope === 'all') {
return false
}
if (options.ownerId === undefined) {
return true
}
return userId !== undefined && options.ownerId !== 0 && options.ownerId === userId
}
+34
View File
@@ -0,0 +1,34 @@
import type { CanOptions } from '@/lib/permissions'
export interface RouteAccess {
key: string
typeKey?: string
typeParam?: string
}
export type CanCheck = (key: string, options?: CanOptions) => boolean
export const ARTICLE_TYPE_KEY = 'article'
function typeKeyOf(access: RouteAccess, params: Record<string, unknown>) {
if (access.typeKey !== undefined) {
return access.typeKey
}
if (access.typeParam === undefined) {
return undefined
}
const value = params[access.typeParam]
return typeof value === 'string' ? value : undefined
}
export function isRouteAllowed(
access: RouteAccess | undefined,
params: Record<string, unknown>,
can: CanCheck,
): boolean {
if (access === undefined) {
return true
}
const typeKey = typeKeyOf(access, params)
return can(access.key, typeKey === undefined ? {} : { typeKey })
}
+3
View File
@@ -6,6 +6,7 @@ import lucide from 'virtual:lucide-subset'
import App from './App.vue'
import router from './router'
import { refreshSessionWhenVisible } from '@/stores/session'
import '@/assets/fonts/fonts.css'
import '@/assets/main.css'
@@ -19,3 +20,5 @@ app.use(ui)
app.use(router)
app.mount('#app')
refreshSessionWhenVisible()
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import RouteAccessGate from '@/components/RouteAccessGate.vue'
import { useSessionStore, type SessionStatus } from '@/stores/session'
import { customAccess, noAccess } from '@/test/access'
import { setLocale } from '@/i18n'
let wrapper: VueWrapper | null = null
interface Setup {
grants: string
role?: string
path: string
status?: SessionStatus
verified?: boolean
}
async function open({
grants,
role = 'Contributor',
path,
status = 'ready',
verified = true,
}: Setup) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { id: 4, verified, displayName: '', access: customAccess(grants, role) }
session.status = status
await router.push(path).catch(() => undefined)
wrapper = mount(
() =>
h(UApp, null, () =>
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' }, 'PAGE')),
),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return wrapper
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
setLocale('en')
document.body.innerHTML = ''
await router.push('/login')
})
const NO_ACCESS_TITLE = "You don't have access to this page"
describe('A direct link to a page the role lacks', () => {
it.each([
['content.read=all', '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=all, content.read:events=none',
'Contributor',
'/content/events',
'Go to Articles',
],
['media.view', 'Media', '/schema', 'Go to Home'],
])(
'%s opening %s shows the page-level 403 and keeps the URL',
async (grants, role, path, action) => {
const view = await open({ grants, role, path })
expect(router.currentRoute.value.fullPath).toBe(path)
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).toContain(NO_ACCESS_TITLE)
expect(view.text()).toContain(
`Your role, ${role}, doesn't include it. Ask an owner or admin if you need it.`,
)
const buttons = view
.findAll('[data-slot="root"] button')
.filter((button) => !button.element.closest('nav'))
expect(buttons.map((button) => button.text()).filter((label) => label !== '')).toEqual([
action,
])
},
)
})
describe('A page the role can use', () => {
it.each([
['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'],
])('%s opens %s without a 403', async (grants, path) => {
const view = await open({ grants, path })
expect(view.find('[data-testid="page"]').exists()).toBe(true)
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
})
})
describe('The guard', () => {
it('shows neither the page nor the 403 while the session is checking', async () => {
const view = await open({ grants: 'media.view', path: '/schema', status: 'checking' })
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
useSessionStore().status = 'ready'
await flushPromises()
expect(view.text()).toContain(NO_ACCESS_TITLE)
})
it('leaves an unverified session to the pending redirect', async () => {
const view = await open({ grants: '', path: '/schema', verified: false })
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
})
it('switches to the 403 state when grants are lowered while the page is open', async () => {
const view = await open({ grants: 'schema.view', path: '/schema' })
expect(view.find('[data-testid="page"]').exists()).toBe(true)
const session = useSessionStore()
session.session = { ...session.session!, access: noAccess('Contributor') }
await flushPromises()
expect(view.find('[data-testid="page"]').exists()).toBe(false)
expect(view.text()).toContain(NO_ACCESS_TITLE)
expect(router.currentRoute.value.fullPath).toBe('/schema')
})
it('shows the gate copy in French', async () => {
setLocale('fr')
const view = await open({ grants: 'content.read=all', path: '/users' })
expect(view.text()).toContain('Vous n’avez pas accès à cette page')
})
})
+10 -5
View File
@@ -6,6 +6,7 @@ import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import router from '@/router'
import { useApi, ApiError } from '@/composables/useApi'
import { ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -86,7 +87,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
})
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -102,7 +103,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
})
it('A verified-only route admits a verified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -341,7 +342,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -360,9 +361,13 @@ describe('Nuxt UI shell foundation and router guards', () => {
it('The /media route is verified, titled Media and renders MediaView inside DashboardShell', async () => {
const route = router.getRoutes().find((candidate) => candidate.path === '/media')
expect(route?.meta).toEqual({ auth: 'verified', title: 'Media' })
expect(route?.meta).toEqual({
auth: 'verified',
title: 'Media',
access: { key: 'media.view' },
})
const getMe = vi.fn().mockResolvedValue({ verified: true })
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
const request = vi.fn().mockResolvedValue({
items: [],
total: 0,
+108 -36
View File
@@ -1,3 +1,4 @@
import { defineComponent, h, type Component } from 'vue'
import {
createRouter,
createWebHistory,
@@ -8,9 +9,34 @@ import {
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { installDocumentTitle } from '@/composables/documentTitle'
import { ARTICLE_TYPE_KEY, type RouteAccess } from '@/lib/routeAccess'
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
export function routeAccessOf(route: RouteLocationNormalized): RouteAccess | undefined {
return route.meta.access as RouteAccess | undefined
}
const gatedViews = new Map<string, Promise<Component>>()
function gated(key: string, load: () => Promise<{ default: Component }>) {
return () => {
let resolved = gatedViews.get(key)
if (resolved === undefined) {
resolved = Promise.all([load(), import('@/components/RouteAccessGate.vue')]).then(
([view, gate]) =>
defineComponent({
name: 'GatedRoute',
setup: () => () =>
h(gate.default, null, { default: () => h(view.default) }),
}),
)
gatedViews.set(key, resolved)
}
return resolved
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
@@ -59,70 +85,116 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
meta: {
auth: 'verified',
title: 'Articles',
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
},
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
meta: {
auth: 'verified',
title: 'Articles',
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
},
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
component: gated('@/views/EditorView.vue', () => import('@/views/EditorView.vue')),
meta: {
auth: 'verified',
title: 'Editor',
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
},
},
{
path: '/media',
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
component: gated('@/views/MediaView.vue', () => import('@/views/MediaView.vue')),
meta: { auth: 'verified', title: 'Media', access: { key: 'media.view' } },
},
{
path: '/authors',
component: () => import('@/views/AuthorsView.vue'),
meta: { auth: 'verified', title: 'Authors' },
component: gated('@/views/AuthorsView.vue', () => import('@/views/AuthorsView.vue')),
meta: { auth: 'verified', title: 'Authors', access: { key: 'authors.view' } },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
component: gated('@/views/UsersView.vue', () => import('@/views/UsersView.vue')),
meta: { auth: 'verified', title: 'Users', access: { key: 'users.view' } },
},
{
path: '/schema',
component: () => import('@/views/SchemaView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
component: gated('@/views/SchemaView.vue', () => import('@/views/SchemaView.vue')),
meta: {
auth: 'verified',
title: 'Schema',
needsBuiltinTypes: true,
access: { key: 'schema.view' },
},
},
{
path: '/schema/new',
component: () => import('@/views/SchemaBuilderView.vue'),
meta: { auth: 'verified', title: 'Schema' },
component: gated(
'@/views/SchemaBuilderView.vue',
() => import('@/views/SchemaBuilderView.vue'),
),
meta: { auth: 'verified', title: 'Schema', access: { key: 'schema.edit' } },
},
{
path: '/schema/:key',
component: () => import('@/views/SchemaTypeView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
component: gated('@/views/SchemaTypeView.vue', () => import('@/views/SchemaTypeView.vue')),
meta: {
auth: 'verified',
title: 'Schema',
needsBuiltinTypes: true,
access: { key: 'schema.view' },
},
},
{
path: '/content/:key',
component: () => import('@/views/ContentListView.vue'),
meta: { auth: 'verified', title: 'Content' },
component: gated(
'@/views/ContentListView.vue',
() => import('@/views/ContentListView.vue'),
),
meta: {
auth: 'verified',
title: 'Content',
access: { key: 'content.read', typeParam: 'key' },
},
},
{
path: '/content/:key/new',
name: 'entry-new',
component: () => import('@/views/EntryEditorView.vue'),
meta: { auth: 'verified', title: 'Content' },
component: gated(
'@/views/EntryEditorView.vue',
() => import('@/views/EntryEditorView.vue'),
),
meta: {
auth: 'verified',
title: 'Content',
access: { key: 'content.create', typeParam: 'key' },
},
},
{
path: '/content/:key/:id',
name: 'entry-edit',
component: () => import('@/views/EntryEditorView.vue'),
meta: { auth: 'verified', title: 'Content' },
component: gated(
'@/views/EntryEditorView.vue',
() => import('@/views/EntryEditorView.vue'),
),
meta: {
auth: 'verified',
title: 'Content',
access: { key: 'content.read', typeParam: 'key' },
},
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
component: gated('@/views/ActivityView.vue', () => import('@/views/ActivityView.vue')),
meta: { auth: 'verified', title: 'Activity', access: { key: 'activity.view' } },
},
{
path: '/profile',
@@ -136,28 +208,28 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/integration/api-keys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { auth: 'verified', title: 'Integration' },
component: gated('@/views/ApiKeysView.vue', () => import('@/views/ApiKeysView.vue')),
meta: { auth: 'verified', title: 'Integration', access: { key: 'apikeys.view' } },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/SettingsView.vue', () => import('@/views/SettingsView.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
},
{
path: '/settings/media/variants',
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/VariantsPage.vue', () => import('@/views/VariantsPage.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
},
{
path: '/settings/sites',
component: () => import('@/views/SitesView.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
},
{
path: '/settings/media/cache',
component: () => import('@/views/CachePage.vue'),
meta: { auth: 'verified', title: 'Settings' },
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
},
{
path: '/:pathMatch(.*)*',
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import type { CanOptions } from '@/lib/permissions'
import { customAccess } from '@/test/access'
function withGrants(spec: string | null, id = 7) {
const session = useSessionStore()
session.session =
spec === null ? null : { id, verified: true, displayName: '', access: customAccess(spec) }
return usePermissionsStore()
}
beforeEach(() => setActivePinia(createPinia()))
describe('can(key, options)', () => {
it.each<[string | null, string, CanOptions, boolean]>([
['content.update=all', 'content.update', {}, true],
['content.update=own', 'content.update', {}, true],
['content.update=own', 'content.update', { scope: 'all' }, false],
['content.update=own', 'content.update', { ownerId: 7 }, true],
['content.update=own', 'content.update', { ownerId: 8 }, false],
['content.update=own', 'content.update', { ownerId: 0 }, false],
['content.update=all', 'content.update', { ownerId: 8 }, true],
['content.update=none', 'content.update', {}, false],
['', '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],
[null, 'media.view', {}, false],
])('grants %s, key %s, options %j gives %s', (grants, key, options, expected) => {
expect(withGrants(grants).can(key, options)).toBe(expected)
})
it('treats an owner id as foreign when the session has no id', () => {
const session = useSessionStore()
session.session = {
verified: true,
displayName: '',
access: customAccess('content.update=own'),
}
expect(usePermissionsStore().can('content.update', { ownerId: 7 })).toBe(false)
})
})
describe('scopeOf', () => {
it('exposes none, own or all, with a typed row replacing the wildcard', () => {
const permissions = withGrants('content.read=all, content.read:events=own')
expect(permissions.scopeOf('content.read')).toBe('all')
expect(permissions.scopeOf('content.read', { typeKey: 'events' })).toBe('own')
expect(permissions.scopeOf('schema.view')).toBe('none')
})
it('is none without a session', () => {
expect(withGrants(null).scopeOf('content.read')).toBe('none')
})
})
describe('session shape', () => {
it('has no isOwner or canManageContent getter and no string role', () => {
const session = useSessionStore() as unknown as Record<string, unknown>
expect('isOwner' in session).toBe(false)
expect('canManageContent' in session).toBe(false)
})
})
+9 -2
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
import { ownerAccess } from '@/test/access'
beforeEach(() => {
setActivePinia(createPinia())
@@ -9,7 +10,7 @@ beforeEach(() => {
describe('session store clear action', () => {
it('resets the session to null and the status to ready regardless of the previous state', () => {
const session = useSessionStore()
session.session = { verified: true }
session.session = { verified: true, access: ownerAccess() }
session.status = 'checking'
session.clear()
@@ -22,7 +23,12 @@ describe('session store clear action', () => {
describe('session store setDisplayName action', () => {
it('replaces the session display name with the given value', () => {
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.session = {
verified: true,
access: ownerAccess(),
username: 'marta',
displayName: '',
}
session.setDisplayName('Marta Alves')
@@ -33,6 +39,7 @@ describe('session store setDisplayName action', () => {
const session = useSessionStore()
session.session = {
verified: true,
access: ownerAccess(),
username: 'marta',
displayName: 'Marta Alves',
}
@@ -1,36 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
function sessionWith(role?: 'owner' | 'admin' | 'member') {
const store = useSessionStore()
store.session = {
verified: true,
displayName: '',
...(role ? { role: role as never } : {}),
}
return store
}
describe('session role getters', () => {
beforeEach(() => setActivePinia(createPinia()))
it.each([
['owner', true, true],
['admin', false, true],
['member', false, false],
[undefined, false, false],
] as const)('%s: isOwner %s, canManageContent %s', (role, owner, manage) => {
const store = sessionWith(role)
expect(store.isOwner).toBe(owner)
expect(store.canManageContent).toBe(manage)
})
it('is false without a session', () => {
const store = useSessionStore()
expect(store.isOwner).toBe(false)
expect(store.canManageContent).toBe(false)
})
})
+2 -1
View File
@@ -13,6 +13,7 @@ import {
startable,
upToDate,
} from '@/views/__tests__/updateTestKit'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -27,7 +28,7 @@ let requestMock: ReturnType<typeof vi.fn>
function setup(role: 'owner' | 'admin', handlers: Record<string, () => Promise<unknown>>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, displayName: '', role }
session.session = { verified: true, displayName: '', access: accessFor(role) }
session.status = 'ready'
requestMock = vi.fn((path: string) => handlers[path]!())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useSessionStore } from '@/stores/session'
import {
allows,
effectiveScope,
type CanOptions,
type RoleRef,
type Scope,
type ScopeOptions,
} from '@/lib/permissions'
export const usePermissionsStore = defineStore('permissions', () => {
const sessionStore = useSessionStore()
const grants = computed(() => sessionStore.session?.access?.permissions ?? [])
const role = computed<RoleRef | undefined>(() => sessionStore.session?.access?.role)
const roleName = computed(() => role.value?.name ?? '')
function can(key: string, options: CanOptions = {}): boolean {
if (!sessionStore.session) {
return false
}
return allows(grants.value, sessionStore.session.id, key, options)
}
function scopeOf(key: string, options: ScopeOptions = {}): Scope {
if (!sessionStore.session) {
return 'none'
}
return effectiveScope(grants.value, key, options)
}
return { grants, role, roleName, can, scopeOf }
})
+38 -9
View File
@@ -1,6 +1,10 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError, useApi, type MeAuthor, type UserRole } from '@/composables/useApi'
import { ref } from 'vue'
import { ApiError, useApi, type MeAuthor } from '@/composables/useApi'
import type { Access } from '@/lib/permissions'
const REFUSAL_REFRESH_GAP_MS = 5000
const STALE_AFTER_MS = 30000
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
@@ -10,7 +14,7 @@ export interface Session {
username?: string
email?: string
displayName: string
role?: UserRole
access?: Access
author?: MeAuthor
}
@@ -19,10 +23,8 @@ export const useSessionStore = defineStore('session', () => {
const session = ref<Session | null>(null)
let pending: Promise<void> | null = null
const isOwner = computed(() => session.value?.role === 'owner')
const canManageContent = computed(
() => session.value?.role === 'owner' || session.value?.role === 'admin',
)
let lastAnswerAt: number | null = null
let lastAttemptAt: number | null = null
function check() {
if (pending) {
@@ -36,6 +38,7 @@ export const useSessionStore = defineStore('session', () => {
.then((result) => {
session.value = result
status.value = 'ready'
lastAnswerAt = Date.now()
})
.catch((error) => {
if (error instanceof ApiError && error.status === 401) {
@@ -59,13 +62,31 @@ export const useSessionStore = defineStore('session', () => {
async function refresh() {
const { getMe } = useApi()
lastAttemptAt = Date.now()
try {
session.value = await getMe()
lastAnswerAt = Date.now()
} catch {
return
}
}
async function refreshAfterRefusal() {
if (lastAttemptAt !== null && Date.now() - lastAttemptAt < REFUSAL_REFRESH_GAP_MS) {
return
}
await refresh()
}
async function refreshIfStale() {
if (!session.value || lastAnswerAt === null) {
return
}
if (Date.now() - lastAnswerAt > STALE_AFTER_MS) {
await refresh()
}
}
function setAuthor(author: MeAuthor | undefined) {
if (!session.value) {
return
@@ -87,13 +108,21 @@ export const useSessionStore = defineStore('session', () => {
return {
status,
session,
isOwner,
canManageContent,
check,
retry,
refresh,
refreshAfterRefusal,
refreshIfStale,
clear,
setAuthor,
setDisplayName,
}
})
export function refreshSessionWhenVisible() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void useSessionStore().refreshIfStale()
}
})
}
+8 -8
View File
@@ -10,7 +10,7 @@ import {
readRestartMemory,
writeRestartMemory,
} from '@/lib/restartMemory'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
@@ -58,7 +58,7 @@ function writeDismissedVersion(version: string) {
}
export const useUpdateStore = defineStore('update', () => {
const session = useSessionStore()
const permissions = usePermissionsStore()
const status = ref<UpdateStatus | null>(null)
const loadState = ref<UpdateLoadState>('idle')
const phase = ref<UpdateCheckPhase>('idle')
@@ -67,20 +67,20 @@ export const useUpdateStore = defineStore('update', () => {
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
const isOwner = computed(() => session.isOwner)
const canApply = computed(() => permissions.can('system.apply_updates'))
const available = computed(() => status.value?.available === true)
const showCue = computed(() => isOwner.value && available.value)
const showCue = computed(() => canApply.value && available.value)
const restarting = computed(() => status.value?.state === 'restarting')
const bannerVisible = computed(
() => showCue.value && !restarting.value && status.value?.latest !== dismissedVersion.value,
)
const jobRunning = computed(
() =>
isOwner.value &&
canApply.value &&
status.value?.can_apply === true &&
(status.value.state === 'downloading' || status.value.state === 'verifying'),
)
const readyToInstall = computed(() => isOwner.value && status.value?.state === 'ready')
const readyToInstall = computed(() => canApply.value && status.value?.state === 'ready')
const restartRun = ref<RestartRun | null>(null)
const restartSlow = ref(false)
const restartNow = ref(Date.now())
@@ -221,7 +221,7 @@ export const useUpdateStore = defineStore('update', () => {
}
function resumeRestart() {
if (!isOwner.value || restartRun.value || status.value?.state !== 'restarting') {
if (!canApply.value || restartRun.value || status.value?.state !== 'restarting') {
return
}
const remembered = readRestartMemory()
@@ -313,7 +313,7 @@ export const useUpdateStore = defineStore('update', () => {
phase,
applyPhase,
retryAfter,
isOwner,
canApply,
available,
showCue,
bannerVisible,
+152
View File
@@ -0,0 +1,152 @@
import type { Access, Grant, RoleRef, Scope } from '../lib/permissions'
export const CATALOGUE_KEYS = [
'content.read',
'content.create',
'content.update',
'content.delete',
'content.publish',
'schema.view',
'schema.edit',
'schema.delete',
'media.view',
'media.upload',
'media.delete',
'authors.view',
'authors.create',
'authors.edit',
'authors.delete',
'activity.view',
'activity.export',
'users.view',
'users.invite',
'users.change_role',
'users.remove',
'settings.view',
'settings.edit_general',
'settings.manage_sites',
'settings.delete_site',
'apikeys.view',
'apikeys.create',
'apikeys.revoke',
'roles.view',
'roles.manage',
'roles.edit_owner_only',
'system.apply_updates',
'system.edit_retention',
'system.transfer_ownership',
] as const
export const OWNER_ONLY_KEYS = [
'settings.delete_site',
'roles.edit_owner_only',
'system.apply_updates',
'system.edit_retention',
'system.transfer_ownership',
] as const
export const OWNER_ROLE: RoleRef = { id: 1, name: 'Owner', built_in: true, locked: true }
export const ADMIN_ROLE: RoleRef = { id: 2, name: 'Admin', built_in: true, locked: false }
export const WRITER_ROLE: RoleRef = { id: 3, name: 'Writer', built_in: true, locked: false }
const WRITER_GRANTS: Grant[] = [
{ key: 'content.read', scope: 'all' },
{ key: 'content.create', scope: 'all' },
{ key: 'content.update', scope: 'all' },
{ key: 'schema.view', scope: 'all' },
{ key: 'media.view', scope: 'all' },
{ key: 'media.upload', scope: 'all' },
{ key: 'authors.view', scope: 'all' },
{ key: 'authors.edit', scope: 'own' },
{ key: 'activity.view', scope: 'own' },
]
function everyKey(skip: readonly string[] = []): Grant[] {
return CATALOGUE_KEYS.filter((key) => !skip.includes(key)).map((key) => ({
key,
scope: 'all' as Scope,
}))
}
export function grantsFrom(spec: string): Grant[] {
return spec
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '')
.map((part) => {
const [key, scope = 'all'] = part.split('=')
return { key: key!.trim(), scope: scope.trim() as Scope }
})
}
export function ownerAccess(): Access {
return { role: { ...OWNER_ROLE }, permissions: everyKey() }
}
export function adminAccess(): Access {
return { role: { ...ADMIN_ROLE }, permissions: everyKey(OWNER_ONLY_KEYS) }
}
export function writerAccess(): Access {
return { role: { ...WRITER_ROLE }, permissions: WRITER_GRANTS.map((grant) => ({ ...grant })) }
}
export function noAccess(name = 'Guest'): Access {
return { role: { id: 9, name, built_in: false, locked: false }, permissions: [] }
}
export function customAccess(grants: string | Grant[], name = 'Contributor', id = 7): Access {
return {
role: { id, name, built_in: false, locked: false },
permissions: typeof grants === 'string' ? grantsFrom(grants) : grants,
}
}
function withoutKeys(access: Access, drop: (key: string) => boolean): Access {
return {
role: { ...access.role, locked: false, name: 'Custom' },
permissions: access.permissions.filter((grant) => !drop(grant.key)),
}
}
export function accessFor(role?: string): Access | undefined {
switch (role) {
case 'owner':
return ownerAccess()
case 'admin':
return adminAccess()
case 'viewer':
return customAccess('content.read, settings.view', 'Viewer')
case 'keyholder':
return customAccess('content.read, apikeys.view=own, apikeys.create', 'Key holder')
case 'noschema':
return withoutKeys(ownerAccess(), (key) => key.startsWith('schema.'))
case 'member':
case 'editor':
case 'verified':
return writerAccess()
case 'user':
return noAccess()
default:
return undefined
}
}
export function meBody(role?: string, extra: Record<string, unknown> = {}) {
const access = accessFor(role)
return { ...(access ? { access } : {}), ...extra }
}
export function roleRefFor(role?: string | null): RoleRef | null {
switch (role) {
case 'owner':
return { ...OWNER_ROLE }
case 'admin':
return { ...ADMIN_ROLE }
case 'member':
case 'editor':
return { ...WRITER_ROLE }
default:
return null
}
}
+2 -1
View File
@@ -40,6 +40,7 @@ const {
people,
chips,
isFullView,
canExport,
isEmpty,
exportDisabled,
showListSkeleton,
@@ -141,7 +142,7 @@ function goHome() {
<p class="text-muted">{{ subtitle }}</p>
</div>
<ActivityExportMenu
v-if="isFullView && !forbidden"
v-if="canExport && !forbidden"
:total="total"
:is-desktop="isDesktop"
:disabled="exportDisabled || exportFlow.currentBusy.value"
+6 -4
View File
@@ -15,6 +15,7 @@ import { useApiKeys, type ApiKey, type CreatedApiKey } from '@/composables/useAp
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { displayPrefix } from '@/lib/apiKeys'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTH_HANDLED_STATUSES = [401, 403]
@@ -27,6 +28,7 @@ type LoadState = 'loading' | 'ready' | 'error'
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const permissions = usePermissionsStore()
const { listApiKeys, revokeApiKey } = useApiKeys()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
@@ -37,7 +39,7 @@ const revealed = ref<{ name: string; key: string } | null>(null)
const revokeDialogOpen = ref(false)
let opener: HTMLElement | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const seesEveryKey = computed(() => permissions.scopeOf('apikeys.view') === 'all')
const isEmpty = computed(() => loadState.value === 'ready' && keys.value.length === 0)
const showList = computed(
() => loadState.value === 'loading' || (loadState.value === 'ready' && keys.value.length > 0),
@@ -47,7 +49,7 @@ const anyDialogOpen = computed(
() => createOpen.value || revealed.value !== null || revokeDialogOpen.value,
)
const description = computed(() =>
isOwner.value
seesEveryKey.value
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
: t('Keys act as you, with your role. You only see your own keys.'),
)
@@ -329,14 +331,14 @@ onMounted(loadKeys)
v-if="isDesktop"
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
:show-created-by="seesEveryKey"
@revoke="confirmRevoke"
/>
<ApiKeyCardList
v-else
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
:show-created-by="seesEveryKey"
@revoke="confirmRevoke"
/>
</template>
+13 -3
View File
@@ -14,12 +14,22 @@ import AppPaginator from '@/components/AppPaginator.vue'
import { useArticleList } from '@/composables/useArticleList'
import { useArticleRowActions } from '@/composables/useArticleRowActions'
import { useArticleSites } from '@/composables/useArticleSites'
import { useContentAccess } from '@/composables/useContentAccess'
import type { Article } from '@/composables/useArticles'
import { effectiveSort } from '@/lib/articleListQuery'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
const DEFAULT_SKELETON_ROWS = 6
const router = useRouter()
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
const writerLine = computed(() =>
access.canCreate.value &&
access.updateScope.value === 'all' &&
access.publishScope.value === 'none'
? t('You can write and edit all articles. Publishing is done by an editor.')
: t("Everything you've written, online and in draft."),
)
const list = useArticleList({
sitesSettled: () => siteList.sitesSettled.value,
@@ -132,11 +142,10 @@ onMounted(() => {
>
{{ t('Articles') }}
</h1>
<p class="text-muted">
{{ t("Everything you've written, online and in draft.") }}
</p>
<p class="text-muted">{{ writerLine }}</p>
</div>
<UButton
v-if="access.canCreate.value"
icon="i-lucide-plus"
class="hidden h-8 px-3 sm:flex"
@click="openNewArticleModal"
@@ -214,6 +223,7 @@ onMounted(() => {
</div>
<UButton
v-if="access.canCreate.value"
icon="i-lucide-plus"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
@click="openNewArticleModal"
+12 -5
View File
@@ -30,6 +30,7 @@ import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
@@ -44,6 +45,7 @@ type PanelTarget =
const route = useRoute()
const session = useSessionStore()
const permissions = usePermissionsStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
@@ -65,7 +67,9 @@ const linkTarget = ref<Author | null>(null)
const deleteOptions = ref<ReassignOption[]>([])
const destination = ref<string | number | undefined>(undefined)
const canManage = computed(() => session.canManageContent)
const canManage = computed(() => permissions.scopeOf('authors.edit') === 'all')
const canAdd = computed(() => permissions.can('authors.create'))
const canDelete = computed(() => permissions.can('authors.delete'))
const firstRun = computed(
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
)
@@ -86,7 +90,7 @@ const overlayOpen = computed(
const showPinnedButton = computed(
() =>
!isDesktop.value &&
canManage.value &&
canAdd.value &&
!overlayOpen.value &&
loadState.value === 'ready' &&
!firstRun.value,
@@ -411,7 +415,7 @@ function menuItems(author: Author): DirectoryMenuItem[] {
onSelect: (trigger) => openEdit(author, trigger),
},
]
if (!author.is_linked) {
if (canManage.value && !author.is_linked) {
items.push({
key: 'link',
label: t('Link user'),
@@ -419,6 +423,9 @@ function menuItems(author: Author): DirectoryMenuItem[] {
onSelect: (trigger) => openLink(author, trigger),
})
}
if (!canDelete.value) {
return items
}
items.push({
key: 'delete',
label: t('Delete…'),
@@ -443,7 +450,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q
</h1>
<p class="text-muted">{{ description }}</p>
</div>
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
<DirectoryAddButton v-if="isDesktop && canAdd" @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
</div>
@@ -486,7 +493,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q
description: 'max-w-85 text-pretty text-muted',
}"
>
<template v-if="canManage" #actions>
<template v-if="canAdd" #actions>
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
{{ t('Add author') }}
</UButton>
+22 -9
View File
@@ -3,7 +3,6 @@ import { formatNumber, t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -13,7 +12,8 @@ import CacheSkeleton from '@/components/variants/CacheSkeleton.vue'
import CacheStatTile from '@/components/variants/CacheStatTile.vue'
import CacheTopImagesCard from '@/components/variants/CacheTopImagesCard.vue'
import TypeToConfirmDialog from '@/components/variants/TypeToConfirmDialog.vue'
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
import NoAccessState from '@/components/NoAccessState.vue'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
import type {
CacheImageStat,
@@ -25,6 +25,7 @@ import { cacheSize } from '@/lib/variants/cacheFormat'
import { isHandledByApiClient, isStatus } from '@/lib/variants/variantErrors'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import { useVariantCacheStore } from '@/stores/variantCache'
const DESKTOP_QUERY = '(min-width: 40rem)'
@@ -32,8 +33,8 @@ const SKELETON_DELAY_MS = 150
const NOT_FOUND = 404
const CONFIRM_WORD = 'purge'
const router = useRouter()
const session = useSessionStore()
const permissions = usePermissionsStore()
const store = useVariantCacheStore()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
@@ -47,9 +48,8 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const canManage = computed(() => permissions.can('settings.view'))
const canEdit = computed(() => permissions.can('settings.edit_general'))
const forbidden = computed(
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
)
@@ -57,10 +57,15 @@ const stats = computed(() => store.stats)
const ready = computed(() => store.loadState === 'ready' && stats.value !== null)
const dimmed = computed(() => store.isPurging)
const purgeUnusedDisabled = computed(
() => !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
() =>
!canEdit.value || !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
)
const purgeAllDisabled = computed(
() => !ready.value || store.isPurging || (stats.value?.generated_files ?? 0) === 0,
() =>
!canEdit.value ||
!ready.value ||
store.isPurging ||
(stats.value?.generated_files ?? 0) === 0,
)
function clearSkeletonTimer() {
@@ -295,7 +300,7 @@ function onPurgeImage(item: CacheImageStat) {
/>
</template>
<VariantsForbidden v-if="forbidden" @home="router.push('/home')" />
<NoAccessState v-if="forbidden" />
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
@@ -339,6 +344,14 @@ function onPurgeImage(item: CacheImageStat) {
</div>
</div>
<ReadOnlyBanner
v-if="!canEdit"
:title="t('You can read these settings but not change them')"
:description="
t('Ask the owner to change them, or ask for a role that can edit settings.')
"
/>
<VariantsTabs active="cache" />
<CacheSkeleton
+3 -1
View File
@@ -7,16 +7,18 @@ import DashboardShell from '@/components/DashboardShell.vue'
import { useContentListPage } from '@/composables/useContentListPage'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const props = defineProps<{ typeKey: string }>()
const session = useSessionStore()
const permissions = usePermissionsStore()
const detail = useContentTypeDetail(
() => props.typeKey,
() => session.status === 'ready',
)
const page = useContentListPage(props.typeKey, detail, () => session.canManageContent)
const page = useContentListPage(props.typeKey, detail, () => permissions.can('schema.edit'))
const notFound = computed(() => page.view.value === 'notFound')
const centered = computed(() => page.centered.value)
+15 -1
View File
@@ -24,11 +24,13 @@ import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
import { useContentAccess } from '@/composables/useContentAccess'
import { useEditorFocus } from '@/composables/useEditorFocus'
import { useEditorImages } from '@/composables/useEditorImages'
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
import { useSeoDismissals } from '@/composables/useSeoDismissals'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site } from '@/composables/useSites'
import { toBylines } from '@/lib/articleMeta'
@@ -232,7 +234,15 @@ const overflowItems = computed(() => [
],
])
const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
const contentAccess = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
const articleAccess = computed(() => contentAccess.rowAccess(article.value?.created_by))
const canPublish = computed(() => contentAccess.canPublish(article.value?.created_by))
const saveEnabled = computed(
() =>
draft.state.value === 'dirty' &&
loadState.value === 'loaded' &&
articleAccess.value.canEdit,
)
const publishDisabled = computed(() => draft.state.value === 'offline')
@@ -426,6 +436,9 @@ useUnsavedChangesGuard({
:save-enabled="saveEnabled"
:publish-pending="publishPending"
:publish-disabled="publishDisabled"
:can-publish="canPublish"
:can-delete="articleAccess.canDelete"
:delete-reason="articleAccess.deleteReason"
@back="goBack"
@retry="retrySave"
@save="performSave"
@@ -526,6 +539,7 @@ useUnsavedChangesGuard({
:save-enabled="saveEnabled"
:publish-pending="publishPending"
:publish-disabled="publishDisabled"
:can-publish="canPublish"
@details="detailsOpen = true"
@save="performSave"
@publish="handlePublish"
+6 -4
View File
@@ -11,6 +11,7 @@ import RecentActivityCard from '@/components/RecentActivityCard.vue'
import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
import { useUpdateStore } from '@/stores/update'
import { accountName } from '@/lib/accountName'
import { formatRelativeDate } from '@/lib/relativeDate'
@@ -24,6 +25,7 @@ type LoadState = 'loading' | 'default' | 'empty' | 'error'
const router = useRouter()
const toast = useLocalizedToast()
const session = useSessionStore()
const permissions = usePermissionsStore()
const update = useUpdateStore()
const { listArticles } = useArticles()
@@ -153,9 +155,9 @@ function dismissBanner() {
}
watch(
() => update.isOwner,
(isOwner) => {
if (isOwner) {
() => update.canApply,
(canApply) => {
if (canApply) {
update.load()
}
},
@@ -337,7 +339,7 @@ onMounted(loadArticles)
</template>
</UAlert>
<RecentActivityCard />
<RecentActivityCard v-if="permissions.scopeOf('activity.view') !== 'none'" />
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
<div
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more