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

@@ -66,6 +66,10 @@ export class ProjectFacade {
readonly canManage = this.store.canManage;
/** Any role but viewer. */
readonly canContribute = this.store.canContribute;
/** Opens tickets and comments (members and guests). */
readonly canReport = this.store.canReport;
/** A guest of a private project: only their own tickets, no project-wide views. */
readonly seesOnlyOwnTickets = this.store.seesOnlyOwnTickets;
/** Statuses, ticket types and areas of this project (names, colours, lookups). */
readonly workflow = this.store.workflow;
readonly savingSettings = this.store.savingSettings;

View File

@@ -107,11 +107,23 @@ export const ProjectStore = signalStore(
(myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN),
),
canContribute: computed(
() =>
!project()?.archivedAt &&
!!myRole() &&
myRole() !== ProjectRole.VIEWER &&
myRole() !== ProjectRole.GUEST,
),
/** Opens tickets and comments: members and guests, not viewers. */
canReport: computed(
() =>
!project()?.archivedAt &&
!!myRole() &&
myRole() !== ProjectRole.VIEWER,
),
/** A guest of a private project: sees only their own tickets. */
seesOnlyOwnTickets: computed(
() => !project()?.isPublic && myRole() === ProjectRole.GUEST,
),
members: computed(() => project()?.members ?? []),
/** Statuses, types and areas of the project, with lookups. */
workflow: computed(() => new Workflow(project()?.settings)),
@@ -262,42 +274,55 @@ export const ProjectStore = signalStore(
),
),
),
// A guest of a private project gets none of the project-wide views.
details$: loaded$.pipe(
switchMap(({ payload }) =>
merge(
projects
.stats(payload.id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
reloadReleases(payload.id),
reloadSprints(payload.id),
activity
.list(payload.id)
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
),
store.seesOnlyOwnTickets()
? []
: merge(
projects
.stats(payload.id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
reloadReleases(payload.id),
reloadSprints(payload.id),
activity
.list(payload.id)
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
),
),
),
live$: loaded$.pipe(
switchMap(({ payload }) =>
merge(
realtime
.projectChanges(payload.id)
.pipe(
map(({ project, deleted }) =>
deleted
? projectRealtimeEvents.projectDeleted(project)
: projectRealtimeEvents.projectChanged(project),
),
store.seesOnlyOwnTickets()
? realtime
.projectChanges(payload.id)
.pipe(
map(({ project, deleted }) =>
deleted
? projectRealtimeEvents.projectDeleted(project)
: projectRealtimeEvents.projectChanged(project),
),
)
: merge(
realtime
.projectChanges(payload.id)
.pipe(
map(({ project, deleted }) =>
deleted
? projectRealtimeEvents.projectDeleted(project)
: projectRealtimeEvents.projectChanged(project),
),
),
realtime
.activity(payload.id)
.pipe(map((a) => projectRealtimeEvents.activityAdded(a))),
realtime
.releaseChanges(payload.id)
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
realtime
.sprintChanges(payload.id)
.pipe(map((c) => projectRealtimeEvents.sprintChanged(c))),
),
realtime
.activity(payload.id)
.pipe(map((a) => projectRealtimeEvents.activityAdded(a))),
realtime
.releaseChanges(payload.id)
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
realtime
.sprintChanges(payload.id)
.pipe(map((c) => projectRealtimeEvents.sprintChanged(c))),
),
),
),
/** Activity means tickets moved: refresh the dashboard (debounced). */

View File

@@ -41,6 +41,8 @@ export enum ProjectRole {
ADMIN = 'ADMIN',
MEMBER = 'MEMBER',
VIEWER = 'VIEWER',
/** From outside (a client): only the tickets they take part in. */
GUEST = 'GUEST',
}
export enum ReleaseStatus {

View File

@@ -272,7 +272,7 @@ export type ProjectMemberInput = {
userId: string;
};
export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER';
export type ProjectRole = 'ADMIN' | 'GUEST' | 'MEMBER' | 'OWNER' | 'VIEWER';
/** The whole configuration (replaces the previous one). Statuses go in board order. */
export type ProjectSettingsInput = {

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<