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 3a5059bea534..afbafae3d770 100644 --- a/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/BatchUpdateRequest/jQuery/index.js @@ -1,12 +1,52 @@ $(() => { - const URL = 'https://js.devexpress.com/Demos/NetCore/api/DataGridWebApi'; + const BASE_PATH = 'http://localhost:5555'; + // const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore'; + const URL = `${BASE_PATH}/api/DataGridBatchUpdateWebApi`; + + function fetchAntiForgeryToken() { + 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'; + d.reject(new Error(`Failed to retrieve anti-forgery token: ${error}`)); + }); + return d.promise(); + } + + 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(_, ajaxOptions) { + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }), pager: { @@ -26,11 +66,11 @@ $(() => { if (e.changes.length) { const changes = normalizeChanges(e.changes); - e.promise = sendBatchRequest(`${URL}/Batch`, changes).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: [{ @@ -77,17 +117,19 @@ $(() => { }); } - function sendBatchRequest(url, changes) { + function sendBatchRequest(url, changes, headers) { const d = $.Deferred(); $.ajax(url, { method: 'POST', data: JSON.stringify(changes), + headers, + xhrFields: { withCredentials: true }, 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.responseText || xhr.statusText || 'Unknown error'; + d.reject(new Error(`Batch save failed: ${errorMessage}`)); }); return d.promise(); diff --git a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js index 91c005ead1be..c6f370967b09 100644 --- a/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js +++ b/apps/demos/Demos/DataGrid/CollaborativeEditing/jQuery/index.js @@ -7,10 +7,41 @@ $(() => { return typeof obj; }; - const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore/'; - const url = `${BASE_PATH}api/DataGridCollaborativeEditing/`; + 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', @@ -18,8 +49,13 @@ $(() => { insertUrl: url, updateUrl: url, deleteUrl: url, - onBeforeSend(method, ajaxOptions) { + async onBeforeSend(_, ajaxOptions) { ajaxOptions.data.groupId = groupId; + const tokenData = await getAntiForgeryTokenValue(); + ajaxOptions.xhrFields = { + withCredentials: true, + headers: { [tokenData.headerName]: tokenData.token }, + }; }, }); }; @@ -59,7 +95,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 +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,