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:
128
libs/web/tracker/application/src/lib/facades/sharing.facade.ts
Normal file
128
libs/web/tracker/application/src/lib/facades/sharing.facade.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user