Compare commits

...
Author SHA1 Message Date
james.maes 09c720f18c Fix ESLint: Use double quotes instead of single quotes in PathUtils
- Change 'script[src]' to "script[src]" on line 88
- Change '/static/' to "/static/" on line 92

Fixes ESLint errors requiring double quotes for string literals.
2025-11-15 06:11:00 -06:00
james.maes 3208704053 Add .vscode to .gitignore 2025-11-15 06:04:41 -06:00
james.maes 3da3c96edd Fix cookie paths in App.tsx and HandleAuthorizationError to use base path
- App.tsx: Fix cookie removal on 401 error to use detectBasePath()
- HandleAuthorizationError: Fix cookie removal to use detectBasePath()

Ensures cookies are properly scoped to the SPA's base path when hosted
at sub-paths, preventing cookie conflicts between multiple SPAs.
2025-11-15 06:04:41 -06:00
james.maes 858999119c Fix authentication modules to use detected base path instead of hardcoded root
OAuth2 Module:
- Fix /token path check to use basePath + /token
- Fix redirect_uri to include base path (e.g., /admin/token)
- Fix cookie path to use basePath instead of '/'
- Fix navigation fallback to use basePath instead of '/'

Auth0 Module:
- Fix redirectUri to include base path
- Fix cookie path to use basePath instead of '/'

Anonymous Module:
- Fix cookie paths (setCookie and removeCookie) to use basePath

These changes ensure authentication works correctly when the SPA is
hosted at sub-paths like /admin or /dashboard, not just at root (/).
2025-11-15 06:04:41 -06:00
james.maes a181e30eef Improve base path detection to handle relative paths and edge cases
- Add normalizeBasePath() helper to ensure paths always start with '/'
- Reorder detection strategies: script detection first (most reliable)
- Add fallback to check all script tags if currentScript unavailable
- Add documentation explaining compatibility with homepage: '.' in package.json
- Improve detection reliability for edge cases like './' or empty paths

