Merge remote-tracking branch 'origin/main' into SaaS

# Conflicts:
#	frontend/editor/src/core/components/shared/AppConfigModal.tsx
This commit is contained in:
Anthony Stirling
2026-06-10 10:51:06 +01:00
143 changed files with 28540 additions and 984 deletions
@@ -8,6 +8,7 @@ import {
} from "@app/auth/springAuthClient";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
import apiClient from "@app/services/apiClient";
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
import {
AxiosError,
type AxiosResponse,
@@ -89,9 +90,18 @@ describe("SpringAuthClient", () => {
);
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
// A 401 from /me triggers an explicit refresh attempt before treating
// the session as invalid; lock that recovery contract in so future
// refactors can't quietly skip it.
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/auth/refresh",
null,
expect.any(Object),
);
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
// 401 is handled gracefully, so error should be null
@@ -117,9 +127,18 @@ describe("SpringAuthClient", () => {
);
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
// A 403 from /me triggers an explicit refresh attempt before treating
// the session as invalid; lock that recovery contract in so future
// refactors can't quietly skip it.
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/auth/refresh",
null,
expect.any(Object),
);
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
// 403 is handled gracefully, so error should be null
@@ -129,6 +148,9 @@ describe("SpringAuthClient", () => {
describe("signInWithPassword", () => {
it("should successfully sign in with email and password", async () => {
// The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
// about defaults - incidental to what this test verifies.
allowConsole.warn(/Cannot decode token for adaptive intervals/);
const credentials = {
email: "[email protected]",
password: "password123",
@@ -176,6 +198,7 @@ describe("SpringAuthClient", () => {
});
it("should return error on failed login", async () => {
expectConsole.error(/\[SpringAuth\] signInWithPassword error/);
const credentials = {
email: "[email protected]",
password: "wrongpassword",
@@ -236,6 +259,7 @@ describe("SpringAuthClient", () => {
});
it("should return error on failed registration", async () => {
expectConsole.error(/\[SpringAuth\] signUp error/);
const credentials = {
email: "[email protected]",
password: "password123",
@@ -283,6 +307,7 @@ describe("SpringAuthClient", () => {
});
it("should clear JWT even if logout request fails", async () => {
expectConsole.error(/\[SpringAuth\] signOut error/);
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
@@ -301,6 +326,9 @@ describe("SpringAuthClient", () => {
describe("refreshSession", () => {
it("should refresh JWT token successfully", async () => {
// The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
// about defaults - incidental to what this test verifies.
allowConsole.warn(/Cannot decode token for adaptive intervals/);
const newToken = "refreshed-jwt-token";
const mockUser = {
id: "123",
@@ -397,22 +397,19 @@ class SpringAuthClient {
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] getSession error:", error);
// If 401/403, token is invalid - try explicit refresh
// 401/403 during getSession is the normal "token expired or invalid"
// path - handled via refresh + JWT clear.
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
// A 401 during startup can be a race with a concurrent refresh. Try one
// explicit refresh before treating the session as invalid.
const refreshResult = await this.refreshSession();
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem("stirling_jwt");
console.debug("[SpringAuth] getSession: Not authenticated");
return { data: { session: null }, error: null };
}
console.error("[SpringAuth] getSession error:", error);
// Don't clear token for other errors (e.g., backend not ready, network issues)
// The token is still valid, just can't verify it right now
return {
@@ -739,10 +736,11 @@ class SpringAuthClient {
return { data: { session }, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] refreshSession error:", error);
localStorage.removeItem("stirling_jwt");
// Handle different error statuses
// 401/403 means the refresh token is no longer valid - normal expired
// state, not an error worth surfacing. Other statuses (network, backend
// down) ARE worth logging.
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
return {
@@ -751,6 +749,7 @@ class SpringAuthClient {
};
}
console.error("[SpringAuth] refreshSession error:", error);
return {
data: { session: null },
error: { message: getErrorMessage(error, "Token refresh failed") },
@@ -1,19 +1,18 @@
import { Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useNavigate } from "react-router-dom";
export function OverviewHeader() {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate("/login");
} catch (error) {
console.error("Logout error:", error);
} finally {
window.location.assign("/login");
}
};
@@ -71,10 +71,6 @@ export const useConfigNavSections = (
"settings.tooltips.enableLoginFirst",
"Enable login mode first",
);
const requiresEnterpriseTooltip = t(
"settings.tooltips.requiresEnterprise",
"Requires Enterprise license",
);
// Workspace
sections.push({
@@ -199,10 +195,10 @@ export const useConfigNavSections = (
label: t("settings.licensingAnalytics.audit", "Audit"),
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
// Non-Enterprise users can still click in: AdminAuditSection
// renders a demo preview when `!hasEnterpriseLicense`.
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminUsage",
@@ -212,10 +208,9 @@ export const useConfigNavSections = (
),
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
// Same demo-preview story as adminAudit above.
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
@@ -441,20 +436,22 @@ export const createConfigNavSections = (
label: "Audit",
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
// Non-Enterprise users can click in to see the demo preview.
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
: undefined,
},
{
key: "adminUsage",
label: "Usage Analytics",
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
// Non-Enterprise users can click in to see the demo preview.
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
: undefined,
},
],
});
@@ -503,7 +503,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.advanced.enableAlphaFunctionality.label",
@@ -543,7 +543,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.advanced.enableUrlToPDF.label",
@@ -581,7 +581,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.advanced.disableSanitize.label",
@@ -959,7 +959,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.advanced.tempFileManagement.startupCleanup.label",
@@ -1002,7 +1002,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label",
@@ -603,7 +603,7 @@ export default function AdminConnectionsSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.connections.ssoAutoLogin.enable",
@@ -674,7 +674,7 @@ export default function AdminConnectionsSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.connections.mobileScanner.enable",
@@ -496,7 +496,7 @@ export default function AdminDatabaseSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.database.enableCustom.label",
@@ -200,7 +200,7 @@ export default function AdminFeaturesSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.features.serverCertificate.enabled.label",
@@ -316,7 +316,7 @@ export default function AdminFeaturesSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
@@ -703,7 +703,7 @@ export default function AdminGeneralSection() {
marginBottom: "1rem",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.general.hideDisabledTools.googleDrive.label",
@@ -747,7 +747,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.general.hideDisabledTools.mobileScanner.label",
@@ -793,7 +793,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.general.showUpdate.label",
@@ -832,7 +832,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.general.showUpdateOnlyAdmin.label",
@@ -873,7 +873,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.general.customHTMLFiles.label",
@@ -938,7 +938,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.general.customMetadata.autoUpdate.label",
@@ -197,7 +197,7 @@ export default function AdminPremiumSection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.premium.enabled.label",
@@ -165,7 +165,7 @@ export default function AdminPrivacySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.enableAnalytics.label",
@@ -203,7 +203,7 @@ export default function AdminPrivacySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.metricsEnabled.label",
@@ -253,7 +253,7 @@ export default function AdminPrivacySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.googleVisibility.label",
@@ -292,7 +292,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.enableLogin.label",
@@ -519,7 +519,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.jwt.persistence.label",
@@ -556,7 +556,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.jwt.enableKeyRotation.label",
@@ -596,7 +596,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.jwt.enableKeyCleanup.label",
@@ -780,7 +780,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.jwt.secureCookie.label",
@@ -840,7 +840,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.audit.enabled.label",
@@ -955,7 +955,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.audit.captureFileHash.label",
@@ -995,7 +995,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.audit.capturePdfAuthor.label",
@@ -1035,7 +1035,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.audit.captureOperationResults.label",
@@ -1097,7 +1097,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.htmlUrlSecurity.enabled.label",
@@ -1374,7 +1374,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label",
@@ -1424,7 +1424,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.htmlUrlSecurity.blockLocalhost.label",
@@ -1473,7 +1473,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.htmlUrlSecurity.blockLinkLocal.label",
@@ -1522,7 +1522,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label",
@@ -585,9 +585,12 @@ export default function PeopleSection() {
{t("workspace.people.user")}
</Table.Th>
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
whiteSpace: "nowrap",
}}
fz="sm"
w={100}
>
{t("workspace.people.role")}
</Table.Th>
@@ -690,7 +693,7 @@ export default function PeopleSection() {
</Box>
</Group>
</Table.Td>
<Table.Td w={100}>
<Table.Td style={{ whiteSpace: "nowrap" }}>
<Badge
size="sm"
variant="light"
@@ -699,6 +702,10 @@ export default function PeopleSection() {
? "blue"
: "cyan"
}
styles={{
root: { maxWidth: "none" },
label: { overflow: "visible" },
}}
>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin", "Admin")
@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { Stack, Text, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { loadStripe } from "@stripe/stripe-js";
@@ -8,9 +8,7 @@ import {
} from "@stripe/react-stripe-js";
import { PlanTier } from "@app/services/licenseService";
// Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface PaymentStageProps {
clientSecret: string | null;
@@ -24,6 +22,14 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
onPaymentComplete,
}) => {
const { t } = useTranslation();
// Load Stripe.js lazily, only when PaymentStage mounts. Loading at module
// scope pulled the Stripe script into every page (CheckoutContext is in
// AppProviders), which triggered the dev "HTTPS required" warning on every
// non-payment route.
const stripePromise = useMemo(
() => (STRIPE_KEY ? loadStripe(STRIPE_KEY) : null),
[],
);
// Show loading while creating checkout session
if (!clientSecret || !selectedPlan) {
@@ -4,6 +4,8 @@ import React, {
useState,
useCallback,
useEffect,
lazy,
Suspense,
ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
@@ -13,7 +15,12 @@ import licenseService, {
mapLicenseToTier,
PlanTier,
} from "@app/services/licenseService";
import { StripeCheckout } from "@app/components/shared/stripeCheckout";
const StripeCheckout = lazy(() =>
import("@app/components/shared/stripeCheckout").then((m) => ({
default: m.StripeCheckout,
})),
);
import { userManagementService } from "@app/services/userManagementService";
import { alert } from "@app/components/toast";
import {
@@ -433,16 +440,18 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
{/* Global Checkout Modal */}
{selectedPlanGroup && (
<StripeCheckout
opened={isOpen}
onClose={closeCheckout}
planGroup={selectedPlanGroup}
minimumSeats={minimumSeats}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
hostedCheckoutSuccess={hostedCheckoutSuccess}
/>
<Suspense fallback={null}>
<StripeCheckout
opened={isOpen}
onClose={closeCheckout}
planGroup={selectedPlanGroup}
minimumSeats={minimumSeats}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
hostedCheckoutSuccess={hostedCheckoutSuccess}
/>
</Suspense>
)}
</CheckoutContext.Provider>
);
@@ -6,6 +6,7 @@ import {
POST_LOGIN_REDIRECT_STORAGE_KEY,
springAuth,
} from "@app/auth/springAuthClient";
import { expectConsole } from "@app/tests/failOnConsole";
// Mock springAuth; keep the real redirect-path helpers.
vi.mock("@app/auth/springAuthClient", async () => {
@@ -90,6 +91,7 @@ describe("AuthCallback", () => {
});
it("should redirect to login when no access token in hash", async () => {
expectConsole.error(/\[AuthCallback\] No access_token in URL fragment/);
// No hash or empty hash
window.location.hash = "";
@@ -109,6 +111,7 @@ describe("AuthCallback", () => {
});
it("should redirect to login when token validation fails", async () => {
expectConsole.error(/\[AuthCallback\] Failed to validate token/);
const invalidToken = "invalid-oauth-token";
window.location.hash = `#access_token=${invalidToken}`;
@@ -137,6 +140,7 @@ describe("AuthCallback", () => {
});
it("should handle errors gracefully", async () => {
expectConsole.error(/\[AuthCallback\] Authentication failed/);
const mockToken = "error-token";
window.location.hash = `#access_token=${mockToken}`;
@@ -18,42 +18,16 @@ export default function AuthCallback() {
const navigate = useNavigate();
const processingRef = useRef(false);
// Log component lifecycle
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`);
return () => {
console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`);
};
}, []);
const startedAt = performance.now();
const elapsed = () => `${(performance.now() - startedAt).toFixed(0)}ms`;
useEffect(() => {
const handleCallback = async () => {
const startTime = performance.now();
const executionId = Math.random().toString(36).substring(7);
console.log(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
console.log(
`[AuthCallback:${executionId}] Starting authentication callback`,
);
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
console.log(
`[AuthCallback:${executionId}] Hash: ${window.location.hash}`,
);
console.log(
`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`,
);
if (
typeof window !== "undefined" &&
window.sessionStorage.getItem("stirling_sso_auto_login_logged_out") ===
"1"
) {
console.warn(
`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`,
);
navigate("/login", {
replace: true,
state: { error: "You have been signed out. Please sign in again." },
@@ -62,30 +36,16 @@ export default function AuthCallback() {
}
// Prevent double execution (React 18 Strict Mode + navigate dependency)
if (processingRef.current) {
console.warn(
`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`,
);
console.warn(
`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`,
);
return;
}
if (processingRef.current) return;
processingRef.current = true;
try {
console.log(
`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`,
);
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get("access_token");
const hash = window.location.hash.substring(1);
const token = new URLSearchParams(hash).get("access_token");
if (!token) {
console.error(
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
`[AuthCallback] No access_token in URL fragment (${elapsed()})`,
);
navigate("/login", {
replace: true,
@@ -94,39 +54,13 @@ export default function AuthCallback() {
return;
}
console.log(
`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`,
);
console.log(
`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`,
);
// Store JWT in localStorage
localStorage.setItem("stirling_jwt", token);
console.log(
`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`,
);
console.log(
`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`,
);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent("jwt-available"));
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
console.log(
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(
`[AuthCallback:${executionId}] Step 4: Validating token with backend`,
);
// Validate the token and load user info
// This calls /api/v1/auth/me with the JWT to get user details
const { data, error } = await springAuth.getSession();
if (error || !data.session) {
console.error(
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
`[AuthCallback] Failed to validate token (${elapsed()}):`,
error,
);
localStorage.removeItem("stirling_jwt");
@@ -137,68 +71,21 @@ export default function AuthCallback() {
return;
}
console.log(
`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`,
);
console.log(
`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`,
);
await handleAuthCallbackSuccess(token);
console.log(
`[AuthCallback:${executionId}] ✓ Callback handlers complete`,
);
console.log(
`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`,
);
// Wait for all context providers to process jwt-available event
// This prevents infinite render loop when coming from cross-domain SAML redirect
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
);
const target = consumePostLoginRedirectPath() ?? "/";
console.log(
`[AuthCallback:${executionId}] Step 7: Navigating to ${target}`,
console.info(
`[AuthCallback] Authenticated ${data.session.user.username} in ${elapsed()}, navigating to ${target}`,
);
navigate(target, { replace: true });
const duration = performance.now() - startTime;
console.log(
`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`,
);
console.log(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
} catch (error) {
const duration = performance.now() - startTime;
console.error(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
console.error(
`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`,
);
console.error(`[AuthCallback:${executionId}] Error:`, error);
console.error(
`[AuthCallback:${executionId}] Error name:`,
(error as Error)?.name,
);
console.error(
`[AuthCallback:${executionId}] Error message:`,
(error as Error)?.message,
);
console.error(
`[AuthCallback:${executionId}] Error stack:`,
(error as Error)?.stack,
);
console.error(
`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`,
);
console.error(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
`[AuthCallback] Authentication failed (${elapsed()}):`,
error,
);
navigate("/login", {
replace: true,
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
@@ -176,7 +176,7 @@ describe("Login", () => {
});
});
it("should show loading state while auth is loading", () => {
it("should show loading state while auth is loading", async () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
@@ -187,13 +187,15 @@ describe("Login", () => {
refreshSession: vi.fn(),
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await act(async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
});
// Component shouldn't redirect or show form while loading
expect(mockNavigate).not.toHaveBeenCalled();
@@ -510,26 +512,30 @@ describe("Login", () => {
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
});
it("should display session expired message from URL param", () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={["/login?expired=true"]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
it("should display session expired message from URL param", async () => {
await act(async () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={["/login?expired=true"]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
});
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
});
it("should display account created success message", () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
it("should display account created success message", async () => {
await act(async () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
});
expect(screen.getByText(/account created/i)).toBeInTheDocument();
});
@@ -287,8 +287,6 @@ export default function Login() {
setPostLoginRedirectPath(returnPath);
}
console.log(`[Login] Signing in with provider: ${provider}`);
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
const { error } = await springAuth.signInWithOAuth({
@@ -520,8 +518,6 @@ export default function Login() {
setError(null);
clearLogoutBlock();
console.log("[Login] Signing in with email:", email);
const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(),
password: password,
@@ -529,13 +525,11 @@ export default function Login() {
});
if (error) {
console.error("[Login] Email sign in error:", error);
setError(error.message);
if (error.mfaRequired || error.code === "invalid_mfa_code") {
setRequiresMfa(true);
}
} else if (user && session) {
console.log("[Login] Email sign in successful");
clearLogoutBlock();
setRequiresMfa(false);
setMfaCode("");