}+K!7S
zBRF03DyE<1qJ3u}&b%s*^`)bZM{a7WAASNMW1ozH|%=Q)JTfHP9;ambgkVWoF7?2z+**19IfH*e?Oo=%^sLt>T7
zZDcK+NSuy|6J^xC3!Yp#?ctpSKEu2fbsSFl{yMgH*M5r=6p4A&d(5d-PsQV)@{dmiANZ}UC90L`(b*tOG05}5
z++k|*#%WttYX!&!X$O3_ieIO4#jw2$-oxPFPS|D}-3d9H+)paIjOQ7Shw+=*)D9;c
z)c8fDSm>LvJBnkE^-KFbUYs`Z4tRiU`WfQncz9bu_%v)+HsF2J!>xZ6Vel?FNzRdL
zdb+U!$D#N>Ew!jS%
z_;Hr!4AbwVu;hzT6S&r6xPCGVbq&-qOGah~l-e
zC&1yoes5hLgx8<5mwZ0^EvMP%@|K-Ow4#k!vGTM2FWZBMm%=*(-x|~T=nBvcq%;$z
z@bA=zV;h0$-Sei`P1JXUILC~*m`Fe^&*S!jIF~EuuTid{pLuU1UFd(lbLxs>GzeDB
z!<{~=9+KM3-+BI>cGCYKZl=GK%+V|P_u&XFeLbq(^l!m;-@RB&W<-=e7lXLpj#1TlM`{d7bjWYC4wS2L6dRRSpjlG-hdoP
z`G1AD{~JF2!Kkb2mwEjH&*mh)zxI^Q#~(_wzzq>D0n6fz(N~4$vvMq9%j(R=g1B3Z
zI)Qr3mgRq|dySR#qJxQqA}IGb!H!oY+7#2SkTT$;9NGv1lLg>ISD28%%L0hF1
zYO!?ekiKho_c{KsF+MsMo0~c2UzMKPyno7*P=f!E)K#?(L%h~3TjkE-(TSQoVYqHJ
zTf0*On38js?e>6STlWQdi5l_W-~C_c9XTF67UVnap1gV!_Z=aY@#wbS{Vf4Lsa=JT
zi^%r^#6m+LO)+HscEC0tG~_xC-KpO+(ajcs7nKCa3rk}!ae<-|q8IY`iO)mEczE2r
zYHroA#~?J11iI3sw`#w0{$OoAV4DTnT64JqK_reLYb|W;h*ETqJWl599HBLXu8o!VzJaEA@bFA{g0ok*3L$ZsAN2uhwph7cHkWSQ8sf?s4Xs
zfl@E5@mMC
z3t5TQoc@x0>e$7bpQ9*+3L#F&1AfC#a#9Zct>vpq2xYlc6Myl7#>n0e7GdMkkZZ^n
z5f{{p>7{~lVOK}cv7Bz`0tf0IkUr7jg=|1;W?fJzraxkLM06=Y3?-*Q(qxK&rE29*
zXUSO`6n0^l)T#kJEx&Pne1qAWN36+&d@8NQ#!%h*FW$sg6WN^w+x7T-{X}V6F|W^#
z%-X!R$9ECYN~biLm3-n*EBfg1-9+1hij~~^yArl2YG
zc*X`rg(vXRTxHQZk?Z(ESuuk$3tTW@>Ym`xWoVVd@M`+_-ld*mC$4KQLENG-Q
z_-VM|8K1Tz>>sLyf3zS}*JEt&fO)Yayou1FO&q|Ch8fleJ8Nq2PdY{i?kEV3Jzt}m
ziLfpglB3txiz~aPw)hg+d?l}Imoda>8_dtrNScS=z2C~5{VIBa<^E2CfYZvm9a|a^cpaG!uQ9J6$5vK_8b-ZC5@q+UBXxK%RbS>E!9UTjn<^
z`<EqA{<$^zDB=NoYO2vj
zf76yo`4W3MNBgGGZZeABlAg(qHl)fl9+N1)rHfG5$=^Oq@GQM&6k|9}5F
aJFjz2q?ldh(5nyMk6D=57=Jv05B)#XI~864
literal 0
HcmV?d00001
diff --git a/frontend/public/branding/old/favicon.svg b/frontend/public/branding/old/favicon.svg
new file mode 100644
index 000000000..0fef4393a
--- /dev/null
+++ b/frontend/public/branding/old/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 9cfe5380b..8c93539c6 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -3177,6 +3177,7 @@
"rememberme": "Remember me",
"invalid": "Invalid username or password.",
"locked": "Your account has been locked.",
+ "sessionExpired": "Your session has expired. Please sign in again.",
"signinTitle": "Please sign in",
"ssoSignIn": "Login via Single Sign-on",
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
@@ -3681,6 +3682,12 @@
"saveSuccess": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Restart Required",
+ "loginRequired": "Login mode must be enabled to modify admin settings",
+ "loginDisabled": {
+ "title": "Login Mode Required",
+ "message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
+ "readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
+ },
"restart": {
"title": "Restart Required",
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
@@ -3751,6 +3758,12 @@
"description": "Default producer for PDF metadata"
}
},
+ "logoStyle": {
+ "label": "Logo Style",
+ "description": "Choose between the modern minimalist logo or the classic S icon",
+ "classic": "Classic",
+ "modern": "Modern"
+ },
"customPaths": {
"label": "Custom Paths",
"description": "Configure custom file system paths for pipeline processing and external tools",
diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx
index f9937f775..e7ddf0f0e 100644
--- a/frontend/src/core/components/shared/AppConfigModal.tsx
+++ b/frontend/src/core/components/shared/AppConfigModal.tsx
@@ -143,16 +143,15 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
{
- if (!isDisabled) {
- setActive(item.key);
- navigate(`/settings/${item.key}`);
- }
+ // Allow navigation even when disabled - the content inside will be disabled
+ setActive(item.key);
+ navigate(`/settings/${item.key}`);
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
- opacity: isDisabled ? 0.5 : 1,
- cursor: isDisabled ? 'not-allowed' : 'pointer',
+ opacity: isDisabled ? 0.6 : 1,
+ cursor: 'pointer',
}}
data-tour={`admin-${item.key}-nav`}
>
diff --git a/frontend/src/core/components/shared/FirstLoginModal.tsx b/frontend/src/core/components/shared/FirstLoginModal.tsx
index fa3e42bae..7cd034edd 100644
--- a/frontend/src/core/components/shared/FirstLoginModal.tsx
+++ b/frontend/src/core/components/shared/FirstLoginModal.tsx
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
- await accountService.changePassword(currentPassword, newPassword);
+ await accountService.changePasswordOnLogin(currentPassword, newPassword);
alert({
alertType: 'success',
diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx
index f25aaedfc..9ee11cbd2 100644
--- a/frontend/src/core/components/shared/LandingPage.tsx
+++ b/frontend/src/core/components/shared/LandingPage.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app';
+import { useLogoPath } from '@app/hooks/useLogoPath';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -14,6 +15,7 @@ const LandingPage = () => {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
+ const logoPath = useLogoPath();
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@@ -72,7 +74,7 @@ const LandingPage = () => {
}}
>
}
+ title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
+ color="blue"
+ variant="light"
+ styles={{
+ root: {
+ borderLeft: '4px solid var(--mantine-color-blue-6)'
+ }
+ }}
+ >
+
+ {t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
+
+
+ {t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
+
+
+ );
+}
diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx
index a1f66480b..36dd4dfcd 100644
--- a/frontend/src/core/components/shared/config/configNavSections.tsx
+++ b/frontend/src/core/components/shared/config/configNavSections.tsx
@@ -2,18 +2,6 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
-import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
-import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
-import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
-import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
-import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
-import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
-import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
-import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
-import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
-import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
-import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
-import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
export interface ConfigNavItem {
key: NavKey;
@@ -40,8 +28,8 @@ export interface ConfigColors {
}
export const createConfigNavSections = (
- isAdmin: boolean = false,
- runningEE: boolean = false,
+ _isAdmin: boolean = false,
+ _runningEE: boolean = false,
_loginEnabled: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
@@ -64,112 +52,5 @@ export const createConfigNavSections = (
},
];
- // Add Admin sections if user is admin
- if (isAdmin) {
- // Configuration
- sections.push({
- title: 'Configuration',
- items: [
- {
- key: 'adminGeneral',
- label: 'System Settings',
- icon: 'settings-rounded',
- component:
- },
- {
- key: 'adminFeatures',
- label: 'Features',
- icon: 'extension-rounded',
- component:
- },
- {
- key: 'adminEndpoints',
- label: 'Endpoints',
- icon: 'api-rounded',
- component:
- },
- {
- key: 'adminDatabase',
- label: 'Database',
- icon: 'storage-rounded',
- component:
- },
- {
- key: 'adminAdvanced',
- label: 'Advanced',
- icon: 'tune-rounded',
- component:
- },
- ],
- });
-
- // Security & Authentication
- sections.push({
- title: 'Security & Authentication',
- items: [
- {
- key: 'adminSecurity',
- label: 'Security',
- icon: 'shield-rounded',
- component:
- },
- {
- key: 'adminConnections',
- label: 'Connections',
- icon: 'link-rounded',
- component:
- },
- ],
- });
-
- // Licensing & Analytics
- sections.push({
- title: 'Licensing & Analytics',
- items: [
- {
- key: 'adminPremium',
- label: 'Premium',
- icon: 'star-rounded',
- component:
- },
- {
- key: 'adminAudit',
- label: 'Audit',
- icon: 'fact-check-rounded',
- component:
,
- disabled: !runningEE,
- disabledTooltip: 'Requires Enterprise license'
- },
- {
- key: 'adminUsage',
- label: 'Usage Analytics',
- icon: 'analytics-rounded',
- component:
,
- disabled: !runningEE,
- disabledTooltip: 'Requires Enterprise license'
- },
- ],
- });
-
- // Policies & Privacy
- sections.push({
- title: 'Policies & Privacy',
- items: [
- {
- key: 'adminLegal',
- label: 'Legal',
- icon: 'gavel-rounded',
- component:
- },
- {
- key: 'adminPrivacy',
- label: 'Privacy',
- icon: 'visibility-rounded',
- component:
- },
- ],
- });
- }
-
return sections;
};
diff --git a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx
index 6209fb4b6..3d07338ac 100644
--- a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx
+++ b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx
@@ -10,6 +10,7 @@ interface ProviderCardProps {
settings?: Record
;
onSave?: (settings: Record) => void;
onDisconnect?: () => void;
+ disabled?: boolean;
}
export default function ProviderCard({
@@ -18,6 +19,7 @@ export default function ProviderCard({
settings = {},
onSave,
onDisconnect,
+ disabled = false,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -39,6 +41,7 @@ export default function ProviderCard({
};
const handleFieldChange = (key: string, value: any) => {
+ if (disabled) return; // Block changes when disabled
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
@@ -63,6 +66,7 @@ export default function ProviderCard({
handleFieldChange(field.key, e.target.checked)}
+ disabled={disabled}
/>
);
@@ -76,6 +80,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
+ disabled={disabled}
/>
);
@@ -88,6 +93,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
+ disabled={disabled}
/>
);
@@ -100,6 +106,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
+ disabled={disabled}
/>
);
}
@@ -174,11 +181,12 @@ export default function ProviderCard({
color="red"
size="sm"
onClick={onDisconnect}
+ disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
)}
-
+
{t('admin.settings.save', 'Save Changes')}
diff --git a/frontend/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/src/core/components/tools/FullscreenToolSurface.tsx
index 169aab44c..7ac5d82c9 100644
--- a/frontend/src/core/components/tools/FullscreenToolSurface.tsx
+++ b/frontend/src/core/components/tools/FullscreenToolSurface.tsx
@@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import { useFocusTrap } from '@app/hooks/useFocusTrap';
import { BASE_PATH } from '@app/constants/app';
+import { useLogoPath } from '@app/hooks/useLogoPath';
import { Tooltip } from '@app/components/shared/Tooltip';
import '@app/components/tools/ToolPanel.css';
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
@@ -51,9 +52,7 @@ const FullscreenToolSurface = ({
useFocusTrap(surfaceRef, !isExiting);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
- const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
- colorScheme === "dark" ? "Dark" : "Light"
- }.svg`;
+ const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx
index 97cae0671..ad47b415b 100644
--- a/frontend/src/core/contexts/AppConfigContext.tsx
+++ b/frontend/src/core/contexts/AppConfigContext.tsx
@@ -19,6 +19,7 @@ export interface AppConfig {
serverPort?: number;
appNameNavbar?: string;
languages?: string[];
+ logoStyle?: 'modern' | 'classic';
enableLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;
diff --git a/frontend/src/core/hooks/useLoginRequired.ts b/frontend/src/core/hooks/useLoginRequired.ts
new file mode 100644
index 000000000..ea9af38d9
--- /dev/null
+++ b/frontend/src/core/hooks/useLoginRequired.ts
@@ -0,0 +1,89 @@
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+import { alert } from '@app/components/toast';
+import { useTranslation } from 'react-i18next';
+
+/**
+ * Hook to manage login-required functionality in admin sections
+ * Provides login state, validation, and alert functionality
+ */
+export function useLoginRequired() {
+ const { config } = useAppConfig();
+ const { t } = useTranslation();
+ const loginEnabled = config?.enableLogin ?? true;
+
+ /**
+ * Show alert when user tries to modify settings with login disabled
+ */
+ const showLoginRequiredAlert = () => {
+ alert({
+ alertType: 'warning',
+ title: t('admin.error', 'Error'),
+ body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'),
+ });
+ };
+
+ /**
+ * Validate that login is enabled before allowing action
+ * Returns true if login is enabled, false otherwise (and shows alert)
+ */
+ const validateLoginEnabled = (): boolean => {
+ if (!loginEnabled) {
+ showLoginRequiredAlert();
+ return false;
+ }
+ return true;
+ };
+
+ /**
+ * Wrap an async handler to check login state before executing
+ */
+ const withLoginCheck = Promise>(
+ handler: T
+ ): T => {
+ return (async (...args: any[]) => {
+ if (!validateLoginEnabled()) {
+ return;
+ }
+ return handler(...args);
+ }) as T;
+ };
+
+ /**
+ * Get styles for disabled inputs (cursor not-allowed)
+ */
+ const getDisabledStyles = () => {
+ if (!loginEnabled) {
+ return {
+ input: { cursor: 'not-allowed' },
+ track: { cursor: 'not-allowed' },
+ thumb: { cursor: 'not-allowed' }
+ };
+ }
+ return undefined;
+ };
+
+ /**
+ * Wrap fetch function to skip API call when login disabled
+ */
+ const withLoginCheckForFetch = Promise>(
+ fetchHandler: T,
+ skipWhenDisabled: boolean = true
+ ): T => {
+ return (async (...args: any[]) => {
+ if (!loginEnabled && skipWhenDisabled) {
+ // Skip fetch when login disabled - component will use default/empty values
+ return;
+ }
+ return fetchHandler(...args);
+ }) as T;
+ };
+
+ return {
+ loginEnabled,
+ showLoginRequiredAlert,
+ validateLoginEnabled,
+ withLoginCheck,
+ withLoginCheckForFetch,
+ getDisabledStyles,
+ };
+}
diff --git a/frontend/src/core/hooks/useLogoPath.ts b/frontend/src/core/hooks/useLogoPath.ts
new file mode 100644
index 000000000..db97f9c08
--- /dev/null
+++ b/frontend/src/core/hooks/useLogoPath.ts
@@ -0,0 +1,31 @@
+import { useMemo } from 'react';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+import { useMantineColorScheme } from '@mantine/core';
+import { BASE_PATH } from '@app/constants/app';
+
+/**
+ * Hook to get the correct logo path based on app config (logo style) and theme (light/dark)
+ *
+ * Logo styles:
+ * - classic: branding/old/favicon.svg (classic S logo - default)
+ * - modern: StirlingPDFLogoNoText{Light|Dark}.svg (minimalist modern design)
+ *
+ * @returns The path to the appropriate logo SVG file
+ */
+export function useLogoPath(): string {
+ const { config } = useAppConfig();
+ const { colorScheme } = useMantineColorScheme();
+
+ return useMemo(() => {
+ const logoStyle = config?.logoStyle || 'classic';
+
+ if (logoStyle === 'classic') {
+ // Classic logo (old favicon) - same for both light and dark modes
+ return `${BASE_PATH}/branding/old/favicon.svg`;
+ }
+
+ // Modern logo - different for light and dark modes
+ const themeSuffix = colorScheme === 'dark' ? 'Dark' : 'Light';
+ return `${BASE_PATH}/branding/StirlingPDFLogoNoText${themeSuffix}.svg`;
+ }, [config?.logoStyle, colorScheme]);
+}
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index 6df688e55..2731ad100 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -8,6 +8,7 @@ import { BASE_PATH } from "@app/constants/app";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
+import { useLogoPath } from "@app/hooks/useLogoPath";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "@app/components/tools/ToolPanel";
@@ -60,9 +61,7 @@ export default function HomePage() {
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
- const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
- colorScheme === "dark" ? "Dark" : "Light"
- }.svg`;
+ const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
diff --git a/frontend/src/core/services/accountService.ts b/frontend/src/core/services/accountService.ts
index f546ed8bf..0b2aa52e0 100644
--- a/frontend/src/core/services/accountService.ts
+++ b/frontend/src/core/services/accountService.ts
@@ -31,4 +31,14 @@ export const accountService = {
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData);
},
+
+ /**
+ * Change user password on first login (resets firstLogin flag)
+ */
+ async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise {
+ const formData = new FormData();
+ formData.append('currentPassword', currentPassword);
+ formData.append('newPassword', newPassword);
+ await apiClient.post('/api/v1/user/change-password-on-login', formData);
+ },
};
diff --git a/frontend/src/core/services/httpErrorHandler.ts b/frontend/src/core/services/httpErrorHandler.ts
index fa5160adf..53e04516a 100644
--- a/frontend/src/core/services/httpErrorHandler.ts
+++ b/frontend/src/core/services/httpErrorHandler.ts
@@ -92,6 +92,32 @@ export async function handleHttpError(error: any): Promise {
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
+
+ // Handle 401 authentication errors
+ const status: number | undefined = error?.response?.status;
+ if (status === 401) {
+ const pathname = window.location.pathname;
+
+ // Check if we're already on an auth page
+ const isAuthPage = pathname.includes('/login') ||
+ pathname.includes('/signup') ||
+ pathname.includes('/auth/') ||
+ pathname.includes('/invite/');
+
+ // If not on auth page, redirect to login with expired session message
+ if (!isAuthPage) {
+ console.debug('[httpErrorHandler] 401 detected, redirecting to login');
+ // Store the current location so we can redirect back after login
+ const currentLocation = window.location.pathname + window.location.search;
+ // Redirect to login with state
+ window.location.href = `/login?expired=true&from=${encodeURIComponent(currentLocation)}`;
+ return true; // Suppress toast since we're redirecting
+ }
+
+ // On auth pages, suppress the toast (user is already trying to authenticate)
+ console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname);
+ return true;
+ }
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
@@ -112,7 +138,6 @@ export async function handleHttpError(error: any): Promise {
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
- const status: number | undefined = error?.response?.status;
const now = Date.now();
const isSpecial =
status === 422 ||
diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts
index 5d3e2d4dc..04fb55957 100644
--- a/frontend/src/proprietary/auth/springAuthClient.ts
+++ b/frontend/src/proprietary/auth/springAuthClient.ts
@@ -251,7 +251,7 @@ class SpringAuthClient {
* This redirects to the Spring OAuth2 authorization endpoint
*/
async signInWithOAuth(params: {
- provider: 'github' | 'google' | 'apple' | 'azure';
+ provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
options?: { redirectTo?: string; queryParams?: Record };
}): Promise<{ error: AuthError | null }> {
try {
diff --git a/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx b/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx
index 00b5dac98..f7157a43e 100644
--- a/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx
+++ b/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx
@@ -1,9 +1,9 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { BASE_PATH } from '@app/constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
-export default function LoginRightCarousel({
+function LoginRightCarousel({
imageSlides = [],
showBackground = true,
initialSeconds = 5,
@@ -157,3 +157,5 @@ export default function LoginRightCarousel({