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:
parent
fb2b8916ff
commit
6c9902b584
29 changed files with 251 additions and 113 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
59
frontend/utils/csrfService.ts
Normal file
59
frontend/utils/csrfService.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue