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,283 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
untracked,
} from '@angular/core';
import { PiCheckboxComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import {
provideSharingFacade,
SharingFacade,
} from '@task-stream/web/tracker/application';
import { SHARED_BLOCKS, SharedBlock } from '@task-stream/web/tracker/domain';
/** What each block shows, for whoever picks them. */
const BLOCK_LABEL: Record<SharedBlock, { label: string; hint: string }> = {
flow: { label: 'Flujo', hint: 'Cuántos tickets hay en cada estado' },
sprint: { label: 'Sprint en curso', hint: 'Días que quedan y lo terminado' },
releases: {
label: 'Próximas releases',
hint: 'Con su fecha probable al ritmo real',
},
epics: { label: 'Épicas', hint: 'Las abiertas, con su progreso' },
activity: { label: 'Actividad', hint: 'Movimientos por día, 12 semanas' },
};
/** What a new link shows until someone picks otherwise. */
const DEFAULT_BLOCKS: SharedBlock[] = ['flow', 'sprint', 'releases'];
/**
* The project's dashboard for clients: a read-only link, without an
* account, showing only the blocks picked here.
*/
@Component({
selector: 'ts-client-share',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiCheckboxComponent],
providers: [provideSharingFacade()],
host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'share-title' },
template: `
<div class="ts-share__head">
<div>
<h3 id="share-title">Panel para clientes</h3>
<p class="ts-muted">
Un enlace de solo lectura, sin cuenta, con los bloques que elijas. No
enseña personas ni tickets sueltos (salvo las épicas).
</p>
</div>
<label class="ts-switch">
<input
type="checkbox"
role="switch"
[checked]="!!sharing.share()"
[disabled]="!sharing.loaded() || sharing.busy()"
(change)="toggle($any($event.target).checked)"
/>
<span class="ts-switch__track" aria-hidden="true"></span>
<span>{{ sharing.share() ? 'Compartido' : 'Sin compartir' }}</span>
</label>
</div>
@if (sharing.share(); as share) {
<fieldset class="ts-share__blocks" [disabled]="sharing.busy()">
<legend>Qué se ve</legend>
@for (block of blocks; track block) {
<div class="ts-share__block">
<pi-checkbox
[label]="label(block).label"
[checked]="share.blocks.includes(block)"
[disabled]="
share.blocks.length === 1 && share.blocks.includes(block)
"
(checkedChange)="setBlock(block, $event)"
/>
<span class="ts-muted">{{ label(block).hint }}</span>
</div>
}
</fieldset>
<div class="ts-share__link">
<input
class="ts-share__url"
readonly
aria-label="Enlace para clientes"
[value]="url()"
(focus)="$any($event.target).select()"
/>
<button type="button" class="ts-plain-link" (click)="copy()">
Copiar
</button>
<a class="ts-plain-link" [href]="url()" target="_blank" rel="noopener"
>Abrir</a
>
<button
type="button"
class="ts-plain-link"
[disabled]="sharing.busy()"
(click)="renew()"
>
Renovar enlace
</button>
</div>
<p class="ts-muted ts-share__note">
Se actualiza solo cada minuto. Renovar hace un enlace nuevo y el
anterior deja de funcionar.
</p>
}
@if (sharing.error(); as error) {
<p class="ts-share__error" role="alert">{{ error }}</p>
}
`,
styles: `
:host {
display: grid;
gap: 0.875rem;
padding: 1.25rem;
}
h3 {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.ts-share__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
> div {
flex: 1 1 26rem;
}
p {
max-width: 46rem;
margin: 0.25rem 0 0;
font-size: 0.8125rem;
}
}
.ts-share__blocks {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
gap: 0.5rem 1.5rem;
margin: 0;
padding: 0;
border: 0;
legend {
margin-bottom: 0.5rem;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
}
.ts-share__block {
display: grid;
.ts-muted {
margin-left: 2.5rem;
font-size: 0.75rem;
}
}
.ts-share__link {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.ts-share__url {
flex: 1 1 20rem;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--ts-line-strong);
border-radius: 0.5rem;
background: var(--ts-sunken);
color: var(--ts-ink);
font-family: var(--ts-font-mono);
font-size: 0.8125rem;
}
.ts-share__note {
margin: 0;
font-size: 0.75rem;
}
.ts-share__error {
margin: 0;
color: var(--ts-c-red);
font-size: 0.875rem;
}
.ts-switch {
display: inline-flex;
align-items: center;
gap: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
input:focus-visible + .ts-switch__track {
outline: 2px solid var(--ts-brand);
outline-offset: 2px;
}
input:disabled ~ * {
opacity: 0.6;
cursor: default;
}
}
.ts-switch__track {
position: relative;
width: 2.5rem;
height: 1.375rem;
border-radius: 999px;
background: var(--ts-line-strong);
transition: background-color 0.15s ease;
&::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: var(--ts-surface);
box-shadow: var(--ts-shadow);
transition: transform 0.15s ease;
}
}
.ts-switch input:checked + .ts-switch__track {
background: var(--ts-brand);
&::after {
transform: translateX(1.125rem);
}
}
`,
})
export class ClientShareComponent {
readonly projectId = input.required<string>();
protected readonly sharing = inject(SharingFacade);
private readonly toast = inject(ToastService);
protected readonly blocks = SHARED_BLOCKS;
protected readonly url = computed(() => {
const share = this.sharing.share();
return share ? `${location.origin}/share/${share.token}` : '';
});
constructor() {
effect(() => {
const projectId = this.projectId();
untracked(() => this.sharing.load(projectId));
});
}
protected label(block: SharedBlock) {
return BLOCK_LABEL[block];
}
protected toggle(on: boolean): void {
if (on) this.sharing.save(DEFAULT_BLOCKS);
else this.sharing.remove();
}
protected setBlock(block: SharedBlock, on: boolean): void {
const current = this.sharing.share()?.blocks ?? [];
const next = on ? [...current, block] : current.filter((b) => b !== block);
if (next.length) this.sharing.save(next);
}
protected renew(): void {
const blocks = this.sharing.share()?.blocks;
if (blocks) this.sharing.save(blocks, true);
}
protected copy(): void {
navigator.clipboard
.writeText(this.url())
.then(() => this.toast.success('Enlace copiado'))
.catch(() => this.toast.error('No se pudo copiar'));
}
}

View File

@@ -61,7 +61,11 @@ export function verdictOf(release: ReleaseForecast): {
<ul class="ts-issues ts-forecast">
@for (row of rows(); track row.releaseId) {
<li>
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
@if (linked()) {
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
} @else {
<span class="ts-title">{{ row.name }}</span>
}
<span class="ts-verdict" [style.--tone]="row.tone">{{
row.label
}}</span>
@@ -110,6 +114,8 @@ export function verdictOf(release: ReleaseForecast): {
})
export class ReleaseForecastComponent {
readonly releases = input.required<ReleaseForecast[]>();
/** False outside the app (the page for clients): no links into it. */
readonly linked = input(true);
protected readonly rows = computed(() =>
this.releases().map((r) => ({

View File

@@ -52,7 +52,9 @@ const decimal = (n: number) =>
@if (paceText(); as pace) {
{{ pace }}
}
<a routerLink="../sprints">Ver el sprint →</a>
@if (linked()) {
<a routerLink="../sprints">Ver el sprint →</a>
}
</p>
`,
styles: `
@@ -105,6 +107,8 @@ const decimal = (n: number) =>
})
export class SprintPulseComponent {
readonly sprint = input.required<SprintPulse>();
/** False outside the app (the page for clients): no links into it. */
readonly linked = input(true);
protected readonly when = computed(
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,