1import Axios from 'axios'; 2import router from '../router'; 3import { setupCache, buildWebStorage } from 'axios-cache-interceptor'; 4import { toRaw } from 'vue'; 5import Cookies from 'js-cookie'; 6 7//Do not change store import. 8//Exact match alias set to support 9//dotenv customizations. 10import store from '.'; 11 12Axios.defaults.headers.common['Accept'] = 'application/json'; 13Axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 14 15// Enable persisting X-Auth-Token in a cookie when explicitly requested 16// This allows direct browser navigation to Redfish endpoints to work 17const shouldPersistAuthToken = 18 import.meta.env.VITE_STORE_SESSION === 'true' || 19 import.meta.env.STORE_SESSION === 'true'; 20 21const axiosInstance = Axios.create({ 22 withCredentials: true, 23}); 24 25const api = setupCache(axiosInstance, { 26 debug: import.meta.env.DEV ? console.log : undefined, 27 methods: ['get'], 28 interpretHeader: false, 29 etag: true, 30 modifiedSince: false, 31 staleIfError: false, 32 ttl: 0, 33 storage: buildWebStorage(localStorage, 'webui-vue-cache:'), 34}); 35 36// Initialize auth header from cookie (opt-in for non-cookie backends) 37if (shouldPersistAuthToken) { 38 const persistedToken = Cookies.get('X-Auth-Token'); 39 if (persistedToken) { 40 axiosInstance.defaults.headers.common['X-Auth-Token'] = persistedToken; 41 } 42} 43 44/** 45 * Strip Vue 3 reactivity properties and Event objects from request payloads. 46 * 47 * Vue 3 Migration Issue: 48 * In Bootstrap-Vue-Next, @change events pass Event objects instead of values. 49 * When reactive form data accidentally includes Event properties (isTrusted, 50 * _vts, etc.), the API receives malformed payloads like: 51 * {"LocationIndicatorActive": {"isTrusted": true, "_vts": 1765562875420}} 52 * instead of: 53 * {"LocationIndicatorActive": true} 54 * 55 * This function provides a safety net by: 56 * 1. Unwrapping Vue Proxy objects using toRaw() 57 * 2. Detecting Event-like objects and logging warnings 58 * 3. Using JSON round-trip to strip non-serializable properties 59 */ 60function stripVueReactivity(obj) { 61 if (obj === null || obj === undefined) return obj; 62 63 // Get raw value (unwrap Vue Proxy) 64 const raw = toRaw(obj); 65 66 // Return primitives as-is 67 if (typeof raw !== 'object') return raw; 68 69 // Skip binary/upload types - they must be passed through unchanged 70 // These types lose their data when JSON serialized 71 if ( 72 raw instanceof File || 73 raw instanceof Blob || 74 raw instanceof FormData || 75 ArrayBuffer.isView(raw) || 76 raw instanceof ArrayBuffer 77 ) { 78 return raw; 79 } 80 81 // Detect Event objects that were accidentally passed as values 82 // This happens when @change handlers pass the event instead of the value 83 if (raw instanceof Event || typeof raw.isTrusted === 'boolean') { 84 console.warn( 85 'API payload contains Event object - check component @change handlers:', 86 raw, 87 ); 88 } 89 90 // For plain objects, use JSON round-trip to strip non-serializable properties 91 // This removes Vue internal properties (_vts, __v_isRef, etc.) and Event metadata 92 try { 93 return JSON.parse(JSON.stringify(raw)); 94 } catch (e) { 95 // If serialization fails (circular refs, etc.), log and return raw 96 console.warn('Could not serialize API payload:', e); 97 return raw; 98 } 99} 100 101// Add request interceptor to strip Vue reactivity from payloads 102api.interceptors.request.use( 103 (config) => { 104 if (config.data) { 105 config.data = stripVueReactivity(config.data); 106 } 107 return config; 108 }, 109 (error) => Promise.reject(error), 110); 111 112api.interceptors.response.use( 113 (response) => { 114 return response; 115 }, 116 (error) => { 117 const response = error?.response; 118 const status = response?.status; 119 120 if (!status) return Promise.reject(error); 121 122 // Auth handling 123 if (status == 401) { 124 // Don't trigger logout on login attempts (POST to SessionService/Sessions) 125 const isLoginAttempt = 126 response.config.method === 'post' && 127 response.config.url?.endsWith('/SessionService/Sessions'); 128 if (!isLoginAttempt) { 129 store.commit('authentication/logout'); 130 router.push('/login'); 131 } 132 } 133 134 if (status == 403) { 135 if (isPasswordExpired(response.data)) { 136 router.push('/change-password'); 137 } else { 138 store.commit('global/setUnauthorized'); 139 } 140 } 141 142 return Promise.reject(error); 143 }, 144); 145 146export default { 147 get(path, config) { 148 return api.get(path, config); 149 }, 150 delete(path, config) { 151 return api.delete(path, config); 152 }, 153 post(path, payload, config) { 154 return api.post(path, payload, config); 155 }, 156 patch(path, payload, config) { 157 return api.patch(path, payload, config); 158 }, 159 put(path, payload, config) { 160 return api.put(path, payload, config); 161 }, 162 all(promises) { 163 return Axios.all(promises); 164 }, 165 spread(callback) { 166 return Axios.spread(callback); 167 }, 168 /** 169 * Sets or clears the X-Auth-Token header used by API requests. 170 * 171 * Notes: 172 * - This function is used by the auth flow when the standard XSRF cookie is 173 * not present (which is abnormal in cookie-backed deployments). In such 174 * cases, a header-based session may be used as a fallback. 175 * - The token is persisted to a session cookie when the build-time env 176 * flag VITE_STORE_SESSION=true (or STORE_SESSION=true) is set. This 177 * allows direct browser navigation to Redfish endpoints to work. 178 * - The cookie is session-only (no expiration) and will be cleared when 179 * the browser is closed or when logout is called. 180 * 181 * @param {string | null | undefined} token - The session token to apply. Pass 182 * a falsy value to clear the header, and to remove any persisted token when 183 * persistence is enabled. 184 */ 185 set_auth_token(token) { 186 if (token) { 187 axiosInstance.defaults.headers.common['X-Auth-Token'] = token; 188 if (shouldPersistAuthToken) { 189 // Store as session cookie (no expiration = cleared on browser close) 190 Cookies.set('X-Auth-Token', token, { 191 secure: window.location.protocol !== 'http:', 192 sameSite: 'strict', 193 }); 194 } 195 } else { 196 delete axiosInstance.defaults.headers.common['X-Auth-Token']; 197 if (shouldPersistAuthToken) { 198 Cookies.remove('X-Auth-Token'); 199 } 200 } 201 }, 202}; 203 204export const getResponseCount = (responses) => { 205 let successCount = 0; 206 let errorCount = 0; 207 208 responses.forEach((response) => { 209 if (response instanceof Error) errorCount++; 210 else successCount++; 211 }); 212 213 return { 214 successCount, 215 errorCount, 216 }; 217}; 218 219export const isPasswordExpired = (data) => { 220 return !!findMessageId(data, 'PasswordChangeRequired'); 221}; 222 223/** 224 * Returns the first ExtendedInfo.Message to start with the 225 * Registry Name (Default: "Base") and end with the given key 226 * Ignore versions (.<X>.<Y>) --or-- (.<X>.<Y>.<Z>.), 227 * but adhere to Registry namespace 228 * @param {object} data - AxiosResponse.data 229 * @param { {MessageKey: string}} key - key into the message registry 230 * @param { {MessageRegistryPrefix: string}} [registry=Base] - the name of the 231 * message registry, undefined param defaults to "Base" 232 * @returns {ExtendedInfo.Message} ExtendedInfo.Message | undefined 233 */ 234export const findMessageId = (data, key, registry = 'Base') => { 235 let extInfoMsgs = data?.['@Message.ExtendedInfo']; 236 237 return ( 238 extInfoMsgs && 239 extInfoMsgs.find((i) => { 240 const words = i.MessageId.split('.'); 241 return words[words.length - 1] === key && words[0] === registry; 242 }) 243 ); 244}; 245