diff --git a/README.md b/README.md index 5018dc8..88b8ac1 100644 --- a/README.md +++ b/README.md @@ -186,12 +186,18 @@ rather than risk a false negative. ### Authorization +> The host is IAM-native: route gates (`requireActions` in `plugin.json`) and +> in-handler checks (`ctx.Permissions().Check`) name IAM actions — +> `dashboard:view`, `dashboard:manage`, `views:write`, `projects:read` — and +> roles grant those actions. The `customPermissions` keys below are those same +> action names. + Access to the dashboard *page* is gated separately from access to individual *data*. Two custom permissions, declared in `plugin.json` and each available at both project and global **permission** scope (the same key checked against two different permission maps, mirroring `paca-plugin-time-logging`). This is the only "scope" the permission system itself has — -`requirePermissions`/`customPermissions` entries are always either +`requireActions`/`customPermissions` entries are always either `"project"` (checked against the caller's per-project permission map) or `"global"` (checked against their global permission map). There is no third, "admin" permission scope, even though the paragraphs below also need to talk @@ -200,61 +206,47 @@ plugin's own table that happens to have a value literally named `"admin"` — every occurrence of "admin" below refers to that column's value or to the `/admin/*`-routed pages, never to a permission scope: -- **`dashboard.view`** — see the project's (or admin's) Dashboard page and +- **`dashboard:view`** — see the project's (or admin's) Dashboard page and its panels. -- **`dashboard.manage`** — create, edit, delete, and rearrange panels, and - run query previews while authoring them. Implies `dashboard.view` for - loading purposes — a role with only `dashboard.manage` isn't blocked from - the page itself. - -Every project-*permission*-scope backend route requires `dashboard.view` at -minimum, declared directly in the manifest's `requirePermissions` — +- **`dashboard:manage`** — create, edit, delete, and rearrange panels, and + run query previews while authoring them. Grant it together with + `dashboard:view`: the routes that load the page require `dashboard:view`, + and the manifest can't express "view or manage". + +Every project-*permission*-scope backend route that reads requires +`dashboard:view`, and every one that changes panels (create, edit, delete, +layout, query preview) requires `dashboard:manage`, declared directly in the +manifest's `requireActions` — including the routes shared with dashboards whose `dashboard_views.scope` is `"integration"` (embedded in Backlog/Sprint/Timeline, see "Integration view architecture" above), which the manifest can't distinguish from the `dashboard_views.scope == "project"` singleton by path alone. There is no -"always open" carve-out for the embedded surface: `dashboard.view` gates +"always open" carve-out for the embedded surface: `dashboard:view` gates the whole feature uniformly, project page and embedded views alike. `views.go`'s `loadViewWithPanels` and `getOrCreate*View` handlers re-check this in-handler too, as defense-in-depth against the manifest ever being misedited — not because the manifest leaves any gap today. -On top of that `dashboard.view` floor, mutating a panel requires a second, -narrower check in `panels.go`'s `canManagePanel`, which resolves the +On top of those manifest gates, mutating a panel is re-checked in `panels.go`'s `canManagePanel`, which resolves the request's `dashboard_views.scope` value and picks the (project-permission- scope) permission that matches: -- row's `dashboard_views.scope` is `"project"`: requires `dashboard.manage`. -- row's `dashboard_views.scope` is `"integration"`: requires `views.write` - instead of `dashboard.manage` — an integration dashboard is embedded in, - and shares the write bar of, the view that hosts it. `views.write` is - exactly the permission that already lets an ordinary project Editor - create that hosting view in the first place; requiring anything higher - here would be a dead end (create the view, never populate it). +- row's `dashboard_views.scope` is `"project"` or `"integration"`: requires + `dashboard:manage`. An embedded dashboard is edited with the dashboard's + own permission; `views:write` only lets someone create the hosting view. - row's `dashboard_views.scope` is `"admin"`: never reaches this function — those routes are already fully gated at the manifest level by - `dashboard.manage` at **permission** scope `"global"`. - -This two-layer shape (`dashboard.view` as a uniform manifest-level floor, -a data-scope-specific manage permission checked in-handler on top) is -deliberate: `requirePermissions` ANDs every permission it lists, so putting -`dashboard.manage` directly in the manifest on a route shared with the -`"integration"` data scope would reject a `views.write`-holding Editor -before the in-handler check ever ran. + `dashboard:manage` at **permission** scope `"global"`. `POST /dashboard/query/preview` (project permission scope) has no -`dashboard_views` row to resolve a data scope from — it validates a -not-yet-saved query directly against the caller's project, and is used by -both the project-dashboard editor and the integration-view editor with no -way to tell which from the request alone. Past the manifest's -`dashboard.view` floor, it requires -`dashboard.manage` in-handler (matching that permission's own description, -which promises coverage of "running query previews while authoring" -panels). The one remaining, accepted gap: a hand-crafted custom role -granted only `views.write` (able to populate an integration dashboard) -can't preview a query for it through this route — closing that fully would -need the frontend to pass a `viewId` so the backend can resolve scope, a -small API contract change tracked as a follow-up rather than done here. +`dashboard_views` row to resolve a data scope from, and is used by both the +project-dashboard editor and the integration-view editor. Past the +manifest's `dashboard:view` floor it requires `dashboard:manage` in-handler, +the same permission every panel mutation now needs. + +Every view response carries `can_manage` (the result of the same check), which +the UI uses to hide the add/edit/delete/drag controls; the routes still +enforce it. ### MCP (`mcp/`) @@ -387,15 +379,15 @@ bun run build Routed at `/projects/:projectId/plugins/com.paca.dashboard/dashboard` via the `project` nav item declared in `plugin.json`, requiring the -`dashboard.view` custom permission (see "Authorization" above). Fetches +`dashboard:view` custom permission (see "Authorization" above). Fetches (get-or-creates) the project's singleton dashboard view and renders the -shared panel-grid UI, with panel mutations requiring `dashboard.manage`. +shared panel-grid UI, with panel mutations requiring `dashboard:manage`. ### `admin.page` — `AdminDashboardPage` Routed at `/admin/plugins/com.paca.dashboard/dashboard` via the `admin` nav -item declared in `plugin.json`, requiring the global-scope `dashboard.view` -custom permission (panel mutations require `dashboard.manage`) — see +item declared in `plugin.json`, requiring the global-scope `dashboard:view` +custom permission (panel mutations require `dashboard:manage`) — see "Authorization" above. Fetches (get-or-creates) the instance-wide singleton dashboard view; its panel queries are cross-project and do not require the `{{project_id}}` placeholder. diff --git a/backend/panels.go b/backend/panels.go index b4f609e..2802784 100644 --- a/backend/panels.go +++ b/backend/panels.go @@ -136,35 +136,28 @@ func (p *dashboardPlugin) previewAdminQuery(req *plugin.Request, res *plugin.Res // ("project" | "integration" | "admin" — a data-model value describing // which kind of dashboard the row is, NOT a permission scope; the // permission system itself only ever has two scopes, "project" and -// "global", set on the requirePermissions/customPermissions entries in +// "global", set on the requireActions/customPermissions entries in // plugin.json). A single permission decides the answer here — not two — -// which is exactly why the manifest's own requirePermissions floor on -// these routes is deliberately just projects.read rather than -// projects.write: requirePermissions ANDs every listed permission, so if -// the manifest floor were projects.write, a role granted only -// dashboard.manage would 403 at the middleware layer before this in-handler +// which is exactly why the manifest's own requireActions floor on +// these routes is deliberately just projects:read rather than +// projects:write: requireActions ANDs every listed permission, so if +// the manifest floor were projects:write, a role granted only +// dashboard:manage would 403 at the middleware layer before this in-handler // check ever ran, silently defeating the point of a dedicated permission. // -// - viewScope == "project": requires dashboard.manage (a project-scope -// permission, i.e. checked against the caller's per-project permission -// map). -// - viewScope == "integration": requires views.write instead of -// dashboard.manage — an integration dashboard is embedded in, and -// shares the write bar of, the view that hosts it. views.write is -// exactly the permission that already lets an ordinary Editor create -// that hosting view in the first place; requiring anything higher here -// would be a dead end (create the view, never populate it). Also a -// project-scope permission. +// - viewScope == "project" or "integration": requires dashboard:manage +// (a project-scope permission, i.e. checked against the caller's +// per-project permission map). An embedded dashboard is edited with the +// dashboard's own permission, not the write bar of the view hosting it: +// holding views:write lets someone create the view, not change what the +// dashboard shows. // - viewScope == "admin": never reaches this function — loadViewWithPanels // only lets projectID == "" through the admin routes, which are already -// fully gated at the manifest level by dashboard.manage checked at +// fully gated at the manifest level by dashboard:manage checked at // permission scope "global" (not "admin" — there is no such permission // scope). -func (p *dashboardPlugin) canManagePanel(viewScope string) bool { - if viewScope == "integration" { - return p.perm.Check("views.write") - } - return p.perm.Check("dashboard.manage") +func (p *dashboardPlugin) canManagePanel(_ string) bool { + return p.perm.Check("dashboard:manage") } // requireManagePanel enforces canManagePanel for view, writing the shared @@ -456,11 +449,11 @@ func (p *dashboardPlugin) runPanelQueryForView(req *plugin.Request, res *plugin. // editor can show a live preview before the user hits Save. func (p *dashboardPlugin) previewQuery(req *plugin.Request, res *plugin.Response, requireProjectScope bool) { // The admin path (requireProjectScope == false) is already fully gated - // at the manifest level (dashboard.manage, global scope) — no view to + // at the manifest level (dashboard:manage, global scope) — no view to // resolve a scope from here, so this only applies the project-scope - // check. dashboard.manage's own description promises it covers "running + // check. dashboard:manage's own description promises it covers "running // query previews while authoring" panels, so this must hold even though - // the manifest's own floor is the non-restrictive projects.read (same + // the manifest's own floor is the non-restrictive projects:read (same // reasoning as canManagePanel). if requireProjectScope && !p.canManagePanel("project") { res.Error(403, "you don't have permission to manage this dashboard") diff --git a/backend/plugin_test.go b/backend/plugin_test.go index 7109122..007122b 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -21,8 +21,8 @@ func setupPlugin(t *testing.T) *plugintest.Context { // Default test caller has full dashboard access; tests for the // permission gate itself (TestLoadView_RequiresDashboardView etc.) // explicitly revoke these to exercise the denied path. - tc.Permissions.Grant("dashboard.view") - tc.Permissions.Grant("dashboard.manage") + tc.Permissions.Grant("dashboard:view") + tc.Permissions.Grant("dashboard:manage") tc.DB.SeedRows("dashboard_views", []string{"id", "project_id", "scope", "host_view_id", "name", "created_by", "created_at", "updated_at"}, @@ -261,53 +261,53 @@ func TestGetView_CrossProjectRejected(t *testing.T) { } } -// ── Dashboard permission gate (dashboard.view / dashboard.manage) ─────────── +// ── Dashboard permission gate (dashboard:view / dashboard:manage) ─────────── func TestGetOrCreateProjectView_RequiresDashboardView(t *testing.T) { tc := setupPlugin(t) - tc.Permissions.Revoke("dashboard.view") - tc.Permissions.Revoke("dashboard.manage") + tc.Permissions.Revoke("dashboard:view") + tc.Permissions.Revoke("dashboard:manage") res := tc.Call("GET", "/dashboard/view", callerReq()) if res.StatusCode != 403 { - t.Fatalf("expected 403 for a caller with neither dashboard.view nor dashboard.manage, got %d: %s", res.StatusCode, res.BodyString()) + t.Fatalf("expected 403 for a caller with neither dashboard:view nor dashboard:manage, got %d: %s", res.StatusCode, res.BodyString()) } } func TestGetOrCreateProjectView_ManageAloneIsSufficientToView(t *testing.T) { tc := setupPlugin(t) - tc.Permissions.Revoke("dashboard.view") - // dashboard.manage stays granted (setupPlugin's default) — manage alone + tc.Permissions.Revoke("dashboard:view") + // dashboard:manage stays granted (setupPlugin's default) — manage alone // must still be enough to load the view. res := tc.Call("GET", "/dashboard/view", callerReq()) if res.StatusCode != 200 { - t.Fatalf("expected 200 for a caller with only dashboard.manage, got %d: %s", res.StatusCode, res.BodyString()) + t.Fatalf("expected 200 for a caller with only dashboard:manage, got %d: %s", res.StatusCode, res.BodyString()) } } // TestGetOrCreateIntegrationView_RequiresDashboardView pins the current -// design: dashboard.view gates every dashboard surface uniformly, including +// design: dashboard:view gates every dashboard surface uniformly, including // integration-scope dashboards embedded in Backlog/Sprint/Timeline — not -// just the dedicated project-scope singleton page. Revoking dashboard.view +// just the dedicated project-scope singleton page. Revoking dashboard:view // from a role now hides the whole feature, embedded views included; there // is no "always open" carve-out for the embedded surface. func TestGetOrCreateIntegrationView_RequiresDashboardView(t *testing.T) { tc := setupPlugin(t) - tc.Permissions.Revoke("dashboard.view") - tc.Permissions.Revoke("dashboard.manage") + tc.Permissions.Revoke("dashboard:view") + tc.Permissions.Revoke("dashboard:manage") res := tc.Call("GET", "/dashboard/view/:hostViewId", withPathParams(callerReq(), map[string]string{"hostViewId": testHostViewID})) if res.StatusCode != 403 { - t.Fatalf("expected 403 for an integration-scope view without dashboard.view, got %d: %s", res.StatusCode, res.BodyString()) + t.Fatalf("expected 403 for an integration-scope view without dashboard:view, got %d: %s", res.StatusCode, res.BodyString()) } } func TestCreatePanel_RequiresDashboardManage(t *testing.T) { tc := setupPlugin(t) view := decodeData[dashboardView](t, tc.Call("GET", "/dashboard/view", callerReq())) - tc.Permissions.Revoke("dashboard.manage") // dashboard.view stays granted + tc.Permissions.Revoke("dashboard:manage") // dashboard:view stays granted res := tc.Call("POST", "/dashboard/views/:viewId/panels", withPathParams(callerReq(), map[string]string{"viewId": view.ID}). @@ -317,7 +317,7 @@ func TestCreatePanel_RequiresDashboardManage(t *testing.T) { "content": "should be rejected before it's ever validated", })) if res.StatusCode != 403 { - t.Fatalf("expected 403 for a caller with dashboard.view but not dashboard.manage, got %d: %s", res.StatusCode, res.BodyString()) + t.Fatalf("expected 403 for a caller with dashboard:view but not dashboard:manage, got %d: %s", res.StatusCode, res.BodyString()) } } @@ -568,53 +568,49 @@ func TestRunPanelQuery_ScopesToOwnProject(t *testing.T) { } // TestPreviewQuery_RequiresDashboardManage pins the fix for the -// query/preview route relying solely on the manifest's projects.read floor -// with no in-handler check at all — dashboard.manage's own description +// query/preview route relying solely on the manifest's projects:read floor +// with no in-handler check at all — dashboard:manage's own description // promises it covers "running query previews while authoring" panels, so a // caller without it must be rejected here too, not just on save. func TestPreviewQuery_RequiresDashboardManage(t *testing.T) { tc := setupPlugin(t) - tc.Permissions.Revoke("dashboard.manage") + tc.Permissions.Revoke("dashboard:manage") res := tc.Call("POST", "/dashboard/query/preview", callerReq().WithJSONBody(map[string]string{ "query": "SELECT id FROM tasks WHERE project_id = {{project_id}}", })) if res.StatusCode != 403 { - t.Fatalf("expected 403 for a caller without dashboard.manage, got %d: %s", res.StatusCode, res.BodyString()) + t.Fatalf("expected 403 for a caller without dashboard:manage, got %d: %s", res.StatusCode, res.BodyString()) } } -// TestCreatePanel_IntegrationScope_RequiresViewsWrite pins the fix for -// integration-scope panel mutations previously having no permission check -// at all in-handler (only the manifest's projects.write, which an ordinary -// Editor — who can create the hosting integration view via views.write — -// doesn't hold, making the view creatable but never populatable). Panel -// mutations on an integration view now require views.write specifically, -// not dashboard.manage — matching the permission that already gates -// creating the view itself. -func TestCreatePanel_IntegrationScope_RequiresViewsWrite(t *testing.T) { +// TestCreatePanel_IntegrationScope_RequiresDashboardManage pins that panel +// mutations on an embedded (integration) dashboard need dashboard:manage; +// views:write, which only lets a caller create the hosting view, is not enough. +func TestCreatePanel_IntegrationScope_RequiresDashboardManage(t *testing.T) { tc := setupPlugin(t) view := decodeData[dashboardView](t, tc.Call("GET", "/dashboard/view/:hostViewId", withPathParams(callerReq(), map[string]string{"hostViewId": testHostViewID}))) + body := map[string]any{"type": "text", "title": "Notes", "content": "x"} + create := func() *plugin.Response { + return tc.Call("POST", "/dashboard/views/:viewId/panels", + withPathParams(callerReq(), map[string]string{"viewId": view.ID}).WithJSONBody(body)) + } - // dashboard.manage alone must NOT be sufficient for an integration view. - tc.Permissions.Revoke("views.write") - denied := tc.Call("POST", "/dashboard/views/:viewId/panels", - withPathParams(callerReq(), map[string]string{"viewId": view.ID}). - WithJSONBody(map[string]any{"type": "text", "title": "Notes", "content": "x"})) - if denied.StatusCode != 403 { - t.Fatalf("expected 403 for an integration view without views.write, got %d: %s", denied.StatusCode, denied.BodyString()) + tc.Permissions.Revoke("dashboard:manage") + tc.Permissions.Grant("views:write") + if r := create(); r.StatusCode != 403 { + t.Fatalf("expected 403 with views:write but not dashboard:manage, got %d: %s", r.StatusCode, r.BodyString()) + } + if v := decodeData[dashboardView](t, tc.Call("GET", "/dashboard/view/:hostViewId", + withPathParams(callerReq(), map[string]string{"hostViewId": testHostViewID}))); v.CanManage { + t.Fatal("can_manage must be false without dashboard:manage") } - // views.write alone (without dashboard.manage) must be sufficient. - tc.Permissions.Revoke("dashboard.manage") - tc.Permissions.Grant("views.write") - allowed := tc.Call("POST", "/dashboard/views/:viewId/panels", - withPathParams(callerReq(), map[string]string{"viewId": view.ID}). - WithJSONBody(map[string]any{"type": "text", "title": "Notes", "content": "x"})) - if allowed.StatusCode != 201 { - t.Fatalf("expected 201 for an integration view with views.write but not dashboard.manage, got %d: %s", allowed.StatusCode, allowed.BodyString()) + tc.Permissions.Grant("dashboard:manage") + if r := create(); r.StatusCode != 201 { + t.Fatalf("expected 201 with dashboard:manage, got %d: %s", r.StatusCode, r.BodyString()) } } diff --git a/backend/types.go b/backend/types.go index 33fa8ba..bf8a93f 100644 --- a/backend/types.go +++ b/backend/types.go @@ -25,9 +25,9 @@ type dashboardPanel struct { // dashboardView is one dashboard (project/admin/integration scope) with its // panels embedded. type dashboardView struct { - ID string `json:"id"` - ProjectID *string `json:"project_id,omitempty"` - Scope string `json:"scope"` // "project" | "admin" | "integration" + ID string `json:"id"` + ProjectID *string `json:"project_id,omitempty"` + Scope string `json:"scope"` // "project" | "admin" | "integration" // HostViewID is set only for scope="integration": the id of the host's // own interaction-view row (a "Dashboard"-type view in Backlog/Sprint/ // Timeline) this dashboard belongs to — exactly one dashboard per host @@ -35,8 +35,11 @@ type dashboardView struct { HostViewID *string `json:"host_view_id,omitempty"` Name string `json:"name"` Panels []dashboardPanel `json:"panels"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // CanManage is whether the caller may add, edit, delete and rearrange + // panels on this view. Set per request; the write routes enforce it too. + CanManage bool `json:"can_manage"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // panelLayoutEntry is one row of a bulk drag/resize layout update. diff --git a/backend/views.go b/backend/views.go index 7acae32..c5ec61d 100644 --- a/backend/views.go +++ b/backend/views.go @@ -24,7 +24,7 @@ import ( // project's single dashboard, creating an empty one on first visit. func (p *dashboardPlugin) getOrCreateProjectView(req *plugin.Request, res *plugin.Response) { // Defense-in-depth: this route is already gated at the manifest level - // (requirePermissions dashboard.view), but every other project-scope + // (requireActions dashboard:view), but every other project-scope // entry point re-checks in-handler too (see loadViewWithPanels below), // so this one shouldn't be the sole exception if the manifest is ever // misedited. @@ -39,6 +39,7 @@ func (p *dashboardPlugin) getOrCreateProjectView(req *plugin.Request, res *plugi res.Error(500, "failed to load project dashboard") return } + view.CanManage = p.canManageView(view.Scope) ok(res, view) } @@ -54,6 +55,7 @@ func (p *dashboardPlugin) getOrCreateAdminView(req *plugin.Request, res *plugin. res.Error(500, "failed to load admin dashboard") return } + view.CanManage = p.canManageView(view.Scope) ok(res, view) } @@ -68,8 +70,8 @@ func (p *dashboardPlugin) getOrCreateAdminView(req *plugin.Request, res *plugin. // just keyed by hostViewId instead of projectID/"". func (p *dashboardPlugin) getOrCreateIntegrationView(req *plugin.Request, res *plugin.Response) { // Defense-in-depth, mirroring getOrCreateProjectView above: this route - // is already gated at the manifest level (requirePermissions - // dashboard.view) so this one shouldn't be the sole exception if the + // is already gated at the manifest level (requireActions + // dashboard:view) so this one shouldn't be the sole exception if the // manifest is ever misedited. if !p.canViewDashboard() { res.Error(403, "you don't have permission to view this dashboard") @@ -87,6 +89,7 @@ func (p *dashboardPlugin) getOrCreateIntegrationView(req *plugin.Request, res *p res.Error(500, "failed to load dashboard view") return } + view.CanManage = p.canManageView(view.Scope) ok(res, view) } @@ -152,6 +155,7 @@ func (p *dashboardPlugin) getView(req *plugin.Request, res *plugin.Response) { if !loaded { return } + view.CanManage = p.canManageView(view.Scope) ok(res, view) } @@ -271,7 +275,7 @@ func (p *dashboardPlugin) loadViewWithPanels(viewID, projectID string, res *plug } // Permission check: views whose dashboard_views.scope is "project" or - // "integration" require dashboard.view/dashboard.manage — a + // "integration" require dashboard:view/dashboard:manage — a // project-scope permission pair, checked against the caller's // per-project permission map. The manifest already enforces this // uniformly for every route that reaches here (see plugin.json); this @@ -279,8 +283,8 @@ func (p *dashboardPlugin) loadViewWithPanels(viewID, projectID string, res *plug // enforcement if the manifest is ever misedited. Rows with // dashboard_views.scope == "admin" are excluded from this check: they // only ever reach here via the admin routes (projectID == ""), which - // are already fully gated at the manifest level by dashboard.view/ - // dashboard.manage checked at permission scope "global" — not "admin"; + // are already fully gated at the manifest level by dashboard:view/ + // dashboard:manage checked at permission scope "global" — not "admin"; // the permission system has no such scope, dashboard_views.scope is an // unrelated data-model value that happens to share the word "admin". if v.Scope != "admin" && !p.canViewDashboard() { @@ -299,13 +303,25 @@ func (p *dashboardPlugin) loadViewWithPanels(viewID, projectID string, res *plug } // canViewDashboard reports whether the caller may view a project-scope -// dashboard — dashboard.view, or dashboard.manage as a superset (a caller +// dashboard — dashboard:view, or dashboard:manage as a superset (a caller // who can manage a dashboard can always view it too). Single source of // truth for that view-or-manage relationship, mirroring canManagePanel's // shape in panels.go, so the three call sites above stay in sync rather // than each hand-rolling the same two-permission check. func (p *dashboardPlugin) canViewDashboard() bool { - return p.perm.Check("dashboard.view") || p.perm.Check("dashboard.manage") + return p.perm.Check("dashboard:view") || p.perm.Check("dashboard:manage") +} + +// canManageView reports whether the caller may edit the panels of a view of +// the given scope, so the UI can hide controls the write routes would refuse. +// The admin scope's routes are gated by dashboard:manage at the manifest, so +// it is checked here directly (globally); the others go through +// canManagePanel. The write handlers still enforce it themselves. +func (p *dashboardPlugin) canManageView(viewScope string) bool { + if viewScope == "admin" { + return p.perm.Check("dashboard:manage") + } + return p.canManagePanel(viewScope) } func viewFromRow(cols []string, row []any) dashboardView { diff --git a/frontend/src/AdminDashboardPage.tsx b/frontend/src/AdminDashboardPage.tsx index 06528cc..ba1a8a5 100644 --- a/frontend/src/AdminDashboardPage.tsx +++ b/frontend/src/AdminDashboardPage.tsx @@ -41,7 +41,7 @@ function Content({ api }: AdminPageProps) { {viewQuery.isLoading &&

