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

@@ -0,0 +1,48 @@
import { inject, Injectable } from '@angular/core';
import {
DashboardShare,
SharedBlock,
SharedDashboard,
SharingGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
DashboardShareDocument,
ShareDashboardDocument,
SharedDashboardDocument,
UnshareDashboardDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlSharingGateway extends SharingGateway {
private readonly gql = inject(GraphqlOperations);
share(projectId: string): Observable<DashboardShare | null> {
return this.gql
.query(DashboardShareDocument, { projectId })
.pipe(map((d) => (d.dashboardShare as DashboardShare | null) ?? null));
}
save(
projectId: string,
blocks: SharedBlock[],
renew: boolean,
): Observable<DashboardShare> {
return this.gql
.mutate(ShareDashboardDocument, { input: { projectId, blocks, renew } })
.pipe(map((d) => d.shareDashboard as DashboardShare));
}
remove(projectId: string): Observable<void> {
return this.gql
.mutate(UnshareDashboardDocument, { projectId })
.pipe(map(() => undefined));
}
shared(token: string): Observable<SharedDashboard | null> {
return this.gql
.query(SharedDashboardDocument, { token })
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
}
}

View File

@@ -329,6 +329,15 @@ export type SavedFilterCriteriaInput = {
unassigned?: boolean | null | undefined;
};
/** The project's link for clients */
export type ShareDashboardInput = {
/** Blocks to show: flow, sprint, releases, epics, activity */
blocks: Array<string>;
projectId: string;
/** A new secret: the old link stops working */
renew?: boolean | null | undefined;
};
/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
@@ -2437,6 +2446,98 @@ export type DeleteMyViewMutationVariables = Exact<{
export type DeleteMyViewMutation = { deleteMyView: boolean };
export type DashboardShareFieldsFragment = {
projectId: string;
token: string;
blocks: Array<string>;
createdAt: string;
};
export type DashboardShareQueryVariables = Exact<{
projectId: string;
}>;
export type DashboardShareQuery = {
dashboardShare: {
projectId: string;
token: string;
blocks: Array<string>;
createdAt: string;
} | null;
};
export type ShareDashboardMutationVariables = Exact<{
input: ShareDashboardInput;
}>;
export type ShareDashboardMutation = {
shareDashboard: {
projectId: string;
token: string;
blocks: Array<string>;
createdAt: string;
};
};
export type UnshareDashboardMutationVariables = Exact<{
projectId: string;
}>;
export type UnshareDashboardMutation = { unshareDashboard: boolean };
export type SharedDashboardQueryVariables = Exact<{
token: string;
}>;
export type SharedDashboardQuery = {
sharedDashboard: {
projectKey: string;
projectName: string;
generatedAt: string;
blocks: Array<string>;
flow: Array<{
status: string;
name: string;
color: string;
count: number;
}> | null;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
} | null;
releases: Array<{
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
likelyDate: string | null;
safeDate: string | null;
verdict: string;
}> | null;
epics: Array<{
key: string;
title: string;
dueDate: string | null;
done: number;
total: number;
percent: number;
}> | null;
activity: Array<{ day: string; count: number }> | null;
} | null;
};
export type SprintFieldsFragment = {
id: string;
projectId: string;
@@ -3948,6 +4049,28 @@ export const SavedFilterFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
export const DashboardShareFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'DashboardShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'DashboardShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>;
export const SprintFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -14377,6 +14500,391 @@ export const DeleteMyViewDocument = {
DeleteMyViewMutation,
DeleteMyViewMutationVariables
>;
export const DashboardShareDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'DashboardShare' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'dashboardShare' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'DashboardShareFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'DashboardShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'DashboardShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<DashboardShareQuery, DashboardShareQueryVariables>;
export const ShareDashboardDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ShareDashboard' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ShareDashboardInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'shareDashboard' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'DashboardShareFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'DashboardShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'DashboardShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
ShareDashboardMutation,
ShareDashboardMutationVariables
>;
export const UnshareDashboardDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnshareDashboard' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unshareDashboard' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
UnshareDashboardMutation,
UnshareDashboardMutationVariables
>;
export const SharedDashboardDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'SharedDashboard' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'token' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sharedDashboard' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'token' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'token' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectName' } },
{ kind: 'Field', name: { kind: 'Name', value: 'generatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'flow' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'status' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'color' } },
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releases' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'targetDate' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'likelyDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'safeDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'verdict' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'epics' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'dueDate' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'percent' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'activity' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
SharedDashboardQuery,
SharedDashboardQueryVariables
>;
export const SprintsDocument = {
kind: 'Document',
definitions: [

View File

@@ -0,0 +1,74 @@
fragment DashboardShareFields on DashboardShare {
projectId
token
blocks
createdAt
}
query DashboardShare($projectId: ID!) {
dashboardShare(projectId: $projectId) {
...DashboardShareFields
}
}
mutation ShareDashboard($input: ShareDashboardInput!) {
shareDashboard(input: $input) {
...DashboardShareFields
}
}
mutation UnshareDashboard($projectId: ID!) {
unshareDashboard(projectId: $projectId)
}
query SharedDashboard($token: String!) {
sharedDashboard(token: $token) {
projectKey
projectName
generatedAt
blocks
flow {
status
name
color
count
}
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
releases {
releaseId
name
targetDate
total
finished
likelyDate
safeDate
verdict
}
epics {
key
title
dueDate
done
total
percent
}
activity {
day
count
}
}
}

View File

@@ -20,6 +20,7 @@ import {
WebhooksGateway,
TransferGateway,
AutomationsGateway,
SharingGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -44,6 +45,7 @@ import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
import { GraphqlSharingGateway } from './adapters/graphql-sharing.gateway';
/**
* The adapters the app shell needs from the start (the command palette, the
@@ -79,5 +81,6 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
{ provide: TransferGateway, useClass: HttpTransferGateway },
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
{ provide: SharingGateway, useClass: GraphqlSharingGateway },
]);
}