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

@@ -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: [