Loading dashboard…

} {viewQuery.isError &&

Failed to load the admin dashboard.

} - {viewQuery.data && } + {viewQuery.data && } ); } diff --git a/frontend/src/DashboardIntegrationView.tsx b/frontend/src/DashboardIntegrationView.tsx index 98ddae6..3bf77d4 100644 --- a/frontend/src/DashboardIntegrationView.tsx +++ b/frontend/src/DashboardIntegrationView.tsx @@ -36,7 +36,7 @@ function Content({ api, viewId }: ViewExtensionProps) { return (
{viewQuery.data && ( - + )}
); diff --git a/frontend/src/ProjectDashboardPage.tsx b/frontend/src/ProjectDashboardPage.tsx index c070dbe..2822862 100644 --- a/frontend/src/ProjectDashboardPage.tsx +++ b/frontend/src/ProjectDashboardPage.tsx @@ -39,7 +39,7 @@ function Content({ api }: ProjectPageProps) { {viewQuery.isLoading &&

Loading dashboard…

} {viewQuery.isError &&

Failed to load this project's dashboard.

} - {viewQuery.data && } + {viewQuery.data && } ); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3a256af..72098ce 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -35,6 +35,9 @@ export interface DashboardView { host_view_id?: string | null; name: string; panels: DashboardPanel[]; + /** Whether the caller may add/edit/delete/rearrange panels. The backend + * enforces it on every write route; this only hides the controls. */ + can_manage: boolean; created_at: string; updated_at: string; } diff --git a/plugin.json b/plugin.json index 3ee185e..d35ab39 100644 --- a/plugin.json +++ b/plugin.json @@ -2,33 +2,21 @@ "id": "com.paca.dashboard", "displayName": "Dashboard", "description": "Customizable panel-based dashboard builder (charts, tables, text) across the project dashboard page, the admin dashboard page, and a single dashboard per Integration-page view (backlog/sprint/timeline).", - "version": "0.4.0", - "minCoreVersion": "v0.13.3", + "version": "0.4.1", + "minCoreVersion": "v0.19.0", "permissions": ["db.read", "db.write", "events.subscribe", "cache"], "customPermissions": [ { - "key": "dashboard.view", + "key": "dashboard:view", + "scope": "both", "label": "View dashboard", - "description": "View the project's \"Dashboard\" page and its panels. Does not affect the per-view dashboards embedded in Backlog/Sprint/Timeline pages, which any project member can already see.", - "scope": "project" + "description": "View the Dashboard page and its panels. Grant on a project resource for the project dashboard, or on a global resource for the instance-wide cross-project dashboard." }, { - "key": "dashboard.manage", + "key": "dashboard:manage", + "scope": "both", "label": "Manage dashboard", - "description": "Create, edit, delete, and rearrange panels on the project's \"Dashboard\" page, and run query previews while authoring them.", - "scope": "project" - }, - { - "key": "dashboard.view", - "label": "View dashboard", - "description": "View the admin-sidebar \"Dashboard\" page — the instance-wide, cross-project dashboard.", - "scope": "global" - }, - { - "key": "dashboard.manage", - "label": "Manage dashboard", - "description": "Create, edit, delete, and rearrange panels on the admin-sidebar \"Dashboard\" page, and run query previews while authoring them.", - "scope": "global" + "description": "Create, edit, delete, and rearrange panels on a Dashboard page, and run query previews while authoring them. Grant on a project resource for the project dashboard, or on a global resource for the instance-wide dashboard." } ], "backend": { @@ -40,7 +28,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:view"] } ] }, { @@ -49,7 +37,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:view"] } ] }, { @@ -58,7 +46,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:view"] } ] }, { @@ -67,7 +55,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:manage"] } ] }, { @@ -76,7 +64,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:manage"] } ] }, { @@ -85,7 +73,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:manage"] } ] }, { @@ -94,7 +82,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:manage"] } ] }, { @@ -103,7 +91,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:view"] } ] }, { @@ -112,7 +100,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "project", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "project", "actions": ["dashboard:manage"] } ] }, { @@ -121,7 +109,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:view"] } ] }, { @@ -130,7 +118,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.manage"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:manage"] } ] }, { @@ -139,7 +127,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.manage"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:manage"] } ] }, { @@ -148,7 +136,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.manage"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:manage"] } ] }, { @@ -157,7 +145,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.manage"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:manage"] } ] }, { @@ -166,7 +154,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.view"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:view"] } ] }, { @@ -175,7 +163,7 @@ "middlewares": [ { "name": "optionalAuthn" }, { "name": "requireFreshPassword" }, - { "name": "requirePermissions", "scope": "global", "permissions": ["dashboard.manage"] } + { "name": "requireActions", "scope": "global", "actions": ["dashboard:manage"] } ] } ] @@ -208,7 +196,7 @@ "icon": "LayoutDashboard", "component": "ProjectDashboardPage", "order": 5, - "requiredPermission": "dashboard.view" + "requiredPermission": "dashboard:view" }, { "scope": "admin", @@ -217,7 +205,7 @@ "icon": "LayoutDashboard", "component": "AdminDashboardPage", "order": 5, - "requiredPermission": "dashboard.view" + "requiredPermission": "dashboard:view" } ] },