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). */