Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ REACT_APP_ENABLE_MESSAGING=true
REACT_APP_ENABLE_REGIONS=true
REACT_APP_ENABLE_TOOLTIPS=true
REACT_APP_ENABLE_STAKEHOLDERS=true
REACT_APP_KEYCLOAK_URL=https://dev-k8s.treetracker.org/keycloak
REACT_APP_KEYCLOAK_REALM=master
REACT_APP_KEYCLOAK_CLIENT_ID=treetracker-admin-client
58,146 changes: 30,903 additions & 27,243 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@
"i": "*",
"import": "*",
"install": "*",
"keycloak-js": "^18.0.1",
"loglevel": "^1.6.3",
"material-ui-chip-input": "^2.0.0-beta.2",
"mdi-material-ui": "*",
"mdi-svg": "*",
"npm": "*",
"os": "npm:os-browserify",
"prop-types": "*",
"react": "^18.2.0",
Expand Down
2 changes: 1 addition & 1 deletion src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class App extends Component {
<ThemeProvider theme={theme}>
<>
<BrowserRouter>
<AppProvider>
<AppProvider initialAuth={this.props.initialAuth}>
<Routers />
</AppProvider>
</BrowserRouter>
Expand Down
35 changes: 13 additions & 22 deletions src/api/earnings.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import axios from 'axios';
import { session } from '../models/auth';
import { authAxios } from './httpClient';

const apiUrl = `${process.env.REACT_APP_EARNINGS_ROOT}`;
const Axios = axios.create({ baseURL: apiUrl });

export default {
/**
Expand All @@ -11,12 +9,9 @@ export default {
* @returns {Promise}
*/
async getEarnings(params) {
const headers = {
'content-type': 'application/json',
Authorization: session.token,
};

return Axios.get(`earnings`, { params, headers }).then((res) => res.data);
return authAxios
.get(`${apiUrl}/earnings`, { params })
.then((res) => res.data);
},

/**
Expand All @@ -27,14 +22,9 @@ export default {
* @returns {Promise}
*/
async patchEarning(earning) {
const headers = {
'content-type': 'application/json',
Authorization: session.token,
};

return Axios.patch(`earnings`, earning, { headers }).then(
(res) => res.data
);
return authAxios
.patch(`${apiUrl}/earnings`, earning)
.then((res) => res.data);
},

/**
Expand All @@ -46,12 +36,13 @@ export default {
async batchPatchEarnings(file) {
const formData = new FormData();
formData.append('csv', file);
const headers = {
accept: 'multipart/form-data',
Authorization: session.token,
};

return Axios.patch(`earnings/batch`, formData, { headers })
return authAxios
.patch(`${apiUrl}/earnings/batch`, formData, {
headers: {
accept: 'multipart/form-data',
},
})
.then((res) => res.data)
.catch((error) => {
throw new Error('Payments Batch Upload Failed!', { cause: error });
Expand Down
54 changes: 10 additions & 44 deletions src/api/growers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { handleResponse, handleError, getOrganization } from './apiUtils';
import { session } from '../models/auth';
import { handleError, getOrganization } from './apiUtils';
import { authAxios } from './httpClient';

export default {
getGrower(id) {
Expand All @@ -8,13 +8,7 @@ export default {
process.env.REACT_APP_API_ROOT
}/api/${getOrganization()}planter/${id}`;

return fetch(growerQuery, {
method: 'GET',
headers: {
'content-type': 'application/json',
Authorization: session.token,
},
}).then(handleResponse);
return authAxios.get(growerQuery).then((res) => res.data);
} catch (error) {
handleError(error);
}
Expand Down Expand Up @@ -46,12 +40,7 @@ export default {
process.env.REACT_APP_API_ROOT
}/api/${getOrganization()}planter?filter=${JSON.stringify(growerFilter)}`;

return fetch(query, {
headers: {
'content-type': 'application/json',
Authorization: session.token,
},
}).then(handleResponse);
return authAxios.get(query).then((res) => res.data);
} catch (error) {
handleError(error);
}
Expand All @@ -65,12 +54,7 @@ export default {
}/api/${getOrganization()}planter/count?where=${JSON.stringify(
filterObj
)}`;
return fetch(query, {
headers: {
'content-type': 'application/json',
Authorization: session.token,
},
}).then(handleResponse);
return authAxios.get(query).then((res) => res.data);
} catch (error) {
handleError(error);
}
Expand All @@ -81,13 +65,7 @@ export default {
const registrationQuery = `${
process.env.REACT_APP_API_ROOT
}/api/${getOrganization()}planter-registration?filter[where][planterId]=${growerId}`;
return fetch(registrationQuery, {
method: 'GET',
headers: {
'content-type': 'application/json',
Authorization: session.token,
},
}).then(handleResponse);
return authAxios.get(registrationQuery).then((res) => res.data);
} catch (error) {
handleError(error);
}
Expand All @@ -107,14 +85,9 @@ export default {
filter
)}`;

return fetch(growerSelfiesQuery, {
method: 'GET',
headers: {
'content-type': 'application/json',
Authorization: session.token,
},
})
.then(handleResponse)
return authAxios
.get(growerSelfiesQuery)
.then((res) => res.data)
.then((items) => {
// Remove duplicates
return [
Expand All @@ -140,14 +113,7 @@ export default {
process.env.REACT_APP_API_ROOT
}/api/${getOrganization()}planter/${id}`;

return fetch(growerQuery, {
method: 'PATCH',
headers: {
'content-type': 'application/json',
Authorization: session.token,
},
body: JSON.stringify(growerUpdate),
}).then(handleResponse);
return authAxios.patch(growerQuery, growerUpdate).then((res) => res.data);
} catch (error) {
handleError(error);
}
Expand Down
50 changes: 50 additions & 0 deletions src/api/httpClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import axios from 'axios';
import {
clearAuthState,
ensureFreshToken,
isKeycloakConfigured,
} from '../auth/keycloak';
import { getLegacyToken } from '../auth/util';

export const authAxios = axios.create();
export const publicAxios = axios.create();

let authInterceptorsConfigured = false;

export function setupAuthAxiosInterceptors() {
if (authInterceptorsConfigured) {
return;
}

authInterceptorsConfigured = true;

authAxios.interceptors.request.use(async (config) => {
const headers = config.headers || {};
if (headers.Authorization) {
config.headers = headers;
return config;
}

if (!isKeycloakConfigured()) {
const legacyToken = getLegacyToken();
if (legacyToken) {
headers.Authorization = legacyToken;
}
config.headers = headers;
return config;
}

try {
const token = await ensureFreshToken(30);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
config.headers = headers;
return config;
} catch (error) {
clearAuthState();
window.location.assign('/login');
return Promise.reject(error);
}
});
}
131 changes: 131 additions & 0 deletions src/api/httpClient.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
describe('httpClient explicit interceptors', () => {
let originalLocation;

beforeEach(() => {
jest.resetModules();
localStorage.clear();
originalLocation = window.location;
delete window.location;
window.location = { assign: jest.fn() };
});

afterEach(() => {
window.location = originalLocation;
});

function loadClientWithMocks({
isConfigured = true,
refreshedToken = 'token-123',
refreshError,
sessionToken,
} = {}) {
let mod;
const ensureFreshToken = refreshError
? jest.fn().mockRejectedValue(refreshError)
: jest.fn().mockResolvedValue(refreshedToken);
const clearAuthState = jest.fn();

jest.isolateModules(() => {
jest.doMock('../auth/keycloak', () => ({
isKeycloakConfigured: jest.fn(() => isConfigured),
ensureFreshToken,
clearAuthState,
}));

jest.doMock('../models/auth', () => ({
session: {
token: sessionToken,
},
}));

mod = require('./httpClient');
mod.setupAuthAxiosInterceptors();
});

return { ...mod, ensureFreshToken, clearAuthState };
}

it('attaches bearer token for keycloak-authenticated requests', async () => {
const { authAxios, ensureFreshToken } = loadClientWithMocks();
const handlers = authAxios.interceptors.request.handlers;
const handler = handlers[handlers.length - 1].fulfilled;

const config = await handler({ headers: {} });

expect(ensureFreshToken).toHaveBeenCalledWith(30);
expect(config.headers.Authorization).toBe('Bearer token-123');
});

it('preserves explicit authorization header when provided', async () => {
const { authAxios, ensureFreshToken } = loadClientWithMocks();
const handlers = authAxios.interceptors.request.handlers;
const handler = handlers[handlers.length - 1].fulfilled;

const config = await handler({
headers: { Authorization: 'Custom header token' },
});

expect(ensureFreshToken).not.toHaveBeenCalled();
expect(config.headers.Authorization).toBe('Custom header token');
});

it('uses legacy session token when keycloak is disabled', async () => {
const { authAxios, ensureFreshToken } = loadClientWithMocks({
isConfigured: false,
sessionToken: 'Legacy token',
});
const handlers = authAxios.interceptors.request.handlers;
const handler = handlers[handlers.length - 1].fulfilled;

const config = await handler({ headers: {} });

expect(ensureFreshToken).not.toHaveBeenCalled();
expect(config.headers.Authorization).toBe('Legacy token');
});

it('uses localStorage token fallback when session token is missing', async () => {
localStorage.setItem('token', JSON.stringify('Stored token'));
const { authAxios, ensureFreshToken } = loadClientWithMocks({
isConfigured: false,
sessionToken: undefined,
});
const handlers = authAxios.interceptors.request.handlers;
const handler = handlers[handlers.length - 1].fulfilled;

const config = await handler({ headers: {} });

expect(ensureFreshToken).not.toHaveBeenCalled();
expect(config.headers.Authorization).toBe('Stored token');
});

it('does not attach authorization when keycloak disabled and no legacy token', async () => {
const { authAxios } = loadClientWithMocks({
isConfigured: false,
sessionToken: undefined,
});
const handlers = authAxios.interceptors.request.handlers;
const handler = handlers[handlers.length - 1].fulfilled;

const config = await handler({ headers: {} });

expect(config.headers.Authorization).toBeUndefined();
});

it('clears auth and redirects when refresh fails', async () => {
const refreshError = new Error('refresh failed');
const { authAxios, clearAuthState } = loadClientWithMocks({
refreshError,
});
const handlers = authAxios.interceptors.request.handlers;
const handler = handlers[handlers.length - 1].fulfilled;

await expect(handler({ headers: {} })).rejects.toThrow('refresh failed');
expect(clearAuthState).toHaveBeenCalledTimes(1);
expect(window.location.assign).toHaveBeenCalledWith('/login');
});

it('does not attach auth interceptor to publicAxios', () => {
const { publicAxios } = loadClientWithMocks();
expect(publicAxios.interceptors.request.handlers.length).toBe(0);
});
});
Loading