feat: guest role for clients and outsiders

A project member can be a GUEST. In a private project a guest only sees
the tickets they take part in (opened, assigned or followed): every other
ticket answers 404, also in my work, realtime changes, blockers and
linked tickets, and project-wide views (activity, statistics, dashboard,
releases, sprints, epics, trash) answer 403. Guests open tickets without
assigning or planning them, and comment, attach, follow and vote on their
own; they change nothing else. In a public project they see what anyone
does. The web gives them only Tablero, Lista and Miembros and loads none
of the project-wide data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 18:02:36 +02:00
parent f60d4c8346
commit 8bf7d47b2e
66 changed files with 607 additions and 130 deletions

View File

@@ -197,7 +197,7 @@ const NOBODY = '__nobody__';
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
>
<span class="ts-spacer"></span>
@if (project.canContribute()) {
@if (project.canReport()) {
<pi-action-button
color="primary"
text="Nuevo ticket"
@@ -385,20 +385,22 @@ const NOBODY = '__nobody__';
<pi-label>Estimación</pi-label>
</pi-select-field>
</div>
<div class="ts-form__row">
<pi-select-field
[formField]="f.assigneeId"
[options]="assigneeOptions()"
>
<pi-label>Asignar a</pi-label>
</pi-select-field>
<pi-select-field
[formField]="f.releaseId"
[options]="releaseOptions()"
>
<pi-label>Release</pi-label>
</pi-select-field>
</div>
@if (project.canContribute()) {
<div class="ts-form__row">
<pi-select-field
[formField]="f.assigneeId"
[options]="assigneeOptions()"
>
<pi-label>Asignar a</pi-label>
</pi-select-field>
<pi-select-field
[formField]="f.releaseId"
[options]="releaseOptions()"
>
<pi-label>Release</pi-label>
</pi-select-field>
</div>
}
<pi-action-button
size="w100"
color="primary"
@@ -913,8 +915,9 @@ export class BoardPage {
priority: v.priority as IssuePriority,
area: v.area || null,
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
assigneeId: v.assigneeId || null,
releaseId: v.releaseId || null,
// Planning it is for the team: a guest only opens it.
assigneeId: this.project.canContribute() ? v.assigneeId || null : null,
releaseId: this.project.canContribute() ? v.releaseId || null : null,
});
this.drawer.closeAll();
}

View File

@@ -463,10 +463,10 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
</div>
} @else if (!project.canContribute()) {
} @else if (!project.canReport()) {
<p class="ts-muted">Sin adjuntos.</p>
}
@if (project.canContribute()) {
@if (project.canReport()) {
<div class="ts-form">
<pi-file-upload
label="Capturas, logs, vídeos…"
@@ -721,9 +721,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</button>
</div>
</form>
} @else if (
project.canContribute() && !thread.comment.deleted
) {
} @else if (project.canReport() && !thread.comment.deleted) {
<button
type="button"
class="ts-plain-link ts-reply__open"
@@ -738,7 +736,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}
</div>
}
@if (project.canContribute()) {
@if (project.canReport()) {
<form
class="ts-form"
(submit)="$event.preventDefault(); addComment()"

View File

@@ -231,6 +231,7 @@ export class MembersPage {
ProjectRole.ADMIN,
ProjectRole.MEMBER,
ProjectRole.VIEWER,
ProjectRole.GUEST,
].map((value) => ({ value, label: ROLE[value] }));
protected readonly candidates = computed(() => {
const members = new Set(this.facade.members().map((m) => m.user.id));

View File

@@ -91,15 +91,20 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
<nav class="ts-tabs" aria-label="Secciones del proyecto">
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
<a routerLink="list" routerLinkActive="is-active">Lista</a>
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
<a routerLink="calendar" routerLinkActive="is-active">Calendario</a>
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
<!-- A guest sees only their tickets: no project-wide views. -->
@if (!facade.seesOnlyOwnTickets()) {
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
<a routerLink="calendar" routerLinkActive="is-active">Calendario</a>
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
<a routerLink="analytics" routerLinkActive="is-active"
>Estadísticas</a
>
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
}
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
@if (facade.canManage() || facade.isOwner()) {
<a routerLink="settings" routerLinkActive="is-active"

View File

@@ -150,6 +150,7 @@ export const ROLE_TONE: Record<ProjectRole, string> = {
[ProjectRole.ADMIN]: 'var(--ts-s-review)',
[ProjectRole.MEMBER]: 'var(--ts-s-todo)',
[ProjectRole.VIEWER]: 'var(--ts-s-cancelled)',
[ProjectRole.GUEST]: 'var(--ts-s-qa)',
};
export const ROLE: Record<ProjectRole, string> = {
@@ -157,6 +158,7 @@ export const ROLE: Record<ProjectRole, string> = {
[ProjectRole.ADMIN]: 'Admin',
[ProjectRole.MEMBER]: 'Miembro',
[ProjectRole.VIEWER]: 'Lector',
[ProjectRole.GUEST]: 'Invitado',
};
export const RELEASE_STATUS: Record<