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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user