mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Merge remote-tracking branch 'origin/main' into SaaS
# Conflicts: # frontend/editor/src/core/components/shared/AppConfigModal.tsx
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+5
-5
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+6
-6
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+14
-14
@@ -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",
|
||||
|
||||
+10
-3
@@ -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")
|
||||
|
||||
+9
-3
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user