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 });