Compare commits

..
2 Commits
Author SHA1 Message Date
westfarn 8ac0b2a4ff Make auth JWT-only for Capacitor WebView origins (#22)
Unit Tests / test (pull_request) Successful in 11s
Drop CSRF cookie dependency (js-cookie), store tokens via localStorage with
optional Capacitor Preferences mirror, and attach Authorization on every
request so Android/iOS shells work without third-party cookies.
2026-07-26 16:06:28 -05:00
westfarn 0fc8739d26 Make routing work from a file-served Capacitor bundle (#24) (#27)
Unit Tests / test (push) Successful in 10s
## Summary
- Closes #24
- Introduce `AppRouter` + `getAppRouter()` so Capacitor uses `HashRouter` (filesystem bundle, no nginx rewrite) while web keeps `BrowserRouter`
- Isolate platform checks in `nativePlatform` (no `Capacitor.isNativePlatform()` sprinkled through the route tree)
- Auth forced redirects (`axiosApi` → `/signin/`) use hash-safe `redirectToAppPath` on native
- Leave CRA `homepage` / absolute `PUBLIC_URL` asset paths unchanged so `/var/www/{env}.chat.aimloperations/html` deploy stays intact; HashRouter keeps document URL at the bundle root so assets resolve

## Test plan
- [x] Unit tests: `AppRouter` + `nativePlatform` (`npm run test:ci`)
- [ ] Capacitor build: open nested route (`/#/account/`), force WebView reload — no blank/404
- [ ] Browser back/forward + Android hardware back with HashRouter
- [ ] Web deploy smoke: deep link reload at `/account/` still works via nginx rewrite
- [ ] Confirm login redirects after 401 still land on sign-in (web path + native hash)Reviewed-on: #27
2026-07-26 13:46:41 -07:00
5 changed files with 98 additions and 4 deletions
+1
View File
@@ -97,6 +97,7 @@ axiosInstance.interceptors.response.use(
if (error.response.status === 401 && isRefreshCall) { if (error.response.status === 401 && isRefreshCall) {
await clearTokens(); await clearTokens();
applyAccessToken(null); applyAccessToken(null);
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter.
redirectToAppPath("/signin/"); redirectToAppPath("/signin/");
return Promise.reject(error); return Promise.reject(error);
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import { BrowserRouter } from 'react-router-dom'; import AppRouter from './llm-fe/platform/AppRouter';
import { AuthProvider } from './llm-fe/contexts/AuthContext'; import { AuthProvider } from './llm-fe/contexts/AuthContext';
import { AccountProvider } from './llm-fe/contexts/AccountContext'; import { AccountProvider } from './llm-fe/contexts/AccountContext';
import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext'; import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext';
@@ -16,7 +16,7 @@ const root = ReactDOM.createRoot(
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <AppRouter>
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script> <script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm7x7m52m03kbddswbswrt17y"></script>
<AuthProvider> <AuthProvider>
<AccountProvider> <AccountProvider>
@@ -33,6 +33,6 @@ root.render(
</WebSocketProvider> </WebSocketProvider>
</AccountProvider> </AccountProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </AppRouter>
</React.StrictMode> </React.StrictMode>
); );
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import AppRouter from './AppRouter';
import { getAppRouter, isNativePlatform } from './nativePlatform';
describe('AppRouter / getAppRouter (#24)', () => {
const originalCapacitor = window.Capacitor;
afterEach(() => {
if (originalCapacitor === undefined) {
delete window.Capacitor;
} else {
window.Capacitor = originalCapacitor;
}
});
it('uses BrowserRouter on web', () => {
delete window.Capacitor;
expect(isNativePlatform()).toBe(false);
const Router = getAppRouter();
expect(Router.name).toBe('BrowserRouter');
});
it('uses HashRouter on native Capacitor', () => {
window.Capacitor = { isNativePlatform: () => true };
expect(isNativePlatform()).toBe(true);
const Router = getAppRouter();
expect(Router.name).toBe('HashRouter');
});
it('renders nested routes under HashRouter without blank screen', () => {
window.Capacitor = { isNativePlatform: () => true };
window.location.hash = '#/account/';
render(
<AppRouter>
<Routes>
<Route path="/account/" element={<div>Account Page</div>} />
<Route path="/" element={<div>Home</div>} />
</Routes>
</AppRouter>,
);
expect(screen.getByText('Account Page')).toBeInTheDocument();
});
it('renders nested routes under BrowserRouter on web', () => {
delete window.Capacitor;
window.history.pushState({}, '', '/account/');
render(
<AppRouter>
<Routes>
<Route path="/account/" element={<div>Account Page</div>} />
<Route path="/" element={<div>Home</div>} />
</Routes>
</AppRouter>,
);
expect(screen.getByText('Account Page')).toBeInTheDocument();
});
});
+17
View File
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import { getAppRouter } from './nativePlatform';
type AppRouterProps = {
children: ReactNode;
};
/**
* Single place that chooses BrowserRouter (web) vs HashRouter (Capacitor).
* Avoids sprinkling Capacitor.isNativePlatform() through the route tree (#24).
*/
const AppRouter = ({ children }: AppRouterProps): JSX.Element => {
const Router = getAppRouter();
return <Router>{children}</Router>;
};
export default AppRouter;
+14 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Capacitor / WebView platform helpers. * Capacitor / WebView platform helpers (#24 / #22).
* Prefer window.Capacitor so unit tests and web builds need no Capacitor package. * Prefer window.Capacitor so unit tests and web builds need no Capacitor package.
*/ */
@@ -50,3 +50,16 @@ export function redirectToAppPath(path) {
} }
window.location.href = appHref(path); window.location.href = appHref(path);
} }
/**
* Router class for the current runtime.
* HashRouter on Capacitor so nested routes reload without a server rewrite.
* BrowserRouter on web (nginx already rewrites to index.html).
*
* @returns {typeof import('react-router-dom').HashRouter | typeof import('react-router-dom').BrowserRouter}
*/
export function getAppRouter() {
// Lazy require keeps this module usable from axios (no React import cycle in tests).
const { BrowserRouter, HashRouter } = require("react-router-dom");
return isNativePlatform() ? HashRouter : BrowserRouter;
}