fix: add CSRF token support to frontend requests (#1025)

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.
This commit is contained in:
Chris 2026-04-14 15:06:56 +03:00 committed by GitHub
parent fb2b8916ff
commit 6c9902b584
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 251 additions and 113 deletions

View file

@ -94,6 +94,7 @@ app.use(sessionMiddleware);
// CSRF protection using lusca (CodeQL recommended library)
const lusca = require('lusca');
const { csrfMiddleware } = require('./middleware/csrf');
// Pre-check middleware to exempt test/Bearer requests before lusca runs
app.use((req, res, next) => {
@ -111,6 +112,9 @@ app.use((req, res, next) => {
const isPublicPath = publicPaths.some((path) => req.path === path);
const isOidcPath = req.path.startsWith('/api/oidc/');
const isFeatureFlagsPath = req.path.startsWith('/api/feature-flags');
const isCalDAVPath =
req.path.startsWith('/caldav/') ||
req.path.startsWith('/.well-known/caldav');
// Mark exempt requests so lusca wrapper can skip them
if (
@ -118,7 +122,8 @@ app.use((req, res, next) => {
req.headers.authorization?.startsWith('Bearer ') ||
isPublicPath ||
isOidcPath ||
isFeatureFlagsPath
isFeatureFlagsPath ||
isCalDAVPath
) {
req._csrfExempt = true;
}
@ -132,10 +137,7 @@ app.use((req, res, next) => {
if (req._csrfExempt || !statefulMethods.includes(req.method)) {
return next();
}
return lusca.csrf({
header: 'x-csrf-token',
cookie: false,
})(req, res, next);
return csrfMiddleware(req, res, next);
});
// Static files

View file

@ -1,16 +1,9 @@
const lusca = require('lusca');
const csrfMiddleware = (req, res, next) => {
if (!req.session) {
return res.status(500).json({ error: 'Session not initialized' });
}
if (!req.session._csrf) {
req.session._csrf = require('crypto').randomBytes(16).toString('hex');
}
next();
};
const csrfMiddleware = lusca.csrf({
header: 'x-csrf-token',
cookie: false,
});
const csrfProtection = (req, res, next) => {
if (
@ -27,16 +20,14 @@ const csrfProtection = (req, res, next) => {
})(req, res, next);
};
const generateToken = (req) => {
if (!req.session) {
return '';
const generateToken = (req, res) => {
if (typeof req.csrfToken === 'function') {
return req.csrfToken();
}
if (!req.session._csrf) {
req.session._csrf = require('crypto').randomBytes(16).toString('hex');
if (res.locals._csrf) {
return res.locals._csrf;
}
return req.session._csrf;
return '';
};
module.exports = {

View file

@ -101,7 +101,7 @@ const authController = {
},
getCsrfToken(req, res) {
const token = generateToken(req);
const token = generateToken(req, res);
res.json({ csrfToken: token });
},
};

View file

@ -28,6 +28,7 @@ import {
import { useStore } from '../../store/useStore';
import { isUrl, extractUrlTitle } from '../../utils/urlService';
import { getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
import InboxSelectedChips from './InboxSelectedChips';
import SuggestionsDropdown from './SuggestionsDropdown';
import InboxCard from './InboxCard';
@ -839,12 +840,14 @@ const QuickCaptureInput = React.forwardRef<
if (analysisRequestIdRef.current === requestId) {
setIsAnalyzing(true);
}
const token = await getCsrfToken();
const response = await fetch(
getApiPath('inbox/analyze-text'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': token,
},
credentials: 'include',
body: JSON.stringify({ content: text }),

View file

@ -11,6 +11,7 @@ import {
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { getApiPath } from '../../config/paths';
import { fetchWithCsrf } from '../../utils/csrfService';
interface Notification {
id: number;
@ -98,7 +99,7 @@ const NotificationsDropdown: React.FC<NotificationsDropdownProps> = ({
const handleMarkAsRead = async (id: number) => {
try {
const response = await fetch(
const response = await fetchWithCsrf(
getApiPath(`notifications/${id}/read`),
{
method: 'POST',
@ -131,7 +132,7 @@ const NotificationsDropdown: React.FC<NotificationsDropdownProps> = ({
const handleMarkAllAsRead = async () => {
try {
const response = await fetch(
const response = await fetchWithCsrf(
getApiPath('notifications/mark-all-read'),
{
method: 'POST',
@ -153,7 +154,7 @@ const NotificationsDropdown: React.FC<NotificationsDropdownProps> = ({
const handleDelete = async (id: number) => {
try {
const response = await fetch(getApiPath(`notifications/${id}`), {
const response = await fetchWithCsrf(getApiPath(`notifications/${id}`), {
method: 'DELETE',
credentials: 'include',
});

View file

@ -8,6 +8,7 @@ import React, {
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { getLocalesPath, getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
import {
UserIcon,
ClockIcon,
@ -590,6 +591,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify({
token: profile.telegram_bot_token,
@ -692,6 +694,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify({ token: formData.telegram_bot_token }),
});
@ -740,6 +743,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify({
chatId: profile.telegram_chat_id,
@ -770,6 +774,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
@ -802,6 +807,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
@ -838,6 +844,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
}
);
@ -909,6 +916,9 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
const response = await fetch(getApiPath('profile/avatar'), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
body: formData,
});
@ -925,6 +935,9 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
const response = await fetch(getApiPath('profile/avatar'), {
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
if (!response.ok) {
@ -962,6 +975,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
Accept: 'application/json',
},
body: JSON.stringify(dataToSend),

View file

@ -21,6 +21,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
interface View {
id: number;
@ -179,6 +180,7 @@ const SidebarViews: React.FC<SidebarViewsProps> = ({
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({
@ -213,6 +215,7 @@ const SidebarViews: React.FC<SidebarViewsProps> = ({
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({

View file

@ -28,6 +28,7 @@ import { useToast } from '../Shared/ToastContext';
import { useStore } from '../../store/useStore';
import { updateTag, deleteTag } from '../../utils/tagsService';
import { getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
import { SortOption } from '../Shared/SortFilterButton';
import IconSortDropdown from '../Shared/IconSortDropdown';
@ -296,7 +297,10 @@ const TagDetails: React.FC = () => {
getApiPath(`task/${updatedTask.uid}`),
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify(updatedTask),
}
);
@ -319,6 +323,9 @@ const TagDetails: React.FC = () => {
getApiPath(`task/${encodeURIComponent(taskUid)}`),
{
method: 'DELETE',
headers: {
'x-csrf-token': await getCsrfToken(),
},
}
);

View file

@ -20,6 +20,7 @@ import {
CheckIcon,
} from '@heroicons/react/24/outline';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from '../utils/csrfService';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@ -384,7 +385,10 @@ const Tasks: React.FC = () => {
getApiPath(`task/${updatedTask.uid}`),
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify(updatedTask),
}
);
@ -446,6 +450,9 @@ const Tasks: React.FC = () => {
getApiPath(`task/${encodeURIComponent(taskUid)}`),
{
method: 'DELETE',
headers: {
'x-csrf-token': await getCsrfToken(),
},
}
);

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
interface SaveViewModalProps {
searchQuery: string;
@ -39,6 +40,7 @@ const SaveViewModal: React.FC<SaveViewModalProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({

View file

@ -11,6 +11,7 @@ import FilterBadge from './FilterBadge';
import SearchResults from './SearchResults';
import { useToast } from '../Shared/ToastContext';
import { getApiPath } from '../../config/paths';
import { getCsrfToken } from '../../utils/csrfService';
interface SearchMenuProps {
searchQuery: string;
@ -158,6 +159,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({

View file

@ -30,6 +30,7 @@ import { getApiPath } from '../config/paths';
import { SortOption } from './Shared/SortFilterButton';
import IconSortDropdown from './Shared/IconSortDropdown';
import { useStore } from '../store/useStore';
import { getCsrfToken } from '../utils/csrfService';
interface View {
id: number;
@ -531,7 +532,10 @@ const ViewDetail: React.FC = () => {
getApiPath(`task/${updatedTask.uid}`),
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify(updatedTask),
}
);
@ -554,6 +558,9 @@ const ViewDetail: React.FC = () => {
getApiPath(`task/${encodeURIComponent(taskUid)}`),
{
method: 'DELETE',
headers: {
'x-csrf-token': await getCsrfToken(),
},
}
);
@ -606,6 +613,7 @@ const ViewDetail: React.FC = () => {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({
@ -636,6 +644,7 @@ const ViewDetail: React.FC = () => {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({
@ -659,6 +668,9 @@ const ViewDetail: React.FC = () => {
const response = await fetch(getApiPath(`views/${view.uid}`), {
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
if (response.ok) {

View file

@ -9,6 +9,7 @@ import {
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from '../utils/csrfService';
interface View {
id: number;
@ -69,6 +70,9 @@ const Views: React.FC = () => {
{
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
}
);
if (response.ok) {
@ -90,6 +94,7 @@ const Views: React.FC = () => {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify({

View file

@ -1,4 +1,5 @@
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export interface ApiKeySummary {
id: number;
@ -38,7 +39,10 @@ export async function createApiKey(payload: {
}): Promise<CreateApiKeyResponse> {
const response = await fetch(getApiPath('profile/api-keys'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify(payload),
});
@ -49,6 +53,9 @@ export async function revokeApiKey(id: number): Promise<ApiKeySummary> {
const response = await fetch(getApiPath(`profile/api-keys/${id}/revoke`), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
return handleResponse<ApiKeySummary>(response);
}
@ -57,6 +64,9 @@ export async function deleteApiKey(id: number): Promise<void> {
const response = await fetch(getApiPath(`profile/api-keys/${id}`), {
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);

View file

@ -1,6 +1,7 @@
import { Area } from '../entities/Area';
import { handleAuthResponse } from './authUtils';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export const fetchAreas = async (): Promise<Area[]> => {
const response = await fetch(getApiPath('areas'), {
@ -17,10 +18,7 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
const response = await fetch(getApiPath('areas'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(areaData),
});
@ -35,10 +33,7 @@ export const updateArea = async (
const response = await fetch(getApiPath(`areas/${areaUid}`), {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(areaData),
});
@ -52,6 +47,7 @@ export const deleteArea = async (areaUid: string): Promise<void> => {
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});

View file

@ -1,5 +1,6 @@
import { Attachment, AttachmentType } from '../entities/Attachment';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
/**
* Upload a file attachment to a task
@ -15,6 +16,9 @@ export async function uploadAttachment(
const response = await fetch(getApiPath('upload/task-attachment'), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
body: formData,
});
@ -55,6 +59,9 @@ export async function deleteAttachment(
{
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
}
);

View file

@ -1,3 +1,5 @@
import { getCsrfToken } from './csrfService';
export const getDefaultHeaders = (): Record<string, string> => {
return {
Accept: 'application/json',
@ -13,6 +15,16 @@ export const getPostHeaders = (): Record<string, string> => {
};
};
export const getPostHeadersWithCsrf = async (): Promise<
Record<string, string>
> => {
const token = await getCsrfToken();
return {
...getPostHeaders(),
'x-csrf-token': token,
};
};
let isRedirecting = false;
export const handleAuthResponse = async (

View file

@ -1,5 +1,6 @@
import { handleAuthResponse } from './authUtils';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export interface BackupData {
version: string;
@ -94,6 +95,7 @@ export const createBackup = async (): Promise<SavedBackup> => {
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
@ -172,10 +174,7 @@ export const restoreSavedBackup = async (
const response = await fetch(getApiPath(`backup/${backupUid}/restore`), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ merge }),
});
@ -192,6 +191,7 @@ export const deleteSavedBackup = async (backupUid: string): Promise<void> => {
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
@ -212,6 +212,9 @@ export const importBackup = async (
const response = await fetch(getApiPath('backup/import'), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
body: formData,
});
@ -229,6 +232,9 @@ export const validateBackup = async (file: File): Promise<ValidationResult> => {
const response = await fetch(getApiPath('backup/validate'), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
body: formData,
});

View file

@ -0,0 +1,59 @@
import { getApiPath } from '../config/paths';
let csrfToken: string | null = null;
let tokenPromise: Promise<string> | null = null;
export const getCsrfToken = async (): Promise<string> => {
if (csrfToken) {
return csrfToken;
}
if (tokenPromise) {
return tokenPromise;
}
tokenPromise = fetch(getApiPath('csrf-token'), {
credentials: 'include',
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch CSRF token');
}
return response.json();
})
.then((data) => {
csrfToken = data.csrfToken;
tokenPromise = null;
return csrfToken!;
})
.catch((error) => {
tokenPromise = null;
throw error;
});
return tokenPromise;
};
export const clearCsrfToken = (): void => {
csrfToken = null;
tokenPromise = null;
};
export const fetchWithCsrf = async (
url: string,
options: RequestInit = {}
): Promise<Response> => {
const needsCsrf = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(
options.method?.toUpperCase() || 'GET'
);
if (needsCsrf) {
const token = await getCsrfToken();
options.headers = {
...options.headers,
'x-csrf-token': token,
};
}
return fetch(url, options);
};

View file

@ -1,5 +1,6 @@
import { getApiPath } from '../config/paths';
import { Task } from '../entities/Task';
import { getCsrfToken } from './csrfService';
export interface HabitCompletion {
id: number;
@ -24,7 +25,10 @@ export async function createHabit(habitData: Partial<Task>): Promise<Task> {
const response = await fetch(getApiPath('habits'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify(habitData),
});
if (!response.ok) throw new Error('Failed to create habit');
@ -36,7 +40,10 @@ export async function logHabitCompletion(habitUid: string, completedAt?: Date) {
const response = await fetch(getApiPath(`habits/${habitUid}/complete`), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify({
completed_at: completedAt?.toISOString(),
}),
@ -71,7 +78,10 @@ export async function updateHabit(
const response = await fetch(getApiPath(`habits/${habitUid}`), {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Failed to update habit');
@ -83,6 +93,9 @@ export async function deleteHabit(habitUid: string): Promise<void> {
const response = await fetch(getApiPath(`habits/${habitUid}`), {
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
if (!response.ok) throw new Error('Failed to delete habit');
}
@ -116,6 +129,9 @@ export async function deleteHabitCompletion(
{
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
}
);
if (!response.ok) throw new Error('Failed to delete completion');

View file

@ -1,7 +1,8 @@
import { InboxItem } from '../entities/InboxItem';
import { useStore } from '../store/useStore';
import { handleAuthResponse } from './authUtils';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
// API functions
export const fetchInboxItems = async (
@ -59,10 +60,7 @@ export const createInboxItem = async (
const response = await fetch(getApiPath('inbox'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(source ? { content, source } : { content }),
});
@ -77,10 +75,7 @@ export const updateInboxItem = async (
const response = await fetch(getApiPath(`inbox/${itemUid}`), {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ content }),
});
@ -94,6 +89,7 @@ export const processInboxItem = async (itemUid: string): Promise<InboxItem> => {
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});
@ -107,6 +103,7 @@ export const deleteInboxItem = async (itemUid: string): Promise<void> => {
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});

View file

@ -2,7 +2,7 @@ import { Note } from '../entities/Note';
import {
handleAuthResponse,
getDefaultHeaders,
getPostHeaders,
getPostHeadersWithCsrf,
} from './authUtils';
import { getApiPath } from '../config/paths';
@ -38,7 +38,7 @@ export const createNote = async (noteData: Note): Promise<Note> => {
const response = await fetch(getApiPath('note'), {
method: 'POST',
credentials: 'include',
headers: getPostHeaders(),
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(requestData),
});
@ -70,7 +70,7 @@ export const updateNote = async (
const response = await fetch(getApiPath(`note/${noteIdentifier}`), {
method: 'PATCH',
credentials: 'include',
headers: getPostHeaders(),
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(requestData),
});
@ -82,7 +82,7 @@ export const deleteNote = async (noteUid: string): Promise<void> => {
const response = await fetch(getApiPath(`note/${noteUid}`), {
method: 'DELETE',
credentials: 'include',
headers: getDefaultHeaders(),
headers: await getPostHeadersWithCsrf(),
});
await handleAuthResponse(response, 'Failed to delete note.');

View file

@ -1,4 +1,5 @@
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export interface OIDCProvider {
slug: string;
@ -48,6 +49,9 @@ export async function unlinkOIDCIdentity(identityId: number): Promise<void> {
const response = await fetch(getApiPath(`oidc/unlink/${identityId}`), {
method: 'DELETE',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
@ -60,6 +64,9 @@ export async function initiateOIDCLink(providerSlug: string): Promise<void> {
const response = await fetch(getApiPath(`oidc/link/${providerSlug}`), {
method: 'POST',
credentials: 'include',
headers: {
'x-csrf-token': await getCsrfToken(),
},
});
if (!response.ok) {

View file

@ -1,4 +1,4 @@
import { handleAuthResponse } from './authUtils';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
interface Profile {
@ -51,10 +51,7 @@ export const updateProfile = async (
const response = await fetch(getApiPath('profile'), {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(profileData),
});
await handleAuthResponse(response, 'Failed to update profile.');
@ -82,10 +79,7 @@ export const sendTaskSummaryNow = async (): Promise<any> => {
const response = await fetch(getApiPath('profile/task-summary/send-now'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
});
await handleAuthResponse(response, 'Failed to send task summary.');
return await response.json();
@ -109,10 +103,7 @@ export const setupTelegram = async (
const response = await fetch(getApiPath('telegram/setup'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({
bot_token: botToken,
chat_id: chatId,
@ -126,10 +117,7 @@ export const startTelegramPolling = async (): Promise<any> => {
const response = await fetch(getApiPath('telegram/start-polling'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
});
await handleAuthResponse(response, 'Failed to start telegram polling.');
return await response.json();
@ -139,10 +127,7 @@ export const stopTelegramPolling = async (): Promise<any> => {
const response = await fetch(getApiPath('telegram/stop-polling'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
});
await handleAuthResponse(response, 'Failed to stop telegram polling.');
return await response.json();
@ -155,10 +140,7 @@ export const testTelegram = async (
const response = await fetch(getApiPath(`telegram/test/${userId}`), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ text: message }),
});
await handleAuthResponse(response, 'Failed to send test message.');
@ -169,10 +151,7 @@ export const toggleTaskSummary = async (): Promise<any> => {
const response = await fetch(getApiPath('profile/task-summary/toggle'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
});
await handleAuthResponse(response, 'Failed to toggle task summary.');
return await response.json();
@ -184,10 +163,7 @@ export const updateTaskSummaryFrequency = async (
const response = await fetch(getApiPath('profile/task-summary/frequency'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ frequency }),
});
await handleAuthResponse(

View file

@ -1,6 +1,7 @@
import { Project } from '../entities/Project';
import { handleAuthResponse } from './authUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export const fetchProjects = async (
stateFilter = 'all',
@ -60,12 +61,14 @@ export const fetchProjectById = async (projectId: string): Promise<Project> => {
export const createProject = async (
projectData: Partial<Project>
): Promise<Project> => {
const token = await getCsrfToken();
const response = await fetch(getApiPath('project'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-csrf-token': token,
},
body: JSON.stringify(projectData),
});
@ -78,12 +81,14 @@ export const updateProject = async (
projectUid: string,
projectData: Partial<Project>
): Promise<Project> => {
const token = await getCsrfToken();
const response = await fetch(getApiPath(`project/${projectUid}`), {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-csrf-token': token,
},
body: JSON.stringify(projectData),
});
@ -99,11 +104,13 @@ export const deleteProject = async (projectUid: string): Promise<void> => {
console.log('Attempting to delete project with UID:', projectUid);
const token = await getCsrfToken();
const response = await fetch(getApiPath(`project/${projectUid}`), {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': token,
},
});

View file

@ -1,4 +1,5 @@
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export type AccessLevel = 'ro' | 'rw';
@ -16,6 +17,7 @@ export async function grantShare(req: ShareGrantRequest): Promise<void> {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify(req),
});
@ -75,6 +77,7 @@ export async function revokeShare(
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
body: JSON.stringify({ resource_type, resource_uid, target_user_id }),
});

View file

@ -1,7 +1,8 @@
import { Tag } from '../entities/Tag';
import { handleAuthResponse } from './authUtils';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { extractUidFromSlug } from './slugUtils';
import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService';
export const fetchTags = async (): Promise<Tag[]> => {
try {
@ -25,10 +26,7 @@ export const createTag = async (tagData: Tag): Promise<Tag> => {
const response = await fetch(getApiPath('tag'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(tagData),
});
@ -57,10 +55,7 @@ export const updateTag = async (tagUid: string, tagData: Tag): Promise<Tag> => {
const response = await fetch(getApiPath(`tag/${tagUid}`), {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(tagData),
});
@ -90,6 +85,7 @@ export const deleteTag = async (tagUid: string): Promise<void> => {
credentials: 'include',
headers: {
Accept: 'application/json',
'x-csrf-token': await getCsrfToken(),
},
});

View file

@ -3,7 +3,7 @@ import { Task } from '../entities/Task';
import {
handleAuthResponse,
getDefaultHeaders,
getPostHeaders,
getPostHeadersWithCsrf,
} from './authUtils';
import { getApiPath } from '../config/paths';
import { isTaskDone, TASK_STATUS } from '../constants/taskStatus';
@ -80,7 +80,7 @@ export const createTask = async (taskData: Task): Promise<Task> => {
const response = await fetch(getApiPath('task'), {
method: 'POST',
credentials: 'include',
headers: getPostHeaders(),
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(taskData),
});
@ -104,7 +104,7 @@ export const updateTask = async (
{
method: 'PATCH',
credentials: 'include',
headers: getPostHeaders(),
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify(payload),
}
);
@ -160,7 +160,7 @@ export const deleteTask = async (taskUid: string): Promise<void> => {
{
method: 'DELETE',
credentials: 'include',
headers: getDefaultHeaders(),
headers: await getPostHeadersWithCsrf(),
}
);

View file

@ -1,4 +1,4 @@
import { handleAuthResponse } from './authUtils';
import { handleAuthResponse, getPostHeadersWithCsrf } from './authUtils';
import { getApiPath } from '../config/paths';
export interface UrlTitleResult {
@ -37,10 +37,7 @@ export const extractTitleFromText = async (
const response = await fetch(getApiPath('url/extract-from-text'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
headers: await getPostHeadersWithCsrf(),
body: JSON.stringify({ text }),
});