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

@@ -178,6 +178,14 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
if (filter.dueBefore)
query['dueDate'] = { $ne: null, $lt: filter.dueBefore };
const and: Record<string, unknown>[] = [];
// Projects where the viewer is a guest: only the tickets they are part of.
if (filter.guestProjectIds?.length && filter.participantId)
and.push({
$or: [
{ projectId: { $nin: filter.guestProjectIds } },
{ $or: participation(filter.participantId) },
],
});
if (filter.typesByProject)
and.push({
$or: Object.entries(filter.typesByProject).map(
@@ -440,6 +448,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
}
}
/** Opened it, has it or follows it. */
const participation = (userId: string) => [
{ 'reporter.id': userId },
{ 'assignee.id': userId },
{ 'watchers.id': userId },
];
function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
// Tickets in the trash are only listed by the trash itself.
const query: FilterQuery<IssueDoc> = {
@@ -476,6 +491,11 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.participantId)
query.$and = [
...(query.$and ?? []),
{ $or: participation(filter.participantId) },
];
if (filter.search?.trim()) {
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
query.$or = [