This commit implements CSRF token support for all session-based API requests to fix the "CSRF token missing" and "CSRF token mismatch" errors introduced after CSRF protection was added in commit 62c4cc84. Changes: - Created csrfService.ts utility for fetching and caching CSRF tokens - Added getPostHeadersWithCsrf() helper to authUtils for async token injection - Updated all service files (*Service.ts) to include CSRF tokens in POST/PUT/PATCH/DELETE requests - Updated components with inline fetch calls to use getCsrfToken() - Fixed CSRF middleware to use single lusca instance instead of creating new instances per request - Improved generateToken() to use req.csrfToken() when available - Added CalDAV path exemption to CSRF protection Technical details: - CSRF tokens are fetched from /api/csrf-token endpoint - Tokens are cached and reused across requests to avoid unnecessary fetches - Tokens are included in x-csrf-token header for state-changing requests - Public endpoints (login, register) remain exempt from CSRF protection - Bearer token authentication remains exempt from CSRF protection Files modified: - Backend: app.js, middleware/csrf.js - Frontend: 13 service files, 8 component files - New file: frontend/utils/csrfService.ts This ensures all session-based requests properly include CSRF tokens while maintaining support for API token authentication.
226 lines
6.8 KiB
TypeScript
226 lines
6.8 KiB
TypeScript
import { Metrics } from '../entities/Metrics';
|
|
import { Task } from '../entities/Task';
|
|
import {
|
|
handleAuthResponse,
|
|
getDefaultHeaders,
|
|
getPostHeadersWithCsrf,
|
|
} from './authUtils';
|
|
import { getApiPath } from '../config/paths';
|
|
import { isTaskDone, TASK_STATUS } from '../constants/taskStatus';
|
|
|
|
export interface GroupedTasks {
|
|
[groupName: string]: Task[];
|
|
}
|
|
|
|
export const fetchTasks = async (
|
|
query = ''
|
|
): Promise<{
|
|
tasks: Task[];
|
|
metrics: Metrics;
|
|
groupedTasks?: GroupedTasks;
|
|
tasks_in_progress?: Task[];
|
|
tasks_today_plan?: Task[];
|
|
tasks_due_today?: Task[];
|
|
tasks_overdue?: Task[];
|
|
suggested_tasks?: Task[];
|
|
tasks_completed_today?: Task[];
|
|
pagination?: {
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
hasMore: boolean;
|
|
};
|
|
}> => {
|
|
// For today view, include dashboard task lists
|
|
const includeLists = query.includes('type=today');
|
|
const tasksQuery =
|
|
includeLists && !query.includes('include_lists')
|
|
? `${query}${query.includes('?') ? '&' : '?'}include_lists=true`
|
|
: query;
|
|
|
|
// Fetch tasks and metrics in parallel for better performance
|
|
const [tasksResponse, metricsResponse] = await Promise.all([
|
|
fetch(getApiPath(`tasks${tasksQuery}`), {
|
|
credentials: 'include',
|
|
headers: getDefaultHeaders(),
|
|
}),
|
|
fetch(getApiPath('tasks/metrics'), {
|
|
credentials: 'include',
|
|
headers: getDefaultHeaders(),
|
|
}),
|
|
]);
|
|
|
|
await handleAuthResponse(tasksResponse, 'Failed to fetch tasks.');
|
|
await handleAuthResponse(metricsResponse, 'Failed to fetch metrics.');
|
|
|
|
const tasksResult = await tasksResponse.json();
|
|
const metrics = await metricsResponse.json();
|
|
|
|
if (!Array.isArray(tasksResult.tasks)) {
|
|
throw new Error('Resulting tasks are not an array.');
|
|
}
|
|
|
|
return {
|
|
tasks: tasksResult.tasks,
|
|
metrics: metrics,
|
|
groupedTasks: tasksResult.groupedTasks,
|
|
// Dashboard task lists (only present when include_lists=true)
|
|
tasks_in_progress: tasksResult.tasks_in_progress,
|
|
tasks_today_plan: tasksResult.tasks_today_plan,
|
|
tasks_due_today: tasksResult.tasks_due_today,
|
|
tasks_overdue: tasksResult.tasks_overdue,
|
|
suggested_tasks: tasksResult.suggested_tasks,
|
|
tasks_completed_today: tasksResult.tasks_completed_today,
|
|
// Pagination metadata
|
|
pagination: tasksResult.pagination,
|
|
};
|
|
};
|
|
|
|
export const createTask = async (taskData: Task): Promise<Task> => {
|
|
const response = await fetch(getApiPath('task'), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: await getPostHeadersWithCsrf(),
|
|
body: JSON.stringify(taskData),
|
|
});
|
|
|
|
await handleAuthResponse(response, 'Failed to create task.');
|
|
return await response.json();
|
|
};
|
|
|
|
export const updateTask = async (
|
|
taskUid: string,
|
|
taskData: Partial<Task>
|
|
): Promise<Task> => {
|
|
// Use original_name to prevent display name (e.g. "Monthly", "Daily")
|
|
// from overwriting the real task name in the database
|
|
const payload = { ...taskData };
|
|
if (payload.original_name && payload.name !== undefined) {
|
|
payload.name = payload.original_name;
|
|
}
|
|
|
|
const response = await fetch(
|
|
getApiPath(`task/${encodeURIComponent(taskUid)}`),
|
|
{
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: await getPostHeadersWithCsrf(),
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
|
|
await handleAuthResponse(response, 'Failed to update task.');
|
|
return await response.json();
|
|
};
|
|
|
|
export const toggleTaskCompletion = async (
|
|
taskUid: string,
|
|
currentTask?: Task
|
|
): Promise<Task> => {
|
|
const task = currentTask ?? (await fetchTaskByUid(taskUid));
|
|
|
|
// Handle habits differently - log completion instead of marking as done
|
|
if (task.habit_mode) {
|
|
const { logHabitCompletion } = await import('./habitsService');
|
|
const result = await logHabitCompletion(taskUid);
|
|
return result.task;
|
|
}
|
|
|
|
const newStatus = isTaskDone(task.status)
|
|
? task.note
|
|
? TASK_STATUS.IN_PROGRESS
|
|
: TASK_STATUS.NOT_STARTED
|
|
: TASK_STATUS.DONE;
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('[toggleTaskCompletion]', {
|
|
taskUid,
|
|
oldStatus: task.status,
|
|
newStatus,
|
|
oldCompletedAt: task.completed_at,
|
|
});
|
|
}
|
|
|
|
const result = await updateTask(taskUid, { status: newStatus });
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('[toggleTaskCompletion] result', {
|
|
taskUid,
|
|
status: result.status,
|
|
completed_at: result.completed_at,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const deleteTask = async (taskUid: string): Promise<void> => {
|
|
const response = await fetch(
|
|
getApiPath(`task/${encodeURIComponent(taskUid)}`),
|
|
{
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
headers: await getPostHeadersWithCsrf(),
|
|
}
|
|
);
|
|
|
|
await handleAuthResponse(response, 'Failed to delete task.');
|
|
};
|
|
|
|
export const fetchTaskById = async (taskId: number): Promise<Task> => {
|
|
const response = await fetch(getApiPath(`task/${taskId}`), {
|
|
credentials: 'include',
|
|
headers: getDefaultHeaders(),
|
|
});
|
|
|
|
await handleAuthResponse(response, 'Failed to fetch task.');
|
|
return await response.json();
|
|
};
|
|
|
|
export const fetchTaskByUid = async (uid: string): Promise<Task> => {
|
|
const response = await fetch(
|
|
getApiPath(`task/${encodeURIComponent(uid)}`),
|
|
{
|
|
credentials: 'include',
|
|
headers: getDefaultHeaders(),
|
|
}
|
|
);
|
|
|
|
await handleAuthResponse(response, 'Failed to fetch task.');
|
|
return await response.json();
|
|
};
|
|
|
|
export const fetchSubtasks = async (parentTaskUid: string): Promise<Task[]> => {
|
|
const response = await fetch(getApiPath(`task/${parentTaskUid}/subtasks`), {
|
|
credentials: 'include',
|
|
headers: getDefaultHeaders(),
|
|
});
|
|
|
|
await handleAuthResponse(response, 'Failed to fetch subtasks.');
|
|
return await response.json();
|
|
};
|
|
|
|
export interface TaskIteration {
|
|
date: string;
|
|
utc_date: string;
|
|
}
|
|
|
|
export const fetchTaskNextIterations = async (
|
|
taskUid: string,
|
|
startFromDate?: string
|
|
): Promise<TaskIteration[]> => {
|
|
const url = startFromDate
|
|
? getApiPath(
|
|
`task/${taskUid}/next-iterations?startFromDate=${startFromDate}`
|
|
)
|
|
: getApiPath(`task/${taskUid}/next-iterations`);
|
|
|
|
const response = await fetch(url, {
|
|
credentials: 'include',
|
|
headers: getDefaultHeaders(),
|
|
});
|
|
|
|
await handleAuthResponse(response, 'Failed to fetch task iterations.');
|
|
const result = await response.json();
|
|
return result.iterations || [];
|
|
};
|