Compare commits

...
Author SHA1 Message Date
Reece BrowneandGitHub 412aae9d8f Merge branch 'V2' into chore/v2/scrolling 2025-11-28 16:30:03 +00:00
Reece BrowneandGitHub e4c6ce5836 Cookie consent banner and footer on login screen (#5042) 2025-11-28 16:24:10 +00:00
Reece 6d8e458aac Merge branch 'chore/v2/scrolling' of https://github.com/Stirling-Tools/Stirling-PDF into chore/v2/scrolling 2025-11-28 16:19:53 +00:00
Reece 7733eed4b8 Tsc 2025-11-28 16:16:48 +00:00
Reece BrowneandGitHub c9578b9ea3 Merge branch 'V2' into chore/v2/scrolling 2025-11-28 16:06:51 +00:00
Reece 289e75b872 Improved language select 2025-11-28 15:59:24 +00:00
ConnorYohandGitHub 250979e271 Desktop Self-hosted guidance improvements (#5060)
Removed timeout when waiting for backend port to allow for slow backend
spinups



<img width="1185" height="951" alt="image"
src="https://github.com/user-attachments/assets/badaf8e5-611d-44aa-aca2-7c1c906c2019"
/>
<img width="1213" height="1533" alt="image"
src="https://github.com/user-attachments/assets/ce78b67a-07e0-4c23-9087-5de0c5f203c6"
/>
<img width="1207" height="1202" alt="image"
src="https://github.com/user-attachments/assets/c6e5b4c5-9cc3-4973-a634-3b7aa1e1dd34"
/>
2025-11-28 15:55:37 +00:00
18 changed files with 354 additions and 100 deletions
@@ -159,6 +159,8 @@ public class RequestUriUtils {
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
@@ -58,6 +58,21 @@ public class UIDataController {
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/footer-info")
@Operation(summary = "Get public footer configuration data")
public ResponseEntity<FooterData> getFooterData() {
FooterData data = new FooterData();
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
data.setAccessibilityStatement(
applicationProperties.getLegal().getAccessibilityStatement());
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
data.setImpressum(applicationProperties.getLegal().getImpressum());
return ResponseEntity.ok(data);
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
@@ -237,6 +252,16 @@ public class UIDataController {
}
// Data classes
@Data
public static class FooterData {
private Boolean analyticsEnabled;
private String termsAndConditions;
private String privacyPolicy;
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
}
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
@@ -24,8 +24,8 @@
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
@@ -34,10 +34,10 @@
--cc-section-category-border: var(--md-sys-color-outline);
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
@@ -47,7 +47,7 @@
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-border-color: var(--cc-bg);
}
.cm__body{
max-width: 90% !important;
@@ -81,4 +81,4 @@
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
}
@@ -299,7 +299,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate",
contextPath + "/api/v1/invite/accept"
contextPath + "/api/v1/invite/accept",
contextPath + "/api/v1/ui-data/footer-info"
};
for (String pattern : publicApiPatterns) {
@@ -54,17 +54,17 @@
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #2d2d2d;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #e5e5e5;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #e5e5e5;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #2d2d2d;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: #555555;
@@ -180,4 +180,27 @@
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
@@ -5777,7 +5777,7 @@ description = "Sign in with your Stirling account"
[setup.mode.selfhosted]
title = "Self-Hosted Server"
description = "Connect to your own Stirling PDF server"
description = "Connect to your own Stirling PDF server with your personal account"
[setup.saas]
title = "Sign in to Stirling"
@@ -5813,6 +5813,13 @@ submit = "Login"
signInWith = "Sign in with"
oauthPending = "Opening browser for authentication..."
orContinueWith = "Or continue with email"
serverRequirement = "Note: The server must have login enabled."
showInstructions = "How to enable?"
hideInstructions = "Hide instructions"
instructions = "To enable login on your Stirling PDF server:"
instructionsEnvVar = "Set the environment variable:"
instructionsOrYml = "Or in settings.yml:"
instructionsRestart = "Then restart your server for the changes to take effect."
[setup.login.username]
label = "Username"
+65 -40
View File
@@ -1,6 +1,7 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useFooterInfo } from '@app/hooks/useFooterInfo';
interface FooterProps {
privacyPolicy?: string;
@@ -9,6 +10,7 @@ interface FooterProps {
cookiePolicy?: string;
impressum?: string;
analyticsEnabled?: boolean;
forceLightMode?: boolean;
}
export default function Footer({
@@ -17,10 +19,36 @@ export default function Footer({
accessibilityStatement,
cookiePolicy,
impressum,
analyticsEnabled = false
analyticsEnabled,
forceLightMode = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
const { footerInfo } = useFooterInfo();
console.log('[Footer] Props analyticsEnabled:', analyticsEnabled);
console.log('[Footer] Fetched footerInfo:', footerInfo);
// Use props if provided, otherwise fall back to fetched footer info
const finalAnalyticsEnabled = analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
const finalPrivacyPolicy = privacyPolicy ?? footerInfo?.privacyPolicy;
const finalTermsAndConditions = termsAndConditions ?? footerInfo?.termsAndConditions;
const finalAccessibilityStatement = accessibilityStatement ?? footerInfo?.accessibilityStatement;
const finalCookiePolicy = cookiePolicy ?? footerInfo?.cookiePolicy;
const finalImpressum = impressum ?? footerInfo?.impressum;
console.log('[Footer] Final analyticsEnabled:', finalAnalyticsEnabled);
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled: finalAnalyticsEnabled, forceLightMode });
// Default URLs
const defaultTermsUrl = "https://www.stirling.com/legal/terms-of-service";
const defaultPrivacyUrl = "https://www.stirling.com/legal/privacy-policy";
const defaultAccessibilityUrl = "https://www.stirling.com/accessibility";
// Use provided URLs or fall back to defaults
const finalTermsUrl = finalTermsAndConditions || defaultTermsUrl;
const finalPrivacyUrl = finalPrivacyPolicy || defaultPrivacyUrl;
const finalAccessibilityUrl = finalAccessibilityStatement || defaultAccessibilityUrl;
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
@@ -28,8 +56,8 @@ export default function Footer({
return (
<div style={{
height: 'var(--footer-height)',
backgroundColor: 'var(--mantine-color-gray-1)',
borderTop: '1px solid var(--mantine-color-gray-2)',
backgroundColor: forceLightMode ? '#f1f3f5' : 'var(--mantine-color-gray-1)',
borderTop: forceLightMode ? '1px solid #e9ecef' : '1px solid var(--mantine-color-gray-2)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
@@ -38,7 +66,10 @@ export default function Footer({
justify="center"
align="center"
direction="row"
style={{ fontSize: '0.75rem' }}>
style={{
fontSize: '0.75rem',
color: forceLightMode ? '#495057' : undefined
}}>
<a
className="footer-link px-3"
id="survey"
@@ -48,57 +79,51 @@ export default function Footer({
>
{t('survey.nav', 'Survey')}
</a>
{isValidLink(privacyPolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalPrivacyUrl}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalTermsUrl}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalAccessibilityUrl}
>
{t('legal.accessibility', 'Accessibility')}
</a>
{isValidLink(finalCookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={privacyPolicy}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
)}
{isValidLink(termsAndConditions) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={termsAndConditions}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
)}
{isValidLink(accessibilityStatement) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={accessibilityStatement}
>
{t('legal.accessibility', 'Accessibility')}
</a>
)}
{isValidLink(cookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={cookiePolicy}
href={finalCookiePolicy}
>
{t('legal.cookie', 'Cookie Policy')}
</a>
)}
{isValidLink(impressum) && (
{isValidLink(finalImpressum) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={impressum}
href={finalImpressum}
>
{t('legal.impressum', 'Impressum')}
</a>
)}
{analyticsEnabled && (
{finalAnalyticsEnabled && (
<button
className="footer-link px-3"
id="cookieBanner"
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { Menu, Button, ActionIcon } from '@mantine/core';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -11,6 +12,7 @@ interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position'];
offset?: number;
compact?: boolean; // icon-only trigger
tooltip?: string; // tooltip text for compact mode
}
interface LanguageOption {
@@ -51,7 +53,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
const { t } = useTranslation();
const label = disabled ? (
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
<p>{option.label}</p>
</Tooltip>
) : (
@@ -64,7 +66,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`,
}}
>
<Button
@@ -90,7 +92,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
transition: 'all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
cursor: disabled ? 'not-allowed' : 'pointer',
'&:hover': !disabled ? {
backgroundColor: isSelected
@@ -126,7 +128,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
animation: 'ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
@@ -149,7 +151,12 @@ const RippleStyles: React.FC = () => (
);
// Main component
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
position = 'bottom-start',
offset = 8,
compact = false,
tooltip
}) => {
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
@@ -184,14 +191,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
setOpened(false);
// Clear ripple effect
setTimeout(() => setRippleEffect(null), 100);
setTimeout(() => setRippleEffect(null), 50);
// Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly
if (typeof window !== 'undefined') {
window.location.reload();
}
}, 300);
}, 200);
}, 150);
}, 100);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
@@ -202,7 +209,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 50);
setTimeout(() => setAnimationTriggered(true), 20);
}
}, [opened]);
@@ -218,7 +225,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
duration: 120,
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
@@ -227,8 +234,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<ActionIcon
variant="subtle"
radius="md"
title={currentLanguage}
className="right-rail-icon"
title={!opened && tooltip ? tooltip : undefined}
styles={{
root: {
color: 'var(--right-rail-icon)',
@@ -274,8 +281,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
@@ -301,8 +307,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
/>
);
})}
</div>
</ScrollArea>
</div>
</Menu.Dropdown>
</Menu>
</>
@@ -216,14 +216,12 @@ export default function RightRail() {
tooltipOffset
)}
{renderWithTooltip(
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>,
t('rightRail.language', 'Language'),
tooltipPosition,
tooltipOffset
)}
<LanguageSelector
position="left-start"
offset={6}
compact
tooltip={t('rightRail.language', 'Language')}
/>
{renderWithTooltip(
<ActionIcon
+84 -14
View File
@@ -19,10 +19,12 @@ declare global {
interface CookieConsentConfig {
analyticsEnabled?: boolean;
forceLightMode?: boolean;
}
export const useCookieConsent = ({
analyticsEnabled = false
analyticsEnabled = false,
forceLightMode = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -69,6 +71,12 @@ export const useCookieConsent = ({
// Detect current theme and set appropriate mode
const detectTheme = () => {
// If forceLightMode is enabled, always use light mode
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
}
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
const hasLightClass = document.documentElement.classList.contains('light');
const hasDarkClass = document.documentElement.classList.contains('dark');
@@ -104,21 +112,24 @@ export const useCookieConsent = ({
return;
}
// Listen for theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
// Listen for theme changes (but not if forceLightMode is enabled)
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
});
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
}
// Initialize cookie consent with full configuration
@@ -245,6 +256,65 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
// Update theme when forceLightMode changes
useEffect(() => {
if (!isInitialized) return;
const detectTheme = () => {
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
}
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
const hasLightClass = document.documentElement.classList.contains('light');
const hasDarkClass = document.documentElement.classList.contains('dark');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let isDarkMode = false;
if (mantineScheme) {
isDarkMode = mantineScheme === 'dark';
} else if (hasLightClass) {
isDarkMode = false;
} else if (hasDarkClass) {
isDarkMode = true;
} else {
isDarkMode = systemPrefersDark;
}
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
return isDarkMode;
};
// Update theme immediately
detectTheme();
// Set up or remove theme observer based on forceLightMode
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
}
return () => {
if (themeObserver) {
themeObserver.disconnect();
}
};
}, [forceLightMode, isInitialized]);
// Hide cookie banner when tour is active
useEffect(() => {
if (!isInitialized || !window.CookieConsent) {
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
export interface FooterInfo {
analyticsEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
/**
* Hook to fetch public footer configuration data.
* This endpoint is always accessible without authentication.
*/
export function useFooterInfo() {
const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchFooterInfo = async () => {
try {
setLoading(true);
console.log('[useFooterInfo] Fetching footer info from /api/v1/ui-data/footer-info...');
const response = await apiClient.get<FooterInfo>('/api/v1/ui-data/footer-info', {
suppressErrorToast: true,
} as any);
console.log('[useFooterInfo] Footer info received:', response.data);
setFooterInfo(response.data);
setError(null);
} catch (err) {
console.error('[useFooterInfo] Failed to fetch footer info:', err);
setError(err as Error);
// Set defaults on error
setFooterInfo({
analyticsEnabled: false,
});
} finally {
setLoading(false);
}
};
fetchFooterInfo();
}, []);
return { footerInfo, loading, error };
}
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group } from '@mantine/core';
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { authService } from '@app/services/authService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
@@ -19,6 +19,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
const [password, setPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [oauthLoading, setOauthLoading] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -92,6 +93,47 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
</Text>
{/* Login requirement note for self-hosted servers */}
{!isSaaS && (
<Box>
<Text size="xs" c="dimmed">
{t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '}
<Anchor
size="xs"
onClick={() => setShowInstructions(!showInstructions)}
style={{ cursor: 'pointer' }}
>
{showInstructions
? t('setup.login.hideInstructions', 'Hide instructions')
: t('setup.login.showInstructions', 'How to enable?')}
</Anchor>
</Text>
<Collapse in={showInstructions}>
<Box mt="xs" p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: '4px' }}>
<Text size="xs" c="dimmed">
{t('setup.login.instructions', 'To enable login on your Stirling PDF server:')}
</Text>
<Text size="xs" mt="xs" c="dimmed">
{t('setup.login.instructionsEnvVar', 'Set the environment variable:')}
</Text>
<Text size="xs" mt="4px" ff="monospace" c="dark">
SECURITY_ENABLELOGIN=true
</Text>
<Text size="xs" mt="xs" c="dimmed">
{t('setup.login.instructionsOrYml', 'Or in settings.yml:')}
</Text>
<Text size="xs" mt="4px" ff="monospace" c="dark">
security.enableLogin: true
</Text>
<Text size="xs" mt="xs" c="dimmed">
{t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')}
</Text>
</Box>
</Collapse>
</Box>
)}
{/* OAuth Login Buttons - Only show for SaaS */}
{isSaaS && (
<>
@@ -63,7 +63,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server with your personal account')}
</Text>
</div>
</Button>
@@ -101,8 +101,8 @@ export class TauriBackendService {
return this.startPromise;
}
private async waitForPort(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
private async waitForPort(): Promise<void> {
while (true) {
try {
const port = await invoke<number | null>('get_backend_port');
if (port) {
@@ -114,7 +114,6 @@ export class TauriBackendService {
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('Failed to detect backend port after 15 seconds');
}
/**
@@ -208,16 +207,14 @@ export class TauriBackendService {
}
}
private async waitForHealthy(maxAttempts = 60): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
private async waitForHealthy(): Promise<void> {
while (true) {
const isHealthy = await this.checkBackendHealth();
if (isHealthy) {
return;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
this.setStatus('unhealthy');
throw new Error('Backend failed to become healthy after 60 seconds');
}
/**
@@ -7,6 +7,7 @@ import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
import { OnboardingProvider } from '@app/contexts/OnboardingContext';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
@@ -65,7 +66,9 @@ vi.mock('react-router-dom', async () => {
// Test wrapper with MantineProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>
<PreferencesProvider>{children}</PreferencesProvider>
<PreferencesProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</PreferencesProvider>
</MantineProvider>
);
@@ -4,6 +4,7 @@ import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
import buildLoginSlides from '@app/components/shared/loginSlides';
import styles from '@app/routes/authShared/AuthLayout.module.css';
import { useLogoVariant } from '@app/hooks/useLogoVariant';
import Footer from '@app/components/shared/Footer';
interface AuthLayoutProps {
children: React.ReactNode
@@ -67,6 +68,9 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
)}
</div>
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, width: '100%', zIndex: 10 }}>
<Footer forceLightMode={true} />
</div>
</div>
);
}
+2
View File
@@ -20,6 +20,8 @@ if errorlevel 1 (
echo ✅ Java and jlink detected
echo ▶ Building Stirling-PDF JAR...
set DISABLE_ADDITIONAL_FEATURES=true
call gradlew.bat clean bootJar --no-daemon
if errorlevel 1 (
echo ❌ Failed to build Stirling-PDF JAR
+1
View File
@@ -60,6 +60,7 @@ fi
# Clean and build the Stirling-PDF JAR
print_step "Building Stirling-PDF JAR..."
export DISABLE_ADDITIONAL_FEATURES=true
./gradlew clean bootJar --no-daemon
if [ ! -f compgen -G "app/core/build/libs/stirling-pdf-*.jar" ]; then