Merge pull request #99 from Gimanh/fix/98

fix: #98
This commit is contained in:
Nikolai Giman
2026-08-04 20:45:47 +02:00
committed by GitHub
14 changed files with 341 additions and 22 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.50.4",
"version": "1.51.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -1,5 +1,8 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, res: Response, next: NextFunction) => {
const goalId = req.body.goalId ? req.body.goalId : req.params.goalId;
@@ -19,5 +22,18 @@ export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, r
return next();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanFetchRolesPermissionsCollaborationRoles middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -16,6 +16,7 @@ import {
} from './collaboration.types';
export class CollaborationController {
/** @deprecated */
fetchAllUsers = async (req: Request, res: Response) => {
const users = await req.appUser.collaborationManager.fetchAllUsers();
return res.tvJson(users);
@@ -25,6 +25,7 @@ export class CollaborationManager {
this.repository = new CollaborationRepository();
}
/** @deprecated */
async fetchAllUsers(): Promise<CollaborationUserWithRoles[] | false> {
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
@@ -70,6 +71,7 @@ export class CollaborationManager {
return Object.values(resultMap);
}
/** @deprecated */
async fetchUsersForGoal(args: FetchGoalUsersArg): Promise<CollaborationUserWithRoles[] | false> {
const users = await this.repository.fetchUsersForGoal(args.goalId);
@@ -104,6 +106,7 @@ export class CollaborationManager {
return Object.values(resultMap);
}
/** @deprecated*/
async toggleUserRoles(args: ToggleUserRolesArg): Promise<number[] | false> {
return await this.repository.updateUserRoles(args.userId, args.roles);
}
@@ -181,7 +184,7 @@ export class CollaborationManager {
return [];
}
const resultMap: Record<string, CollaborationUserWithRoles> = {};
users.forEach((item) => {
@@ -214,7 +217,7 @@ export class CollaborationManager {
return [];
}
const resultMap: Record<string, CollaborationUserWithRoles> = {};
users.forEach((item) => {
@@ -1,5 +1,6 @@
import { and, eq, inArray } from 'drizzle-orm';
import { and, eq, exists, inArray } from 'drizzle-orm';
import {
CollaborationRolesSchema,
CollaborationUsersSchema,
type CollaborationUsersSchemaTypeForSelect,
CollaborationUsersToGoalsSchema,
@@ -24,6 +25,7 @@ export class CollaborationRepository {
this.db = Database.getInstance();
}
/** @deprecated */
async fetchAllUsers(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
if (goalIds.length === 0) {
return [];
@@ -35,6 +37,10 @@ export class CollaborationRepository {
FROM collaboration.users u
left join collaboration.users_to_goals utg on u.id = utg.user_id
LEFT JOIN collaboration.users_to_roles utr ON u.id = utr.user_id
AND EXISTS (
SELECT 1 FROM collaboration.roles r
WHERE r.id = utr.role_id AND r.goal_id = utg.goal_id
)
WHERE utg.goal_id IN (${placeholders})
`;
@@ -92,7 +98,8 @@ export class CollaborationRepository {
return result.rows[0];
}
/** @deprecated */
async fetchUsersForGoal(goalId: number): Promise<FetchUsersForGoal[] | false> {
const query = `
SELECT u.*, u.invitation_date::text, utr.role_id, utg.goal_id
@@ -112,6 +119,7 @@ export class CollaborationRepository {
return result.rows;
}
/** @deprecated */
async fetchUsersForGoals(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
if (goalIds.length === 0) {
return [];
@@ -146,6 +154,7 @@ export class CollaborationRepository {
return !!(result.rowCount && result.rowCount > 0);
}
/** @deprecated */
async updateUserRoles(userId: number, roles: number[]): Promise<number[] | false> {
const deleteQuery = `DELETE FROM collaboration.users_to_roles WHERE user_id = $1`;
let i = 1;
@@ -247,13 +256,29 @@ export class CollaborationRepository {
async toggleUserRolesNew(args: CollaborationArgToggleUserRoles): Promise<number[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.transaction(async (tx) => {
await tx
.delete(CollaborationUsersToRolesSchema)
.where(eq(CollaborationUsersToRolesSchema.userId, args.userId));
if (args.roles.length > 0) {
const goalRoles = await tx
.select({ id: CollaborationRolesSchema.id })
.from(CollaborationRolesSchema)
.where(eq(CollaborationRolesSchema.goalId, args.goalId));
const goalRoleIds = goalRoles.map((role) => role.id);
if (goalRoleIds.length > 0) {
await tx
.delete(CollaborationUsersToRolesSchema)
.where(
and(
eq(CollaborationUsersToRolesSchema.userId, args.userId),
inArray(CollaborationUsersToRolesSchema.roleId, goalRoleIds)
)
);
}
const rolesToAssign = args.roles.filter((roleId) => goalRoleIds.includes(roleId));
if (rolesToAssign.length > 0) {
return await tx
.insert(CollaborationUsersToRolesSchema)
.values(args.roles.map((roleId) => ({ userId: args.userId, roleId })))
.values(rolesToAssign.map((roleId) => ({ userId: args.userId, roleId })))
.returning();
}
return [];
@@ -284,7 +309,20 @@ export class CollaborationRepository {
)
.leftJoin(
CollaborationUsersToRolesSchema,
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
and(
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
exists(
this.db.dbDrizzle
.select()
.from(CollaborationRolesSchema)
.where(
and(
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
)
)
)
)
)
.where(inArray(CollaborationUsersToGoalsSchema.goalId, goalIds))
);
@@ -308,7 +346,20 @@ export class CollaborationRepository {
)
.leftJoin(
CollaborationUsersToRolesSchema,
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
and(
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
exists(
this.db.dbDrizzle
.select()
.from(CollaborationRolesSchema)
.where(
and(
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
)
)
)
)
)
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
);
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: What is TaskView
description: TaskView is an open-source, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
description: TaskView is a source-available, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
navigation:
icon: i-lucide-house
---
+19 -1
View File
@@ -50,7 +50,7 @@ Only **owners** and **admins** can manage members. The **Members** tab is not vi
2. Go to the **Members** tab
3. Enter an email address and click **Add Member**
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members can only be invited by email. The person needs to have a TaskView account with that email.
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members are invited by email address. The person doesn't need a TaskView account yet - membership is stored against the email, so you can add someone in advance and they join the organization as soon as they sign up with that address.
::callout{icon="i-lucide-alert-triangle" color="warning"}
When a project member with the **Manage users** permission invites someone into a project, that person is automatically added to the organization as a **member** - even though only admins and owners can add members directly. This is by design: a person can't be in a project without being in its organization. The auto-added member gets the minimum role and can't manage the organization.
@@ -82,3 +82,21 @@ Organizations and projects have separate permission systems:
Being an organization admin doesn't automatically give you permissions inside projects. You still need to be added to each project and assigned a project role. See [Roles and Permissions](/docs/collaboration/roles-and-permissions) for project-level access control.
The member list API endpoint is restricted to owners and admins. Regular members cannot fetch the list of organization members.
## Who can see which projects
| Who | Sees |
|-----|------|
| **Organization owner** | **Every project of the organization**, including projects created by other members |
| **Organization admin** | Only the projects they were added to |
| **Organization member** | Only the projects they were added to |
Projects created inside an organization belong to the organization, not to the person who created them: the organization owner is recorded as their owner. That is what keeps a project reachable when the person who created it leaves the company - nothing is lost with them. The flip side is that the owner sees every project of their organization, whoever created it.
Everyone else - admins included - gets access to a project only by being added to it and given a project role. Being an organization admin means administering the organization (members, settings, SSO), not its content.
::callout{icon="i-lucide-shield-alert" color="warning"}
**Access is granted explicitly, never inherited from a title.** There is deliberately no "admins can see all projects" switch: if a project should be visible to someone, they get invited to it. This keeps sensitive projects - finance, HR, salaries - private by default instead of silently opening them the moment someone is promoted to admin.
The one exception is the organization owner, who sees everything by design (see above). If a project must stay private from the owner too, keep it in your **personal workspace** rather than in the organization.
::
+2 -2
View File
@@ -13,9 +13,9 @@ TaskView is built for teams. You can invite people to your projects, assign them
2. Enter the person's email address in the input field
3. Click **Add**
The person needs to have a TaskView account with that email. If they don't have one yet, they'll need to register first (using the same email you invited them with).
The person doesn't need a TaskView account yet - the invite is stored against the email address. If they already have an account, the project appears in their sidebar right away. If they don't, they simply register with that same email and find the project waiting for them.
Once added, they'll see the project in their sidebar and can start working immediately.
Inviting someone into a project also adds them to the project's organization as a **member**, since a person can't be in a project without being in its organization.
## Removing members
+2 -2
View File
@@ -1,6 +1,6 @@
---
title: Frequently Asked Questions
description: Common questions about TaskView - self-hosted open-source task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
description: Common questions about TaskView - self-hosted source-available task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
navigation:
icon: i-lucide-circle-help
---
@@ -77,7 +77,7 @@ Yes. You can attach a monetary amount to any task and mark it as income or expen
### How do I invite team members?
Open a project, go to the Collaboration tab, and enter the person's email address. They need to have a TaskView account with that email. See [Team Members](/docs/collaboration/members).
Open a project, go to the Collaboration tab, and enter the person's email address. They don't need a TaskView account yet - if they register later with that same email, the project is already there. See [Team Members](/docs/collaboration/members).
### Does TaskView have role-based access control?
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.50.4",
"version": "1.51.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -0,0 +1,109 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { initApi } from './init-api'
describe('Collaboration roles access control', () => {
let user1Api: TvApi
let user2Api: TvApi
let user2Email: string
let deleteAllGoals: () => Promise<void>
let manageUsersPermissionId: number
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user2Api = init.$tvApiForSecondUser
user2Email = init.user2Email
deleteAllGoals = init.deleteAllGoals
const allPermissions = await user1Api.collaboration.fetchAllPermissions()
const found = allPermissions.find(p => p.name === TvPermissions.GOAL_CAN_MANAGE_USERS)
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
manageUsersPermissionId = found.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status, `Expected ${status}, got ${e.response?.status}`).toBe(status)
}
}
async function createGoalWithUser2(grantManageUsers: boolean) {
const goal = await user1Api.goals.createGoal({ name: `Roles access ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await user1Api.collaboration.inviteUserToGoal({ email: user2Email, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await user1Api.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Manager ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
if (grantManageUsers) {
const toggled = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected goal_can_manage_users to be added, got ${JSON.stringify(toggled)}`)
}
}
await user1Api.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
return { goal, role }
}
it('collaborator with goal_can_manage_users can read the role-to-permission matrix', async () => {
const { goal, role } = await createGoalWithUser2(true)
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
expect(matrix).toBeDefined()
// the granted permission is visible in the matrix of the role user2 holds
expect(matrix?.some(row => row.roleId === role.id && row.permissionId === manageUsersPermissionId)).toBe(true)
})
it('collaborator without goal_can_manage_users cannot read the matrix', async () => {
const { goal } = await createGoalWithUser2(false)
await expectHttpStatus(user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id), 403)
})
it('changing role permissions stays owner-only', async () => {
const { goal, role } = await createGoalWithUser2(true)
// reading is allowed...
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
expect(matrix).toBeDefined()
// ...but editing the matrix is not
await expectHttpStatus(
user2Api.collaboration.toggleRolePermission({ roleId: role.id, permissionId: manageUsersPermissionId }),
403,
)
// owner still can edit
const ownerToggle = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
expect(ownerToggle?.add).toBe(false)
await expect(user1Api.goals.deleteGoal(goal.id)).resolves.toBeTruthy()
})
})
@@ -308,6 +308,127 @@ describe('Collaboration', () => {
});
// Regression for #98, the reporter's exact path: an org admin who is a collaborator
// in several projects created a new project in that org and lost every role he had.
// The trigger is creating a goal in an organization owned by SOMEONE ELSE - only then
// does the API add the creator as a collaborator, which used to wipe his other roles.
it('an org member creating a project in that org keeps his roles in other projects', async () => {
const { $tvApiForSecondUser, user2Email } = await initApi();
const org = await $api.organizations.create({ name: `Roles org ${Date.now()}` });
expect(org?.id).toBeDefined();
// the reporter's case: the second user is an ADMIN of the organization
await $api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'admin',
});
// owner creates two projects in the org and gives the admin roles in both
const projectA = await $api.goals.createGoal({ name: 'Org project A', organizationId: org.id });
const projectB = await $api.goals.createGoal({ name: 'Org project B', organizationId: org.id });
expect(projectA?.id).toBeDefined();
expect(projectB?.id).toBeDefined();
const collabA = await $api.collaboration.inviteUserToGoal({ goalId: projectA!.id, email: user2Email });
const collabB = await $api.collaboration.inviteUserToGoal({ goalId: projectB!.id, email: user2Email });
expect(collabA?.id).toBeDefined();
expect(collabB?.id).toEqual(collabA?.id);
const editorA = (await $api.collaboration.fetchRolesForGoal(projectA!.id))?.find((r) => r.name === 'editor');
const editorB = (await $api.collaboration.fetchRolesForGoal(projectB!.id))?.find((r) => r.name === 'editor');
expect(editorA?.id).toBeDefined();
expect(editorB?.id).toBeDefined();
await $api.collaboration.toggleUserRoles({
goalId: projectA!.id,
userId: collabA!.id,
roles: [editorA!.id],
});
await $api.collaboration.toggleUserRoles({
goalId: projectB!.id,
userId: collabA!.id,
roles: [editorB!.id],
});
// ...the admin now creates his own project INSIDE the owner's organization
const ownProject = await $tvApiForSecondUser.goals.createGoal({
name: 'Project created by the org admin',
organizationId: org.id,
});
expect(ownProject?.id).toBeDefined();
// roles in the pre-existing projects must survive
const usersA = await $api.collaboration.fetchUsersForGoal(projectA!.id);
const usersB = await $api.collaboration.fetchUsersForGoal(projectB!.id);
expect(usersA?.find((u) => u.email === user2Email)?.roles).toEqual([editorA!.id]);
expect(usersB?.find((u) => u.email === user2Email)?.roles).toEqual([editorB!.id]);
// and the new project is still usable by its creator
const ownGoals = await $tvApiForSecondUser.goals.fetchGoals(org.id);
expect(ownGoals?.some((g) => g.id === ownProject!.id)).toBe(true);
await $api.organizations.delete(org.id).catch(() => { });
});
// Regression for #98: toggling roles in one goal wiped the user's roles in every other goal
it('toggling roles in one goal must not touch the same user roles in another goal', async () => {
const email = `multi-goal-${Date.now()}@fff.com`;
const goalA = await $api.goals.createGoal({ name: 'Roles isolation goal A' });
const goalB = await $api.goals.createGoal({ name: 'Roles isolation goal B' });
expect(goalA).toBeTruthy();
expect(goalB).toBeTruthy();
const userInA = await $api.collaboration.inviteUserToGoal({ goalId: goalA!.id, email });
const userInB = await $api.collaboration.inviteUserToGoal({ goalId: goalB!.id, email });
expect(userInA?.id).toBeDefined();
// the same collaboration user is shared between goals
expect(userInB?.id).toEqual(userInA?.id);
const rolesA = await $api.collaboration.fetchRolesForGoal(goalA!.id);
const rolesB = await $api.collaboration.fetchRolesForGoal(goalB!.id);
const editorA = rolesA?.find((r) => r.name === 'editor');
const editorB = rolesB?.find((r) => r.name === 'editor');
expect(editorA?.id).toBeDefined();
expect(editorB?.id).toBeDefined();
// assign a role in goal B first
const toggledB = await $api.collaboration.toggleUserRoles({
goalId: goalB!.id,
userId: userInA?.id!,
roles: [editorB?.id!],
});
expect(toggledB).toEqual([editorB?.id!]);
// toggling roles in goal A must not clear the role in goal B
const toggledA = await $api.collaboration.toggleUserRoles({
goalId: goalA!.id,
userId: userInA?.id!,
roles: [editorA?.id!],
});
expect(toggledA).toEqual([editorA?.id!]);
const usersInB = await $api.collaboration.fetchUsersForGoal(goalB!.id);
expect(usersInB?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
// each goal's collaborator list shows only that goal's roles
const usersInA = await $api.collaboration.fetchUsersForGoal(goalA!.id);
expect(usersInA?.find((u) => u.email === email)?.roles).toEqual([editorA?.id!]);
// a role id belonging to another goal must not be assignable through this goal
const toggledForeign = await $api.collaboration.toggleUserRoles({
goalId: goalA!.id,
userId: userInA?.id!,
roles: [editorB?.id!],
}).catch(() => null);
expect(toggledForeign ?? []).toEqual([]);
const usersInB2 = await $api.collaboration.fetchUsersForGoal(goalB!.id);
expect(usersInB2?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
});
it('should handle inviting already existing collaborator', async () => {
const addResult1 = await $api.collaboration.inviteUserToGoal({
goalId: collaborationGoal?.id!,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-mcp",
"version": "1.48.3",
"version": "1.51.0",
"description": "MCP (Model Context Protocol) server for TaskView — lets AI assistants (Claude Code, Claude Desktop, etc.) manage projects and tasks via the TaskView API",
"type": "module",
"bin": {
@@ -62,4 +62,4 @@
"vitest": "^4.1.2",
"zod": "^3.23.8"
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.50.10",
"version": "1.51.0",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",