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

@@ -1,6 +1,7 @@
export * from './lib/notifications';
export * from './lib/facades/projects.facade';
export * from './lib/facades/calendar.facade';
export * from './lib/facades/sharing.facade';
export * from './lib/facades/push.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';

View File

@@ -0,0 +1,128 @@
import {
DestroyRef,
inject,
Injectable,
Provider,
signal,
} from '@angular/core';
import {
DashboardShare,
SharedBlock,
SharedDashboard,
SharingGateway,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
import { describeError } from '../notifications';
/**
* The project's link for clients, in Configuración: turn it on, pick its
* blocks, renew or drop it. Seldom touched, so it lives in this facade
* alone, without a store.
*/
@Injectable()
export class SharingFacade {
private readonly gateway = inject(SharingGateway);
private projectId: string | null = null;
readonly share = signal<DashboardShare | null>(null);
readonly loaded = signal(false);
readonly busy = signal(false);
readonly error = signal<string | null>(null);
load(projectId: string): void {
this.projectId = projectId;
this.run(this.gateway.share(projectId));
}
/** On (or new blocks); `renew` makes a new secret, the old link dies. */
save(blocks: SharedBlock[], renew = false): void {
if (this.projectId)
this.run(this.gateway.save(this.projectId, blocks, renew));
}
remove(): void {
if (!this.projectId) return;
this.busy.set(true);
this.gateway.remove(this.projectId).subscribe({
next: () => this.done(null),
error: (error) => this.failed(error),
});
}
private run(call: Observable<DashboardShare | null>): void {
this.busy.set(true);
this.error.set(null);
call.subscribe({
next: (share) => this.done(share),
error: (error) => this.failed(error),
});
}
private done(share: DashboardShare | null): void {
this.share.set(share);
this.loaded.set(true);
this.busy.set(false);
}
private failed(error: unknown): void {
this.loaded.set(true);
this.busy.set(false);
this.error.set(describeError(error));
}
}
/** The link for clients, in Configuración: provided by the page. */
export function provideSharingFacade(): Provider[] {
return [SharingFacade];
}
/** Every minute: there is no live connection without a session. */
const REFRESH_MS = 60_000;
/** What a client sees through the link, read again every minute. */
@Injectable()
export class SharedDashboardFacade {
private readonly gateway = inject(SharingGateway);
private timer: ReturnType<typeof setInterval> | null = null;
readonly dashboard = signal<SharedDashboard | null>(null);
readonly loaded = signal(false);
/** No project has this link (never had, renewed or dropped). */
readonly missing = signal(false);
readonly error = signal<string | null>(null);
constructor() {
inject(DestroyRef).onDestroy(() => this.stop());
}
open(token: string): void {
this.stop();
this.read(token);
this.timer = setInterval(() => this.read(token), REFRESH_MS);
}
private read(token: string): void {
this.gateway.shared(token).subscribe({
next: (dashboard) => {
this.dashboard.set(dashboard);
this.missing.set(!dashboard);
this.error.set(null);
this.loaded.set(true);
},
error: (error) => {
this.error.set(describeError(error));
this.loaded.set(true);
},
});
}
private stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
}
/** The page for clients: provided by its route. */
export function provideSharedDashboardFacade(): Provider[] {
return [SharedDashboardFacade];
}