Make auth JWT-only for Capacitor WebView origins (#22) (#26)
Unit Tests / test (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
## Summary - Closes #22 - Drop CSRF cookie / `js-cookie` path; axios uses JWT `Authorization` only (`withCredentials: false`) - Unified token storage: `localStorage` sync source of truth + optional Capacitor Preferences mirror/hydrate for native shells - Request interceptor always attaches fresh bearer token; 401 refresh + sign-in redirect use hash-safe native paths - Companion backend PR: `ai_ml_operations/chat_backend` branch `capacitor-cors-csrf-22` (CORS/CSRF Capacitor origins) ## Test plan - [x] Unit tests: `tokenStorage`, `nativePlatform`, `jwtHelpers`, Auth/SignIn/WebSocket (`npm run test:ci`) - [ ] Login from Capacitor Android (`https://localhost`) and iOS (`capacitor://localhost`) - [ ] Token refresh after access expiry; logout blacklist; password reset; 401 → sign-in - [ ] Confirm browser build at `chat.aimloperations.com` unchanged - [ ] Merge companion backend PR so prod CORS includes Capacitor origins when `CORS_ORIGIN_ALLOW_ALL=false`Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
Generated
-9
@@ -23,7 +23,6 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"chroma-js": "^3.1.2",
|
||||
"formik": "^2.4.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-to-jsx": "^7.7.2",
|
||||
@@ -12814,14 +12813,6 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"chroma-js": "^3.1.2",
|
||||
"formik": "^2.4.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-to-jsx": "^7.7.2",
|
||||
|
||||
+102
-57
@@ -1,17 +1,31 @@
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
authorizationHeader,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
} from "./llm-fe/auth/tokenStorage";
|
||||
import {
|
||||
decodeJwtPayload,
|
||||
isTokenNotValidError,
|
||||
} from "./llm-fe/auth/jwtHelpers";
|
||||
import { redirectToAppPath } from "./llm-fe/platform/nativePlatform";
|
||||
|
||||
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
|
||||
|
||||
/**
|
||||
* Shared JWT axios client — Authorization bearer only, no cookies/CSRF (#22).
|
||||
* Works for browser + Capacitor WebView origins (https://localhost / capacitor://localhost).
|
||||
*/
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
Authorization: "JWT " + localStorage.getItem("access_token"),
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
export const cleanAxiosInstance = axios.create({
|
||||
@@ -21,77 +35,108 @@ export const cleanAxiosInstance = axios.create({
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
export const axiosInstanceCSRF = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"X-CSRFToken": Cookies.get("csrftoken"), // Include CSRF token in headers
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
/**
|
||||
* Apply access token to axios defaults (login / refresh).
|
||||
* @param {string|null} access
|
||||
*/
|
||||
export function applyAccessToken(access) {
|
||||
const header = authorizationHeader(access);
|
||||
if (header) {
|
||||
axiosInstance.defaults.headers.common["Authorization"] = header;
|
||||
} else {
|
||||
delete axiosInstance.defaults.headers.common["Authorization"];
|
||||
}
|
||||
}
|
||||
|
||||
applyAccessToken(getAccessToken());
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
config.timeout = 100000;
|
||||
config.withCredentials = false;
|
||||
const header = authorizationHeader(getAccessToken());
|
||||
if (header) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = header;
|
||||
} else if (config.headers) {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<{ access: string, refresh?: string }>}
|
||||
*/
|
||||
export async function refreshAccessToken(refreshToken) {
|
||||
const response = await axiosInstance.post("/token/refresh/", {
|
||||
refresh: refreshToken,
|
||||
});
|
||||
await setTokens(response.data.access, response.data.refresh ?? refreshToken);
|
||||
applyAccessToken(response.data.access);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export { decodeJwtPayload, isTokenNotValidError };
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Prevent infinite loop
|
||||
if (
|
||||
error.response.status === 401 &&
|
||||
originalRequest.url === baseURL + "/token/refresh/"
|
||||
) {
|
||||
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
|
||||
redirectToAppPath("/signin/");
|
||||
//console.log('remove the local storage here')
|
||||
if (!error.response || !originalRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (
|
||||
error.response.data.code === "token_not_valid" &&
|
||||
error.response.status === 401 &&
|
||||
error.response.statusText === "Unauthorized"
|
||||
) {
|
||||
const refresh_token = localStorage.getItem("refresh_token");
|
||||
const requestUrl = originalRequest.url || "";
|
||||
const isRefreshCall =
|
||||
requestUrl.includes("/token/refresh/") ||
|
||||
requestUrl === `${baseURL}/token/refresh/`;
|
||||
|
||||
if (refresh_token) {
|
||||
const tokenParts = JSON.parse(atob(refresh_token.split(".")[1]));
|
||||
|
||||
const now = Math.ceil(Date.now() / 1000);
|
||||
//console.log(tokenParts.exp)
|
||||
|
||||
if (tokenParts.exp > now) {
|
||||
return axiosInstance
|
||||
.post("/token/refresh/", { refresh: refresh_token })
|
||||
.then((response) => {
|
||||
localStorage.setItem("access_token", response.data.access);
|
||||
localStorage.setItem("refresh_token", response.data.refresh);
|
||||
|
||||
axiosInstance.defaults.headers["Authorization"] =
|
||||
"JWT " + response.data.access;
|
||||
originalRequest.headers["Authorization"] =
|
||||
"JWT " + response.data.access;
|
||||
|
||||
return axiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
} else {
|
||||
console.log("Refresh token is expired");
|
||||
redirectToAppPath("/signin/");
|
||||
}
|
||||
} else {
|
||||
console.log("Refresh token not available");
|
||||
redirectToAppPath("/signin/");
|
||||
}
|
||||
if (error.response.status === 401 && isRefreshCall) {
|
||||
await clearTokens();
|
||||
applyAccessToken(null);
|
||||
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
|
||||
redirectToAppPath("/signin/");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isTokenNotValidError(error) && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
const refresh_token = getRefreshToken();
|
||||
|
||||
if (!refresh_token) {
|
||||
await clearTokens();
|
||||
applyAccessToken(null);
|
||||
redirectToAppPath("/signin/");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const tokenParts = decodeJwtPayload(refresh_token);
|
||||
const now = Math.ceil(Date.now() / 1000);
|
||||
|
||||
if (tokenParts?.exp != null && tokenParts.exp > now) {
|
||||
try {
|
||||
const data = await refreshAccessToken(refresh_token);
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = authorizationHeader(
|
||||
data.access,
|
||||
);
|
||||
return axiosInstance(originalRequest);
|
||||
} catch (err) {
|
||||
await clearTokens();
|
||||
applyAccessToken(null);
|
||||
redirectToAppPath("/signin/");
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
await clearTokens();
|
||||
applyAccessToken(null);
|
||||
redirectToAppPath("/signin/");
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Pure JWT auth helpers (no axios) so unit tests avoid ESM transform issues.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode JWT payload without verifying signature (client-side checks only).
|
||||
* @param {string} token
|
||||
* @returns {{ exp?: number } | null}
|
||||
*/
|
||||
export function decodeJwtPayload(token) {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
||||
return JSON.parse(atob(padded));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ response?: { status?: number, data?: { code?: string } } }} error
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTokenNotValidError(error) {
|
||||
const status = error.response?.status;
|
||||
const code = error.response?.data?.code;
|
||||
return status === 401 && code === "token_not_valid";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { decodeJwtPayload, isTokenNotValidError } from './jwtHelpers';
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
authorizationHeader,
|
||||
clearTokens,
|
||||
setTokens,
|
||||
} from './tokenStorage';
|
||||
|
||||
describe('jwtHelpers', () => {
|
||||
it('decodeJwtPayload reads exp claim', () => {
|
||||
const payload = btoa(JSON.stringify({ exp: 1999999999 }));
|
||||
const token = `h.${payload}.s`;
|
||||
expect(decodeJwtPayload(token)).toEqual({ exp: 1999999999 });
|
||||
});
|
||||
|
||||
it('decodeJwtPayload returns null for garbage', () => {
|
||||
expect(decodeJwtPayload('not-a-jwt')).toBeNull();
|
||||
});
|
||||
|
||||
it('isTokenNotValidError detects SimpleJWT shape', () => {
|
||||
expect(
|
||||
isTokenNotValidError({
|
||||
response: { status: 401, data: { code: 'token_not_valid' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTokenNotValidError({
|
||||
response: { status: 403, data: { code: 'token_not_valid' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JWT-only auth contract (#22)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores tokens without cookies and builds Authorization header', async () => {
|
||||
await setTokens('access-xyz', 'refresh-xyz');
|
||||
expect(document.cookie).not.toMatch(/csrftoken/);
|
||||
expect(authorizationHeader()).toBe('JWT access-xyz');
|
||||
await clearTokens();
|
||||
expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull();
|
||||
expect(authorizationHeader()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Header-only JWT storage for web + Capacitor WebViews (#22).
|
||||
*
|
||||
* localStorage is the sync source of truth (axios interceptors).
|
||||
* When Capacitor Preferences is present, tokens are mirrored there so native
|
||||
* builds do not depend on third-party cookies (blocked in WKWebView).
|
||||
*/
|
||||
|
||||
export const ACCESS_TOKEN_KEY = "access_token";
|
||||
export const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/**
|
||||
* @returns {{ get?: Function, set?: Function, remove?: Function } | undefined}
|
||||
*/
|
||||
function getPreferencesPlugin() {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return window.Capacitor?.Plugins?.Preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getToken(key) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
return getToken(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getRefreshToken() {
|
||||
return getToken(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist tokens to localStorage and best-effort mirror to Capacitor Preferences.
|
||||
* @param {string} access
|
||||
* @param {string} [refresh]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function setTokens(access, refresh) {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
||||
if (refresh != null) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||||
}
|
||||
}
|
||||
|
||||
const Preferences = getPreferencesPlugin();
|
||||
if (!Preferences?.set) {
|
||||
return;
|
||||
}
|
||||
|
||||
const writes = [Preferences.set({ key: ACCESS_TOKEN_KEY, value: access })];
|
||||
if (refresh != null) {
|
||||
writes.push(Preferences.set({ key: REFRESH_TOKEN_KEY, value: refresh }));
|
||||
}
|
||||
await Promise.all(writes.map((p) => Promise.resolve(p).catch(() => undefined)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tokens from localStorage and Preferences.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function clearTokens() {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
const Preferences = getPreferencesPlugin();
|
||||
if (!Preferences?.remove) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY].map((key) =>
|
||||
Promise.resolve(Preferences.remove({ key })).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* On native, hydrate localStorage from Preferences if local keys are empty.
|
||||
* Safe no-op on web / when Preferences missing.
|
||||
* @returns {Promise<{ access: string|null, refresh: string|null }>}
|
||||
*/
|
||||
export async function hydrateTokensFromNativeStorage() {
|
||||
const Preferences = getPreferencesPlugin();
|
||||
if (!Preferences?.get) {
|
||||
return { access: getAccessToken(), refresh: getRefreshToken() };
|
||||
}
|
||||
|
||||
let access = getAccessToken();
|
||||
let refresh = getRefreshToken();
|
||||
|
||||
if (!access) {
|
||||
try {
|
||||
const result = await Preferences.get({ key: ACCESS_TOKEN_KEY });
|
||||
if (result?.value) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, result.value);
|
||||
access = result.value;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (!refresh) {
|
||||
try {
|
||||
const result = await Preferences.get({ key: REFRESH_TOKEN_KEY });
|
||||
if (result?.value) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, result.value);
|
||||
refresh = result.value;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return { access, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization header value for SimpleJWT (`AUTH_HEADER_TYPES`: JWT).
|
||||
* @param {string|null|undefined} access
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function authorizationHeader(access = getAccessToken()) {
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
return `JWT ${access}`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
REFRESH_TOKEN_KEY,
|
||||
authorizationHeader,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
hydrateTokensFromNativeStorage,
|
||||
setTokens,
|
||||
} from './tokenStorage';
|
||||
|
||||
describe('tokenStorage', () => {
|
||||
const originalCapacitor = window.Capacitor;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
delete window.Capacitor;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCapacitor === undefined) {
|
||||
delete window.Capacitor;
|
||||
} else {
|
||||
window.Capacitor = originalCapacitor;
|
||||
}
|
||||
});
|
||||
|
||||
it('stores and reads tokens from localStorage', async () => {
|
||||
await setTokens('access-1', 'refresh-1');
|
||||
expect(getAccessToken()).toBe('access-1');
|
||||
expect(getRefreshToken()).toBe('refresh-1');
|
||||
expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBe('access-1');
|
||||
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe('refresh-1');
|
||||
});
|
||||
|
||||
it('clears tokens from localStorage', async () => {
|
||||
await setTokens('a', 'r');
|
||||
await clearTokens();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(getRefreshToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('builds JWT Authorization header', () => {
|
||||
expect(authorizationHeader('tok')).toBe('JWT tok');
|
||||
expect(authorizationHeader(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('mirrors tokens to Capacitor Preferences when available', async () => {
|
||||
const set = jest.fn().mockResolvedValue(undefined);
|
||||
window.Capacitor = {
|
||||
Plugins: {
|
||||
Preferences: { set, get: jest.fn(), remove: jest.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
await setTokens('access-n', 'refresh-n');
|
||||
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
key: ACCESS_TOKEN_KEY,
|
||||
value: 'access-n',
|
||||
});
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
key: REFRESH_TOKEN_KEY,
|
||||
value: 'refresh-n',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates localStorage from Preferences when empty', async () => {
|
||||
const get = jest.fn(async ({ key }) => {
|
||||
if (key === ACCESS_TOKEN_KEY) {
|
||||
return { value: 'pref-access' };
|
||||
}
|
||||
if (key === REFRESH_TOKEN_KEY) {
|
||||
return { value: 'pref-refresh' };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
window.Capacitor = {
|
||||
Plugins: { Preferences: { get, set: jest.fn(), remove: jest.fn() } },
|
||||
};
|
||||
|
||||
const result = await hydrateTokensFromNativeStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
access: 'pref-access',
|
||||
refresh: 'pref-refresh',
|
||||
});
|
||||
expect(getAccessToken()).toBe('pref-access');
|
||||
expect(getRefreshToken()).toBe('pref-refresh');
|
||||
});
|
||||
|
||||
it('does not overwrite existing localStorage on hydrate', async () => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, 'local-access');
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, 'local-refresh');
|
||||
const get = jest.fn();
|
||||
window.Capacitor = {
|
||||
Plugins: { Preferences: { get, set: jest.fn(), remove: jest.fn() } },
|
||||
};
|
||||
|
||||
const result = await hydrateTokensFromNativeStorage();
|
||||
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
access: 'local-access',
|
||||
refresh: 'local-refresh',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import React, { useContext } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { axiosInstance } from '../../../axiosApi';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import { AccountBox, Dashboard, Feedback, Logout } from '@mui/icons-material';
|
||||
|
||||
@@ -19,12 +20,11 @@ const Header = ({drawerWidth=0, handleDrawerToggle}: HeaderProps): JSX.Element =
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try{
|
||||
const response = await axiosInstance.post('blacklist/',{
|
||||
'refresh_token': localStorage.getItem("refresh_token")
|
||||
await axiosInstance.post('blacklist/',{
|
||||
'refresh_token': getRefreshToken()
|
||||
})
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
axiosInstance.defaults.headers['Authorization'] = null;
|
||||
await clearTokens();
|
||||
applyAccessToken(null);
|
||||
setAuthentication(false)
|
||||
setAccount(undefined);
|
||||
navigate('/signin/')
|
||||
|
||||
@@ -3,7 +3,8 @@ import styled, { useTheme } from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { axiosInstance } from '../../../axiosApi';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||
|
||||
const HeaderContainer = styled.header`
|
||||
position: absolute;
|
||||
@@ -129,11 +130,10 @@ const Header2 = ({ absolute = false, light = false, isMini = false }: Header2Pro
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await axiosInstance.post('blacklist/', {
|
||||
'refresh_token': localStorage.getItem("refresh_token")
|
||||
'refresh_token': getRefreshToken()
|
||||
})
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
axiosInstance.defaults.headers['Authorization'] = null;
|
||||
await clearTokens();
|
||||
applyAccessToken(null);
|
||||
setAuthentication(false)
|
||||
setAccount(undefined);
|
||||
navigate('/signin/')
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { createContext, ReactNode, useState, useEffect } from "react"
|
||||
import {
|
||||
getAccessToken,
|
||||
hydrateTokensFromNativeStorage,
|
||||
} from "../auth/tokenStorage";
|
||||
|
||||
type AuthProviderProps = {
|
||||
children?: ReactNode;
|
||||
@@ -31,27 +35,33 @@ const AuthProvider = ({children}: AuthProviderProps) => {
|
||||
const [loading, setLoading] = useState(true); // Add a loading state
|
||||
|
||||
useEffect(() => {
|
||||
//console.log('we are in the auth provider')
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (accessToken) {
|
||||
const decodedToken = jwtDecode(accessToken)
|
||||
//console.log(decodedToken)
|
||||
if(decodedToken.exp){
|
||||
//console.log(decodedToken.exp * 1000)
|
||||
//console.log(Date.now())
|
||||
if (decodedToken.exp * 1000> Date.now()) {
|
||||
//console.log('We are setting that we are authenticated')
|
||||
setAuthentication(true);
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const bootstrap = async () => {
|
||||
await hydrateTokensFromNativeStorage();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
try {
|
||||
const decodedToken = jwtDecode(accessToken)
|
||||
if (decodedToken.exp && decodedToken.exp * 1000 > Date.now()) {
|
||||
setAuthentication(true);
|
||||
}
|
||||
} catch {
|
||||
/* invalid token — stay unauthenticated */
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [])
|
||||
//console.log(authenticated)
|
||||
const [ needsNewPassword, setNeedsNewPassword] = useState(initialValues.needsNewPassword)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AccountContext } from "./AccountContext";
|
||||
import { axiosInstance } from "../../axiosApi";
|
||||
import { applyAccessToken, axiosInstance } from "../../axiosApi";
|
||||
import { setTokens } from "../auth/tokenStorage";
|
||||
import {
|
||||
ConnectionStatus,
|
||||
DEFAULT_RECONNECT_CONFIG,
|
||||
@@ -183,8 +184,11 @@ function WebSocketProvider({ children }) {
|
||||
const response = await axiosInstance.post("/token/refresh/", {
|
||||
refresh,
|
||||
});
|
||||
axiosInstance.defaults.headers["Authorization"] =
|
||||
"JWT " + response.data.access;
|
||||
await setTokens(
|
||||
response.data.access,
|
||||
response.data.refresh ?? refresh,
|
||||
);
|
||||
applyAccessToken(response.data.access);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,8 +10,15 @@ import {
|
||||
jest.mock('../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
post: jest.fn(),
|
||||
defaults: { headers: {} },
|
||||
defaults: { headers: { common: {} } },
|
||||
},
|
||||
applyAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../auth/tokenStorage', () => ({
|
||||
setTokens: jest.fn().mockResolvedValue(undefined),
|
||||
getAccessToken: jest.fn(() => null),
|
||||
getRefreshToken: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
|
||||
@@ -15,8 +15,9 @@ jest.mock('../../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
defaults: { headers: {} as Record<string, string | null> },
|
||||
defaults: { headers: { common: {} as Record<string, string | null> } },
|
||||
},
|
||||
applyAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
const renderSignIn = () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { axiosInstance } from '../../../axiosApi';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { setTokens } from '../../auth/tokenStorage';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
@@ -148,9 +149,8 @@ const SignIn = (): JSX.Element => {
|
||||
password: password,
|
||||
|
||||
})
|
||||
axiosInstance.defaults.headers['Authorization'] = 'JWT ' + response.data.access;
|
||||
localStorage.setItem('access_token', response.data.access);
|
||||
localStorage.setItem('refresh_token', response.data.refresh);
|
||||
await setTokens(response.data.access, response.data.refresh);
|
||||
applyAccessToken(response.data.access);
|
||||
|
||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function isNativePlatform() {
|
||||
|
||||
/**
|
||||
* Absolute in-app path for full page navigations.
|
||||
* Native Capacitor builds use HashRouter; web keeps BrowserRouter paths.
|
||||
* Native Capacitor builds use HashRouter (#24); web keeps BrowserRouter paths.
|
||||
*
|
||||
* @param {string} path e.g. "/signin/"
|
||||
* @returns {string}
|
||||
|
||||
Vendored
+7
-5
@@ -1,16 +1,18 @@
|
||||
/// <reference types="react-scripts" />
|
||||
|
||||
interface CapacitorPreferencesPlugin {
|
||||
get?: (options: { key: string }) => Promise<{ value?: string | null }>;
|
||||
set?: (options: { key: string; value: string }) => Promise<void>;
|
||||
remove?: (options: { key: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
interface CapacitorBridge {
|
||||
isNativePlatform?: () => boolean;
|
||||
isNative?: boolean;
|
||||
Plugins?: {
|
||||
Preferences?: CapacitorPreferencesPlugin;
|
||||
App?: { addListener?: (...args: unknown[]) => unknown };
|
||||
Network?: { addListener?: (...args: unknown[]) => unknown };
|
||||
Preferences?: {
|
||||
get?: (options: { key: string }) => Promise<{ value?: string | null }>;
|
||||
set?: (options: { key: string; value: string }) => Promise<void>;
|
||||
remove?: (options: { key: string }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user