From 4330596da24be6bb69dbf2d6158ceab011ec9c73 Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:32:41 +0200 Subject: [PATCH 01/12] jQuery: 1 BeforeSend + 1 jQuery.ajax / req --- .../BatchUpdateRequest/jQuery/index.js | 20 ++++++++++++--- .../CollaborativeEditing/jQuery/index.js | 25 ++++++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js index 3a5059bea534..92666d8eb36e 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -1,5 +1,9 @@ +const BASE_PATH = 'http://localhost:5555'; +//const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +let csrf = null; + $(() => { - const URL = 'https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi'; + const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; $('#gridContainer').dxDataGrid({ dataSource: DevExpress.data.AspNet.createStore({ @@ -26,7 +30,8 @@ $(() => { if (e.changes.length) { const changes = normalizeChanges(e.changes); - e.promise = sendBatchRequest(`${URL}/Batch`, changes).done(() => { + e.promise = sendBatchRequest(`${URL}/Batch`, changes, + { [csrf['headerName']]: csrf['token'] }).done(() => { e.component.refresh(true).done(() => { e.component.cancelEditData(); }); @@ -77,12 +82,13 @@ $(() => { }); } - function sendBatchRequest(url, changes) { + function sendBatchRequest(url, changes, headers) { const d = $.Deferred(); $.ajax(url, { method: 'POST', data: JSON.stringify(changes), + headers: headers, cache: false, contentType: 'application/json', xhrFields: { withCredentials: true }, @@ -93,3 +99,11 @@ $(() => { return d.promise(); } }); + +(async () => { + const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { + credentials: 'include' + }); + const data = await response.text(); + csrf = JSON.parse(data); +})(); diff --git a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js index 91de882ae2a3..fb32c85ac017 100644 --- a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js @@ -1,3 +1,7 @@ +const BASE_PATH = 'http://localhost:5555'; +//const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +let csrf = null; + $(() => { $.type = $.type || function (obj) { if (obj == null) { @@ -7,8 +11,7 @@ $(() => { return typeof obj; }; - const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore/'; - const url = `${BASE_PATH}api/DataGridCollaborativeEditing/`; + const url = `${BASE_PATH}/api/DataGridCollaborativeEditing/`; const groupId = new DevExpress.data.Guid().toString(); const createStore = function () { @@ -20,6 +23,12 @@ $(() => { deleteUrl: url, onBeforeSend(method, ajaxOptions) { ajaxOptions.data.groupId = groupId; + ajaxOptions.xhrFields = { withCredentials: true }; + if (method === 'insert') { + ajaxOptions.headers = { + [csrf['headerName']]: csrf['token'] + }; + } }, }); }; @@ -59,7 +68,7 @@ $(() => { lookup: { dataSource: DevExpress.data.AspNet.createStore({ key: 'ID', - loadUrl: `${BASE_PATH}api/DataGridStatesLookup`, + loadUrl: `${BASE_PATH}/api/DataGridStatesLookup`, }), displayExpr: 'Name', valueExpr: 'ID', @@ -90,7 +99,7 @@ $(() => { createDataGrid('grid1', store1); createDataGrid('grid2', store2); - const hubUrl = `${BASE_PATH}dataGridCollaborativeEditingHub?GroupId=${groupId}`; + const hubUrl = `${BASE_PATH}/DataGridCollaborativeEditingHub?GroupId=${groupId}`; const connection = new signalR.HubConnectionBuilder() .withUrl(hubUrl, { skipNegotiation: true, @@ -114,3 +123,11 @@ $(() => { }); }); }); + +(async () => { + const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { + credentials: 'include' + }); + const data = await response.text(); + csrf = JSON.parse(data); +})(); From 81a41095f7e0030170f6a7803bdf0b4bf2888c9b Mon Sep 17 00:00:00 2001 From: Tom <22076961+artem-kurchenko@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:21:28 +0100 Subject: [PATCH 02/12] Possible solution for anti-forgery token handling in DXT demos (#42) * Refactor anti-forgery token handling in BatchUpdateRequest demo - Fetch fresh CSRF token for each load and save operation - Use jQuery $.ajax() consistently instead of mixing with fetch API - Move error handling into helper functions (getAntiForgeryToken, sendBatchRequest) - Improve error messages with descriptive context - Use promise chaining with .then() for better readability - Remove stale global csrf variable * Refactor anti-forgery token handling to use caching --- .../BatchUpdateRequest/jQuery/index.js | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js index 92666d8eb36e..169bcb73017e 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -1,16 +1,49 @@ const BASE_PATH = 'http://localhost:5555'; -//const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; -let csrf = null; +// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; $(() => { const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; + function fetchAntiForgeryToken() { + return $.ajax({ + url: `${BASE_PATH}/api/Common/GetAntiForgeryToken`, + method: 'GET', + xhrFields: { withCredentials: true }, + cache: false, + }).fail((xhr) => { + const error = xhr.responseJSON?.message || xhr.statusText || 'Unknown error'; + throw new Error(`Failed to retrieve anti-forgery token: ${error}`); + }); + } + + function getAntiForgeryTokenValue() { + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + const headerName = tokenMeta.dataset.headerName || 'RequestVerificationToken'; + const token = tokenMeta.getAttribute('content'); + return $.Deferred().resolve({ headerName, token }); + } + + return fetchAntiForgeryToken().then((tokenData) => { + const meta = document.createElement('meta'); + meta.name = 'csrf-token'; + meta.content = tokenData.token; + meta.dataset.headerName = tokenData.headerName; + document.head.appendChild(meta); + return tokenData; + }); + } + $('#gridContainer').dxDataGrid({ dataSource: DevExpress.data.AspNet.createStore({ key: 'OrderID', loadUrl: `${URL}/Orders`, - onBeforeSend(method, ajaxOptions) { - ajaxOptions.xhrFields = { withCredentials: true }; + async onBeforeSend(__method, ajaxOptions) { + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }), pager: { @@ -30,12 +63,11 @@ $(() => { if (e.changes.length) { const changes = normalizeChanges(e.changes); - e.promise = sendBatchRequest(`${URL}/Batch`, changes, - { [csrf['headerName']]: csrf['token'] }).done(() => { - e.component.refresh(true).done(() => { + e.promise = getAntiForgeryTokenValue().then((tokenData) => sendBatchRequest(`${URL}/Batch`, changes, { [tokenData.headerName]: tokenData.token })) + .then(() => e.component.refresh(true)) + .then(() => { e.component.cancelEditData(); }); - }); } }, columns: [{ @@ -88,22 +120,15 @@ $(() => { $.ajax(url, { method: 'POST', data: JSON.stringify(changes), - headers: headers, + headers, cache: false, contentType: 'application/json', xhrFields: { withCredentials: true }, }).done(d.resolve).fail((xhr) => { - d.reject(xhr.responseJSON ? xhr.responseJSON.Message : xhr.statusText); + const errorMessage = xhr.responseJSON?.Message || xhr.statusText || 'Unknown error'; + d.reject(new Error(`Batch save failed: ${errorMessage}`)); }); return d.promise(); } }); - -(async () => { - const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { - credentials: 'include' - }); - const data = await response.text(); - csrf = JSON.parse(data); -})(); From d134ffbfcfe6146be882007c9e0e0c3096ef5730 Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:38:03 +0200 Subject: [PATCH 03/12] synchronize snippets, closure, arrange withCredentials --- .../BatchUpdateRequest/jQuery/index.js | 9 ++- .../CollaborativeEditing/jQuery/index.js | 59 ++++++++++++------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js index 169bcb73017e..7e7d717ed36b 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -1,7 +1,6 @@ -const BASE_PATH = 'http://localhost:5555'; -// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; - $(() => { + const BASE_PATH = 'http://localhost:5555'; + // const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; function fetchAntiForgeryToken() { @@ -38,7 +37,7 @@ $(() => { dataSource: DevExpress.data.AspNet.createStore({ key: 'OrderID', loadUrl: `${URL}/Orders`, - async onBeforeSend(__method, ajaxOptions) { + async onBeforeSend(_, ajaxOptions) { const tokenData = await getAntiForgeryTokenValue(); ajaxOptions.xhrFields = { withCredentials: true, @@ -121,9 +120,9 @@ $(() => { method: 'POST', data: JSON.stringify(changes), headers, + xhrFields: { withCredentials: true }, cache: false, contentType: 'application/json', - xhrFields: { withCredentials: true }, }).done(d.resolve).fail((xhr) => { const errorMessage = xhr.responseJSON?.Message || xhr.statusText || 'Unknown error'; d.reject(new Error(`Batch save failed: ${errorMessage}`)); diff --git a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js index fb32c85ac017..baf30c759a7e 100644 --- a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js @@ -1,7 +1,3 @@ -const BASE_PATH = 'http://localhost:5555'; -//const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; -let csrf = null; - $(() => { $.type = $.type || function (obj) { if (obj == null) { @@ -11,9 +7,41 @@ $(() => { return typeof obj; }; + const BASE_PATH = 'http://localhost:5555'; + // const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const url = `${BASE_PATH}/api/DataGridCollaborativeEditing/`; const groupId = new DevExpress.data.Guid().toString(); + function fetchAntiForgeryToken() { + return $.ajax({ + url: `${BASE_PATH}/api/Common/GetAntiForgeryToken`, + method: 'GET', + xhrFields: { withCredentials: true }, + cache: false, + }).fail((xhr) => { + const error = xhr.responseJSON?.message || xhr.statusText || 'Unknown error'; + throw new Error(`Failed to retrieve anti-forgery token: ${error}`); + }); + } + + function getAntiForgeryTokenValue() { + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + const headerName = tokenMeta.dataset.headerName || 'RequestVerificationToken'; + const token = tokenMeta.getAttribute('content'); + return $.Deferred().resolve({ headerName, token }); + } + + return fetchAntiForgeryToken().then((tokenData) => { + const meta = document.createElement('meta'); + meta.name = 'csrf-token'; + meta.content = tokenData.token; + meta.dataset.headerName = tokenData.headerName; + document.head.appendChild(meta); + return tokenData; + }); + } + const createStore = function () { return DevExpress.data.AspNet.createStore({ key: 'ID', @@ -21,14 +49,13 @@ $(() => { insertUrl: url, updateUrl: url, deleteUrl: url, - onBeforeSend(method, ajaxOptions) { + async onBeforeSend(_, ajaxOptions) { ajaxOptions.data.groupId = groupId; - ajaxOptions.xhrFields = { withCredentials: true }; - if (method === 'insert') { - ajaxOptions.headers = { - [csrf['headerName']]: csrf['token'] - }; - } + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }); }; @@ -99,7 +126,7 @@ $(() => { createDataGrid('grid1', store1); createDataGrid('grid2', store2); - const hubUrl = `${BASE_PATH}/DataGridCollaborativeEditingHub?GroupId=${groupId}`; + const hubUrl = `${BASE_PATH}/dataGridCollaborativeEditingHub?GroupId=${groupId}`; const connection = new signalR.HubConnectionBuilder() .withUrl(hubUrl, { skipNegotiation: true, @@ -123,11 +150,3 @@ $(() => { }); }); }); - -(async () => { - const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { - credentials: 'include' - }); - const data = await response.text(); - csrf = JSON.parse(data); -})(); From 5adcaf5fcea2346096a433acda32dfc948926836 Mon Sep 17 00:00:00 2001 From: Tom <22076961+artem-kurchenko@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:51:29 +0100 Subject: [PATCH 04/12] Implement CSRF for JS (#43) * Refactor anti-forgery token handling in BatchUpdateRequest demo - Fetch fresh CSRF token for each load and save operation - Use jQuery $.ajax() consistently instead of mixing with fetch API - Move error handling into helper functions (getAntiForgeryToken, sendBatchRequest) - Improve error messages with descriptive context - Use promise chaining with .then() for better readability - Remove stale global csrf variable * Refactor anti-forgery token handling to use caching * jq - improve error handling * react - implement csrf * react - refactoring * vue - implement csrf * angular - implement csrf --- .../Angular/app/anti-forgery-token.service.ts | 76 ++++++++++++++++ .../Angular/app/anti-forgery.interceptor.ts | 33 +++++++ .../Angular/app/app.component.ts | 50 +++++++---- .../DataGrid/BatchUpdateRequest/React/App.tsx | 83 ++++++++++++++---- .../DataGrid/BatchUpdateRequest/Vue/App.vue | 87 +++++++++++++++---- .../BatchUpdateRequest/jQuery/index.js | 10 ++- 6 files changed, 286 insertions(+), 53 deletions(-) create mode 100644 apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts create mode 100644 apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts new file mode 100644 index 000000000000..e864f5c7eb88 --- /dev/null +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, shareReplay } from 'rxjs/operators'; + +interface TokenData { + headerName: string; + token: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class AntiForgeryTokenService { + private BASE_PATH = 'http://localhost:5555'; + // private BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; + + private tokenCache$: Observable | null = null; + + constructor(private http: HttpClient) {} + + getToken(): Observable { + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + const headerName = tokenMeta.dataset.headerName || 'RequestVerificationToken'; + const token = tokenMeta.getAttribute('content') || ''; + return of({ headerName, token }); + } + + if (!this.tokenCache$) { + this.tokenCache$ = this.fetchToken().pipe( + map((tokenData) => { + this.storeTokenInMeta(tokenData); + return tokenData; + }), + shareReplay({ bufferSize: 1, refCount: false }), + catchError((error) => { + this.tokenCache$ = null; + return throwError(() => error); + }), + ); + } + + return this.tokenCache$; + } + + private fetchToken(): Observable { + return this.http.get( + `${this.BASE_PATH}/api/Common/GetAntiForgeryToken`, + { + withCredentials: true, + }, + ).pipe( + catchError((error) => { + const errorMessage = typeof error.error === 'string' ? error.error : (error.statusText || 'Unknown error'); + return throwError(() => new Error(`Failed to retrieve anti-forgery token: ${errorMessage}`)); + }), + ); + } + + private storeTokenInMeta(tokenData: TokenData): void { + const meta = document.createElement('meta'); + meta.name = 'csrf-token'; + meta.content = tokenData.token; + meta.dataset.headerName = tokenData.headerName; + document.head.appendChild(meta); + } + + clearToken(): void { + this.tokenCache$ = null; + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + tokenMeta.remove(); + } + } +} diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts new file mode 100644 index 000000000000..254f2b480da7 --- /dev/null +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts @@ -0,0 +1,33 @@ +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, switchMap, throwError } from 'rxjs'; +import { AntiForgeryTokenService } from './anti-forgery-token.service'; + +export const antiForgeryInterceptor: HttpInterceptorFn = (req, next) => { + const tokenService = inject(AntiForgeryTokenService); + + if (req.method === 'GET' && req.url.includes('/GetAntiForgeryToken')) { + return next(req); + } + + if (req.method !== 'GET') { + return tokenService.getToken().pipe( + switchMap((tokenData) => { + const clonedRequest = req.clone({ + setHeaders: { + [tokenData.headerName]: tokenData.token, + }, + }); + return next(clonedRequest); + }), + catchError((error: HttpErrorResponse) => { + if (error.status === 401 || error.status === 403) { + tokenService.clearToken(); + } + return throwError(() => error); + }), + ); + } + + return next(req); +}; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts index ae20def78885..ea711ae5d05e 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts @@ -1,15 +1,19 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { Component, enableProdMode, provideZoneChangeDetection } from '@angular/core'; -import { HttpClient, provideHttpClient, withFetch } from '@angular/common/http'; +import { HttpClient, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { lastValueFrom } from 'rxjs'; import * as AspNetData from 'devextreme-aspnet-data-nojquery'; import { DxDataGridComponent, DxDataGridModule, DxDataGridTypes } from 'devextreme-angular/ui/data-grid'; +import { antiForgeryInterceptor } from './anti-forgery.interceptor'; +import { AntiForgeryTokenService } from './anti-forgery-token.service'; if (!/localhost/.test(document.location.host)) { enableProdMode(); } -const URL = 'https://js.devexpress.com/Demos/NetCore/api/DataGridBatchUpdateWebApi'; +const BASE_PATH = 'http://localhost:5555'; +// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; let modulePrefix = ''; // @ts-ignore @@ -28,12 +32,16 @@ if (window && window.config?.packageConfigPaths) { export class AppComponent { ordersStore: AspNetData.CustomStore; - constructor(private http: HttpClient) { + constructor(private http: HttpClient, private tokenService: AntiForgeryTokenService) { this.ordersStore = AspNetData.createStore({ key: 'OrderID', loadUrl: `${URL}/Orders`, - onBeforeSend(method, ajaxOptions) { - ajaxOptions.xhrFields = { withCredentials: true }; + async onBeforeSend(_method, ajaxOptions) { + const tokenData = await lastValueFrom(tokenService.getToken()); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }); } @@ -52,16 +60,23 @@ export class AppComponent { changes: DxDataGridTypes.DataChange[], component: DxDataGridComponent['instance'], ): Promise { - await lastValueFrom( - this.http.post(url, JSON.stringify(changes), { - withCredentials: true, - headers: { - 'Content-Type': 'application/json', - }, - }), - ); - await component.refresh(true); - component.cancelEditData(); + try { + await lastValueFrom( + this.http.post(url, JSON.stringify(changes), { + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + await component.refresh(true); + component.cancelEditData(); + } catch (error: any) { + const errorMessage = (typeof error?.error === 'string' && error.error) + ? error.error + : (error?.statusText || 'Unknown error'); + throw new Error(`Batch save failed: ${errorMessage}`); + } } normalizeChanges(changes: DxDataGridTypes.DataChange[]): DxDataGridTypes.DataChange[] { @@ -93,6 +108,9 @@ export class AppComponent { bootstrapApplication(AppComponent, { providers: [ provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), - provideHttpClient(withFetch()), + provideHttpClient( + withFetch(), + withInterceptors([antiForgeryInterceptor]), + ), ], }); diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx b/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx index 0c7cbf6142f4..ee41fe771da4 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx @@ -4,13 +4,56 @@ import type { DataGridRef, DataGridTypes } from 'devextreme-react/data-grid'; import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -const URL = 'https://js.devexpress.com/Demos/NetCore/api/DataGridBatchUpdateWebApi'; +const BASE_PATH = 'http://localhost:5555'; +// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; + +async function fetchAntiForgeryToken(): Promise<{ headerName: string; token: string }> { + try { + const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { + method: 'GET', + credentials: 'include', + cache: 'no-cache', + }); + + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(`Failed to retrieve anti-forgery token: ${errorMessage || response.statusText}`); + } + + return await response.json(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(errorMessage); + } +} + +async function getAntiForgeryTokenValue(): Promise<{ headerName: string; token: string }> { + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + const headerName = tokenMeta.dataset.headerName || 'RequestVerificationToken'; + const token = tokenMeta.getAttribute('content') || ''; + return Promise.resolve({ headerName, token }); + } + + const tokenData = await fetchAntiForgeryToken(); + const meta = document.createElement('meta'); + meta.name = 'csrf-token'; + meta.content = tokenData.token; + meta.dataset.headerName = tokenData.headerName; + document.head.appendChild(meta); + return tokenData; +} const ordersStore = createStore({ key: 'OrderID', loadUrl: `${URL}/Orders`, - onBeforeSend: (method, ajaxOptions) => { - ajaxOptions.xhrFields = { withCredentials: true }; + async onBeforeSend(_method, ajaxOptions) { + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }); @@ -39,25 +82,31 @@ function normalizeChanges(changes: DataGridTypes.DataChange[]): DataGridTypes.Da }) as DataGridTypes.DataChange[]; } -async function sendBatchRequest(url: string, changes: DataGridTypes.DataChange[]) { - const result = await fetch(url, { - method: 'POST', - body: JSON.stringify(changes), - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - credentials: 'include', - }); - - if (!result.ok) { - const json = await result.json(); +async function sendBatchRequest(url: string, changes: DataGridTypes.DataChange[], headers: Record) { + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(changes), + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + ...headers, + }, + credentials: 'include', + }); - throw json.Message; + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(`Batch save failed: ${errorMessage || response.statusText}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(errorMessage); } } async function processBatchRequest(url: string, changes: DataGridTypes.DataChange[], component: ReturnType) { - await sendBatchRequest(url, changes); + const tokenData = await getAntiForgeryTokenValue(); + await sendBatchRequest(url, changes, { [tokenData.headerName]: tokenData.token }); await component.refresh(true); component.cancelEditData(); } diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue index 39ad192b13bd..2d97e1a97a53 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue @@ -36,13 +36,56 @@ import { import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -const URL = 'https://js.devexpress.com/Demos/NetCore/api/DataGridBatchUpdateWebApi'; +const BASE_PATH = 'http://localhost:5555'; +// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; + +async function fetchAntiForgeryToken(): Promise<{ headerName: string; token: string }> { + try { + const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { + method: 'GET', + credentials: 'include', + cache: 'no-cache', + }); + + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(`Failed to retrieve anti-forgery token: ${errorMessage || response.statusText}`); + } + + return await response.json(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(errorMessage); + } +} + +async function getAntiForgeryTokenValue(): Promise<{ headerName: string; token: string }> { + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + const headerName = tokenMeta.dataset.headerName || 'RequestVerificationToken'; + const token = tokenMeta.getAttribute('content') || ''; + return Promise.resolve({ headerName, token }); + } + + const tokenData = await fetchAntiForgeryToken(); + const meta = document.createElement('meta'); + meta.name = 'csrf-token'; + meta.content = tokenData.token; + meta.dataset.headerName = tokenData.headerName; + document.head.appendChild(meta); + return tokenData; +} const ordersStore = createStore({ key: 'OrderID', loadUrl: `${URL}/Orders`, - onBeforeSend: (method, ajaxOptions) => { - ajaxOptions.xhrFields = { withCredentials: true }; + async onBeforeSend(_method, ajaxOptions) { + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }); @@ -83,26 +126,36 @@ function normalizeChanges(changes: DxDataGridTypes.DataChange[]): DxDataGridType async function processBatchRequest( url: string, changes: DxDataGridTypes.DataChange[], component: DxDataGrid['instance'], ) { - await sendBatchRequest(url, changes); + const tokenData = await getAntiForgeryTokenValue(); + await sendBatchRequest(url, changes, { [tokenData.headerName]: tokenData.token }); await component?.refresh(true); component?.cancelEditData(); } -async function sendBatchRequest(url: string, changes: DxDataGridTypes.DataChange[]) { - const result = await fetch(url, { - method: 'POST', - body: JSON.stringify(changes), - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - credentials: 'include', - }); - - if (!result.ok) { - const json = await result.json(); +async function sendBatchRequest( + url: string, + changes: DxDataGridTypes.DataChange[], + headers: Record, +) { + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(changes), + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + ...headers, + }, + credentials: 'include', + }); - throw json.Message; + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(`Batch save failed: ${errorMessage || response.statusText}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(errorMessage); } } diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js index 7e7d717ed36b..afbafae3d770 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -4,15 +4,19 @@ $(() => { const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; function fetchAntiForgeryToken() { - return $.ajax({ + const d = $.Deferred(); + $.ajax({ url: `${BASE_PATH}/api/Common/GetAntiForgeryToken`, method: 'GET', xhrFields: { withCredentials: true }, cache: false, + }).done((data) => { + d.resolve(data); }).fail((xhr) => { const error = xhr.responseJSON?.message || xhr.statusText || 'Unknown error'; - throw new Error(`Failed to retrieve anti-forgery token: ${error}`); + d.reject(new Error(`Failed to retrieve anti-forgery token: ${error}`)); }); + return d.promise(); } function getAntiForgeryTokenValue() { @@ -124,7 +128,7 @@ $(() => { cache: false, contentType: 'application/json', }).done(d.resolve).fail((xhr) => { - const errorMessage = xhr.responseJSON?.Message || xhr.statusText || 'Unknown error'; + const errorMessage = xhr.responseText || xhr.statusText || 'Unknown error'; d.reject(new Error(`Batch save failed: ${errorMessage}`)); }); From b0162c0d61a2571edf45a69e6d7ab48ab0abcea5 Mon Sep 17 00:00:00 2001 From: Tom <22076961+artem-kurchenko@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:04:09 +0100 Subject: [PATCH 05/12] Test anti-forgery - Change folders for the Angular demo (#45) * Refactor anti-forgery token handling in BatchUpdateRequest demo - Fetch fresh CSRF token for each load and save operation - Use jQuery $.ajax() consistently instead of mixing with fetch API - Move error handling into helper functions (getAntiForgeryToken, sendBatchRequest) - Improve error messages with descriptive context - Use promise chaining with .then() for better readability - Remove stale global csrf variable * Refactor anti-forgery token handling to use caching * jq - improve error handling * react - implement csrf * react - refactoring * vue - implement csrf * angular - implement csrf * change folder structure * merge services in one file * app.service in root --------- Co-authored-by: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> --- .../Angular/app/anti-forgery.interceptor.ts | 33 ------------------ .../Angular/app/app.component.ts | 3 +- ...orgery-token.service.ts => app.service.ts} | 34 +++++++++++++++++-- 3 files changed, 33 insertions(+), 37 deletions(-) delete mode 100644 apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts rename apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/{anti-forgery-token.service.ts => app.service.ts} (69%) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts deleted file mode 100644 index 254f2b480da7..000000000000 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery.interceptor.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { catchError, switchMap, throwError } from 'rxjs'; -import { AntiForgeryTokenService } from './anti-forgery-token.service'; - -export const antiForgeryInterceptor: HttpInterceptorFn = (req, next) => { - const tokenService = inject(AntiForgeryTokenService); - - if (req.method === 'GET' && req.url.includes('/GetAntiForgeryToken')) { - return next(req); - } - - if (req.method !== 'GET') { - return tokenService.getToken().pipe( - switchMap((tokenData) => { - const clonedRequest = req.clone({ - setHeaders: { - [tokenData.headerName]: tokenData.token, - }, - }); - return next(clonedRequest); - }), - catchError((error: HttpErrorResponse) => { - if (error.status === 401 || error.status === 403) { - tokenService.clearToken(); - } - return throwError(() => error); - }), - ); - } - - return next(req); -}; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts index ea711ae5d05e..a5f3490737ce 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts @@ -4,8 +4,7 @@ import { HttpClient, provideHttpClient, withFetch, withInterceptors } from '@ang import { lastValueFrom } from 'rxjs'; import * as AspNetData from 'devextreme-aspnet-data-nojquery'; import { DxDataGridComponent, DxDataGridModule, DxDataGridTypes } from 'devextreme-angular/ui/data-grid'; -import { antiForgeryInterceptor } from './anti-forgery.interceptor'; -import { AntiForgeryTokenService } from './anti-forgery-token.service'; +import { antiForgeryInterceptor, AntiForgeryTokenService } from './app.service'; if (!/localhost/.test(document.location.host)) { enableProdMode(); diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts similarity index 69% rename from apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts rename to apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts index e864f5c7eb88..9eb9b8df00ea 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/anti-forgery-token.service.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs'; -import { catchError, map, shareReplay } from 'rxjs/operators'; +import { catchError, switchMap, map, shareReplay } from 'rxjs/operators'; +import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; interface TokenData { headerName: string; @@ -74,3 +75,32 @@ export class AntiForgeryTokenService { } } } + +export const antiForgeryInterceptor: HttpInterceptorFn = (req, next) => { + const tokenService = inject(AntiForgeryTokenService); + + if (req.method === 'GET' && req.url.includes('/GetAntiForgeryToken')) { + return next(req); + } + + if (req.method !== 'GET') { + return tokenService.getToken().pipe( + switchMap((tokenData) => { + const clonedRequest = req.clone({ + setHeaders: { + [tokenData.headerName]: tokenData.token, + }, + }); + return next(clonedRequest); + }), + catchError((error: HttpErrorResponse) => { + if (error.status === 401 || error.status === 403) { + tokenService.clearToken(); + } + return throwError(() => error); + }), + ); + } + + return next(req); +}; From 2bde20aeb54d6550aaf89b25975c5679a7b33ba0 Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:07:28 +0200 Subject: [PATCH 06/12] url --- .../DataGrid/BatchUpdateRequest/Angular/app/app.component.ts | 4 ++-- .../DataGrid/BatchUpdateRequest/Angular/app/app.service.ts | 4 ++-- apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx | 4 ++-- apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue | 4 ++-- apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js | 4 ++-- .../demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts index a5f3490737ce..e7d8695f4e7b 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts @@ -10,8 +10,8 @@ if (!/localhost/.test(document.location.host)) { enableProdMode(); } -const BASE_PATH = 'http://localhost:5555'; -// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +// const BASE_PATH = 'http://localhost:5555'; +const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; let modulePrefix = ''; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts index 9eb9b8df00ea..34c5761db50e 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts @@ -13,8 +13,8 @@ interface TokenData { providedIn: 'root', }) export class AntiForgeryTokenService { - private BASE_PATH = 'http://localhost:5555'; - // private BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; + // private BASE_PATH = 'http://localhost:5555'; + private BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; private tokenCache$: Observable | null = null; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx b/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx index ee41fe771da4..64ee0ad439ef 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx @@ -4,8 +4,8 @@ import type { DataGridRef, DataGridTypes } from 'devextreme-react/data-grid'; import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -const BASE_PATH = 'http://localhost:5555'; -// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +// const BASE_PATH = 'http://localhost:5555'; +const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; async function fetchAntiForgeryToken(): Promise<{ headerName: string; token: string }> { diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue index 2d97e1a97a53..d5aac704799d 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue @@ -36,8 +36,8 @@ import { import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -const BASE_PATH = 'http://localhost:5555'; -// const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +// const BASE_PATH = 'http://localhost:5555'; +const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; async function fetchAntiForgeryToken(): Promise<{ headerName: string; token: string }> { diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js index afbafae3d770..afbccb2f3d09 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -1,6 +1,6 @@ $(() => { - const BASE_PATH = 'http://localhost:5555'; - // const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; + // const BASE_PATH = 'http://localhost:5555'; + const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; function fetchAntiForgeryToken() { diff --git a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js index c6f370967b09..ddc66e6b1713 100644 --- a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js @@ -7,8 +7,8 @@ $(() => { return typeof obj; }; - const BASE_PATH = 'http://localhost:5555'; - // const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; + // const BASE_PATH = 'http://localhost:5555'; + const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const url = `${BASE_PATH}/api/DataGridCollaborativeEditing/`; const groupId = new DevExpress.data.Guid().toString(); From 379ef7081c46016633f6f7603a417bfec5e4d2bd Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:08:46 +0200 Subject: [PATCH 07/12] ReactJs --- .../BatchUpdateRequest/ReactJs/App.js | 80 +++++++++++++++---- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js index 14c79c49f8e2..ef4d098415ee 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js @@ -2,13 +2,52 @@ import React from 'react'; import DataGrid, { Column, Editing, Pager } from 'devextreme-react/data-grid'; import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; - -const URL = 'https://js.devexpress.com/Demos/NetCore/api/DataGridBatchUpdateWebApi'; +// const BASE_PATH = 'http://localhost:5555'; +const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; +const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; +async function fetchAntiForgeryToken() { + try { + const response = await fetch(`${BASE_PATH}/api/Common/GetAntiForgeryToken`, { + method: 'GET', + credentials: 'include', + cache: 'no-cache', + }); + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error( + `Failed to retrieve anti-forgery token: ${errorMessage || response.statusText}`, + ); + } + return await response.json(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(errorMessage); + } +} +async function getAntiForgeryTokenValue() { + const tokenMeta = document.querySelector('meta[name="csrf-token"]'); + if (tokenMeta) { + const headerName = tokenMeta.dataset.headerName || 'RequestVerificationToken'; + const token = tokenMeta.getAttribute('content') || ''; + return Promise.resolve({ headerName, token }); + } + const tokenData = await fetchAntiForgeryToken(); + const meta = document.createElement('meta'); + meta.name = 'csrf-token'; + meta.content = tokenData.token; + meta.dataset.headerName = tokenData.headerName; + document.head.appendChild(meta); + return tokenData; +} const ordersStore = createStore({ key: 'OrderID', loadUrl: `${URL}/Orders`, - onBeforeSend: (method, ajaxOptions) => { - ajaxOptions.xhrFields = { withCredentials: true }; + async onBeforeSend(_method, ajaxOptions) { + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }); function normalizeChanges(changes) { @@ -35,22 +74,29 @@ function normalizeChanges(changes) { } }); } -async function sendBatchRequest(url, changes) { - const result = await fetch(url, { - method: 'POST', - body: JSON.stringify(changes), - headers: { - 'Content-Type': 'application/json;charset=UTF-8', - }, - credentials: 'include', - }); - if (!result.ok) { - const json = await result.json(); - throw json.Message; +async function sendBatchRequest(url, changes, headers) { + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(changes), + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + ...headers, + }, + credentials: 'include', + }); + if (!response.ok) { + const errorMessage = await response.text(); + throw new Error(`Batch save failed: ${errorMessage || response.statusText}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(errorMessage); } } async function processBatchRequest(url, changes, component) { - await sendBatchRequest(url, changes); + const tokenData = await getAntiForgeryTokenValue(); + await sendBatchRequest(url, changes, { [tokenData.headerName]: tokenData.token }); await component.refresh(true); component.cancelEditData(); } From 7bdbf6dcbebbf6118828322673e1a13b7d7ef978 Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:19:51 +0200 Subject: [PATCH 08/12] lint --- .../DataGrid/BatchUpdateRequest/Angular/app/app.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts index 34c5761db50e..47510ce56000 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts @@ -1,8 +1,7 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs'; import { catchError, switchMap, map, shareReplay } from 'rxjs/operators'; -import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; interface TokenData { headerName: string; From 52800546cb15e234d0b886bf605ef219b237afab Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Thu, 15 Jan 2026 19:04:33 +0200 Subject: [PATCH 09/12] localhost --- .../DataGrid/BatchUpdateRequest/Angular/app/app.component.ts | 1 - .../Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts | 1 - apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx | 1 - apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js | 1 - apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue | 1 - apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js | 1 - apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js | 1 - 7 files changed, 7 deletions(-) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts index e7d8695f4e7b..b193fda9ee3d 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.component.ts @@ -10,7 +10,6 @@ if (!/localhost/.test(document.location.host)) { enableProdMode(); } -// const BASE_PATH = 'http://localhost:5555'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts index 47510ce56000..185bed7e6c3d 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Angular/app/app.service.ts @@ -12,7 +12,6 @@ interface TokenData { providedIn: 'root', }) export class AntiForgeryTokenService { - // private BASE_PATH = 'http://localhost:5555'; private BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; private tokenCache$: Observable | null = null; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx b/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx index 64ee0ad439ef..0cd9c8bf553e 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/React/App.tsx @@ -4,7 +4,6 @@ import type { DataGridRef, DataGridTypes } from 'devextreme-react/data-grid'; import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -// const BASE_PATH = 'http://localhost:5555'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js index ef4d098415ee..8fb1d12a81cd 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js @@ -2,7 +2,6 @@ import React from 'react'; import DataGrid, { Column, Editing, Pager } from 'devextreme-react/data-grid'; import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -// const BASE_PATH = 'http://localhost:5555'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; async function fetchAntiForgeryToken() { diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue index d5aac704799d..a3aa421c640d 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/Vue/App.vue @@ -36,7 +36,6 @@ import { import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; -// const BASE_PATH = 'http://localhost:5555'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js index afbccb2f3d09..cfb60d3753c4 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -1,5 +1,4 @@ $(() => { - // const BASE_PATH = 'http://localhost:5555'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; diff --git a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js index ddc66e6b1713..b687ab6e9901 100644 --- a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js @@ -7,7 +7,6 @@ $(() => { return typeof obj; }; - // const BASE_PATH = 'http://localhost:5555'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const url = `${BASE_PATH}/api/DataGridCollaborativeEditing/`; const groupId = new DevExpress.data.Guid().toString(); From 7d84cb72728f56c38703f80b10322f3a21deb64b Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:29:28 +0200 Subject: [PATCH 10/12] lint --- apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js b/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js index 8fb1d12a81cd..fcde2ae8ae4b 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/ReactJs/App.js @@ -2,6 +2,7 @@ import React from 'react'; import DataGrid, { Column, Editing, Pager } from 'devextreme-react/data-grid'; import { createStore } from 'devextreme-aspnet-data-nojquery'; import 'whatwg-fetch'; + const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; async function fetchAntiForgeryToken() { From 3da36cff3ed0a1e758c22397b1e8e906129399b9 Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:41:18 +0200 Subject: [PATCH 11/12] skip `BatchUpdateRequest` test --- apps/demos/testing/skipped-tests.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/demos/testing/skipped-tests.js b/apps/demos/testing/skipped-tests.js index de76d4cf9662..476ee77c0684 100644 --- a/apps/demos/testing/skipped-tests.js +++ b/apps/demos/testing/skipped-tests.js @@ -1,15 +1,16 @@ export const skippedTests = { jQuery: { + DataGrid: ['SignalRService', 'BatchUpdateRequest'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], }, Angular: { Common: ['PopupAndNotificationsOverview'], - DataGrid: ['SignalRService'], + DataGrid: ['SignalRService', 'BatchUpdateRequest'], Scheduler: ['Templates'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], }, React: { - DataGrid: ['SignalRService'], + DataGrid: ['SignalRService', 'BatchUpdateRequest'], // NOTE: 'GroupByDate' demo has problems with rendering Scheduler: ['GroupByDate', 'Templates'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], @@ -17,7 +18,7 @@ export const skippedTests = { Vue: { Common: ['PopupAndNotificationsOverview'], Scheduler: ['Templates'], - DataGrid: ['SignalRService'], + DataGrid: ['SignalRService', 'BatchUpdateRequest'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], }, }; From 25c90e77de50ad431f77817605a2fc01d91816f8 Mon Sep 17 00:00:00 2001 From: Mikhail Preyskurantov <5574159+mpreyskurantov@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:07:53 +0200 Subject: [PATCH 12/12] /apps/demos/testing/skipped-tests.js -> /apps/demos/utils/visual-tests/matrix-test-helper.ts --- apps/demos/testing/skipped-tests.js | 7 +++---- apps/demos/utils/visual-tests/matrix-test-helper.ts | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/demos/testing/skipped-tests.js b/apps/demos/testing/skipped-tests.js index 476ee77c0684..de76d4cf9662 100644 --- a/apps/demos/testing/skipped-tests.js +++ b/apps/demos/testing/skipped-tests.js @@ -1,16 +1,15 @@ export const skippedTests = { jQuery: { - DataGrid: ['SignalRService', 'BatchUpdateRequest'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], }, Angular: { Common: ['PopupAndNotificationsOverview'], - DataGrid: ['SignalRService', 'BatchUpdateRequest'], + DataGrid: ['SignalRService'], Scheduler: ['Templates'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], }, React: { - DataGrid: ['SignalRService', 'BatchUpdateRequest'], + DataGrid: ['SignalRService'], // NOTE: 'GroupByDate' demo has problems with rendering Scheduler: ['GroupByDate', 'Templates'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], @@ -18,7 +17,7 @@ export const skippedTests = { Vue: { Common: ['PopupAndNotificationsOverview'], Scheduler: ['Templates'], - DataGrid: ['SignalRService', 'BatchUpdateRequest'], + DataGrid: ['SignalRService'], Map: ['ProvidersAndTypes', 'Markers', 'Routes'], }, }; diff --git a/apps/demos/utils/visual-tests/matrix-test-helper.ts b/apps/demos/utils/visual-tests/matrix-test-helper.ts index 198eec79f4d0..b6e92f28fe02 100644 --- a/apps/demos/utils/visual-tests/matrix-test-helper.ts +++ b/apps/demos/utils/visual-tests/matrix-test-helper.ts @@ -205,11 +205,11 @@ export function shouldRunTestAtIndex(testIndex) { const SKIPPED_TESTS = { jQuery: { - DataGrid: ['EditStateManagement', 'RemoteGrouping'], + DataGrid: ['BatchUpdateRequest', 'EditStateManagement', 'RemoteGrouping'], }, Angular: { Common: ['PopupAndNotificationsOverview'], - DataGrid: ['EditStateManagement', 'RemoteGrouping'], + DataGrid: ['BatchUpdateRequest', 'EditStateManagement', 'RemoteGrouping'], Scheduler: ['ContextMenu'], FileUploader: ['CustomDropzone'], }, @@ -217,13 +217,13 @@ const SKIPPED_TESTS = { Common: ['PopupAndNotificationsOverview'], // NOTE: Context menu item position is different across themes Scheduler: ['ContextMenu'], - DataGrid: ['EditStateManagement', 'RemoteGrouping'], + DataGrid: ['BatchUpdateRequest', 'EditStateManagement', 'RemoteGrouping'], FileUploader: ['CustomDropzone'], }, React: { Common: ['PopupAndNotificationsOverview'], Scheduler: ['ContextMenu'], - DataGrid: ['EditStateManagement', 'RemoteGrouping'], + DataGrid: ['BatchUpdateRequest', 'EditStateManagement', 'RemoteGrouping'], FileUploader: ['CustomDropzone'], }, };