feat: roles SPA foundations
This commit is contained in:
1 parent
80f7037d3a
commit
907949aa12
180 files changed
+4265
-792
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)!"
|
||||
|
||||
@@ -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)!"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -8,7 +8,8 @@ import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
article: Article
|
||||
state: ArticleDraftState
|
||||
savedAt: Date | null
|
||||
@@ -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') }}
|
||||
|
||||
@@ -4,13 +4,17 @@ import { t } from '@/i18n'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface DirectoryMenuItem {
|
||||
icon: string
|
||||
destructive?: boolean
|
||||
unavailable?: boolean
|
||||
ownerOnly?: boolean
|
||||
hint?: string
|
||||
separated?: boolean
|
||||
onSelect: (trigger: HTMLElement | null) => void
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in new issue
Block a user