Merge pull request #105 from Gimanh/fix/issues

Fix/issues
This commit is contained in:
Nikolai Giman
2026-08-27 20:44:20 +02:00
committed by GitHub
103 changed files with 2322 additions and 660 deletions
+1 -1
View File
@@ -428,7 +428,7 @@ For commercial licensing questions, hosted service permissions, or other use cas
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately using the contact information provided in the repository or on the TaskView website.
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
When running TaskView in production:
+46
View File
@@ -0,0 +1,46 @@
# Security Policy
## Reporting a vulnerability
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report them privately using one of these channels:
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
Please include as much of the following as you can:
- A description of the issue and its impact
- Affected component (API, web app, MCP server, mobile app) and version
- Steps to reproduce, or a proof of concept
- Any suggested mitigation
## What to expect
- We will acknowledge your report within **5 business days**.
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
## Supported versions
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
## Scope
In scope:
- The TaskView API server, web app, MCP server, and mobile app in this repository
- The hosted service at `app.taskview.tech`
Out of scope:
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
- Findings that require a compromised admin account or physical access to the server
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
- Denial-of-service testing against the hosted service
## Safe harbor
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.52.0",
"version": "1.53.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -5,18 +5,12 @@ import { OverdueKpi } from './kpi/OverdueKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
@@ -59,6 +53,12 @@ import { sectionLocales } from './locales'
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
@@ -82,25 +82,25 @@ const builders: SectionBuilder[] = [
new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
new BlockedByDependenciesSection(),
// new BlockedByDependenciesSection(), // disabled
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new OverdueByAgeSection(), // disabled
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
new StatusDistributionSection(),
new ActiveProjectsSection(),
// new ActiveProjectsSection(), // disabled
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
new IncomePerTagMonthSection(),
new ExpensePerTagMonthSection(),
new TopProjectsByAmountSection(),
// new IncomePerTagMonthSection(), // disabled
// new ExpensePerTagMonthSection(), // disabled
// new TopProjectsByAmountSection(), // disabled
]
export class SectionRegistry {
+10
View File
@@ -367,6 +367,16 @@ export default class AuthController {
// Invalidate code immediately to prevent replay attacks
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
if (userData.block) {
if (!userData.confirm_email_code) {
return res.status(403).send({ message: 'account_blocked' });
}
const confirmed = await req.appUser.authManager.repository.markEmailConfirmed(userData.email);
if (!confirmed) {
return res.status(500).end();
}
}
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
+15
View File
@@ -119,6 +119,21 @@ export default class AuthModel {
}
}
async markEmailConfirmed(email: string): Promise<boolean> {
if (!email) return false;
try {
const result = await this.db.dbDrizzle
.update(UsersSchema)
.set({ confirmEmailCode: null, block: 0 })
.where(eq(UsersSchema.email, email));
return (result.rowCount ?? 0) > 0;
} catch (error) {
$logger.error(error, `Error marking email confirmed for ${email}`);
return false;
}
}
async confirmEmail(login: string, code: string, block: number): Promise<boolean> {
const query = `UPDATE tv_auth.users
SET confirm_email_code = NULL, block = $1
@@ -512,4 +512,65 @@ describe('Login API', () => {
expect(te).toBe(0);
});
it('loginByCode confirms and admits a blocked-unconfirmed account', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const userModel = new AuthModel();
const before = await userModel.getUserByLogin(email, true);
expect(before).toBeTruthy();
expect((before as any).block).toBe(1);
expect((before as any).confirm_email_code).toBeTruthy();
const code = '654321';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
const response = await axios.post(`${url}/module/auth/login-by-code`, { email, code });
expect(response.status).toBe(200);
expect(response.data.access).toBeTruthy();
expect(response.data.refresh).toBeTruthy();
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(0);
expect((after as any).confirm_email_code).toBeNull();
});
it('loginByCode rejects a banned account (blocked, no confirm code)', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const db = Database.getInstance();
await db.query('update tv_auth.users set block = 1, confirm_email_code = null where email = $1', [email]);
const userModel = new AuthModel();
const code = '112233';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
let status = 0;
let message = '';
await axios.post(`${url}/module/auth/login-by-code`, { email, code }).catch((err) => {
status = err.response.status;
message = err.response.data.message;
});
expect(status).toBe(403);
expect(message).toBe('account_blocked');
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(1);
});
});
@@ -5,7 +5,8 @@ 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;
// the only route using this guard names the goal in the path
const goalId = req.params.goalId;
if (!goalId) {
return res.status(400).end();
@@ -5,7 +5,7 @@ import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
import { CollaborationController } from './CollaborationController';
import { CanAddUserCollaboration } from './middlewares/CanAddUserCollaboration';
import { CanDeleteUserCollaboration } from './middlewares/CanDeleteUserCollaboration';
// import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanToggleRolesCollaboration } from './middlewares/CanToggleRolesCollaboration';
export default class CollaborationRoutes implements Routable {
@@ -56,6 +56,10 @@ export default class CollaborationRoutes implements Routable {
/**
* Fetch users for goal for collaboration
*/
this.router.get('/:goalId', [IsLoggedIn], this.collaborationController.fetchUsersForGoalNew);
this.router.get(
'/:goalId',
[IsLoggedIn, CanFetchUsersCollaboration],
this.collaborationController.fetchUsersForGoalNew
);
}
}
@@ -21,7 +21,8 @@ export const CanFetchUsersCollaboration = async (req: Request, res: Response, ne
if (
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS) ||
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)
) {
return next();
}
@@ -33,6 +33,15 @@ export class GraphController {
return res.tvJson(edges);
};
fetchTaskEdges = async (req: Request, res: Response) => {
const taskId = Number(req.params.taskId);
if (!Number.isFinite(taskId)) {
return res.status(400).send('Task ID is required');
}
const edges = await req.appUser.graphManager.fetchEdgesForTask(taskId);
return res.tvJson(edges);
};
deleteEdge = async (req: Request, res: Response) => {
if (!req.params.id) {
return res.status(400).send('Edge ID is required');
+4
View File
@@ -19,6 +19,10 @@ export class GraphManager {
return await this.repository.fetchAllEdges(goalId);
}
async fetchEdgesForTask(taskId: number) {
return await this.repository.fetchEdgesForTask(taskId);
}
async deleteEdge(id: number) {
return await this.repository.deleteEdge(id);
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, or } from 'drizzle-orm';
import { GraphRelationsSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
@@ -32,6 +32,16 @@ export class GraphRepository {
return result ?? [];
}
public async fetchEdgesForTask(taskId: number): Promise<GraphReturnRelationsType[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(GraphRelationsSchema)
.where(or(eq(GraphRelationsSchema.fromTaskId, taskId), eq(GraphRelationsSchema.toTaskId, taskId)))
);
return result ?? [];
}
public async deleteEdge(id: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
+1
View File
@@ -21,6 +21,7 @@ export default class GraphRoutes implements Routable {
initRoutes() {
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
this.router.get('/task/:taskId', [IsLoggedIn, CanViewGraph], this.graphController.fetchTaskEdges);
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
}
@@ -3,28 +3,32 @@ import { GraphRepository } from '../GraphRepository';
import { TasksRepository } from '../../tasks/TasksRepository';
/**
* Resolves goalId from graph request.
* - GET /:goalId → params.goalId
* - POST (addEdge) → resolve via fromTaskId (body.source)
* - DELETE /:id → resolve via edge id
* Resolves the single goal a graph request belongs to.
*
* The source is chosen by what the route actually carries, not by probing every
* field in turn: a route parameter always wins, and only a request with no
* parameters at all (addEdge) is resolved from the body. Reading the body first
* would let a caller point the guard at a task they own while the handler acts
* on someone else's edge.
*
* A graph lives inside one project, so an edge whose endpoints sit in different
* goals is not a permission question — it is an impossible object. It resolves
* to null and the guards reject it before any permission is considered, the same
* invariant the tasks.check_task_graph_relation_goal trigger enforces in the DB.
*/
export async function resolveGoalId(req: Request): Promise<number | null> {
// Direct goalId in params (fetchAllEdges)
// fetchAllEdges: GET /:goalId
if (req.params.goalId) {
const id = Number(req.params.goalId);
return isNaN(id) ? null : id;
const goalId = Number(req.params.goalId);
return isNaN(goalId) ? null : goalId;
}
// addEdge: resolve goalId from task
if (req.body?.source) {
const taskId = Number(req.body.source);
if (isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
// fetchTaskEdges: GET /task/:taskId
if (req.params.taskId) {
return goalIdForTask(req.params.taskId);
}
// deleteEdge: resolve goalId from edge
// deleteEdge: DELETE /:id
if (req.params.id) {
const edgeId = Number(req.params.id);
if (isNaN(edgeId)) return null;
@@ -33,5 +37,25 @@ export async function resolveGoalId(req: Request): Promise<number | null> {
return edge?.goalId ?? null;
}
// addEdge: POST with { source, target } — both endpoints must be in one goal
if (req.body?.source) {
const sourceGoalId = await goalIdForTask(req.body.source);
if (sourceGoalId === null) return null;
const targetGoalId = await goalIdForTask(req.body.target);
if (targetGoalId !== sourceGoalId) return null;
return sourceGoalId;
}
return null;
}
async function goalIdForTask(rawTaskId: unknown): Promise<number | null> {
const taskId = Number(rawTaskId);
if (!taskId || isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
}
@@ -2,22 +2,26 @@ import type { Request } from 'express';
import { IntegrationsRepository } from '../IntegrationsRepository';
/**
* Resolves projectId from request.
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
* Resolves the project to authorize the request against.
*
* When the request names an integration, the project is derived from that
* integration and a projectId supplied by the caller is ignored: every handler
* that takes an integration id acts on the integration, so authorizing a
* caller-supplied project would guard a different object than the one touched.
*
* Only create and fetch carry no integration id — there the project itself is
* the object being acted on, so it is read from the request.
*/
export async function resolveProjectId(req: Request): Promise<number | null> {
// Direct projectId in body or query
const directId = req.body?.projectId ?? req.query?.projectId;
if (directId) {
const id = Number(directId);
return isNaN(id) ? null : id;
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (integrationId && !isNaN(integrationId)) {
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
}
// integrationId from body or query, or id from body
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (!integrationId || isNaN(integrationId)) return null;
const projectId = Number(req.body?.projectId || req.query?.projectId);
if (!projectId || isNaN(projectId)) return null;
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
return projectId;
}
+84 -11
View File
@@ -1,10 +1,11 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { GoalPermissions } from '../../types/auth.types';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { KanbanController } from './KanbanController';
import { CanManageKanban } from './middlewares/CanManageKanban';
import { CanViewKanban } from './middlewares/CanViewKanban';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { goalIdFromBody, goalIdFromParam, goalIdFromStatusBody } from './middlewares/goal-id-resolvers';
import { requireKanbanPermission } from './middlewares/require-kanban-permission';
export default class KanbanRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly kanbanController: KanbanController;
@@ -20,17 +21,89 @@ export default class KanbanRoutes implements Routable {
}
initRoutes() {
this.router.post('/fetch-statuses', [IsLoggedIn, CanViewKanban], this.kanbanController.fetchAllColumns);
this.router.post('/add-status', [IsLoggedIn, CanManageKanban], this.kanbanController.addStatus);
this.router.post('/delete-status', [IsLoggedIn, CanManageKanban], this.kanbanController.deleteStatus);
this.router.post('/update-status', [IsLoggedIn, CanManageKanban], this.kanbanController.updateStatus);
this.router.post(
'/fetch-statuses',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromBody,
}),
],
this.kanbanController.fetchAllColumns
);
// this.router.get('columns/:goalId', [IsLoggedIn], this.kanbanController.fetchAllColumns);
this.router.get('/tasks/:goalId/:columnId/:cursor', [IsLoggedIn, CanViewKanban, CanFetchTasks], this.kanbanController.fetchTasksForColumn);
this.router.post(
'/add-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.addStatus
);
this.router.post(
'/delete-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.deleteStatus
);
this.router.post(
'/update-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.updateStatus
);
this.router.get(
'/tasks/:goalId/:columnId/:cursor',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam,
}),
requireKanbanPermission({
anyOf: [GoalPermissions.COMPONENT_CAN_WATCH_CONTENT],
resolveGoalId: goalIdFromParam,
}),
],
this.kanbanController.fetchTasksForColumn
);
//we do not use this route in the client (no logic for this route on the client side)!!!
this.router.get('/tasks-order/:goalId/:columnId/:cursor', [IsLoggedIn, CanManageKanban], this.kanbanController.getTasksOrderForColumnAndCursor);
this.router.get(
'/tasks-order/:goalId/:columnId/:cursor',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam
})
],
this.kanbanController.getTasksOrderForColumnAndCursor
);
this.router.patch('/update-tasks-order-and-column', [IsLoggedIn, CanManageKanban], this.kanbanController.updateTasksOrderAndColumn);
this.router.patch(
'/update-tasks-order-and-column',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.updateTasksOrderAndColumn
);
}
}
@@ -1,33 +0,0 @@
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';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanFetchTasks = async (req: Request, res: Response, next: NextFunction) => {
const props = req.body.goalId ? req.body : req.params;
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT)) {
return next();
}
return res.status(403).end();
};
@@ -1,54 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanManageKanban = async (req: Request, res: Response, next: NextFunction) => {
let props = req.body.goalId ? req.body : req.params;
switch (req.url) {
case '/update-status':
const result = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
props = {
goalId: result?.goal_id,
};
break;
case '/delete-status':
const result2 = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
props = {
goalId: result2?.goal_id,
};
break;
default:
break;
}
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
) {
return next();
}
return res.status(403).end();
};
@@ -1,33 +0,0 @@
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';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanViewKanban = async (req: Request, res: Response, next: NextFunction) => {
const props = req.body.goalId ? req.body : req.params;
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.KANBAN_CAN_VIEW)) {
return next();
}
return res.status(403).end();
};
@@ -0,0 +1,19 @@
import type { Request } from 'express';
export function goalIdFromParam(req: Request): number | null {
const goalId = Number(req.params.goalId);
return goalId && !isNaN(goalId) ? goalId : null;
}
export function goalIdFromBody(req: Request): number | null {
const goalId = Number(req.body?.goalId);
return goalId && !isNaN(goalId) ? goalId : null;
}
export async function goalIdFromStatusBody(req: Request): Promise<number | null> {
const statusId = Number(req.body?.id);
if (!statusId || isNaN(statusId)) return null;
const status = await req.appUser.kanbanManager.repository.fetchStatus(statusId);
return status?.goal_id ?? null;
}
@@ -0,0 +1,24 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { logError } from '../../../utils/api';
import type { RequireKanbanPermissionArgs } from '../types';
export function requireKanbanPermission({ anyOf, resolveGoalId }: RequireKanbanPermissionArgs) {
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = await resolveGoalId(req);
if (!goalId) return res.status(400).end();
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not resolve kanban permissions');
return res.status(500).end();
}
if (anyOf.some((permission) => permissions.hasPermissions(permission))) return next();
return res.status(403).end();
};
}
+8 -4
View File
@@ -1,6 +1,8 @@
import { type } from 'arktype';
import type { Request } from 'express';
import { z } from 'zod';
import { StringToNumber } from '../../types/app.types';
import type { GoalPermissionType } from '../../types/auth.types';
// ============ Arktype schemas ============
@@ -102,11 +104,13 @@ export const KanbanArkTypeUpdateTasksOrder = type({
export type KanbanArgUpdateTasksOrder = typeof KanbanArkTypeUpdateTasksOrder.infer;
export const KanbanArkTypeCanManageKanban = type({
goalId: NumberFromString,
});
export type KanbanGoalIdResolver = (req: Request) => Promise<number | null> | number | null;
export type KanbanArgCanManageKanban = typeof KanbanArkTypeCanManageKanban.infer;
export type RequireKanbanPermissionArgs = {
/** the caller must hold at least ONE of these */
anyOf: GoalPermissionType[];
resolveGoalId: KanbanGoalIdResolver;
};
// ============ Deprecated Zod schemas ============
+4
View File
@@ -184,6 +184,10 @@ export class SsoController {
const userData = resolved.user
if (userData.block && !userData.confirm_email_code) {
return this.redirectSsoError(res, 'account_blocked')
}
await this.orgRepo.addMember(config.organizationId, userData.email, config.defaultOrgRole)
await this.ssoRepo.upsertIdentity({
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { deriveSamlEmail } from '../sso.utils'
const EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
const PERSISTENT_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
const EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
describe('deriveSamlEmail', () => {
it('takes the email attribute and lowercases it', () => {
expect(deriveSamlEmail({ email: 'User@Company.com', nameID: 'abc' })).toBe('user@company.com')
})
it('falls back to the xmlsoap emailaddress claim', () => {
expect(deriveSamlEmail({ [EMAIL_CLAIM]: 'a@b.com', nameID: 'abc' })).toBe('a@b.com')
})
it('uses nameID only when the NameID Format is emailAddress', () => {
expect(deriveSamlEmail({
nameID: 'user@company.com',
nameIDFormat: EMAIL_NAMEID_FORMAT,
})).toBe('user@company.com')
})
it('does not use nameID for a non-email NameID Format', () => {
expect(deriveSamlEmail({
nameID: 'user@company.com',
nameIDFormat: PERSISTENT_NAMEID_FORMAT,
})).toBeNull()
})
it('does not use nameID when no format is provided', () => {
expect(deriveSamlEmail({ nameID: 'user@company.com' })).toBeNull()
})
it('prefers the email attribute over an emailAddress-format nameID', () => {
expect(deriveSamlEmail({
email: 'attr@company.com',
nameID: 'name@company.com',
nameIDFormat: EMAIL_NAMEID_FORMAT,
})).toBe('attr@company.com')
})
it('returns null for a blank or non-string email attribute', () => {
expect(deriveSamlEmail({ email: ' ', nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({ email: 123, nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({ nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({})).toBeNull()
})
})
@@ -2,6 +2,7 @@ import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import { deriveSamlEmail } from '../sso.utils'
import type { SamlOptionsArgs } from '../types'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
import { SamlDbCacheProvider } from './saml-cache-provider'
@@ -72,14 +73,13 @@ export class SamlProvider implements SsoProvider {
throw new Error('SAML response missing nameID')
}
const email = (
profile.email
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
?? profile.nameID
) as string
const email = deriveSamlEmail(profile as Record<string, unknown>)
if (!email) {
throw new Error('SAML response missing email attribute')
}
return {
email: email.toLowerCase(),
email,
externalId: profile.nameID,
displayName: (profile.displayName
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']) as string | undefined,
+18 -3
View File
@@ -14,6 +14,21 @@ export function generateDomainVerifyToken(): string {
return `tvdom_${randomBytes(32).toString('hex')}`
}
const SAML_EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
const SAML_EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
export function deriveSamlEmail(profile: Record<string, unknown>): string | null {
const fromAttribute = profile.email ?? profile[SAML_EMAIL_CLAIM]
if (typeof fromAttribute === 'string' && fromAttribute.trim()) {
return fromAttribute.trim().toLowerCase()
}
if (profile.nameIDFormat === SAML_EMAIL_NAMEID_FORMAT
&& typeof profile.nameID === 'string' && profile.nameID.trim()) {
return profile.nameID.trim().toLowerCase()
}
return null
}
export function trustedSsoDomains(): string[] {
const raw = process.env.SSO_TRUSTED_DOMAINS
if (!raw?.trim()) return []
@@ -80,9 +95,9 @@ export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Pro
const urls = process.env.NODE_ENV === 'production'
? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`]
: [
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
]
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
]
for (const url of urls) {
const urlError = validateMetadataUrl(url)
+1 -1
View File
@@ -128,7 +128,7 @@ export type ApplySsoIdpEmailArgs = {
email: string
}
export type SsoCallbackError = 'authentication_failed' | 'email_in_use'
export type SsoCallbackError = 'authentication_failed' | 'email_in_use' | 'account_blocked'
export type ResolveSsoUserResult =
| { ok: true, user: UserDbRecord }
-11
View File
@@ -2,25 +2,14 @@ import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { CanAddTaskNew } from './middlewares/CanAddTaskNew';
// import { CanAddTask } from './middlewares/CanAddTask';
// import { CanUpdateTaskStatus } from './middlewares/CanUpdateTaskStatus';
import { CanDeleteTask } from './middlewares/CanDeleteTask';
// import { CanUpdateTaskAssignee } from './middlewares/CanUpdateTaskAssignee';
import { CanFetchTask } from './middlewares/CanFetchTask';
// import { CanUpdateTaskDescription } from './middlewares/CanUpdateTaskDescription';
// import { CanUpdateTaskNote } from './middlewares/CanUpdateTaskNote';
// import { CanUpdateTaskDeadline } from './middlewares/CanUpdateTaskDeadline';
// import { CanFetchSubtasks } from './middlewares/CanFetchSubtasks';
// import { CanUpdateTaskPriority } from './middlewares/CanUpdateTaskPriority';
// import { CanMoveTask } from './middlewares/CanMoveTask';
// import { CanSeeTaskAssignedUsers } from './middlewares/CanSeeTaskAssignedUsers';
import { CanFetchTaskHistory } from './middlewares/CanFetchTaskHistory';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { CanRecoveryTaskHistory } from './middlewares/CanRecoveryTaskHistory';
import { CanUpdateTask } from './middlewares/CanUpdateTask';
import { CanUpdateTaskAssigneeNew } from './middlewares/CanUpdateTaskAssigneeNew';
import { TasksController } from './TasksController';
// import { MainCanCreateTaskAction } from './middlewares/MainCanCreateTaskAction';
export default class TasksRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
@@ -1,39 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
import { logError } from '../../../utils/api';
export const CanAddTask = async (req: Request, res: Response, next: NextFunction) => {
const listId = req.body.componentId;
if (!listId) {
return res.status(400).end();
}
let permissions;
if (Number(listId) === ALL_TASKS_LIST_ID && req.body.goalId && req.body.goalId !== DEFAULT_ID) {
permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(req.body.goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
} else {
permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(listId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASKLIST)
.catch(logError);
}
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanFetchSubtasks = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.query.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanFetchSubtasks');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_SUBTASKS)) {
return next();
}
return res.status(403).end();
};
@@ -5,7 +5,7 @@ import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.query.taskId || req.params.taskId;
const taskId = req.params.taskId;
if (!taskId) {
return res.status(400).end();
@@ -1,28 +0,0 @@
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 CanMoveTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanMoveTask');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_DELETE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanSeeTaskAssignedUsers = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanSeeTaskAssignedUsers');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -1,29 +0,0 @@
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';
/** @deprecated */
export const CanUpdateTaskAssignee = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDescription');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanUpdateTaskDeadline = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDeadline');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DEADLINE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanUpdateTaskDescription = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDescription');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DESCRIPTION)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanUpdateTaskNote = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskNote');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_NOTE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanUpdateTaskPriority = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskPriority');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_PRIORITY)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
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 CanUpdateTaskStatus = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return next();
}
return res.status(403).end();
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.52.0",
"version": "1.53.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -0,0 +1,300 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, initApi } from './init-api'
/**
* GET /module/collaboration/:goalId must be an object-level protected route:
* only a member of the goal holding task_can_assign_users or goal_can_manage_users
* may read its collaborator list (emails, invitation dates, roles, goalOwner flag).
*/
describe('Collaboration goal member list access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let outsiderEmail: string
let deleteAllGoals: () => Promise<void>
let manageUsersPermissionId: number
const permissionIdByName = new Map<string, number>()
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
outsiderEmail = init.user2Email
deleteAllGoals = init.deleteAllGoals
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
for (const permission of allPermissions) {
permissionIdByName.set(permission.name, permission.id)
}
const found = permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS)
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
manageUsersPermissionId = found
})
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 ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
// A goal owned by user1 that user2 is NOT a member of, holding a third-party email
async function createPrivateGoal(organizationId?: number) {
const goal = await ownerApi.goals.createGoal({
name: `Private goal ${Date.now()}`,
...(organizationId ? { organizationId } : {}),
})
if (!goal) throw new Error('Failed to create goal')
const invitedEmail = `outside-party-${Date.now()}@test.com`
const invited = await ownerApi.collaboration.inviteUserToGoal({ email: invitedEmail, goalId: goal.id })
if (!invited) throw new Error('Failed to invite third-party email')
return { goal, invitedEmail }
}
// Invite user2 into user1's goal and grant a role carrying goal_can_manage_users
async function shareGoalWithOutsider() {
const goal = await ownerApi.goals.createGoal({ name: `Shared goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Manager ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggled = await ownerApi.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 ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
return { goal, role, collab }
}
describe('JWT session of a non-member', () => {
it('cannot read the collaborator list of someone else goal', async () => {
const { goal } = await createPrivateGoal()
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
it('cannot enumerate collaborator emails by walking goal ids', async () => {
const { goal, invitedEmail } = await createPrivateGoal()
let leaked: Awaited<ReturnType<typeof outsiderApi.collaboration.fetchUsersForGoal>> | null = null
try {
leaked = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
} catch {
return
}
expect(
leaked ?? [],
`Leaked collaborator list of goal ${goal.id}: ${JSON.stringify(leaked)}`,
).toEqual([])
expect((leaked ?? []).some(u => u.email === invitedEmail)).toBe(false)
expect((leaked ?? []).some(u => u.goalOwner)).toBe(false)
})
it('gets the same rejection for a goal id that does not exist', async () => {
const nonExistentGoalId = 999999999
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(nonExistentGoalId), 403)
})
})
describe('Organization boundary', () => {
it('a member of the same organization who is not a member of the goal is still rejected', async () => {
const org = await ownerApi.organizations.create({ name: `Access org ${Date.now()}` })
if (!org) throw new Error('Failed to create organization')
const added = await ownerApi.organizations.addMember({
organizationId: org.id,
email: outsiderEmail,
role: 'member',
})
if (!added) throw new Error('Failed to add user2 to the organization')
// The goal lives in the shared org, but user2 was never invited into the goal itself
const { goal } = await createPrivateGoal(org.id)
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
describe('Unauthenticated access', () => {
it('is rejected with 401 rather than served', async () => {
const { goal } = await createPrivateGoal()
const response = await axios.get(`${API_URL}/module/collaboration/${goal.id}`, {
validateStatus: () => true,
})
expect(
response.status,
`Anonymous request returned ${response.status}: ${JSON.stringify(response.data)}`,
).toBe(401)
})
})
describe('API token of a non-member', () => {
it('cannot read the collaborator list of someone else goal', async () => {
const created = await outsiderApi.apiTokens.create({ name: `Access probe ${Date.now()}` })
if (!created) throw new Error('Failed to create API token for user2')
const tokenApi = new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
}))
const { goal } = await createPrivateGoal()
try {
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
} finally {
await outsiderApi.apiTokens.delete(created.item.id)
}
})
it('cannot read a goal that is outside the token allowedGoalIds scope', async () => {
const ownGoal = await outsiderApi.goals.createGoal({ name: `User2 goal ${Date.now()}` })
if (!ownGoal) throw new Error('Failed to create user2 goal')
// Token is explicitly scoped to user2's own goal only
const created = await outsiderApi.apiTokens.create({
name: `Scoped probe ${Date.now()}`,
allowedGoalIds: [ownGoal.id],
})
if (!created) throw new Error('Failed to create scoped API token for user2')
const tokenApi = new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
}))
const { goal } = await createPrivateGoal()
try {
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
} finally {
await outsiderApi.apiTokens.delete(created.item.id)
}
})
})
describe('Legitimate access is preserved', () => {
it('the goal owner can read the collaborator list', async () => {
const { goal, invitedEmail } = await createPrivateGoal()
const users = await ownerApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === invitedEmail)).toBe(true)
})
it('a member with goal_can_manage_users can read the collaborator list', async () => {
const { goal } = await shareGoalWithOutsider()
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
})
/**
* A rank-and-file member must still see the project roster, otherwise the UI
* cannot render task assignees. Both default roles created by the goal trigger
* (editor and executor, migration 1.6.1/5.default-roles-for-project.sql) carry
* task_can_watch_assigned_users, so this is the common case, not an edge one.
*/
it('a member with only task_can_watch_assigned_users can read the collaborator list', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Executor goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const roles = await ownerApi.collaboration.fetchRolesForGoal(goal.id)
const executor = roles?.find(r => r.name === 'executor')
if (!executor) throw new Error('Default "executor" role is missing on a fresh goal')
// the role grants the watch permission and neither of the two management ones,
// so a pass here can only come from task_can_watch_assigned_users
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(goal.id)
const executorPermissionIds = (matrix ?? [])
.filter(row => row.roleId === executor.id)
.map(row => row.permissionId)
expect(executorPermissionIds).toContain(permissionIdByName.get(TvPermissions.TASK_CAN_WATCH_ASSIGNED_USERS))
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS))
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.TASK_CAN_ASSIGN_USERS))
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [executor.id],
})
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
})
it('a member whose role carries none of the three permissions is rejected', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Bare role goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
// a freshly created custom role carries no permissions at all
const bareRole = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Bare ${Date.now()}`,
})
if (!bareRole) throw new Error('Failed to create role')
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [bareRole.id],
})
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
describe('Revoked access', () => {
it('a removed collaborator loses access to the collaborator list', async () => {
const { goal, collab } = await shareGoalWithOutsider()
// sanity: access is real before removal
const before = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(before?.some(u => u.email === outsiderEmail)).toBe(true)
const removed = await ownerApi.collaboration.deleteUserFromGoal({ goalId: goal.id, id: collab.id })
expect(removed).toBeTruthy()
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
})
@@ -0,0 +1,161 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* resolveGoalId() for the graph module inspects req.body.source before falling
* back to req.params.id, while deleteEdge acts on req.params.id. A caller must
* not be able to point the guard at a task they own while the handler operates
* on an edge belonging to someone else.
*/
describe('Graph object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerTaskId: number
let victimTaskId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim graph ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const victimTask = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-task-${Date.now()}`,
})
if (!victimTask) throw new Error('Failed to create victim task')
victimTaskId = victimTask.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker graph ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
const attackerTask = await outsiderApi.tasks.createTask({
goalId: attackerGoal.id,
description: `attacker-task-${Date.now()}`,
})
if (!attackerTask) throw new Error('Failed to create attacker task')
attackerTaskId = attackerTask.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 ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
async function createVictimEdge(): Promise<number> {
const from = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-edge-from-${Date.now()}`,
})
const to = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-edge-to-${Date.now()}`,
})
if (!from || !to) throw new Error('Failed to create victim tasks')
const edge = await ownerApi.graph.addEdge({ source: from.id, target: to.id })
if (!edge) throw new Error('Failed to create victim edge')
return edge.id
}
async function victimEdgeExists(edgeId: number): Promise<boolean> {
const edges = await ownerApi.graph.fetchAllEdges(victimGoalId)
return (edges ?? []).some(e => e.id === edgeId)
}
it('rejects deleting another user edge even when a self-owned source task is supplied', async () => {
const edgeId = await createVictimEdge()
const response = await attackerAxios.delete(`/module/graph/${edgeId}`, {
data: { source: attackerTaskId },
})
expect(
await victimEdgeExists(edgeId),
`victim edge ${edgeId} was destroyed by a non-member`,
).toBe(true)
expect(response.status).toBe(403)
})
// A graph lives inside one project, so an edge across two of them is not a
// permission question but an impossible object: it is refused before any
// permission is looked at. Driven through the SDK on purpose — this needs no
// crafted request at all, an ordinary client using the public API reaches it.
it('rejects creating an edge whose endpoints live in different projects', async () => {
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: attackerTaskId, target: victimTaskId }),
400,
)
})
// the mirror of the case above: a foreign source with an own target. This one
// fails closed even without the endpoint comparison (the goal would resolve to
// the victim project and the permission check would deny it), which is exactly
// why it needs pinning — a regression here would be silent
it('rejects creating an edge from a foreign task into a project the caller owns', async () => {
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: victimTaskId, target: attackerTaskId }),
400,
)
})
// both endpoints inside the victim project: the goal resolves cleanly, so this
// is decided purely by the permission check on that goal
it('rejects creating an edge between two tasks of a project the caller is not a member of', async () => {
const second = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-second-${Date.now()}`,
})
if (!second) throw new Error('Failed to create second victim task')
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: victimTaskId, target: second.id }),
403,
)
})
it('control: without the injected source the guard already rejects the delete', async () => {
const edgeId = await createVictimEdge()
const response = await attackerAxios.delete(`/module/graph/${edgeId}`)
expect(response.status).toBe(403)
expect(await victimEdgeExists(edgeId)).toBe(true)
})
it('control: the owner can still delete their own edge', async () => {
const edgeId = await createVictimEdge()
const deleted = await ownerApi.graph.deleteEdge(edgeId)
expect(deleted).toBeTruthy()
expect(await victimEdgeExists(edgeId)).toBe(false)
})
})
@@ -0,0 +1,113 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* Several guards pick the goal to authorize with `req.body.goalId ? req.body : req.params`,
* while their handlers read `req.params`. Supplying a body that names a goal the caller owns
* must not authorize a request whose path points at someone else's goal.
*/
describe('Guard/handler parameter confusion', () => {
let ownerApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerGoalId: number
let victimColumnId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim confusion ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const attackerGoal = await axios.post(
`${API_URL}/module/goals`,
{ name: `Attacker confusion ${Date.now()}` },
{ headers: { Authorization: `Bearer ${auth.data.access}` } },
)
attackerGoalId = attackerGoal.data.response.id
await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `secret-task-${Date.now()}`,
})
const columns = await ownerApi.kanban.fetchAllColumns(victimGoalId)
if (!columns?.length) throw new Error('Victim goal has no kanban columns')
victimColumnId = columns[0].id
})
afterAll(async () => {
await deleteAllGoals()
})
it('rejects reading another goal kanban tasks when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`,
data: { goalId: attackerGoalId, columnId: victimColumnId },
})
expect(
response.status,
`Leaked kanban tasks of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('rejects reading another goal task order when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/kanban/tasks-order/${victimGoalId}/${victimColumnId}/0`,
data: { goalId: attackerGoalId, columnId: victimColumnId },
})
expect(response.status).toBe(403)
})
it('rejects reading another goal role-to-permission matrix when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/collaborationroles/role-to-permissions/${victimGoalId}`,
data: { goalId: attackerGoalId },
})
expect(
response.status,
`Leaked role matrix of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('control: the same requests without a body are already rejected', async () => {
const kanban = await attackerAxios.get(`/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`)
expect(kanban.status).toBe(403)
const roles = await attackerAxios.get(`/module/collaborationroles/role-to-permissions/${victimGoalId}`)
expect(roles.status).toBe(403)
})
it('control: the owner still reads their own kanban tasks and role matrix', async () => {
const tasks = await ownerApi.kanban
.fetchTasksForColumn(victimGoalId, victimColumnId, 0)
.catch((e: any) => { throw new Error(`kanban read failed: ${e.response?.status}`) })
expect(tasks).toBeDefined()
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(victimGoalId)
.catch((e: any) => { throw new Error(`role matrix read failed: ${e.response?.status}`) })
expect(matrix).toBeDefined()
})
})
@@ -0,0 +1,236 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* The integrations guards resolve the project to authorize against via
* resolveProjectId(), which prefers a projectId supplied by the caller over the
* one derived from integrationId. The handlers, however, act on integrationId.
* A caller must not be able to pass a project they own alongside someone else's
* integration id and have the guard authorize the wrong object.
*/
describe('Integrations object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let outsiderEmail: string
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerGoalId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
outsiderEmail = init.user2Email
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim project ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker project ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
attackerGoalId = attackerGoal.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function createVictimIntegration(): Promise<number> {
const created = await ownerApi.integrations.createIntegration({
provider: 'github',
repoFullName: `victim-org/private-repo-${Date.now()}`,
projectId: victimGoalId,
})
if (!created) throw new Error('Failed to create victim integration')
return created.id
}
async function victimIntegrationExists(integrationId: number): Promise<boolean> {
const list = await ownerApi.integrations.fetchIntegrations(victimGoalId)
return (list ?? []).some(i => i.id === integrationId)
}
it('rejects deleting another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.delete('/module/integrations', {
data: { id: integrationId, projectId: attackerGoalId },
})
// impact first, mechanism second — so a failure reports whether data was actually destroyed
expect(
await victimIntegrationExists(integrationId),
`victim integration ${integrationId} was destroyed by a non-member`,
).toBe(true)
expect(
response.status,
`Guard authorized project ${attackerGoalId} while the handler acted on integration ${integrationId}`,
).toBe(403)
})
it('rejects toggling another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.patch('/module/integrations/toggle', {
id: integrationId,
isActive: false,
projectId: attackerGoalId,
})
expect(response.status).toBe(403)
})
it('rejects reading another user integration repos even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.get('/module/integrations/repos', {
params: { integrationId, projectId: attackerGoalId },
})
expect(response.status).toBe(403)
})
it('rejects syncing another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.post('/module/integrations/sync', {
integrationId,
projectId: attackerGoalId,
})
expect(response.status).toBe(403)
})
it('control: without the injected projectId the guard already rejects the same request', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.delete('/module/integrations', {
data: { id: integrationId },
})
expect(response.status).toBe(403)
expect(await victimIntegrationExists(integrationId)).toBe(true)
})
// select-repo is the most consequential handler of the four: besides writing to
// the integration it kicks off syncIssues() and registerWebhook() against the repo
it('rejects selecting a repo on another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.patch('/module/integrations/select-repo', {
integrationId,
repoFullName: 'attacker-org/planted-repo',
repoExternalId: '424242',
projectId: attackerGoalId,
})
expect(response.status).toBe(403)
})
// resolveProjectId reads projectId from the query string too, so a fix that only
// hardens the body would still leave this door open
it('rejects the same bypass when projectId arrives via the query string', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.delete('/module/integrations', {
params: { projectId: attackerGoalId },
data: { id: integrationId },
})
expect(
await victimIntegrationExists(integrationId),
`victim integration ${integrationId} was destroyed via a query-string projectId`,
).toBe(true)
expect(response.status).toBe(403)
})
it('rejects listing the integrations of a project the caller is not a member of', async () => {
await createVictimIntegration()
const response = await attackerAxios.get('/module/integrations', {
params: { projectId: victimGoalId },
})
expect(
response.status,
`Leaked integrations of project ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('rejects planting a new integration into a project the caller is not a member of', async () => {
const response = await attackerAxios.post('/module/integrations', {
provider: 'github',
repoFullName: 'attacker-org/planted-repo',
projectId: victimGoalId,
})
expect(response.status).toBe(403)
})
it('control: the owner can still manage their own integration', async () => {
const integrationId = await createVictimIntegration()
const deleted = await ownerApi.integrations.deleteIntegration(integrationId)
expect(deleted).toBeTruthy()
expect(await victimIntegrationExists(integrationId)).toBe(false)
})
// guards against an over-strict fix: a project member holding integrations_can_manage
// must keep working, not just the goal owner
it('control: a project member with integrations_can_manage can delete the integration', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Shared integrations ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
const managePermission = allPermissions.find(p => p.name === TvPermissions.INTEGRATIONS_CAN_MANAGE)
if (!managePermission) throw new Error('Permission "integrations_can_manage" is not in DB')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Integrator ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggled = await ownerApi.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: managePermission.id,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected integrations_can_manage to be added, got ${JSON.stringify(toggled)}`)
}
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
const created = await ownerApi.integrations.createIntegration({
provider: 'github',
repoFullName: `shared-org/repo-${Date.now()}`,
projectId: goal.id,
})
if (!created) throw new Error('Failed to create integration')
const deleted = await outsiderApi.integrations.deleteIntegration(created.id)
expect(deleted).toBeTruthy()
})
})
@@ -0,0 +1,220 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* Board writes (columns and task placement) are gated by kanban_can_manage, the
* permission the UI has always used. They used to accept component_can_add_tasks
* or task_can_add_subtasks instead, which let a rank-and-file member delete other
* people's board columns.
*/
describe('Kanban permission boundaries', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let outsiderEmail: string
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
const permissionIdByName = new Map<string, number>()
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
outsiderEmail = init.user2Email
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
for (const permission of await ownerApi.collaboration.fetchAllPermissions()) {
permissionIdByName.set(permission.name, permission.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 ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
/** A goal of user1 that user2 joins through a role carrying exactly `permissionNames`. */
async function shareGoalWith(permissionNames: string[]) {
const goal = await ownerApi.goals.createGoal({ name: `Kanban access ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Role ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
for (const name of permissionNames) {
const permissionId = permissionIdByName.get(name)
if (!permissionId) throw new Error(`Permission "${name}" is not in DB`)
const toggled = await ownerApi.collaboration.toggleRolePermission({ roleId: role.id, permissionId })
if (!toggled || toggled.add !== true) {
throw new Error(`Expected "${name}" to be added, got ${JSON.stringify(toggled)}`)
}
}
await ownerApi.collaboration.toggleUserRoles({ goalId: goal.id, userId: collab.id, roles: [role.id] })
return goal
}
async function addColumn(goalId: number, name: string) {
const column = await ownerApi.kanban.addColumn({ goalId, name })
if (!column) throw new Error('Failed to create column')
return column
}
describe('a member holding kanban_can_manage', () => {
it('can create, rename and delete a board column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_MANAGE])
const created = await outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Created by member' })
expect(created?.id).toBeGreaterThan(0)
const renamed = await outsiderApi.kanban.updateColumn({ id: created!.id, name: 'Renamed by member' })
expect(renamed).toBeTruthy()
const deleted = await outsiderApi.kanban.deleteColumn({ id: created!.id })
expect(deleted).toBeTruthy()
})
it('can move a task into another column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_MANAGE])
const from = await addColumn(goal.id, 'From')
const to = await addColumn(goal.id, 'To')
const task = await ownerApi.tasks.createTask({
goalId: goal.id,
description: `movable-${Date.now()}`,
statusId: from.id,
})
if (!task) throw new Error('Failed to create task')
const moved = await outsiderApi.kanban.updateTasksOrderAndColumn({
goalId: goal.id,
columnId: to.id,
taskId: task.id,
prevTaskId: null,
nextTaskId: null,
})
expect(moved).toBeDefined()
})
})
describe('a member holding only task-level permissions', () => {
// exactly the pair the routes used to accept — the escalation that was closed
const TASK_LEVEL = [TvPermissions.COMPONENT_CAN_ADD_TASKS, TvPermissions.TASK_CAN_ADD_SUBTASKS]
it('cannot create a board column', async () => {
const goal = await shareGoalWith(TASK_LEVEL)
await expectHttpStatus(outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Nope' }), 403)
})
it('cannot rename or delete a board column', async () => {
const goal = await shareGoalWith(TASK_LEVEL)
const column = await addColumn(goal.id, 'Owned by user1')
await expectHttpStatus(outsiderApi.kanban.updateColumn({ id: column.id, name: 'Nope' }), 403)
await expectHttpStatus(outsiderApi.kanban.deleteColumn({ id: column.id }), 403)
const survivors = await ownerApi.kanban.fetchAllColumns(goal.id)
expect(survivors?.some(c => c.id === column.id), 'column was destroyed').toBe(true)
})
it('cannot move a task into another column', async () => {
const goal = await shareGoalWith(TASK_LEVEL)
const from = await addColumn(goal.id, 'From')
const to = await addColumn(goal.id, 'To')
const task = await ownerApi.tasks.createTask({
goalId: goal.id,
description: `pinned-${Date.now()}`,
statusId: from.id,
})
if (!task) throw new Error('Failed to create task')
await expectHttpStatus(
outsiderApi.kanban.updateTasksOrderAndColumn({
goalId: goal.id,
columnId: to.id,
taskId: task.id,
prevTaskId: null,
nextTaskId: null,
}),
403,
)
})
})
describe('a member holding only kanban_can_view', () => {
it('can read the task order of a column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_VIEW])
const column = await addColumn(goal.id, 'Readable')
const order = await outsiderApi.kanban.getTaskOrdersForColumnAndCursor(goal.id, column.id, null)
expect(order).toBeDefined()
})
it('cannot create a board column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_VIEW])
await expectHttpStatus(outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Nope' }), 403)
})
})
describe('the goal a column belongs to is never taken from the request', () => {
it('rejects deleting or renaming a foreign column even when a self-owned goalId is supplied', async () => {
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim board ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
const victimColumn = await addColumn(victimGoal.id, 'Victim column')
// a project user2 fully controls, offered to the guard as the authorization target
const ownGoal = await outsiderApi.goals.createGoal({ name: `Attacker board ${Date.now()}` })
if (!ownGoal) throw new Error('Failed to create attacker goal')
const deleteResponse = await attackerAxios.post('/module/kanban/delete-status', {
id: victimColumn.id,
goalId: ownGoal.id,
})
const updateResponse = await attackerAxios.post('/module/kanban/update-status', {
id: victimColumn.id,
name: 'Renamed by an outsider',
goalId: ownGoal.id,
})
const survivors = await ownerApi.kanban.fetchAllColumns(victimGoal.id)
const survivor = (survivors ?? []).find(c => c.id === victimColumn.id)
expect(survivor, `victim column ${victimColumn.id} was destroyed`).toBeDefined()
expect(survivor?.name, 'victim column was renamed').toBe('Victim column')
expect(deleteResponse.status).toBe(403)
expect(updateResponse.status).toBe(403)
})
})
})
@@ -0,0 +1,80 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* CanFetchTask authorizes `req.query.taskId || req.params.taskId`, while
* fetchTaskByIdNew reads `req.params`. A caller must not be able to name a task
* they own in the query string and have the guard authorize it while the handler
* returns someone else's task.
*/
describe('Task object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimTaskId: number
let attackerTaskId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim tasks ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
const victimTask = await ownerApi.tasks.createTask({
goalId: victimGoal.id,
description: `victim-secret-${Date.now()}`,
})
if (!victimTask) throw new Error('Failed to create victim task')
victimTaskId = victimTask.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker tasks ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
const attackerTask = await outsiderApi.tasks.createTask({
goalId: attackerGoal.id,
description: `attacker-own-${Date.now()}`,
})
if (!attackerTask) throw new Error('Failed to create attacker task')
attackerTaskId = attackerTask.id
})
afterAll(async () => {
await deleteAllGoals()
})
it('rejects reading another user task when a self-owned taskId is put in the query string', async () => {
const response = await attackerAxios.get(`/module/tasks/${victimTaskId}`, {
params: { taskId: attackerTaskId },
})
expect(
response.status,
`Leaked task ${victimTaskId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('control: without the query parameter the guard already rejects the same request', async () => {
const response = await attackerAxios.get(`/module/tasks/${victimTaskId}`)
expect(response.status).toBe(403)
})
it('control: the owner can still read their own task', async () => {
const task = await ownerApi.tasks.fetchTaskById(victimTaskId)
expect(task?.id).toBe(victimTaskId)
})
})
@@ -13,6 +13,14 @@ export default class TvGraph extends TvApiBase {
);
}
public async fetchTaskEdges(taskId: number) {
return this.request(
this.$axios.get<AppResponse<GraphResponseAddEdge[]>>(
`${this.moduleUrl}/task/${taskId}`
)
);
}
public async fetchAllEdges(goalId: number) {
return this.request(
this.$axios.get<AppResponse<GraphResponseAddEdge[]>>(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-mcp",
"version": "1.52.0",
"version": "1.53.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": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.52.0",
"version": "1.53.0",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
@@ -68,6 +68,10 @@
placeholder="https://api.example.com"
class="w-full"
autofocus
autocapitalize="none"
autocorrect="off"
spellcheck="false"
inputmode="url"
@keyup.enter="handleAddServer"
/>
</UFormField>
@@ -104,6 +108,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
import { useAdditionalServer } from '@/composables/useAdditionalServer'
import { normalizeServerUrl } from '@/helpers/serverUrl'
const { t } = useI18n()
const bp = useBreakpoints({ ...breakpointsTailwind, fullscreenModalMax: 1366 })
@@ -153,15 +158,7 @@ const serverOptions = computed(() => {
return options
})
const isValidUrl = computed(() => {
if (!newServerUrl.value) return false
try {
const url = new URL(newServerUrl.value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
})
const isValidUrl = computed(() => normalizeServerUrl(newServerUrl.value) !== null)
watch(selectedServer, (newServer) => {
if (newServer && newServer !== mainServer.value) {
@@ -171,9 +168,8 @@ watch(selectedServer, (newServer) => {
})
function handleAddServer() {
if (!isValidUrl.value) return
const url = newServerUrl.value.trim().replace(/\/$/, '')
const url = normalizeServerUrl(newServerUrl.value)
if (!url) return
if (!allServers.value.includes(url) && url !== systemServer.value) {
addServerFn.value(url)
@@ -1,16 +1,9 @@
import { ALL_TASKS_LIST_ID, type DefaultView } from 'taskview-api'
import type { RouteLocationRaw, Router } from 'vue-router'
import { $tvApi } from '@/plugins/axios'
import { useUserStore } from '@/stores/user.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
const VIEW_ROUTES: Record<DefaultView, string> = {
tasks: 'user',
kanban: 'kanban',
graph: 'graph',
sprints: 'sprints',
}
import { useProjectRoute } from '@/composables/useProjectRoute'
export const redirectToUser = async (router: Router) => {
const userStore = useUserStore()
@@ -36,15 +29,12 @@ export const resolveDefaultRoute = async (): Promise<RouteLocationRaw | null> =>
// The default project may live in any of the user's organizations; try the current one first
const orgStore = useOrganizationStore()
const orgs = [...orgStore.organizations].sort((a) => (a.slug === orgStore.currentOrgSlug ? -1 : 1))
const { projectRoute } = useProjectRoute()
for (const org of orgs) {
const goals = await $tvApi.goals.fetchGoals(org.id)
if (!goals?.some((goal) => goal.id === projectId)) continue
const view = uiPrefs.settings.defaultView ?? 'tasks'
const params: Record<string, string | number> = { orgSlug: org.slug, projectId }
if (view === 'tasks') params.listId = ALL_TASKS_LIST_ID
return { name: VIEW_ROUTES[view], params }
const goal = goals?.find((g) => g.id === projectId)
if (goal) return projectRoute(goal, org.slug)
}
return null
@@ -18,6 +18,10 @@
class="w-full"
:ui="{ list: 'rounded-2xl', trigger: 'rounded-xl', indicator: 'rounded-xl' }"
>
<template #default="{ item }">
<span :data-testid="`collab-tab-${item.value}`">{{ item.label }}</span>
</template>
<template #members>
<MembersList
:members="users"
@@ -3,6 +3,7 @@
v-model="email"
type="email"
:placeholder="t('collaboration.members.emailPlaceholder')"
data-testid="collab-member-email-input"
size="xl"
variant="soft"
class="w-full"
@@ -16,6 +17,7 @@
v-if="isValid"
icon="i-lucide-corner-down-left"
:label="t('collaboration.members.add')"
data-testid="collab-member-add-button"
color="primary"
variant="ghost"
size="xs"
@@ -24,6 +24,7 @@
<label
v-for="role in roles"
:key="role.id"
:data-testid="`collab-member-role-${role.name}`"
class="flex items-center gap-3 p-2 rounded hover:bg-elevated cursor-pointer"
>
<UCheckbox
@@ -51,6 +52,7 @@
/>
<UButton
:label="t('common.save')"
data-testid="collab-member-roles-save"
color="primary"
variant="soft"
@click="handleSave"
@@ -1,5 +1,8 @@
<template>
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
<div
:data-testid="`collab-member-${member.email}`"
class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors"
>
<div class="flex items-center gap-3">
<UAvatar
:alt="member.email"
@@ -42,6 +45,7 @@
color="neutral"
variant="ghost"
size="xs"
data-testid="collab-member-menu-trigger"
@click.stop="$emit('menu', $event)"
/>
</div>
@@ -47,6 +47,7 @@
<div class="p-1 flex flex-col gap-1">
<UButton
:label="t('collaboration.members.assignRoles')"
data-testid="collab-member-assign-roles"
icon="i-lucide-user-cog"
variant="ghost"
color="neutral"
@@ -56,6 +57,7 @@
<USeparator class="my-1" />
<UButton
:label="t('collaboration.members.remove')"
data-testid="collab-member-remove"
icon="i-lucide-user-minus"
variant="ghost"
color="error"
@@ -3,6 +3,7 @@
<UInput
v-model="searchQuery"
:placeholder="t('collaboration.permissions.searchPlaceholder')"
data-testid="collab-permission-search"
size="xl"
variant="soft"
class="w-full"
@@ -39,6 +40,7 @@
<label
v-for="permission in group.permissions"
:key="permission.id"
:data-testid="`collab-permission-${permission.name}`"
class="flex items-start gap-3 p-2 rounded hover:bg-elevated cursor-pointer"
>
<UCheckbox
@@ -2,6 +2,7 @@
<UInput
v-model="name"
:placeholder="t('collaboration.roles.namePlaceholder')"
data-testid="collab-role-name-input"
size="xl"
variant="soft"
class="w-full"
@@ -18,6 +19,7 @@
variant="ghost"
size="xs"
:aria-label="t('collaboration.roles.create')"
data-testid="collab-role-add-button"
@click="addRole"
/>
<UIcon
@@ -19,6 +19,7 @@
/>
<UButton
:label="t('contextMenu.delete')"
data-testid="collab-role-delete-confirm"
color="error"
variant="soft"
@click="handleDelete"
@@ -1,5 +1,8 @@
<template>
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
<div
:data-testid="`collab-role-${role.name}`"
class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors"
>
<div class="flex items-center gap-2">
<UIcon
name="i-lucide-shield"
@@ -18,6 +21,7 @@
color="neutral"
variant="ghost"
size="xs"
data-testid="collab-role-menu-trigger"
@click.stop="$emit('menu', $event)"
/>
</div>
@@ -47,6 +47,7 @@
<div class="p-1 flex flex-col gap-1">
<UButton
:label="t('collaboration.roles.assignPermissions')"
data-testid="collab-role-assign-permissions"
icon="i-lucide-shield-check"
variant="ghost"
color="neutral"
@@ -56,6 +57,7 @@
<USeparator class="my-1" />
<UButton
:label="t('contextMenu.delete')"
data-testid="collab-role-delete"
icon="i-lucide-trash-2"
variant="ghost"
color="error"
@@ -4,7 +4,7 @@
v-model:nodes="store.nodes"
v-model:edges="store.edges"
:min-zoom="-2"
fit-view-on-init
only-render-visible-elements
elevate-edges-on-select
elevate-nodes-on-select
:pan-on-scroll-mode="PanOnScrollMode.Free"
@@ -14,6 +14,7 @@
:nodes-connectable="canManageGraph"
:nodes-draggable="canManageGraph"
class="h-full w-full"
:class="{ 'opacity-0': !layoutReady }"
@connect-start="onConnectStart"
@connect-end="onConnectEnd"
>
@@ -118,7 +119,9 @@ const applyFilters = () => {
const nodeIds = new Set(filtered.map((n) => n.id))
store.nodes = filtered
store.edges = store.allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
setTimeout(() => layoutGraph(layoutDirection.value), 50)
// Node sizes are estimated from data, so the layout can run right away —
// no need to wait for nodes to render and be measured
layoutGraph(layoutDirection.value)
}
watch(listIds, applyFilters, { deep: true })
@@ -132,8 +135,6 @@ const {
onEdgesChange,
onEdgeClick,
onNodeDragStop,
getEdges,
updateEdgeData,
removeEdges,
screenToFlowCoordinate,
} = useVueFlow()
@@ -144,6 +145,8 @@ const store = useGraphStore()
const { t } = useI18n()
const { canManageGraph, canViewGraph } = useGoalPermissions()
const layoutReady = ref(false)
const addNewTaskToGraph = ref(false)
const currentSession = ref<number | null>(null)
const successfulSession = ref<number | null>(null)
@@ -152,7 +155,7 @@ const nodePosition = ref<{ x: number; y: number } | undefined>(undefined)
const defaultEdgeOptions: DefaultEdgeOptions = {
type: 'smoothstep',
animated: true,
animated: false,
style: {
strokeWidth: 3,
},
@@ -170,6 +173,7 @@ watch(
projectId,
(id) => {
if (!id) return
layoutReady.value = false
store.fetchAllTasksAndLists(id).then(() => {
applyFilters()
})
@@ -199,10 +203,15 @@ onConnect(async (params) => {
addEdges([newEdge])
})
function setAnimatedEdge(id: string | null) {
store.edges = store.edges.map((edge) => ({ ...edge, animated: edge.id === id }))
}
onEdgesChange((params) => {
params.forEach((param) => {
if (param.type === 'select' && !param.selected) {
selectedEdge.value = null
setAnimatedEdge(null)
}
if (param.type === 'remove') {
deleteSelectedEdge(+param.id)
@@ -213,6 +222,7 @@ onEdgesChange((params) => {
onEdgeClick((params) => {
selectedEdge.value = params.edge
setAnimatedEdge(params.edge.id)
})
const newToken = () => {
@@ -285,9 +295,7 @@ const layoutGraph = async (direction: 'LR' | 'TB') => {
store.nodes = layout(store.nodes, store.edges, direction)
nextTick(() => {
fitView()
getEdges.value.forEach((edge) => {
updateEdgeData(edge.id, edge)
})
layoutReady.value = true
})
}
</script>
@@ -27,10 +27,10 @@
:style="sourceHandleStyle"
/>
<TaskItem
:task="props.data.task"
<TaskItemCard
:task="props.data.task"
class="w-full"
@toggle="toggleComplete($event)"
@toggle="toggleComplete($event)"
/>
</div>
</template>
@@ -39,6 +39,7 @@
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { TaskItem } from '@/types/tasks.types'
import TaskItemCard from '@/components/features/tasks/parts/TaskItem.vue'
import { useTasksStore } from '@/stores/tasks.store'
import { Task } from 'taskview-api'
@@ -1,56 +1,106 @@
import dagre from '@dagrejs/dagre'
import { type Edge, type Node, Position, useVueFlow } from '@vue-flow/core'
import { ref } from 'vue'
import { type Edge, type Node, Position } from '@vue-flow/core'
const NODE_WIDTH = 288 // w-72 wrapper in TaskNode
const NODE_MIN_HEIGHT = 74 // checkbox + priority column with paddings
const NODE_PADDING_Y = 28 // p-3.5 top + bottom
const TITLE_LINE_HEIGHT = 24 // text-base
// Conservative: word-wrapping rarely fills lines completely, better to
// overestimate height than to let ranks overlap
const TITLE_CHARS_PER_LINE = 22
const BADGE_ROW_HEIGHT = 30
const BADGE_ROW_GAP = 8
const TITLE_BADGES_GAP = 4
// Handles stick out ~8px beyond the card on both sides, and the height estimate
// can be off by a line — this safety margin keeps neighbors from touching
const NODE_SAFETY = 24
const CONTENT_WIDTH = 230 // node width minus paddings and the checkbox column
const BADGE_CHROME_WIDTH = 34 // badge paddings + icon
const BADGE_CHAR_WIDTH = 6.5
const BADGE_GAP = 8
const ISOLATED_GAP_X = 40
const ISOLATED_GAP_Y = 32
const ISOLATED_BLOCK_OFFSET = 120
const ISOLATED_MIN_ROW_WIDTH = 1200
// Estimates the rendered TaskNode size from task data alone, so the layout can
// run before (and without) rendering every node — a prerequisite for
// only-render-visible-elements, where offscreen nodes are never measured.
function estimateNodeSize(node: Node): { width: number; height: number } {
const task = node.data?.task
if (!task) return { width: NODE_WIDTH, height: NODE_MIN_HEIGHT }
const titleLines = Math.max(1, Math.ceil((task.description?.length ?? 0) / TITLE_CHARS_PER_LINE))
// Estimated pixel widths of the badges TaskItem renders, in render order
const badgeWidth = (labelLength: number) =>
Math.min(CONTENT_WIDTH, BADGE_CHROME_WIDTH + labelLength * BADGE_CHAR_WIDTH)
const badgeWidths: number[] = []
if (task.endDate) badgeWidths.push(badgeWidth(11)) // dd.Mon.yyyy
if (task.recurrenceRuleId) badgeWidths.push(BADGE_CHROME_WIDTH) // icon-only
if (task.goalListId) badgeWidths.push(badgeWidth(10)) // list name (unknown here)
if (task.amount) badgeWidths.push(badgeWidth(String(task.amount).length + 1))
for (let i = 0; i < (task.assignedUsers?.length ?? 0); i++) badgeWidths.push(badgeWidth(20)) // email
for (let i = 0; i < (task.tags?.length ?? 0); i++) badgeWidths.push(badgeWidth(9)) // tag name (unknown here)
// Greedy flex-wrap simulation: how many rows the badges take
let badgeRows = 0
let rowRemaining = 0
for (const width of badgeWidths) {
if (width + (badgeRows === 0 || rowRemaining === CONTENT_WIDTH ? 0 : BADGE_GAP) > rowRemaining) {
badgeRows += 1
rowRemaining = CONTENT_WIDTH - width
} else {
rowRemaining -= width + BADGE_GAP
}
}
const height =
NODE_PADDING_Y +
titleLines * TITLE_LINE_HEIGHT +
(badgeRows > 0 ? TITLE_BADGES_GAP + badgeRows * BADGE_ROW_HEIGHT + (badgeRows - 1) * BADGE_ROW_GAP : 0)
return { width: NODE_WIDTH, height: Math.max(NODE_MIN_HEIGHT, height) + NODE_SAFETY }
}
/**
* Composable to run the layout algorithm on the graph.
* It uses the `dagre` library to calculate the layout of the nodes and edges.
* Connected nodes are laid out with `dagre`; isolated nodes (no edges) are
* arranged in a grid below the graph so they don't push linked nodes apart.
* Node sizes are estimated from data, so no prior render is required.
*/
export function useLayout() {
const { findNode } = useVueFlow()
const graph = ref(new dagre.graphlib.Graph())
const previousDirection = ref('LR')
function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph()
graph.value = dagreGraph
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
// Isolated nodes would become extra dagre roots and push linked nodes apart —
// lay out only the connected subgraph, grid the rest separately
const connectedIds = new Set(edges.flatMap((edge) => [edge.source, edge.target]))
const connectedNodes = nodes.filter((node) => connectedIds.has(node.id))
const isolatedNodes = nodes.filter((node) => !connectedIds.has(node.id))
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
rankdir: direction,
// align: 'UL', // Align to upper left
nodesep: 50, // Minimum space between nodes
ranksep: 100, // Minimum space between ranks
marginx: 20,
marginy: 20,
})
previousDirection.value = direction
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
dagreGraph.setNode(node.id, {
width: graphNode?.dimensions.width || 150,
height: graphNode?.dimensions.height || 50,
})
for (const node of connectedNodes) {
dagreGraph.setNode(node.id, estimateNodeSize(node))
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph)
// set nodes with updated positions
const layoutedNodes = nodes.map((node) => {
// dagre returns node centers — keep them as centers for the TB inversion below
const layoutedConnected = connectedNodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
@@ -63,58 +113,60 @@ export function useLayout() {
// For TB mode, invert Y coordinates to put root at top
if (!isHorizontal) {
const maxY = Math.max(...layoutedNodes.map((node) => node.position.y))
const maxY = Math.max(...layoutedConnected.map((node) => node.position.y))
layoutedNodes.forEach((node) => {
const graphNode = findNode(node.id)
const nodeHeight = graphNode?.dimensions.height || 50
// Invert Y coordinate and adjust for node height to keep center aligned
node.position.y = maxY - node.position.y + nodeHeight
layoutedConnected.forEach((node) => {
node.position.y = maxY - node.position.y
})
}
return layoutedNodes
// Convert centers to top-left corners (what vue-flow positions actually are)
layoutedConnected.forEach((node) => {
const { width, height } = estimateNodeSize(node)
node.position.x -= width / 2
node.position.y -= height / 2
})
// Grid for isolated nodes below the connected graph
const hasConnected = layoutedConnected.length > 0
const boundsBottom = hasConnected
? Math.max(...layoutedConnected.map((node) => node.position.y + estimateNodeSize(node).height))
: 0
const boundsLeft = hasConnected
? Math.min(...layoutedConnected.map((node) => node.position.x))
: 0
const boundsWidth = hasConnected
? Math.max(...layoutedConnected.map((node) => node.position.x + estimateNodeSize(node).width)) - boundsLeft
: 0
const rowWidth = Math.max(boundsWidth, ISOLATED_MIN_ROW_WIDTH)
let x = boundsLeft
let y = boundsBottom + (hasConnected ? ISOLATED_BLOCK_OFFSET : 0)
let rowHeight = 0
const layoutedIsolated = isolatedNodes.map((node) => {
const { width, height } = estimateNodeSize(node)
if (x > boundsLeft && x + width > boundsLeft + rowWidth) {
x = boundsLeft
y += rowHeight + ISOLATED_GAP_Y
rowHeight = 0
}
const position = { x, y }
x += width + ISOLATED_GAP_X
rowHeight = Math.max(rowHeight, height)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position,
}
})
return [...layoutedConnected, ...layoutedIsolated]
}
// function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
// // we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
// const dagreGraph = new dagre.graphlib.Graph()
// graph.value = dagreGraph
// dagreGraph.setDefaultEdgeLabel(() => ({}))
// const isHorizontal = direction === 'LR'
// dagreGraph.setGraph({ rankdir: direction })
// previousDirection.value = direction
// for (const node of nodes) {
// // if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
// const graphNode = findNode(node.id)
// dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 })
// }
// for (const edge of edges) {
// dagreGraph.setEdge(edge.source, edge.target)
// }
// dagre.layout(dagreGraph)
// // set nodes with updated positions
// return nodes.map((node) => {
// const nodeWithPosition = dagreGraph.node(node.id)
// return {
// ...node,
// targetPosition: isHorizontal ? Position.Left : Position.Top,
// sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
// position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
// }
// })
// }
return { graph, layout, previousDirection }
return { layout }
}
@@ -11,6 +11,7 @@
<div
v-for="status in kanbanStore.statuses"
:key="status.id"
:data-testid="`kanban-column-${t(status.name)}`"
class="h-full max-w-[340px] min-w-[272px] shadow-lg gap-2 flex flex-col w-[91.666667%] rounded-lg"
>
<div class="bg-elevated rounded-lg p-2 px-3 flex items-center text-base h-10 rounded-b-none">
@@ -46,6 +47,7 @@
<div
:data-order="element.kanbanOrder"
:data-task-id="element.id"
data-testid="kanban-task-card"
>
<TaskItem
:task="element"
@@ -4,6 +4,7 @@
<UInput
v-model="statusName"
:placeholder="t('kanban.addColumn')"
data-testid="kanban-add-column-input"
icon="i-lucide-plus"
size="xl"
variant="soft"
@@ -12,6 +13,7 @@
<UButton
v-if="statusName.trim()"
data-testid="kanban-add-column-submit"
@click="addStatus"
>
{{ t('kanban.addColumn') }}
@@ -32,6 +32,7 @@
<UButton
color="error"
variant="soft"
data-testid="kanban-delete-confirm"
@click="confirmDelete"
>
{{ t('common.delete') }}
@@ -20,6 +20,7 @@
<UInput
v-model="name"
:placeholder="t('kanban.columnName')"
data-testid="kanban-edit-name"
class="w-full"
/>
</UFormField>
@@ -35,6 +36,7 @@
</UButton>
<UButton
variant="outline"
data-testid="kanban-edit-save"
@click="save"
>
{{ t('common.save') }}
@@ -9,6 +9,7 @@
color="neutral"
variant="ghost"
size="sm"
data-testid="kanban-column-menu-trigger"
/>
</UDropdownMenu>
@@ -48,6 +49,7 @@ const menuItems = computed<DropdownMenuItem[][]>(() => [
label: t('kanban.edit'),
icon: 'i-lucide-pencil',
ui,
'data-testid': 'kanban-menu-edit',
// size: 'lg',
onSelect: () => {
editOpen.value = true
@@ -58,6 +60,7 @@ const menuItems = computed<DropdownMenuItem[][]>(() => [
icon: 'i-lucide-trash-2',
color: 'error' as const,
ui,
'data-testid': 'kanban-menu-delete',
onSelect: () => {
deleteOpen.value = true
},
@@ -9,11 +9,9 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGoalsStore } from '@/stores/goals.store'
import { useTaskView } from '@/composables/useTaskView'
const { t } = useI18n()
const goalsStore = useGoalsStore()
const hasActiveGoals = computed(() => goalsStore.goals.length > 0)
const { hasActiveGoals } = useTaskView()
</script>
@@ -10,9 +10,17 @@
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="config.enabled ? 'success' : 'neutral'">
<span
class="text-xs"
:class="config.enabled ? 'text-success' : 'text-dimmed'"
>
{{ config.enabled ? t('sso.enabled') : t('sso.disabled') }}
</UBadge>
</span>
<USwitch
:model-value="!!config.enabled"
:loading="toggling"
@update:model-value="toggleEnabled"
/>
<UButton
icon="i-lucide-pencil"
size="xs"
@@ -58,18 +66,20 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { $tvApi } from '@/plugins/axios'
import type { SsoConfig } from 'taskview-api'
import OrgSsoScimSection from './OrgSsoScimSection.vue'
import OrgSsoDomainSection from './OrgSsoDomainSection.vue'
defineProps<{
const props = defineProps<{
config: SsoConfig
callbackUrl: string
scimEndpointUrl: string
}>()
defineEmits<{
const emit = defineEmits<{
edit: []
delete: []
updated: []
@@ -78,6 +88,24 @@ defineEmits<{
const { t } = useI18n()
const toast = useToast()
const toggling = ref(false)
async function toggleEnabled(enabled: boolean) {
toggling.value = true
try {
await $tvApi.sso.updateConfig(props.config.id, { enabled: enabled ? 1 : 0 })
emit('updated')
} catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
toast.add({
title: status === 403 ? t('sso.enableRequiresVerifiedDomain') : t('sso.toggleFailed'),
color: 'error',
})
} finally {
toggling.value = false
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
@@ -25,9 +25,10 @@ import { useOrganizationStore } from '@/stores/organization.store'
import ProjectsList from '@/components/features/projects/ProjectsList.vue'
import ArchiveList from '@/components/features/projects/ArchiveList.vue'
import type { Project, ProjectSaveData } from '@/components/features/projects/types'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { useProjectRoute } from '@/composables/useProjectRoute'
const router = useRouter()
const { projectRoute } = useProjectRoute()
const goalsStore = useGoalsStore()
const orgStore = useOrganizationStore()
@@ -75,7 +76,7 @@ async function handleAdd(name: string) {
...(orgStore.currentOrg && { organizationId: orgStore.currentOrg.id }),
})
if (newProject) {
router.push({ name: 'user', params: { projectId: newProject.id, listId: ALL_TASKS_LIST_ID } })
router.push(projectRoute(newProject))
}
}
</script>
@@ -26,7 +26,7 @@
v-for="project in projects"
:key="project.id"
variant="taskview"
:to="{ name: 'user', params: { projectId: project.id, listId: '-1401' } }"
:to="projectRoute(project)"
:active="currentProjectId === project.id"
>
<div
@@ -209,6 +209,7 @@ import ProjectDeleteDialog from '@/components/features/projects/parts/ProjectDel
import ProjectAddInput from '@/components/features/projects/parts/ProjectAddInput.vue'
import type { Project, ProjectSaveData } from '@/components/features/projects/types'
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
import { useProjectRoute } from '@/composables/useProjectRoute'
import { useI18n } from 'vue-i18n'
const route = useRoute()
@@ -234,6 +235,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { projectRoute } = useProjectRoute()
const isOpen = defineModel<boolean>('open', { required: false, default: true })
@@ -15,7 +15,7 @@ const LANGUAGE_OPTIONS = [
{ label: 'Русский', value: 'ru' },
{ label: 'Deutsch', value: 'de' },
{ label: 'Español', value: 'es' },
{ label: 'Português do Brasil', value: 'ptBR' },
{ label: 'Português do Brasil', value: 'pt-BR' },
]
export function useSettingsHub() {
@@ -103,6 +103,12 @@
:class="colClass(fieldId)"
/>
</template>
<TaskDependencies
v-else-if="fieldId === 'dependencies'"
:task-id="task.id"
:goal-id="task.goalId"
:class="colClass(fieldId)"
/>
<TaskAmountEditor
v-else-if="fieldId === 'amount'"
:task-id="task.id"
@@ -169,6 +175,7 @@ import TvDeadlineSelect from '@/components/features/base/TvDeadlineSelect.vue'
import TaskRecurrence from '@/components/features/tasks/parts/TaskRecurrence.vue'
import TaskSourceLink from '@/components/features/tasks/parts/TaskSourceLink.vue'
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
import TaskDependencies from '@/components/features/tasks/parts/TaskDependencies.vue'
import TaskIdCopy from '@/components/features/tasks/parts/TaskIdCopy.vue'
import TvSprintSelect from '@/components/features/base/TvSprintSelect.vue'
import TaskEstimateInput from '@/components/features/tasks/parts/TaskEstimateInput.vue'
@@ -0,0 +1,10 @@
export type DependencyTask = {
id: number
description: string
complete: number | boolean
}
export type DependencyEntry = {
edgeId: number
task: DependencyTask
}
@@ -0,0 +1,126 @@
<template>
<div
v-if="canViewGraph"
class="w-full h-fit rounded-2xl bg-accented/20 p-3.5"
data-testid="task-dependencies"
>
<label class="text-sm text-muted mb-2 block">{{ t('tasks.dependencies.title') }}</label>
<div class="grid grid-cols-1 @lg:grid-cols-[1fr_auto_1fr] gap-3 items-stretch">
<TaskDependencyGroup
:title="t('tasks.dependencies.previous')"
icon="i-lucide-move-left"
direction="previous"
:entries="previous"
:goal-id="goalId"
:excluded-ids="excludedIds"
:can-manage="canManageGraph"
@open="openTask"
@add="(task) => addEdge({ source: task.id, target: taskId, task })"
@remove="removeEdge"
/>
<div class="hidden @lg:flex flex-col items-center self-center text-dimmed">
<UIcon
name="i-lucide-git-commit-horizontal"
class="size-5"
/>
</div>
<TaskDependencyGroup
:title="t('tasks.dependencies.next')"
icon="i-lucide-move-right"
direction="next"
:entries="next"
:goal-id="goalId"
:excluded-ids="excludedIds"
:can-manage="canManageGraph"
@open="openTask"
@add="(task) => addEdge({ source: taskId, target: task.id, task })"
@remove="removeEdge"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GraphResponseAddEdge } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useTaskDetailPanel } from '@/composables/useTaskDetailPanel'
import TaskDependencyGroup from './TaskDependencyGroup.vue'
import type { DependencyEntry, DependencyTask } from './TaskDependencies.types'
const props = defineProps<{
taskId: number
goalId: number
}>()
const { t } = useI18n()
const { canViewGraph, canManageGraph } = useGoalPermissions()
const { openTask } = useTaskDetailPanel()
const edges = ref<GraphResponseAddEdge[]>([])
const taskNames = ref(new Map<number, DependencyTask>())
const taskEdges = computed(() =>
edges.value.filter((e) => e.fromTaskId === props.taskId || e.toTaskId === props.taskId),
)
function toEntries(direction: 'previous' | 'next'): DependencyEntry[] {
return taskEdges.value
.filter((e) => (direction === 'previous' ? e.toTaskId === props.taskId : e.fromTaskId === props.taskId))
.map((e) => {
const linkedId = direction === 'previous' ? e.fromTaskId : e.toTaskId
const task = taskNames.value.get(linkedId)
return task ? { edgeId: e.id, task } : null
})
.filter((entry): entry is DependencyEntry => entry !== null)
}
const previous = computed(() => toEntries('previous'))
const next = computed(() => toEntries('next'))
const excludedIds = computed(() => [
props.taskId,
...taskEdges.value.flatMap((e) => [e.fromTaskId, e.toTaskId]),
])
async function resolveNames() {
const ids = new Set(
taskEdges.value.flatMap((e) => [e.fromTaskId, e.toTaskId]).filter((id) => id !== props.taskId),
)
await Promise.all(
[...ids]
.filter((id) => !taskNames.value.has(id))
.map(async (id) => {
const task = await $tvApi.tasks.fetchTaskById(id).catch((err: unknown) => logError(err))
if (task) taskNames.value.set(id, { id: task.id, description: task.description, complete: task.complete })
}),
)
}
async function load() {
const result = await $tvApi.graph.fetchTaskEdges(props.taskId).catch((err: unknown) => logError(err))
edges.value = result ?? []
await resolveNames()
}
watch(() => props.taskId, load, { immediate: true })
async function addEdge({ source, target, task }: { source: number; target: number; task: DependencyTask }) {
const edge = await $tvApi.graph.addEdge({ source, target }).catch((err: unknown) => logError(err))
if (!edge) return
taskNames.value.set(task.id, task)
edges.value.push(edge)
}
async function removeEdge(edgeId: number) {
const result = await $tvApi.graph.deleteEdge(edgeId).catch((err: unknown) => logError(err))
if (!result) return
edges.value = edges.value.filter((e) => e.id !== edgeId)
}
</script>
@@ -0,0 +1,84 @@
<template>
<div class="flex flex-col gap-1 min-w-0 h-full">
<div class="flex items-center gap-1.5 px-1">
<UIcon
:name="icon"
class="size-3.5 text-muted"
/>
<span class="text-xs text-muted">{{ title }}</span>
</div>
<div
v-for="entry in entries"
:key="entry.edgeId"
class="flex items-center gap-2 rounded-xl bg-default shadow-xs px-3 py-2"
:data-testid="`dependency-${direction}-${entry.task.id}`"
>
<UIcon
:name="entry.task.complete ? 'i-lucide-circle-check' : 'i-lucide-circle-dashed'"
class="size-3.5 shrink-0"
:class="entry.task.complete ? 'text-success' : 'text-muted'"
/>
<button
type="button"
class="flex-1 min-w-0 text-left text-sm truncate cursor-pointer"
:class="{ 'text-muted line-through': entry.task.complete }"
@click="$emit('open', entry.task.id)"
>
{{ entry.task.description }}
</button>
<UButton
v-if="canManage"
icon="i-lucide-x"
color="error"
variant="ghost"
size="xs"
class="shrink-0"
:aria-label="t('common.delete')"
@click="$emit('remove', entry.edgeId)"
/>
</div>
<p
v-if="entries.length === 0"
class="text-sm text-dimmed px-2 py-1"
>
{{ t('tasks.dependencies.none') }}
</p>
<div
v-if="canManage"
class="mt-auto"
>
<TaskDependencyPicker
:goal-id="goalId"
:excluded-ids="excludedIds"
@select="(task) => $emit('add', task)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import TaskDependencyPicker from './TaskDependencyPicker.vue'
import type { DependencyEntry, DependencyTask } from './TaskDependencies.types'
defineProps<{
title: string
icon: string
direction: 'previous' | 'next'
entries: DependencyEntry[]
goalId: number
excludedIds: number[]
canManage: boolean
}>()
defineEmits<{
open: [taskId: number]
add: [task: DependencyTask]
remove: [edgeId: number]
}>()
const { t } = useI18n()
</script>
@@ -0,0 +1,121 @@
<template>
<UPopover
v-model:open="open"
class="w-full"
>
<UButton
icon="i-lucide-plus"
:label="t('tasks.dependencies.add')"
color="neutral"
variant="soft"
size="sm"
block
class="text-muted"
:ui="{ base: 'rounded-xl justify-start' }"
/>
<template #content>
<div class="w-72 p-2 flex flex-col gap-2">
<UInput
v-model="query"
:placeholder="t('tasks.dependencies.searchPlaceholder')"
icon="i-lucide-search"
variant="soft"
size="sm"
autofocus
:loading="loading"
/>
<div class="max-h-64 overflow-y-auto flex flex-col gap-0.5">
<button
v-for="option in options"
:key="option.id"
type="button"
class="flex items-center gap-2 w-full text-left rounded-10 px-2 py-1.5 hover:bg-muted/10 cursor-pointer"
@click="pick(option)"
>
<UIcon
:name="option.complete ? 'i-lucide-circle-check' : 'i-lucide-circle-dashed'"
class="size-3.5 shrink-0"
:class="option.complete ? 'text-success' : 'text-muted'"
/>
<span
class="text-sm truncate"
:class="{ 'text-muted line-through': option.complete }"
>
{{ option.description }}
</span>
</button>
<p
v-if="!loading && options.length === 0"
class="text-sm text-muted text-center py-3"
>
{{ t('tasks.dependencies.noResults') }}
</p>
</div>
</div>
</template>
</UPopover>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
import { logError } from '@/helpers/Helper'
import type { DependencyTask } from './TaskDependencies.types'
const props = defineProps<{
goalId: number
excludedIds: number[]
}>()
const emit = defineEmits<{
select: [task: DependencyTask]
}>()
const { t } = useI18n()
const open = ref(false)
const query = ref('')
const loading = ref(false)
const results = ref<DependencyTask[]>([])
const options = computed(() =>
results.value.filter((task) => !props.excludedIds.includes(task.id)),
)
async function search() {
loading.value = true
const tasks = await $tvApi.tasks
.fetch({
goalId: props.goalId,
componentId: ALL_TASKS_LIST_ID,
page: 0,
showCompleted: 0,
ignoreCompleted: true,
firstNew: 1,
searchText: query.value.trim() || undefined,
})
.catch((err: unknown) => logError(err))
results.value = tasks ?? []
loading.value = false
}
const debouncedSearch = useDebounceFn(search, 300)
watch(query, () => debouncedSearch())
watch(open, (isOpen) => {
if (isOpen) {
query.value = ''
results.value = []
search()
}
})
function pick(task: DependencyTask) {
open.value = false
emit('select', task)
}
</script>
@@ -123,9 +123,11 @@ const emit = defineEmits<{
const { t } = useI18n()
// Keep seconds: a timer entry started and stopped within the same minute
// otherwise collapses to start == end and can never pass validation
const fromDate = (d: Date): { date: CalendarDate; time: Time } => ({
date: new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate()),
time: new Time(d.getHours(), d.getMinutes()),
time: new Time(d.getHours(), d.getMinutes(), d.getSeconds()),
})
const fromIso = (iso: string | undefined): { date?: CalendarDate; time?: Time } => {
@@ -152,7 +154,7 @@ const endOpen = ref(false)
const toJsDate = (date: CalendarDate | undefined, time: Time | undefined): Date | null => {
if (!date) return null
return new Date(date.year, date.month - 1, date.day, time?.hour ?? 0, time?.minute ?? 0)
return new Date(date.year, date.month - 1, date.day, time?.hour ?? 0, time?.minute ?? 0, time?.second ?? 0)
}
const startJs = computed(() => toJsDate(startDate.value, startTime.value))
@@ -11,7 +11,10 @@
<USeparator />
</template>
<template #body>
<div class="flex flex-col gap-4">
<div
class="flex flex-col gap-4"
data-testid="tasks-filter-drawer"
>
<!-- User Filter -->
<UFormField
:label="t('filters.assignee')"
@@ -158,7 +161,7 @@ watch(isOpen, (open) => {
// Fetch users for current project
const projectId = route.params.projectId
if (projectId) {
collaborationStore.fetchCollaborationUsersForGoal(Number(projectId))
collaborationStore.fetchCollaborationUsersForGoal(Number(projectId)).catch(() => null)
}
}
})
@@ -8,6 +8,7 @@
:color="hasActiveFilters ? 'primary' : 'info'"
variant="soft"
size="sm"
data-testid="tasks-filter-button"
@click="isFilterDrawerOpen = true"
/>
</UTooltip>
@@ -38,7 +38,7 @@
v-model="defaultView"
:items="viewItems"
value-key="value"
:disabled="defaultProject === NONE"
data-testid="default-view-select"
variant="soft"
class="w-full lg:w-72"
size="xl"
@@ -70,7 +70,6 @@ const defaultProject = computed<number>({
get: () => store.settings.defaultProjectId ?? NONE,
set: (value) => {
store.setSetting('defaultProjectId', value === NONE ? undefined : value)
if (value === NONE) store.setSetting('defaultView', undefined)
},
})
@@ -50,8 +50,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { useDashboard } from '@/composables/useDashboard'
import { useProjectRoute } from '@/composables/useProjectRoute'
import { useGoalsStore } from '@/stores/goals.store'
import { useOrganizationStore } from '@/stores/organization.store'
import SearchActivator from '@/components/features/main/screen-main/parts/SearchActivator.vue'
@@ -69,6 +69,7 @@ import SidebarOrgSelect from './dashboard-second/SidebarOrgSelect.vue'
const { t } = useI18n()
const router = useRouter()
const { projectRoute } = useProjectRoute()
const { isSidebarOpen, isSidebarCollapsed } = useDashboard()
const goalsStore = useGoalsStore()
const orgStore = useOrganizationStore()
@@ -79,7 +80,7 @@ async function handleAddProject(name: string) {
...(orgStore.currentOrg && { organizationId: orgStore.currentOrg.id }),
})
if (newProject) {
router.push({ name: 'user', params: { projectId: newProject.id, listId: ALL_TASKS_LIST_ID } })
router.push(projectRoute(newProject))
}
}
</script>
@@ -150,9 +150,9 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { useGoalsStore } from '@/stores/goals.store'
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
import { useProjectRoute } from '@/composables/useProjectRoute'
import type { Project, ProjectSaveData } from '@/components/features/projects/types'
import ProjectEditModal from '@/components/features/projects/parts/ProjectEditModal.vue'
import ProjectDeleteDialog from '@/components/features/projects/parts/ProjectDeleteDialog.vue'
@@ -160,6 +160,7 @@ import ProjectDeleteDialog from '@/components/features/projects/parts/ProjectDel
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { projectRoute } = useProjectRoute()
const goalsStore = useGoalsStore()
const { goals } = storeToRefs(goalsStore)
@@ -186,7 +187,7 @@ const { canEditGoal, canDeleteGoal } = useGoalPermissionsFor(currentProject)
function selectProject(project: Project) {
open.value = false
router.push({ name: 'user', params: { projectId: project.id, listId: ALL_TASKS_LIST_ID } })
router.push(projectRoute(project))
}
async function archive() {
+8 -5
View File
@@ -4,6 +4,7 @@ import $api from '@/helpers/axios'
import { $ls, $tvApi } from '@/plugins/axios'
import { additionalUrlStore } from '@/stores/additional-url.store'
import { getConfiguredApiUrl } from '@/helpers/serverConfig'
import { normalizeServerUrl } from '@/helpers/serverUrl'
export const LS_KEY_ADDITIONAL_SERVERS = 'additionalServers'
export const LS_KEY_MAIN_SERVER = 'mainServer'
@@ -32,14 +33,16 @@ export const useAdditionalServer = async () => {
(process.env.NODE_ENV !== 'production' ? 'http://localhost:1401' : 'https://api.taskview.tech')
const setMainServer = (server: string) => {
mainServer.value = server
$ls.setValue(LS_KEY_MAIN_SERVER, server)
$api.defaults.baseURL = server
$tvApi?.setBaseUrl(server)
const normalized = normalizeServerUrl(server) ?? server
mainServer.value = normalized
$ls.setValue(LS_KEY_MAIN_SERVER, normalized)
$api.defaults.baseURL = normalized
$tvApi?.setBaseUrl(normalized)
}
const addServer = (server: string) => {
allServers.value.push(server)
const normalized = normalizeServerUrl(server) ?? server
allServers.value.push(normalized)
$ls.setValue(LS_KEY_ADDITIONAL_SERVERS, allServers.value)
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function useProjectDataLoader(projectId: Ref<number>) {
if (id > 0) {
Promise.all([
kanbanStore.fetchStatuses(id),
collaborationStore.fetchCollaborationUsersForGoal(id),
collaborationStore.fetchCollaborationUsersForGoal(id).catch(() => null),
goalListsStore.fetchLists(id),
])
}
+37
View File
@@ -0,0 +1,37 @@
import type { RouteLocationRaw } from 'vue-router'
import { ALL_TASKS_LIST_ID, type DefaultView, type GoalItem, type GoalPermissions } from 'taskview-api'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
import { AllGoalPermissions } from '@/types/goals.types'
export const VIEW_ROUTES: Record<DefaultView, string> = {
tasks: 'user',
kanban: 'kanban',
graph: 'graph',
sprints: 'sprints',
}
const VIEW_PERMISSIONS: Record<Exclude<DefaultView, 'tasks'>, (keyof GoalPermissions)[]> = {
kanban: [AllGoalPermissions.KANBAN_CAN_VIEW, AllGoalPermissions.KANBAN_CAN_MANAGE],
graph: [AllGoalPermissions.GRAPH_CAN_VIEW, AllGoalPermissions.GRAPH_CAN_MANAGE],
sprints: [AllGoalPermissions.SPRINT_CAN_VIEW],
}
function canOpenView(goal: GoalItem, view: DefaultView): boolean {
if (view === 'tasks') return true
return VIEW_PERMISSIONS[view].some((perm) => !!goal.permissions[perm])
}
export function useProjectRoute() {
const uiPrefs = useUiPreferencesStore()
function projectRoute(goal: GoalItem, orgSlug?: string): RouteLocationRaw {
const preferred = uiPrefs.settings.defaultView ?? 'tasks'
const view: DefaultView = canOpenView(goal, preferred) ? preferred : 'tasks'
const params: Record<string, string | number> = { projectId: goal.id }
if (orgSlug) params.orgSlug = orgSlug
if (view === 'tasks') params.listId = ALL_TASKS_LIST_ID
return { name: VIEW_ROUTES[view], params }
}
return { projectRoute }
}
+8 -4
View File
@@ -46,10 +46,14 @@ const _useTaskDetailPanel = () => {
const taskId = String(task.id)
if (route.params.taskId !== taskId) {
router.push({
name: 'user',
params: { projectId, listId, taskId },
})
// Switching from one open task to another (e.g. via dependencies) replaces
// the history entry, so back/close returns to the list, not the previous task
const target = { name: 'user', params: { projectId, listId, taskId } }
if (route.params.taskId) {
router.replace(target)
} else {
router.push(target)
}
}
}
}
+2 -1
View File
@@ -13,7 +13,8 @@ export const useTaskView = () => {
const isFullscreenModal = bp.smallerOrEqual('fullscreenModalMax')
return {
hasActiveGoals: computed(() => goalsStore.goals.some(g => !g.isInbox)),
// Any non-archived project counts, including the Inbox — tasks can be added there too
hasActiveGoals: computed(() => goalsStore.goals.some(g => !g.archive)),
isMobile,
isDesktop,
isFullscreenModal,
+12
View File
@@ -0,0 +1,12 @@
export function normalizeServerUrl(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) return null
try {
const url = new URL(trimmed)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
const path = url.pathname.replace(/\/+$/, '')
return `${url.protocol}//${url.host}${path}`
} catch {
return null
}
}
+14 -1
View File
@@ -69,6 +69,7 @@ export default {
ssoError: 'SSO-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
ssoDomainUnverified: 'SSO ist nicht verfügbar, bis die Organisation diese E-Mail-Domain bestätigt.',
ssoEmailInUse: 'Diese E-Mail wird bereits von einem anderen TaskView-Konto verwendet.',
ssoAccountBlocked: 'Dieses Konto ist gesperrt. Wenden Sie sich an Ihren Administrator.',
invalidEmail: 'Ungültige E-Mail-Adresse',
loginRequired: 'Anmeldung erforderlich',
passwordRequired: 'Passwort erforderlich',
@@ -534,6 +535,15 @@ export default {
sprint: 'Sprint',
estimate: 'Schätzung',
},
dependencies: {
title: 'Abhängigkeiten',
previous: 'Vorherige Aufgaben',
next: 'Nächste Aufgaben',
add: 'Hinzufügen',
searchPlaceholder: 'Aufgaben suchen…',
noResults: 'Nichts gefunden',
none: 'Keine',
},
status: 'Status',
selectStatus: 'Status auswählen',
searchStatuses: 'Status suchen...',
@@ -809,7 +819,7 @@ export default {
defaultProjectHint: 'Dieses Projekt direkt nach der Anmeldung öffnen.',
defaultProjectNone: 'Startbildschirm (Standard)',
defaultView: 'Standardansicht',
defaultViewHint: 'Welche Ansicht des Projekts geöffnet wird.',
defaultViewHint: 'Welche Ansicht beim Öffnen eines Projekts angezeigt wird.',
viewTasks: 'Aufgaben',
viewKanban: 'Kanban',
viewGraph: 'Graph',
@@ -826,6 +836,7 @@ export default {
estimate: 'Schätzung',
tags: 'Tags',
deadline: 'Frist',
dependencies: 'Abhängigkeiten',
amount: 'Betrag',
timeTracking: 'Zeiterfassung',
history: 'Verlauf',
@@ -866,6 +877,8 @@ export default {
callbackUrlAutoHint: 'Leer lassen, um die URL automatisch zu ermitteln',
enabled: 'Aktiviert',
disabled: 'Deaktiviert',
toggleFailed: 'SSO-Status konnte nicht geändert werden',
enableRequiresVerifiedDomain: 'Bestätigen Sie die E-Mail-Domain, bevor Sie diesen Anbieter aktivieren',
callbackUrlLabel: 'Callback-URL (in Ihrem IdP verwenden)',
copied: 'In die Zwischenablage kopiert',
copyFailed: 'Kopieren fehlgeschlagen',
+14 -1
View File
@@ -81,6 +81,7 @@ export default {
ssoError: 'SSO authentication failed. Please try again.',
ssoDomainUnverified: 'SSO is not available until the organization verifies this email domain.',
ssoEmailInUse: 'This email is already used by another TaskView account.',
ssoAccountBlocked: 'This account is blocked. Contact your administrator.',
// Validation
invalidEmail: 'Invalid email address',
@@ -548,6 +549,15 @@ export default {
sprint: 'Sprint',
estimate: 'Estimate',
},
dependencies: {
title: 'Dependencies',
previous: 'Previous tasks',
next: 'Next tasks',
add: 'Add',
searchPlaceholder: 'Search tasks…',
noResults: 'Nothing found',
none: 'None',
},
status: 'Status',
selectStatus: 'Select status',
searchStatuses: 'Search statuses...',
@@ -823,7 +833,7 @@ export default {
defaultProjectHint: 'Open this project right after signing in.',
defaultProjectNone: 'Home screen (default)',
defaultView: 'Default view',
defaultViewHint: 'Which view of the default project to open.',
defaultViewHint: 'Which view to open when you enter a project.',
viewTasks: 'Tasks',
viewKanban: 'Kanban',
viewGraph: 'Graph',
@@ -840,6 +850,7 @@ export default {
estimate: 'Estimate',
tags: 'Tags',
deadline: 'Deadline',
dependencies: 'Dependencies',
amount: 'Amount',
timeTracking: 'Time tracking',
history: 'History',
@@ -880,6 +891,8 @@ export default {
callbackUrlAutoHint: 'Leave empty to detect automatically',
enabled: 'Enabled',
disabled: 'Disabled',
toggleFailed: 'Failed to change SSO status',
enableRequiresVerifiedDomain: 'Verify the email domain before enabling this provider',
callbackUrlLabel: 'Callback URL (use this in your IdP)',
copied: 'Copied to clipboard',
copyFailed: 'Failed to copy',
+14 -1
View File
@@ -69,6 +69,7 @@ export default {
ssoError: 'Error de autenticación SSO. Inténtalo de nuevo.',
ssoDomainUnverified: 'SSO no está disponible hasta que la organización verifique este dominio de correo.',
ssoEmailInUse: 'Este correo ya está usado por otra cuenta de TaskView.',
ssoAccountBlocked: 'Esta cuenta está bloqueada. Contacta con tu administrador.',
invalidEmail: 'Dirección de correo electrónico inválida',
loginRequired: 'El usuario es obligatorio',
passwordRequired: 'La contraseña es obligatoria',
@@ -534,6 +535,15 @@ export default {
sprint: 'Sprint',
estimate: 'Estimación',
},
dependencies: {
title: 'Dependencias',
previous: 'Tareas anteriores',
next: 'Tareas siguientes',
add: 'Añadir',
searchPlaceholder: 'Buscar tareas…',
noResults: 'Sin resultados',
none: 'Ninguna',
},
status: 'Estado',
selectStatus: 'Seleccionar estado',
searchStatuses: 'Buscar estados...',
@@ -809,7 +819,7 @@ export default {
defaultProjectHint: 'Abrir este proyecto justo después de iniciar sesión.',
defaultProjectNone: 'Pantalla de inicio (predeterminado)',
defaultView: 'Vista predeterminada',
defaultViewHint: 'Qué vista del proyecto abrir.',
defaultViewHint: 'Qué vista abrir al entrar en un proyecto.',
viewTasks: 'Tareas',
viewKanban: 'Kanban',
viewGraph: 'Grafo',
@@ -826,6 +836,7 @@ export default {
estimate: 'Estimación',
tags: 'Etiquetas',
deadline: 'Fecha límite',
dependencies: 'Dependencias',
amount: 'Importe',
timeTracking: 'Registro de tiempo',
history: 'Historial',
@@ -866,6 +877,8 @@ export default {
callbackUrlAutoHint: 'Déjalo vacío para detectarla automáticamente',
enabled: 'Activado',
disabled: 'Desactivado',
toggleFailed: 'No se pudo cambiar el estado de SSO',
enableRequiresVerifiedDomain: 'Verifica el dominio de correo antes de activar este proveedor',
callbackUrlLabel: 'URL de Callback (úsala en tu IdP)',
copied: 'Copiado al portapapeles',
copyFailed: 'Error al copiar',
+3 -3
View File
@@ -1,17 +1,17 @@
import de from './de'
import en from './en'
import es from './es'
import ptBr from './pt-br'
import ru from './ru'
import ptBR from './ptBR'
export const messages = {
en,
ru,
de,
es,
ptBR
'pt-BR': ptBr,
}
export type Locale = keyof typeof messages
export const locales: Locale[] = ['en', 'ru', 'de', 'es', 'ptBR']
export const locales: Locale[] = ['en', 'ru', 'de', 'es', 'pt-BR']
export const defaultLocale: Locale = 'en'
@@ -548,6 +548,15 @@ export default {
sprint: 'Sprint',
estimate: 'Estimativa',
},
dependencies: {
title: 'Dependências',
previous: 'Tarefas anteriores',
next: 'Próximas tarefas',
add: 'Adicionar',
searchPlaceholder: 'Buscar tarefas…',
noResults: 'Nada encontrado',
none: 'Nenhuma',
},
status: 'Status',
selectStatus: 'Selecionar status',
searchStatuses: 'Pesquisar status...',
@@ -840,6 +849,7 @@ export default {
estimate: 'Estimativa',
tags: 'Tags',
deadline: 'Prazo',
dependencies: 'Dependências',
amount: 'Valor',
timeTracking: 'Controle de tempo',
history: 'História',
+14 -1
View File
@@ -81,6 +81,7 @@ export default {
ssoError: 'Ошибка SSO авторизации. Попробуйте снова.',
ssoDomainUnverified: 'SSO недоступен, пока организация не подтвердит этот email-домен.',
ssoEmailInUse: 'Эта почта уже занята другим аккаунтом TaskView.',
ssoAccountBlocked: 'Аккаунт заблокирован. Обратитесь к администратору.',
// Validation
invalidEmail: 'Неверный email адрес',
@@ -521,6 +522,15 @@ export default {
sprint: 'Спринт',
estimate: 'Оценка',
},
dependencies: {
title: 'Зависимости',
previous: 'Предыдущие задачи',
next: 'Следующие задачи',
add: 'Добавить',
searchPlaceholder: 'Поиск задач…',
noResults: 'Ничего не найдено',
none: 'Нет',
},
status: 'Статус',
selectStatus: 'Выбрать статус',
searchStatuses: 'Поиск статусов...',
@@ -796,7 +806,7 @@ export default {
defaultProjectHint: 'Открывать этот проект сразу после входа.',
defaultProjectNone: 'Главный экран (по умолчанию)',
defaultView: 'Вид по умолчанию',
defaultViewHint: 'Какой вид проекта открывать.',
defaultViewHint: 'Какой вид открывать при переходе в проект.',
viewTasks: 'Задачи',
viewKanban: 'Канбан',
viewGraph: 'Граф',
@@ -813,6 +823,7 @@ export default {
estimate: 'Оценка',
tags: 'Теги',
deadline: 'Дедлайн',
dependencies: 'Зависимости',
amount: 'Сумма',
timeTracking: 'Учёт времени',
history: 'История',
@@ -853,6 +864,8 @@ export default {
callbackUrlAutoHint: 'Можно оставить пустым — адрес будет определён автоматически',
enabled: 'Включён',
disabled: 'Выключен',
toggleFailed: 'Не удалось изменить статус SSO',
enableRequiresVerifiedDomain: 'Перед включением провайдера подтвердите домен почты',
callbackUrlLabel: 'Callback URL (укажи в настройках IdP)',
copied: 'Скопировано',
copyFailed: 'Не удалось скопировать',
+1
View File
@@ -55,6 +55,7 @@ onMounted(async () => {
'registration-disabled': 'auth.registrationDisabled',
domain_unverified: 'auth.ssoDomainUnverified',
email_in_use: 'auth.ssoEmailInUse',
account_blocked: 'auth.ssoAccountBlocked',
}[String(route.query.sso_error)] ?? 'auth.ssoError'
toast.add({
+2 -2
View File
@@ -29,9 +29,9 @@
<div class="relative h-full">
<div
v-if="showFilters"
class="rounded absolute top-0 left-0 right-0 z-10 overflow-x-auto border-b border-default bg-elevated/90 backdrop-blur-sm px-3 py-2"
class="rounded-3xl absolute top-0 left-0 right-0 z-10 overflow-x-auto border-b border-default bg-elevated/90 backdrop-blur-sm "
>
<div class="flex items-center justify-end gap-2 w-fit ml-auto">
<div class="flex items-center justify-end gap-2 w-fit ml-auto p-2 px-2.5">
<TvKanbanFilters
v-model:list-ids="selectedListIds"
v-model:assignee-ids="selectedAssigneeIds"

Some files were not shown because too many files have changed in this diff Show More