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

@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
async function gql<T = Record<string, any>>(
token: string,
token: string | null,
query: string,
variables?: object,
): Promise<T> {
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ query, variables }),
});
@@ -1915,6 +1915,118 @@ describe('task-stream (e2e)', () => {
);
});
it('shares a dashboard with clients through a secret link', async () => {
const key = 'K' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', {
key,
name: `Cliente ${key}`,
});
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
role: 'MEMBER',
});
const epic = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Pagos',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${epic}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Parte',
parent: epic,
});
expect(
(await rest('token-ana', 'GET', `/projects/${key}/share`)).status,
).toBe(204);
// Only known blocks are kept; a member cannot share.
const made = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['epics', 'flow', 'nope'],
});
expect(made.status).toBe(200);
expect(made.body['blocks']).toEqual(['flow', 'epics']);
const token = made.body['token'] as string;
expect(token.length).toBeGreaterThan(30);
expect(
(
await rest('token-bob', 'PUT', `/projects/${key}/share`, {
blocks: ['flow'],
})
).status,
).toBe(403);
// Anyone with the link sees the chosen blocks, nothing else.
const seen = await eventually(
() => rest(null, 'GET', `/shared/dashboards/${token}`),
(r) => r.status === 200 && r.body['epics']?.[0]?.total === 1,
);
expect(seen.body).toMatchObject({
projectKey: key,
sprint: null,
releases: null,
activity: null,
});
expect(
seen.body['flow'].reduce(
(n: number, f: { count: number }) => n + f.count,
0,
),
).toBe(2);
expect(seen.body['epics'][0]).toMatchObject({
key: epic,
title: 'Pagos',
done: 0,
});
const viaGql = await gql(
null,
`query($t: String!) { sharedDashboard(token: $t) { projectKey blocks } }`,
{ t: token },
);
expect(viaGql.sharedDashboard).toEqual({
projectKey: key,
blocks: ['flow', 'epics'],
});
// Changing blocks keeps the link; renewing it kills the old one.
const changed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['activity'],
});
expect(changed.body['token']).toBe(token);
const renewed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['activity'],
renew: true,
});
expect(renewed.body['token']).not.toBe(token);
expect(
(await rest(null, 'GET', `/shared/dashboards/${token}`)).status,
).toBe(404);
const fresh = await rest(
null,
'GET',
`/shared/dashboards/${renewed.body['token']}`,
);
expect(fresh.body['activity']).toHaveLength(84);
expect(fresh.body['flow']).toBeNull();
// Off: the link stops working.
expect(
(await rest('token-ana', 'DELETE', `/projects/${key}/share`)).status,
).toBe(204);
expect(
(await rest(null, 'GET', `/shared/dashboards/${renewed.body['token']}`))
.status,
).toBe(404);
});
it('fills the home page: agenda, activity, the week and who is here', async () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });

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": {

View File

