xref: /openbmc/webui-vue/src/store/api.js (revision 063a8e0efc946b319300c610a3fb44dde4433d31)
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