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:
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user