feat: a dashboard for clients, behind a secret link

Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.

REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 10:39:26 +02:00
parent 4dc673997d
commit 8fe581ff3e
44 changed files with 3299 additions and 12 deletions

View File

@@ -10680,6 +10680,324 @@
]
}
},
"/api/projects/{project}/share": {
"get": {
"operationId": "SharingController_share",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardShareDto"
}
}
}
},
"204": {
"description": "It has none"
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "The project's link for clients, if any",
"tags": [
"Projects"
]
},
"put": {
"operationId": "SharingController_save",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShareDashboardDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardShareDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Turn the link on or change its blocks (renew: a new secret)",
"tags": [
"Projects"
]
},
"delete": {
"operationId": "SharingController_remove",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "The link stops working",
"tags": [
"Projects"
]
}
},
"/api/shared/dashboards/{token}": {
"get": {
"description": "Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.",
"operationId": "SharingController_shared",
"parameters": [
{
"name": "token",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedDashboardDto"
}
}
}
}
},
"summary": "What a link for clients shows (no session)",
"tags": [
"Projects"
],
"security": []
}
},
"/api/projects/{project}/export.csv": {
"get": {
"description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.",
@@ -16285,6 +16603,186 @@
}
}
},
"DashboardShareDto": {
"type": "object",
"properties": {
"projectId": {
"type": "string"
},
"token": {
"type": "string",
"description": "Secret of the link /share/{token}"
},
"blocks": {
"example": [
"flow",
"sprint",
"releases"
],
"type": "array",
"items": {
"type": "string"
}
},
"createdAt": {
"type": "string"
}
},
"required": [
"projectId",
"token",
"blocks",
"createdAt"
]
},
"ShareDashboardDto": {
"type": "object",
"properties": {
"blocks": {
"example": [
"flow",
"sprint",
"releases"
],
"description": "Blocks to show: flow, sprint, releases, epics, activity",
"type": "array",
"items": {
"type": "string"
}
},
"renew": {
"type": "boolean",
"description": "A new secret: the old link stops working"
}
},
"required": [
"blocks"
]
},
"SharedFlowDto": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"name": {
"type": "string"
},
"color": {
"type": "string",
"description": "A colour name of the project settings"
},
"count": {
"type": "number"
}
},
"required": [
"status",
"name",
"color",
"count"
]
},
"SharedEpicDto": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"title": {
"type": "string"
},
"dueDate": {
"type": "string",
"nullable": true
},
"done": {
"type": "number"
},
"total": {
"type": "number"
},
"percent": {
"type": "number"
}
},
"required": [
"key",
"title",
"dueDate",
"done",
"total",
"percent"
]
},
"SharedDashboardDto": {
"type": "object",
"properties": {
"projectKey": {
"type": "string"
},
"projectName": {
"type": "string"
},
"generatedAt": {
"type": "string"
},
"blocks": {
"type": "array",
"items": {
"type": "string"
}
},
"flow": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/SharedFlowDto"
}
},
"sprint": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/SprintPulseDto"
}
]
},
"releases": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/ReleaseForecastDto"
}
},
"epics": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/SharedEpicDto"
}
},
"activity": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/ActivityDayDto"
}
}
},
"required": [
"projectKey",
"projectName",
"generatedAt",
"blocks",
"flow",
"sprint",
"releases",
"epics",
"activity"
]
},
"ImportLineDto": {
"type": "object",
"properties": {