This ensures base path detection works correctly even when HTML uses
relative paths (homepage: '.') because browsers resolve them to absolute
URLs before the script executes.
2025-11-15 06:04:39 -06:00
7 changed files with 105 additions and 34 deletions

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
src/main/resources/material-dashboard/
**/.idea*
.vscode
*.iml
.yalc*
yalc.lock
+3 -2
View File
@@ -48,7 +48,7 @@ import ProcessRun from "qqq/pages/processes/ProcessRun";
import ReportRun from "qqq/pages/processes/ReportRun";
import EntityCreate from "qqq/pages/records/create/RecordCreate";
import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView";
import {resolveAssetUrl} from "qqq/utils/PathUtils";
import {detectBasePath, resolveAssetUrl} from "qqq/utils/PathUtils";
import EntityEdit from "qqq/pages/records/edit/RecordEdit";
import RecordQuery from "qqq/pages/records/query/RecordQuery";
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
@@ -558,7 +558,8 @@ export default function App({authenticationMetaData}: Props)
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
doLogout();
return;
+3 -1
View File
@@ -23,6 +23,7 @@ import {useAuth0} from "@auth0/auth0-react";
import React, {useEffect} from "react";
import {useCookies} from "react-cookie";
import {SESSION_UUID_COOKIE_NAME} from "App";
import {detectBasePath} from "qqq/utils/PathUtils";
interface Props
{
@@ -39,7 +40,8 @@ function HandleAuthorizationError({errorMessage}: Props)
useEffect(() =>
{
logout();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
});
return (
@@ -22,6 +22,7 @@
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {detectBasePath} from "qqq/utils/PathUtils";
import {useCookies} from "react-cookie";
import {Md5} from "ts-md5/dist/md5";
@@ -49,7 +50,8 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
console.log("Generating random token...");
setIsFullyAuthenticated(true);
Client.setGotAuthenticationInAllControllers();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
const basePath = detectBasePath();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: basePath});
console.log("Token generation complete.");
};
@@ -60,7 +62,8 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
};
@@ -26,6 +26,7 @@ import jwt_decode from "jwt-decode";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import {detectBasePath} from "qqq/utils/PathUtils";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
@@ -142,7 +143,8 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
useAuth0Logout();
return;
}
@@ -232,12 +234,14 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
return <App authenticationMetaData={authenticationMetaData} />
}
const basePath = detectBasePath();
const redirectUri = `${window.location.origin}${basePath}`;
return (
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}>
redirectUri={redirectUri}>
<MaterialUIControllerProvider>
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
@@ -22,6 +22,7 @@
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {detectBasePath} from "qqq/utils/PathUtils";
import {useCookies} from "react-cookie";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
@@ -59,8 +60,10 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
{
try
{
const basePath = detectBasePath();
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
if (window.location.pathname == "/token")
const tokenPath = basePath === "/" ? "/token" : `${basePath}/token`;
if (window.location.pathname === tokenPath)
{
///////////////////////////////////////////////////////////////////////////
// if we're at a path of /token, get code & state params, look up values //
@@ -87,7 +90,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true});
navigate(preSigninRedirectPathname ?? basePath, {replace: true});
}
else
{
@@ -143,7 +146,8 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
const basePath = detectBasePath();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: basePath});
authOidc?.signoutRedirect();
};
@@ -163,11 +167,13 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
);
}
const basePath = detectBasePath();
const tokenPath = basePath === "/" ? "/token" : `${basePath}/token`;
const oidcConfig =
{
authority: authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/token`,
redirect_uri: `${window.location.origin}${tokenPath}`,
response_type: "code",
scope: "openid profile email offline_access",
};
+77 -23
View File
@@ -19,36 +19,58 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Normalizes a path to always be an absolute path starting with "/".
* Handles edge cases like "./", "", or paths without leading slash.
*
* @param path The path to normalize
* @returns An absolute path starting with "/"
*/
function normalizeBasePath(path: string): string
{
if (!path || path === "" || path === "./" || path === ".")
{
return "/";
}
// Ensure it starts with "/"
if (!path.startsWith("/"))
{
return "/" + path.replace(/^\.\//, "").replace(/\/$/, "");
}
// Ensure it doesn't end with "/" (except for root)
if (path !== "/" && path.endsWith("/"))
{
return path.slice(0, -1);
}
return path;
}
/**
* Detects the base path where the SPA is running.
* Works for both root and sub-paths like /admin, /dashboard, etc.
*
* This function works correctly with `homepage: "."` in package.json because:
* - Even though HTML may reference assets relatively (./static/js/main.js),
* - The browser resolves these to absolute URLs when loading
* - document.currentScript.src will always contain the full absolute URL
*
* Detection strategy (in order of reliability):
* 1. Extract from current script location (most reliable)
* 2. Extract from known path patterns in current pathname
* 3. Default to / (root)
* 1. Extract from current script location (most reliable - works even with relative paths in HTML)
* 2. Extract from all script tags as fallback
* 3. Extract from known path patterns in current pathname
* 4. Default to / (root)
*
* @returns The detected base path (e.g., "/admin", "/", "/dashboard")
* Always returns an absolute path starting with "/"
*/
export function detectBasePath(): string
{
// Strategy 1: Check current URL path first (most reliable for dev and prod)
const currentPath = window.location.pathname;
const parts = currentPath.split("/").filter(p => p);
// If first part is a known SPA path like "admin", "dashboard", etc., use it
if (parts.length > 0)
{
const firstPart = parts[0];
// You can add more known paths here as needed
if (["admin", "dashboard", "app", "manager", "console"].includes(firstPart))
{
return "/" + firstPart;
}
}
// Strategy 2: Try to extract from the current script's source
// When served from /admin/static/js/main.abc123.js, extract /admin
// Strategy 1: Try to extract from the current script's source (most reliable)
// Works even when homepage: "." uses relative paths because browser resolves them to absolute URLs
// Example: HTML has "./static/js/main.js" but browser resolves to "http://localhost:8000/admin/static/js/main.js"
if (document.currentScript && document.currentScript instanceof HTMLScriptElement)
{
const src = document.currentScript.src;
@@ -57,12 +79,44 @@ export function detectBasePath(): string
const match = src.match(/^https?:\/\/[^\/]+([\/\w-]*?)\/static\//);
if (match && match[1])
{
return match[1];
return normalizeBasePath(match[1]);
}
}
// Default to root
return "/";
// Strategy 2: Fallback - try to extract from any script tag with /static/ in the path
// This handles cases where currentScript might not be available
const scripts = document.querySelectorAll("script[src]");
for (let i = 0; i < scripts.length; i++)
{
const script = scripts[i] as HTMLScriptElement;
if (script.src && script.src.includes("/static/"))
{
const match = script.src.match(/^https?:\/\/[^\/]+([\/\w-]*?)\/static\//);
if (match && match[1])
{
return normalizeBasePath(match[1]);
}
}
}
// Strategy 3: Extract from current URL pathname
// Check if first part of pathname matches known SPA paths
const currentPath = window.location.pathname;
const parts = currentPath.split("/").filter(p => p);
if (parts.length > 0)
{
const firstPart = parts[0];
// You can add more known paths here as needed
if (["admin", "dashboard", "app", "manager", "console"].includes(firstPart))
{
return normalizeBasePath("/" + firstPart);
}
}
// Strategy 4: Default to root
// This handles the case where SPA is served from root (/)
return normalizeBasePath("/");
}
/**