@@ -465,6 +465,16 @@ input CustomFieldValueInput {
value: String
}
"""The project's link for clients (owners and admins)"""
type DashboardShare {
blocks: [String!]!
createdAt: String!
projectId: ID!
"""Secret of the link /share/{token}"""
token: String!
}
"""A commit, branch or pull request that mentions the ticket"""
type DevLink {
at: String!
@@ -1010,6 +1020,9 @@ type Mutation {
"""Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
"""Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare!
"""Start a planned sprint (only one under way per project)"""
startSprint(sprintId: ID!): CommandResult!
@@ -1033,6 +1046,9 @@ type Mutation {
unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""The link stops working"""
unshareDashboard(projectId: ID!): Boolean!
"""Push off on this device"""
unsubscribePush(endpoint: String!): Boolean!
@@ -1456,6 +1472,9 @@ type Query {
): [Activity!]!
automations(projectId: ID!): [Automation!]!
"""The project's link for clients, if any (owners and admins)"""
dashboardShare(projectId: ID!): DashboardShare
"""
A project's epics: top-level tickets that group others, with the progress of those
"""
@@ -1532,6 +1551,9 @@ type Query {
"""My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]!
"""What a link for clients shows, for anyone with it"""
sharedDashboard(token: String!): SharedDashboard
sprint(id: ID!): Sprint!
"""
@@ -1707,6 +1729,50 @@ input SavedFilterCriteriaInput {
unassigned: Boolean
}
"""The project's link for clients"""
input ShareDashboardInput {
"""Blocks to show: flow, sprint, releases, epics, activity"""
blocks: [String!]!
projectId: ID!
"""A new secret: the old link stops working"""
renew: Boolean
}
"""
What a link for clients shows: only the chosen blocks (the others are null)
"""
type SharedDashboard {
activity: [ActivityDay!]
blocks: [String!]!
epics: [SharedEpic!]
flow: [SharedFlow!]
generatedAt: String!
projectKey: String!
projectName: String!
releases: [ReleaseForecast!]
sprint: SprintPulse
}
"""An open epic and how far it got"""
type SharedEpic {
done: Int!
dueDate: String
key: String!
percent: Int!
title: String!
total: Int!
}
"""Tickets in one status"""
type SharedFlow {
"""A colour name of the project settings"""
color: String!
count: Int!
name: String!
status: String!
}
"""A time-boxed iteration: the tickets the team commits to"""
type Sprint {
burndown: SprintBurndown!

View File

@@ -1636,6 +1636,45 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects/{project}/share": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The project's link for clients, if any */
get: operations["SharingController_share"];
/** Turn the link on or change its blocks (renew: a new secret) */
put: operations["SharingController_save"];
post?: never;
/** The link stops working */
delete: operations["SharingController_remove"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/shared/dashboards/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* What a link for clients shows (no session)
* @description Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.
*/
get: operations["SharingController_shared"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/export.csv": {
parameters: {
query?: never;
@@ -3424,6 +3463,59 @@ export interface components {
/** @description Paused webhooks get nothing */
active?: boolean;
};
DashboardShareDto: {
projectId: string;
/** @description Secret of the link /share/{token} */
token: string;
/**
* @example [
* "flow",
* "sprint",
* "releases"
* ]
*/
blocks: string[];
createdAt: string;
};
ShareDashboardDto: {
/**
* @description Blocks to show: flow, sprint, releases, epics, activity
* @example [
* "flow",
* "sprint",
* "releases"
* ]
*/
blocks: string[];
/** @description A new secret: the old link stops working */
renew?: boolean;
};
SharedFlowDto: {
status: string;
name: string;
/** @description A colour name of the project settings */
color: string;
count: number;
};
SharedEpicDto: {
key: string;
title: string;
dueDate: string | null;
done: number;
total: number;
percent: number;
};
SharedDashboardDto: {
projectKey: string;
projectName: string;
generatedAt: string;
blocks: string[];
flow: components["schemas"]["SharedFlowDto"][] | null;
sprint: components["schemas"]["SprintPulseDto"] | null;
releases: components["schemas"]["ReleaseForecastDto"][] | null;
epics: components["schemas"]["SharedEpicDto"][] | null;
activity: components["schemas"]["ActivityDayDto"][] | null;
};
ImportLineDto: {
/** @description Line of the file (the header is 1) */
line: number;
@@ -10881,6 +10973,246 @@ export interface operations {
};
};
};
SharingController_share: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DashboardShareDto"];
};
};
/** @description It has none */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_save: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ShareDashboardDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DashboardShareDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_remove: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_shared: {
parameters: {
query?: never;
header?: never;
path: {
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SharedDashboardDto"];
};
};
};
};
TransferController_exportCsv: {
parameters: {
query?: never;

View File

@@ -24,6 +24,7 @@ describe('appRoutes', () => {
'',
'portfolio',
'explore',
'share/:token',
'cartera',
'explorar',
'p/:key',
@@ -31,10 +32,12 @@ describe('appRoutes', () => {
expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toContain(authGuard);
expect(children[2].canActivate).toBeUndefined();
expect(children[5].canActivate).toBeUndefined();
// The page for clients needs no session: its link is the key.
expect(children[3].canActivate).toBeUndefined();
expect(children[6].canActivate).toBeUndefined();
// The Spanish paths of shared links lead to the English ones.
expect(children[3].redirectTo).toBe('portfolio');
expect(children[4].redirectTo).toBe('explore');
expect(children[4].redirectTo).toBe('portfolio');
expect(children[5].redirectTo).toBe('explore');
});
it('gives each page its own state, not the root', async () => {