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:
@@ -861,3 +861,22 @@ export class SaveMyViewInput {
|
||||
@Type(() => MyViewCriteriaInput)
|
||||
criteria!: MyViewCriteriaInput;
|
||||
}
|
||||
|
||||
@InputType({ description: "The project's link for clients" })
|
||||
export class ShareDashboardInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => [String], {
|
||||
description: 'Blocks to show: flow, sprint, releases, epics, activity',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
blocks!: string[];
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'A new secret: the old link stops working',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
renew?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
CurrentUser,
|
||||
OptionalAuth,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
import { ShareDashboardInput } from './inputs';
|
||||
import { DashboardShareGql, SharedDashboardGql } from './types';
|
||||
|
||||
/** The project's dashboard for clients, behind a secret link. */
|
||||
@Resolver(() => DashboardShareGql)
|
||||
export class SharingResolver {
|
||||
constructor(private readonly sharing: SharingService) {}
|
||||
|
||||
@Query(() => DashboardShareGql, {
|
||||
nullable: true,
|
||||
description: "The project's link for clients, if any (owners and admins)",
|
||||
})
|
||||
dashboardShare(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.sharing.share(me.id, projectId);
|
||||
}
|
||||
|
||||
@Mutation(() => DashboardShareGql, {
|
||||
description:
|
||||
'Turn the link on or change its blocks; renew for a new secret',
|
||||
})
|
||||
shareDashboard(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: ShareDashboardInput,
|
||||
) {
|
||||
return this.sharing.save(
|
||||
me.id,
|
||||
input.projectId,
|
||||
input.blocks,
|
||||
input.renew ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: 'The link stops working' })
|
||||
async unshareDashboard(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
await this.sharing.remove(me.id, projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => SharedDashboardGql, {
|
||||
nullable: true,
|
||||
description: 'What a link for clients shows, for anyone with it',
|
||||
})
|
||||
sharedDashboard(@Args('token') token: string) {
|
||||
return this.sharing.shared(token);
|
||||
}
|
||||
}
|
||||
@@ -1260,3 +1260,53 @@ export class HomeGql {
|
||||
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
|
||||
@Field(() => [OnlineGql]) online!: OnlineGql[];
|
||||
}
|
||||
|
||||
@ObjectType('DashboardShare', {
|
||||
description: "The project's link for clients (owners and admins)",
|
||||
})
|
||||
export class DashboardShareGql {
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field({ description: 'Secret of the link /share/{token}' }) token!: string;
|
||||
@Field(() => [String]) blocks!: string[];
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('SharedFlow', { description: 'Tickets in one status' })
|
||||
export class SharedFlowGql {
|
||||
@Field() status!: string;
|
||||
@Field() name!: string;
|
||||
@Field({ description: 'A colour name of the project settings' })
|
||||
color!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('SharedEpic', { description: 'An open epic and how far it got' })
|
||||
export class SharedEpicGql {
|
||||
@Field() key!: string;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||
@Field(() => Int) done!: number;
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int) percent!: number;
|
||||
}
|
||||
|
||||
@ObjectType('SharedDashboard', {
|
||||
description:
|
||||
'What a link for clients shows: only the chosen blocks (the others are null)',
|
||||
})
|
||||
export class SharedDashboardGql {
|
||||
@Field() projectKey!: string;
|
||||
@Field() projectName!: string;
|
||||
@Field() generatedAt!: string;
|
||||
@Field(() => [String]) blocks!: string[];
|
||||
@Field(() => [SharedFlowGql], { nullable: true })
|
||||
flow!: SharedFlowGql[] | null;
|
||||
@Field(() => SprintPulseGql, { nullable: true })
|
||||
sprint!: SprintPulseGql | null;
|
||||
@Field(() => [ReleaseForecastGql], { nullable: true })
|
||||
releases!: ReleaseForecastGql[] | null;
|
||||
@Field(() => [SharedEpicGql], { nullable: true })
|
||||
epics!: SharedEpicGql[] | null;
|
||||
@Field(() => [ActivityDayGql], { nullable: true })
|
||||
activity!: ActivityDayGql[] | null;
|
||||
}
|
||||
|
||||
@@ -1526,3 +1526,22 @@ export class AddGitRepositoryDto {
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class ShareDashboardDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['flow', 'sprint', 'releases'],
|
||||
description: 'Blocks to show: flow, sprint, releases, epics, activity',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
blocks!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'A new secret: the old link stops working',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
renew?: boolean;
|
||||
}
|
||||
|
||||
@@ -1305,3 +1305,46 @@ export class HomeDto {
|
||||
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
|
||||
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
|
||||
}
|
||||
|
||||
export class DashboardShareDto {
|
||||
@ApiProperty() projectId!: string;
|
||||
@ApiProperty({ description: 'Secret of the link /share/{token}' })
|
||||
token!: string;
|
||||
@ApiProperty({ type: [String], example: ['flow', 'sprint', 'releases'] })
|
||||
blocks!: string[];
|
||||
@ApiProperty() createdAt!: string;
|
||||
}
|
||||
|
||||
export class SharedFlowDto {
|
||||
@ApiProperty() status!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty({ description: 'A colour name of the project settings' })
|
||||
color!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class SharedEpicDto {
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) dueDate!: string | null;
|
||||
@ApiProperty() done!: number;
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() percent!: number;
|
||||
}
|
||||
|
||||
export class SharedDashboardDto {
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty() projectName!: string;
|
||||
@ApiProperty() generatedAt!: string;
|
||||
@ApiProperty({ type: [String] }) blocks!: string[];
|
||||
@ApiProperty({ type: [SharedFlowDto], nullable: true })
|
||||
flow!: SharedFlowDto[] | null;
|
||||
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||
sprint!: SprintPulseDto | null;
|
||||
@ApiProperty({ type: [ReleaseForecastDto], nullable: true })
|
||||
releases!: ReleaseForecastDto[] | null;
|
||||
@ApiProperty({ type: [SharedEpicDto], nullable: true })
|
||||
epics!: SharedEpicDto[] | null;
|
||||
@ApiProperty({ type: [ActivityDayDto], nullable: true })
|
||||
activity!: ActivityDayDto[] | null;
|
||||
}
|
||||
|
||||
120
libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
Normal file
120
libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
Public,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
import type { Response } from 'express';
|
||||
import { ShareDashboardDto } from './dto/requests.dto';
|
||||
import { DashboardShareDto, SharedDashboardDto } from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
});
|
||||
|
||||
/**
|
||||
* The project's dashboard for clients: a secret link (/share/{token}) that
|
||||
* shows the blocks its owners and admins chose, without an account.
|
||||
*/
|
||||
@ApiTags('Projects')
|
||||
@Controller()
|
||||
export class SharingController {
|
||||
constructor(
|
||||
private readonly sharing: SharingService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/share')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: "The project's link for clients, if any" })
|
||||
@ApiOkResponse({ type: DashboardShareDto })
|
||||
@ApiNoContentResponse({ description: 'It has none' })
|
||||
@ApiReadErrors()
|
||||
async share(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const share = await this.sharing.share(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
);
|
||||
if (!share) response.status(204);
|
||||
return share ?? undefined;
|
||||
}
|
||||
|
||||
@Put('projects/:project/share')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Turn the link on or change its blocks (renew: a new secret)',
|
||||
})
|
||||
@ApiOkResponse({ type: DashboardShareDto })
|
||||
@ApiCommandErrors()
|
||||
async save(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: ShareDashboardDto,
|
||||
) {
|
||||
return this.sharing.save(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
body.blocks,
|
||||
body.renew ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('projects/:project/share')
|
||||
@HttpCode(204)
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'The link stops working' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async remove(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Get('shared/dashboards/:token')
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: '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.',
|
||||
})
|
||||
@ApiOkResponse({ type: SharedDashboardDto })
|
||||
async shared(@Param('token') token: string) {
|
||||
const dashboard = /^[\w-]{20,100}$/.test(token)
|
||||
? await this.sharing.shared(token)
|
||||
: null;
|
||||
if (!dashboard) throw new NotFoundException('Unknown link');
|
||||
return dashboard;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||
import { SharingResolver } from './graphql/sharing.resolver';
|
||||
import { WebhooksController } from './http/webhooks.controller';
|
||||
import { SharingController } from './http/sharing.controller';
|
||||
import { TransferController } from './http/transfer.controller';
|
||||
import { AutomationResolver } from './graphql/automation.resolver';
|
||||
|
||||
@@ -65,6 +67,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
SprintsController,
|
||||
GitController,
|
||||
WebhooksController,
|
||||
SharingController,
|
||||
TransferController,
|
||||
],
|
||||
providers: [
|
||||
@@ -84,6 +87,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
WebhookResolver,
|
||||
SharingResolver,
|
||||
AutomationResolver,
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user