diff --git a/llm-fe/package-lock.json b/llm-fe/package-lock.json index 9e011ea..e95b38f 100644 --- a/llm-fe/package-lock.json +++ b/llm-fe/package-lock.json @@ -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", diff --git a/llm-fe/package.json b/llm-fe/package.json index a6714c1..dac056c 100644 --- a/llm-fe/package.json +++ b/llm-fe/package.json @@ -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", diff --git a/llm-fe/src/axiosApi.js b/llm-fe/src/axiosApi.js index 2b79527..0245b8a 100644 --- a/llm-fe/src/axiosApi.js +++ b/llm-fe/src/axiosApi.js @@ -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); }, ); diff --git a/llm-fe/src/llm-fe/auth/jwtHelpers.js b/llm-fe/src/llm-fe/auth/jwtHelpers.js new file mode 100644 index 0000000..d23e974 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/jwtHelpers.js @@ -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"; +} diff --git a/llm-fe/src/llm-fe/auth/jwtHelpers.test.js b/llm-fe/src/llm-fe/auth/jwtHelpers.test.js new file mode 100644 index 0000000..9c6e2d4 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/jwtHelpers.test.js @@ -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(); + }); +}); diff --git a/llm-fe/src/llm-fe/auth/tokenStorage.js b/llm-fe/src/llm-fe/auth/tokenStorage.js new file mode 100644 index 0000000..a4b1fd9 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/tokenStorage.js @@ -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} + */ +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} + */ +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}`; +} diff --git a/llm-fe/src/llm-fe/auth/tokenStorage.test.js b/llm-fe/src/llm-fe/auth/tokenStorage.test.js new file mode 100644 index 0000000..3f240e8 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/tokenStorage.test.js @@ -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', + }); + }); +}); diff --git a/llm-fe/src/llm-fe/components/Header/Header.tsx b/llm-fe/src/llm-fe/components/Header/Header.tsx index 17fa7d6..ccdba34 100644 --- a/llm-fe/src/llm-fe/components/Header/Header.tsx +++ b/llm-fe/src/llm-fe/components/Header/Header.tsx @@ -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/') diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.tsx index 701544d..9ca26d5 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.tsx @@ -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/') diff --git a/llm-fe/src/llm-fe/contexts/AuthContext.tsx b/llm-fe/src/llm-fe/contexts/AuthContext.tsx index dfe9110..986db47 100644 --- a/llm-fe/src/llm-fe/contexts/AuthContext.tsx +++ b/llm-fe/src/llm-fe/contexts/AuthContext.tsx @@ -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 ( @@ -61,4 +71,4 @@ const AuthProvider = ({children}: AuthProviderProps) => { ) } -export { AuthContext, AuthProvider } \ No newline at end of file +export { AuthContext, AuthProvider } diff --git a/llm-fe/src/llm-fe/contexts/WebSocketContext.js b/llm-fe/src/llm-fe/contexts/WebSocketContext.js index 6eeb84c..bd6cdb9 100644 --- a/llm-fe/src/llm-fe/contexts/WebSocketContext.js +++ b/llm-fe/src/llm-fe/contexts/WebSocketContext.js @@ -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; }, }); diff --git a/llm-fe/src/llm-fe/contexts/WebSocketContext.test.js b/llm-fe/src/llm-fe/contexts/WebSocketContext.test.js index fc7f3d2..7cf0705 100644 --- a/llm-fe/src/llm-fe/contexts/WebSocketContext.test.js +++ b/llm-fe/src/llm-fe/contexts/WebSocketContext.test.js @@ -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 { diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx index ae36c6e..dd4c2fd 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx @@ -15,8 +15,9 @@ jest.mock('../../../axiosApi', () => ({ axiosInstance: { post: (...args: unknown[]) => mockPost(...args), get: (...args: unknown[]) => mockGet(...args), - defaults: { headers: {} as Record }, + defaults: { headers: { common: {} as Record } }, }, + applyAccessToken: jest.fn(), })); const renderSignIn = () => { diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index b3ca830..f3d22bc 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -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 = await axiosInstance.get('/user/get/') diff --git a/llm-fe/src/llm-fe/platform/nativePlatform.js b/llm-fe/src/llm-fe/platform/nativePlatform.js index a976e3d..9273bf8 100644 --- a/llm-fe/src/llm-fe/platform/nativePlatform.js +++ b/llm-fe/src/llm-fe/platform/nativePlatform.js @@ -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} diff --git a/llm-fe/src/react-app-env.d.ts b/llm-fe/src/react-app-env.d.ts index 2e4afca..dd46596 100644 --- a/llm-fe/src/react-app-env.d.ts +++ b/llm-fe/src/react-app-env.d.ts @@ -1,16 +1,18 @@ /// +interface CapacitorPreferencesPlugin { + get?: (options: { key: string }) => Promise<{ value?: string | null }>; + set?: (options: { key: string; value: string }) => Promise; + remove?: (options: { key: string }) => Promise; +} + 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; - remove?: (options: { key: string }) => Promise; - }; }; }